Files
ng-eventually/.project/concepts/app-contract/contract_sdk-surface.md
T
Sylvain Duchesne 30f6263db5 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.
2026-08-10 09:25:01 +02:00

140 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<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.