Files
ng-eventually/.project/concepts/app-contract/contract_sdk-surface.md
T
Sylvain Duchesne 7076c0cca8 fix: readUnion regroupe par sujet réel — la fusion était une divergence
`readUnion` indexait par DOCUMENT une table nommée `bySubject`, créait ses entrées avec
`subject: doc`, et jetait le sujet réellement lu après s'en être servi pour écarter la
machinerie. Tout triplet non-machinerie d'un document tombait donc dans un sac unique
étiqueté par la référence du document : deux entités écrites sous deux sujets revenaient
**conflées**, une entité écrite sous un autre sujet revenait **ré-étiquetée**. Sans erreur,
sans trace.

**C'était une divergence, et c'est à ce titre qu'elle tombe.** NextGraph dit l'inverse aux
deux niveaux : une requête ancrée résout le graphe du repo comme graphe par défaut et rend
les sujets tels qu'ils sont ; et l'ORM porte sur chaque objet **deux** propriétés
distinctes, `@id` et `@graph`, dont il FABRIQUE la première quand on la laisse vide
(`graphIri + ":q:" + aléa`). Plusieurs objets par graphe est le cas prévu, et `@id` existe
pour les distinguer à l'intérieur d'un `@graph`.

La règle du projet reste **« un document séparé par entité métier »**, mais c'est une
recommandation de placement dictée par le modèle de sécurité — une clé est par repo, donc
l'isolation par entité exige un repo par entité. Ce n'est pas une contrainte que la lecture
a le droit d'imposer en rendant l'autre disposition invisible. Le contrat porte désormais la
recommandation, le code porte la capacité ; il faisait exactement l'inverse.

**La justification de l'épinglage était une erreur de catégorie**, et elle a été retirée
plutôt que contournée : `repo_graph_name` formate un nom de GRAPHE, il est estampillé sur
les quads et aucun sujet n'est réécrit. Deux confirmations indépendantes, dont la suite e2e
qui écrit un sujet puis le relit par correspondance exacte contre le vrai broker.

`UnionSubject.subject` passe de `Nuri` à `string` — un sujet RDF réel est un IRI
quelconque. `graph` reste `Nuri` et devient le champ à repasser au SDK ; l'app d'exemple
l'utilise à ses deux sites, où le sens était « le document ».

**Et la suite e2e ne comptait que les entrées.** C'est pour cela qu'elle est restée verte
pendant tout le défaut : compter ne distingue pas un regroupement par document d'un
regroupement par sujet. Elle écrit maintenant deux sujets dans le dernier document et
vérifie les trois choses qui comptent — quatre entrées pour trois documents, chaque entrée
portant le sujet sous lequel elle a été écrite, et son `graph` étant la référence du
document.

202 tests unitaires (5 ajoutés, dont 3 échouent si l'on restaure l'ancien repliage),
e2e 42/42 et applicatif 12/12.
2026-08-10 14:50:07 +02:00

149 lines
13 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 ───────────────────────────────────────────────────────────
// A type is published only when a published signature uses it. `ReadCap` and
// `InboxScope` were withdrawn on 2026-08-10: no published call takes or returns
// either. They still exist inside the library — they are simply not yours to hold.
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
// ── 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: string; 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.
**A document may hold several objects, and reading returns them separately.** `readUnion` yields one entry per distinct subject present in a document: `subject` is that subject's IRI exactly as it was written, `graph` is the document reference you passed in. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Only `graph` is a `Nuri`, and it is the field to hand back to this surface; `subject` is a `string`, because a subject may be any IRI. **One document per business entity stays the recommended placement** — a key is per document, so isolating an entity requires a document of its own — but it is a recommendation, and reading reports the objects a document actually holds.
**`urn:ng-eventually:` is a reserved name space.** Subjects under that prefix belong to the library and are not returned by `readUnion`. An application that writes its own data under it will not read it back; every other IRI is yours.
**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.