Files
ng-eventually/.project/concepts/app-contract/contract_sdk-surface.md
T
Sylvain Duchesne cdc09a1a1d refactor(api): l'application ne nomme plus son identité — elle l'apprend
Lot C de la revue adverse. Cinq corrections, dont une qui change la forme de la surface.

**L'application ne pouvait pas obtenir son identité par l'API.** `ensureIdentity()`
rendait `void`, `getCurrentUser` n'est plus publié — et pourtant `createEntityDoc(id, …)`
et `listMyEntityDocs(id, …)` l'exigeaient. L'app d'exemple s'en sortait en lisant
`localStorage["ng-eventually:identity"]` et le paramètre `?ng-id`, deux constantes
PRIVÉES du portail d'accès. Une frontière qu'aucun consommateur ne devrait voir, et
encore moins dont il devrait dépendre.

Vérifié au niveau 2 avant de trancher : `session_start(wallet_name, user_id)` prend
l'identité — donc en amont l'application la DÉTIENT, elle la tient du portefeuille
qu'elle a ouvert. Ici c'est le portail qui la choisit, donc c'est au portail de la
rendre. Deux changements, tous deux vers la cible :

- `ensureIdentity()` rend l'identité qu'il a établie ;
- `createEntityDoc(scope)`, `listMyEntityDocs(scope)`, `resolveWriteGraph(scope)`
  perdent leur paramètre d'identité. En amont `doc_create(session_id, …)` ne porte
  aucun utilisateur : une session EST celle d'un utilisateur. Passer la sienne à chaque
  appel de placement était un geste sans successeur.

L'application garde l'identité pour l'afficher, et ne la passe plus à rien.

**`inbox.share` provisionnait un destinataire inexistant.** Une faute de frappe créait
les trois stores et l'inbox de ce nom, et la clé atterrissait où personne ne regarde —
sans la moindre erreur. En amont on ne peut pas viser un nom qu'on invente : un dépôt est
scellé vers une clé d'inbox qui vous est parvenue par un contact entrant. Refuser est
fidèle ; provisionner était l'invention.

**`createEntityDoc` avalait l'échec de ses deux écritures** et rendait quand même une
référence — le document n'était dans aucun store, donc la session suivante ne le listait
pas et sa lecture rendait vide, en silence. Il lève maintenant, comme `doc_create` en
amont propage les siennes.

**Deux entrées prenaient `Nuri` au lieu de `NuriLike`** (`inbox.watch`,
`openDocumentInbox`), ce qui contredisait la raison même pour laquelle aucune garde de
type n'est publiée. Et **deux messages d'erreur nommaient des symboles retirés**
(`storeRegistry.documentInboxAddress`, `setCurrentUser`) : une erreur qui envoie vers une
fonction inexistante est pire qu'une erreur muette.

Contrat d'API et feuille `contract_sdk-surface` mis à jour ; `readForDocument` et le refus
de `share` obtiennent enfin leur règle en §9.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:31:15 +02:00

144 lines
12 KiB
Markdown

---
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<PrincipalId>; // returns who you are
// ── 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: { // no identity parameter — the session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): 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: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, 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, and it tells you who you are.** It settles the identity, waits for the connection work (restoring what others shared with you, draining your inboxes), and **returns the identity**. It takes no identifier — naming your own identity is the part that disappears — but it hands one back, because knowing which user you are is something an application legitimately has upstream too. Keep it for display; **no call takes it**: a session belongs to one user, so placement is named by scope alone.
**Sharing names a person who exists.** `inbox.share(doc, toUser)` refuses a recipient nobody has signed in as, rather than creating them — you cannot address a name you invented.
**A failed creation fails.** `createEntityDoc` throws if the document cannot be recorded in its store, instead of returning a reference that would read empty forever.
**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.