docs(concept): le contrat entre le polyfill et l'application qui l'utilise

Amorce le système `concept` dans ce dépôt et ouvre `app-contract` — la frontière
entre cette bibliothèque et les applications qui la consomment.

C'est un contrat **inter-dépôts** et ce dépôt en est le FOURNISSEUR : les applications
vivent ailleurs et tireront `sdk-surface` d'ici. D'où le type `contract_`, ses cinq
sections obligatoires, et l'inscription dans `.project/contracts.yaml` — c'est
l'inscription qui publie.

Trois feuilles :

- **`contract_sdk-surface`** — l'engagement, écrit du point de vue de l'appelant.
  Ce qu'il peut tenir pour acquis : permissif en entrée et précis en sortie ; toute
  référence rendue est NUE, aucun appel ne rend jamais de clé ; lire est la possession,
  écrire est la propriété ; donner à lire est un seul acte et le destinataire n'appelle
  rien ; un dépôt s'adresse à une inbox, jamais à un document ; `ensureIdentity()` est
  toute la connexion ; et `configure` est le seul appel qu'il supprimera.
  Ce qu'il ne doit PAS tenir pour acquis, dit aussi crûment : aucune confidentialité,
  rien de « par lecteur » sur un document public, aucune révocation, aucune écriture
  déléguée, et les références ne voyagent que dans un déploiement.

- **`rule_would-the-caller-unlearn-it`** — le test qui décide de tout : est-ce que
  ceci ferait apprendre à l'appelant quelque chose qu'il devra DÉSAPPRENDRE ? Avec les
  deux tells que la revue de ces jours-ci a rendus concrets : une exception nommée
  cesse d'en être une dès qu'on la publie, et un symbole gardé parce qu'il était là
  n'est pas une décision.

- **`knowledge_what-an-app-deletes-at-migration`** — les deux destins d'un symbole
  publié, le cas intermédiaire d'`ensureIdentity` (substance jetée, site d'appel
  conservé), et le fait que la liste de suppression n'est plus portée par un chemin
  d'import depuis la fusion des entrées : une garantie mécanique remplacée par une
  garantie documentaire, dont seule la moitié est tenue par un test.

Le vocabulaire du concept fixe trois termes que ce projet a déjà payé cher :
`reference` (jamais « lien »), `ReadCap`, `polyfill-era`. `lint` est conformant.

Reste à décider : ce dépôt n'a pas de `CLAUDE.md` racine, donc l'`AGENTS.md` généré
n'est chargé nulle part.
This commit is contained in:
Sylvain Duchesne
2026-08-10 09:25:01 +02:00
parent 44a9b6ee04
commit 30f6263db5
7 changed files with 252 additions and 0 deletions
@@ -0,0 +1,38 @@
---
type: overview
summary: What an application may rely on from @ng-eventually/sdk, and what it will have to delete
triggers:
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing]
paths:
- "packages/sdk/src/index.ts"
- "packages/sdk/src/surface/**"
- "packages/sdk/README.md"
- "examples/notebook/**"
- "docs/api-contract.md"
vocabulary:
- term: reference
gloss: a NURI that names a document and grants nothing — what an application circulates
not: [link, lien, share-link]
see: contract_sdk-surface
- term: ReadCap
gloss: upstream's word for what opens a document — a reference carrying its secret
not: [token, credential, permission]
- term: polyfill-era
gloss: a published symbol with no counterpart in the target SDK, deleted at migration
not: [transitional, shim-only]
see: knowledge_what-an-app-deletes-at-migration
---
# app-contract — the boundary between this library and the applications that use it
This library exists so an application can be **written today against the NextGraph that does not ship yet**, and keep its code when it does. Everything under this concept governs that boundary: what the package publishes, what a caller may rely on, what it must not, and what disappears at migration.
The distinguishing question, asked at every surface choice: **would this make a caller learn something it has to UNLEARN?** If yes it is a deviation, whatever it buys — see `rule_would-the-caller-unlearn-it`.
This repo is the **provider** of `contract_sdk-surface`; consuming applications live in other repos and pull it. The per-symbol ruling, with an epistemic label on every target-side claim, stays here in `docs/api-contract.md` — that is maintainer material, not the engagement.
## Read first
- `contract_sdk-surface` — the engagement itself, written from the caller's point of view.
- `rule_would-the-caller-unlearn-it` — the test that decides what may be published.
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
@@ -0,0 +1,139 @@
---
type: contract
summary: What @ng-eventually/sdk offers an application, what it guarantees, and what it refuses to promise
---
# contract_sdk-surface — `@ng-eventually/sdk`
## Scope
`@ng-eventually/sdk` is the surface an application codes against **instead of** `@ng-org/web` / `@ng-org/orm`, during the period where NextGraph's multi-user model is not yet shipped. It emulates, over a single shared wallet and a single broker, the parts of that model an application needs: distinct users, per-document keys, inboxes, and a public store that serves what it holds.
The engagement is not "these functions work". It is: **an application written against this surface keeps its code when the real SDK arrives.** Most of what is published has a target counterpart and is replaced in place; one call does not, and is listed as such below.
Out of scope: confidentiality of any kind (see Non-guarantees), anything about a NextGraph deployment's operation, and the shape of the future SDK's own names — where this document says a call is polyfill-era, it means it disappears, not that a differently-named successor is promised.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/sdk`. The load-bearing signatures:
```ts
// ── bootstrap — the ONE polyfill-era call ────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the real @ng-org/web `ng`
useShape: UseShapeLike; // the real @ng-org/orm `useShape`
getSession?: () => Promise<RegistrySession>; // resolve the wallet session (a thunk)
normalizeId?: (id: string) => string;
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
currentUser?: PrincipalId;
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<void>;
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type ReadCap = `did:ng:${string}:r:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
export type InboxScope = "public" | "protected";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: {
createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: Nuri): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: Nuri; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
export function watchShape(query: ShapeQuery): ShapeObservable;
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// ── raw document / SPARQL primitives ─────────────────────────────────────
export const docs: {
docCreate(sessionId: string, crdt?: string, cls?: string, dest?: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
sparqlUpdate(sessionId: string, query: string, anchor?: NuriLike, label?: string): Promise<void>;
};
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: Nuri): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: Nuri): Promise<Deposit[]>;
processInbox(targetInbox: Nuri): Promise<Deposit[]>;
watch(targetInbox: Nuri, onDeposits: (d: Deposit[]) => void): () => void;
materialize: typeof read;
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: Record<string, any>; // drop-in for @ng-org/web's `ng`
export function init(...args: any[]): any;
export function initNg(...args: any[]): any;
```
## Guarantees
**Permissive in, precise out.** Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. A value read from storage, a URL, a form or JSON goes straight in — no guard to call, no cast to write. No type guard is published, deliberately: publishing one would invite the cast the types exist to prevent.
**Every reference this surface returns is BARE.** It names a document and grants nothing. No call returns a key, ever — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject`/`.graph`. What an application circulates (a message, a QR code, another document) is that bare reference.
**Reading is possession of the key, and nothing else is consulted.** You read a document whose key you hold — because you created it, because someone gave it to you, or because the document sits in a **public store**, which serves its read key to whoever asks. There is no authorization list anywhere, and no call answers "may I read this?": you read, and you get what you get.
**A reference is not recursive.** A widely circulated document may point at a restricted one; following the reference yields a name, not a key. This is what lets confidentiality be composed inside a document you share, and it holds through every read path here.
**Writing is ownership.** Only a document's owner writes to it. Holding its read key — however it arrived — never grants a write.
**Giving to read is ONE act, and the recipient calls nothing.** `inbox.share(doc, toUser)` names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting. There is no "receive" operation, and an application never handles a key or an inbox address.
**A deposit is addressed to an inbox, never to a document.** `inbox.post` refuses a target that is not an inbox. To reach a document's owner, name the document: `inbox.postToDocument(doc, …)`.
**An inbox is read only by its owner.** Anyone may deposit; only the owner reads.
**`ensureIdentity()` is the whole of signing in.** It resolves who you are and waits for the connection work (restoring what others shared with you, draining your inboxes). It takes **no identifier**, deliberately — naming your own identity is the part that disappears, so it is not in the signature. After it resolves, what was shared with you is readable.
**One polyfill-era call.** `configure` is the only published symbol with no counterpart in the target, and therefore the whole of what an application deletes at migration. Everything else is replaced in place by the real SDK.
## Non-guarantees
**No confidentiality. None.** The stand-in key is a constant, nothing is encrypted, and several read paths still bypass the boundary — notably a `docs.sparqlQuery` issued **without an anchor**, which spans every document in the shared wallet. Nothing built on this may be described as private, anonymous or secure. The shape of the access model is real; the protection is not.
**Nothing per-reader on a document in a public store.** No grant, no revocation, no audience list — upstream has none, so a UI that enumerates or revokes "the readers of my public document" is built on nothing.
**No revocation of a shared key.** `inbox.share` is irreversible: nothing is checked later, and there is no taking back a key already handed out.
**No delegated writing.** Adding members or permissions is not emulated, so only the owner writes. An application must not build shared editing on a received key: it works here and cannot work upstream.
**References travel within one deployment only.** Nothing produced here carries a locator, so a reference is resolvable by users of the same broker and not beyond.
**Enumerating inbox deposits is emulation detail.** Rely on *"my inbox is processed when I connect, and what was shared with me becomes readable"*. A mailbox UI built on the raw deposit list should expect that surface to change shape entirely.
**Two shapes are known departures from the target**, small and deliberate: `subscribeDoc` returns its unsubscribe **synchronously** where the real call is async (an adapter is one line at migration), and `watchShape`'s load-state shape is this library's invention, not an announced API.
**The read-filtered view of `useShape` refuses what it cannot filter.** Members that yield items are filtered; mutations pass through; anything else throws rather than returning unfiltered items. A document in a public store reached through that view alone — read nowhere else first — does not appear, because the view is synchronous and cannot ask the network.
**`docs.*` are raw primitives, not an application's normal path.** They exist because the emulation needs them; prefer `storeRegistry`, `readUnion`, `inbox`.
## Change policy
**This surface changes when it gets CLOSER to the target — that is the point, not a cost.** The package is pre-1.0 and does not offer semver stability; a consuming application should expect the surface to shrink over time, and should re-pull this contract at every upgrade.
**A symbol is removed when an application coding against it would learn something to unlearn.** That test outranks convenience, and it has already removed: a call returning a document's key alongside its reference (it converted "reference AND key" into "reference alone" and collapsed composable confidentiality); a "do I hold this key?" predicate (it read like "may I read this?", and a readable public document answered `false`); a "set my identity" call (naming one's own identity is the gesture that inverts the model); an "await the connection" call (folded into `ensureIdentity`); and a second bootstrap call (folded into `configure`).
**A removal is a shrinking of what you delete later**, so it is never a regression of the engagement — but it is a breaking change to your code, and it is announced by this document changing.
**At migration**, the build alias resolving `@ng-eventually/sdk` is removed, `configure` and its config type are deleted with their call site, and every other import resolves to the real SDK unchanged. `ensureIdentity` keeps its call site — an application still awaits a session before it renders — while the barrier it shows today stops appearing.
@@ -0,0 +1,30 @@
---
type: knowledge
summary: The two fates a published symbol can have, and why the deletion list is now carried by a block and a test rather than by an import path
---
# What an application deletes at migration, and what it keeps
Every published symbol has exactly one of two fates, and knowing which is the whole point of this boundary.
**Replaced in place.** The build alias resolving `@ng-eventually/sdk` is removed, the import resolves to the real SDK, and the application's code is unchanged. This is almost everything: `ng`, `useShape`, `watchShape`, `init`, `initNg`, `readUnion`, `subscribeDoc(s)`, `docs.*`, `inbox.*`, `storeRegistry.*`, and the types.
**Deleted.** The symbol has no counterpart at any level of the target, exists only because one shared wallet hosts every user, and goes with its call site. Today that is `configure` and its config type.
`ensureIdentity` sits between the two and is worth stating precisely: its **substance** is scaffolding — a barrier that hands out a shared wallet file and takes an identifier, a step that exists only because users share a wallet — while its **call site survives**, because an application still awaits a session before it renders. Its signature was designed for that: it takes no identifier, so the line does not change the day the wallet supplies the identity and the barrier stops appearing.
## Why the deletion list is not an import path any more
There were two entry points until 2026-08-07, `.` and `./polyfill`, and the second one carried a signal worth naming: *what you import from that path is exactly what you will delete*. That was a mechanical guarantee — the compiler produced the list.
Merging them lost it. Nothing at an import line now distinguishes `configure`, which goes away, from `docs`, which is replaced. Three things carry it instead, and it is worth knowing that only the last two are enforced:
- the **`POLYFILL-ERA` block** in the package's entry module, which is the deletion list, kept short by construction;
- **`docs/api-contract.md`**, which rules on every symbol with an epistemic label (PASSTHROUGH / LEVEL-1 SHAPE / ASSUMPTION / NO COUNTERPART) and whose export inventory is pinned by a test — so it cannot go stale quietly, which a hand-kept list would;
- the **names themselves**, each built from the target's own vocabulary or carrying a marker saying why it exists only here, pinned by the same test.
The trade was deliberate and it is a real reduction in enforcement: a documentary signal where a mechanical one used to be. To verify the pinning is doing its job, look for the vocabulary test beside the package's other tests — it compares the contract's inventory against the entry's real exports, in both directions, and it caught five drifted sections the day it was extended to the rulings.
## The direction of travel
The polyfill-era list only shrinks. It has gone from four published calls to one, and each removal was a symbol an application should never have had: naming its own identity, awaiting the connection, wiring a second bootstrap, reaching a machinery accessor. A symbol added to that block is a promise to delete it later — so the question at every addition is whether the application genuinely needs it, or whether the library is passing on a problem of its own.
@@ -0,0 +1,21 @@
---
type: rule
summary: Before publishing anything, ask whether a caller would have to unlearn it — that outranks cost, latency and convenience
---
# Would the caller have to UNLEARN it?
**Rule:** Before adding, keeping or changing anything on the published surface, ask: *would an application coding against this learn something it has to **unlearn** at migration?* If yes, it is a deviation — whatever it buys in cost, latency or ergonomics. Both halves are binding: the **surface** must be as close as possible to the future SDK, and the **implementation** as close as possible to what NextGraph actually plans. Where upstream's behaviour is known, it is a specification, not a reference.
"Known" is narrow: read in `nextgraph-rs`, or stated by its author. Never inferred from what an npm package happens to expose, and **never inferred from an absent implementation***"the engine does not do X"* says nothing about whether the target will.
**Why:** this library's entire value is that an application keeps its code. A layer that teaches a false model destroys exactly what it was built to produce, and it does so silently: nothing fails, the application simply learns a habit that has no successor. Two instances, both caught only by asking the question — *"every document has a native inbox"*, written from general reasoning, false, and already an implementation; and a per-document inbox pointed at its owner's inbox to absorb a measured cost, emulating a many-to-one relation the target cannot express and which would have made applications tag their deposits, for nothing.
**The pressure to deviate never announces itself as one.** It arrives as a cost, a latency, an ergonomic wrinkle — all real, all legitimate. That disguise is what makes it dangerous. When shape and cost conflict: keep the shape and attack the cost elsewhere (usually the lever is *who* pays and *when*). If the cost is genuinely unsolvable, say so rather than bending the model quietly.
**How to apply:** the tell is a symbol that makes the caller handle something the target will never hand it — a key, an inbox address, a store id, its own identity. Two secondary tells, both of which have produced real holes here:
- **A named exception stops being one the moment it is published.** A door documented as *"only this internal caller uses it"* is a door any application can open; the note is not a mechanism. Move it out of the published surface instead.
- **A symbol kept because it was already there is not a decision.** At every surface change, re-ask whether an application still needs each neighbouring symbol; inertia has repeatedly left published what nothing calls.
Applies to the published surface first, but also to internal choices that shape it: an emulation whose relation the target cannot express will surface as a habit sooner or later. When a deviation is genuinely necessary, it must be **deliberate, documented and invisible to the caller** — what is forbidden is the silent one, adopted because it was convenient.