Files
ng-eventually/docs/api-contract.md
T
Sylvain Duchesne 54f8389e9e refactor(api): l'app nomme une personne ou un document, jamais une adresse d'inbox
L'app d'exemple a servi de juge, et elle a immédiatement montré ce que
l'inventaire ne montrait pas : pour partager une note elle résolvait l'inbox du
destinataire, pour lire ses messages elle résolvait l'adresse de la sienne. Deux
gestes qu'aucune application n'aura à faire une fois la chose native — donc deux
gestes qu'elle ne doit pas apprendre.

- `shareCap(cap, toUser)` remplace `shareCap(cap, toInbox)`. Partager est un acte
  envers quelqu'un ; où est son inbox regarde la bibliothèque.
- `inbox.readForDocument(doc)` : le propriétaire lit ses messages en nommant la
  note, comme le déposant la nomme pour en laisser un.
- `storeRegistry.userInbox` et `documentInboxAddress` sortent de la surface
  publiée. Ils restent joignables en interne, où le shim en a besoin.

Sortent aussi de `/polyfill`, chacun parce qu'une app qui code contre apprend ce
qu'il faudra désapprendre :

- `getCaps` / `CapRegistry` — la salle des machines. La question du consommateur
  est `capFor(doc)` : est-ce que je le détiens ? Le registre n'a ni successeur ni
  forme inerte ; ce qui s'appuie dessus sera à réécrire, pas à laisser en place.
- `getCurrentUser` — une app sait qui elle a connecté ; le redemander à la
  bibliothèque est une commodité du wallet partagé.
- `virtualUsers` / `IdentityStore` — se souvenir d'une identité entre deux
  sessions est aussi le travail de l'app en amont. L'écran d'accès persiste ce
  dont IL a besoin ; rien d'autre n'a à être exposé.

Reste sur `/polyfill` ce qu'une app appelle vraiment : `configure` et
`setCurrentUser`. Le reste y est du test ou de l'injection interne.

170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la
bibliothèque, l'exemple et le harnais.
2026-08-05 18:55:30 +02:00

626 lines
42 KiB
Markdown

# API contract — what `@ng-eventually/client` exposes today, and what the future SDK should expose per subject
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
**Scope: the APP-FACING contract only.** Everything reachable from the two published entry points, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
**Scope.** The real exported surface of `@ng-eventually/client` (verified against the `export` statements in `packages/client/src/index.ts` and `packages/client/src/polyfill.ts``package.json` maps exactly two entry points, `.` and `./polyfill`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
**How to read the epistemic labels.** Every target-side claim carries one of:
- **PASSTHROUGH (level 3 / level 2, VERIFIED)** — the target function exists today; the lib forwards to it. Citation into `nextgraph-rs` or the installed `.d.ts`. Level numbers per `README.md` § *The three references*: 3 = JS ORM (`sdk/js/orm`), 2 = wasm binding / `@ng-org/web` (`sdk/js/lib-wasm`, `sdk/js/web`), 1 = Rust engine (`engine/`).
- **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)** — the engine's model constrains the shape and is cited, but **no JS surface exists at any level**, so the signature offered here is this library's invention. The future SDK's name and parameter order for it are unknown.
- **ASSUMPTION** — nothing at any layer constrains this; the bet and what bounds it are stated.
- **NO COUNTERPART** — the subject has no image in the target at any layer, usually because it is shared-wallet machinery that disappears at migration. That is a finding about the emulation, not a gap in the target.
Per the design principle (`README.md` § *Design principle*): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
---
## 1. Bootstrap and configuration
### Today — `@ng-eventually/client/polyfill` (everything here is removed at migration)
```ts
// polyfill.ts:44
export interface EventuallyConfig {
ng: NgLike;
useShape: UseShapeLike;
sharedWallet?: { name: string; secret: string };
currentUser?: PrincipalId;
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// polyfill.ts:99
export function configure(c: EventuallyConfig): void;
// polyfill.ts:113 — tests only
export function resetConfig(): void;
// polyfill.ts:24
export interface StoreRegistryDeps {
getSession: () => Promise<RegistrySession>;
normalizeId?: (id: string) => string;
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
}
// polyfill.ts:124
export function configureStoreRegistry(deps: StoreRegistryDeps): void;
// polyfill.ts:161 — tests only
export function resetStoreRegistry(): void;
// polyfill.ts:106 / :153 — both tagged @internal, exported so the SDK-shaped wrappers can reach the injected SDK
export function getConfig(): EventuallyConfig;
export function getStoreRegistryDeps(): ResolvedRegistryDeps;
```
### Target
**NO COUNTERPART, by design.** The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and `configure` / `configureStoreRegistry` are deleted (`docs/migration-guide.md` § 7). Nothing in the target takes an "injected `ng`".
---
## 2. Lifecycle
### Today — `@ng-eventually/client`
```ts
// lifecycle.ts:11 — forwards to the real @ng-org/web init injected at configure()
export function init(...args: any[]): any;
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
export function initNg(...args: any[]): any;
```
### Target
**PASSTHROUGH, VERIFIED at both levels.** The wrapper's `...args: any[]` is deliberately shapeless; the real signatures it forwards to are:
```ts
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
export declare const init: (callback: Function | null, singleton: boolean, access_requests: any) => Promise<void>;
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
export function initNgSignals(ngImpl: NG, session: Session): void;
// level 2 — the Session initNg consumes: index.d.ts:264-272
export declare type Session = {
session_id: string | number;
protected_store_id: string;
private_store_id: string;
public_store_id: string;
ng: typeof NGModule;
[key: string]: unknown;
};
```
Divergence: none in behaviour (pure forwarding), but the wrapper erases the parameter types. A consumer typing calls against the wrapper learns nothing it must unlearn — it just gets no compile-time help the real SDK would give.
---
## 3. The `ng` object
### Today — `@ng-eventually/client`
```ts
// index.ts:55
export const ng: Record<string, any>;
// type re-export, index.ts:50
export type { NG } from "@ng-org/web";
```
`ng` is a `Proxy` (`ng-proxy.ts:10`) forwarding every property to the injected real `ng`, overriding exactly two things: `login` / `session_start` (currently a passthrough with a TODO for shared-wallet credentials) and `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap).
### Target
**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, 77 exported members (`index.d.ts:136-231`). The surface is identical by construction — the proxy adds no member and removes none.
The two overrides:
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until P1b.
---
## 4. Reactive typed reads — `useShape`
### Today — `@ng-eventually/client`
```ts
// use-shape.ts:12
export function useShape(shapeType: unknown, scope: unknown): unknown;
// type re-exports, index.ts:48-49
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
```
Behaviour: forwards to the injected real `useShape`; once any emulated cap exists (`caps.isEnforcing()`), the returned set is wrapped in a read-filtered view keeping only items whose document cap the current holder has.
### Target
**PASSTHROUGH (level 3, VERIFIED), with a signature the wrapper widens.** The real hook:
```ts
// level 3 — @ng-org/orm/react: sdk/js/orm/src/frontendAdapters/react/useShape.ts:86-124
const useShape = <T extends BaseType>(
shape: ShapeType<T>,
scope: Scope | string | undefined
) => DeepSignalSet<T>;
// its Scope — sdk/js/orm/src/model/types.ts:25-38 (NOT this lib's Scope, see § 12)
export type Scope = {
graphs?: string[] | string;
subjects?: string[];
};
```
The read filter disappears at migration: in the target, isolation is cryptographic — a repo whose cap the wallet does not hold is never decrypted, a union read over it yields nothing, and a targeted read errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163` via `resolve_target`). VERIFIED at level 1; the *consumer-visible* result (you only see what you hold) is the same, which is the point of the emulation.
Divergence to note: the wrapper types everything `unknown`, losing the generic `T`. A consumer wanting typed sets today must cast; at migration the real generic signature gives it back. Nothing to unlearn, only ergonomics deferred.
---
## 5. Reactive typed reads with load state — `watchShape`
### Today — `@ng-eventually/client`
```ts
// watch-shape.ts:73
export interface ShapeQuery<T = UnionSubject> {
data: T[];
isPending: boolean;
isSuccess: boolean;
isError: boolean;
error: unknown;
}
// watch-shape.ts:90
export interface ShapeObservable<T = UnionSubject> {
getSnapshot(): ShapeQuery<T>;
subscribe(onChange: () => void): () => void;
refetch(): void;
}
// watch-shape.ts:166
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T>;
```
### Target
**Partly ASSUMPTION — flagged deliberately.** `surface/watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
```ts
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
OrmSubscription.getOrCreate<T extends BaseType>(shape: ShapeType<T>, scope: NormalizedScope): OrmSubscription<T>;
get readyPromise(): Promise<void>; // resolves when the subscription is synced — the native "no longer pending" signal
public close(): void;
```
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
---
## 6. One-shot listing — the read-model
### Today — `@ng-eventually/client`
```ts
// read-model.ts:59
export interface UnionSubject {
subject: string;
graph: string;
props: Record<string, string[]>;
}
// read-model.ts:140
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
```
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
### Target
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
```ts
// level 2, VERIFIED — the primitive readUnion composes: index.d.ts:295, source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope | string
); // returns a deep-cloned Set of matching objects
```
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None``set_default_graph_as_union`). At migration `readUnion` survives as composition (the anchored per-doc read is native); a consumer that wants typed results should be on `useShape`/`getObjects`, not on `UnionSubject` — the property-bag shape is a polyfill artifact, kept generic precisely so the consumer maps it into its own types and can drop it later.
---
## 7. Raw document / SPARQL primitives — `docs.*`
### Today — `@ng-eventually/client` (namespace `docs`)
```ts
// docs.ts:46
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri>;
// docs.ts:85
export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<void>;
// docs.ts:130
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown>;
// docs.ts:113 — machinery, see § 15
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void>;
```
### Target
**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded):
```ts
// index.d.ts:60 — the installed web SDK's doc_create
declare function doc_create(session_id: any, crdt: string, class_name: string, destination: string, store_repo: any): Promise<any>;
// index.d.ts:297
declare function sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>;
// index.d.ts:295
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
```
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document.
**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone:
- The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option<StoreRepo>` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form.
- The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option<String>` + `store_repo: Option<String>` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`).
So the target's direction for scope placement is **already visible in the source** (level 2, VERIFIED, nodejs SDK): name the store by type + repo id strings. The migration-guide's anticipated `getNativeStore(scope)`-style resolver should expect to produce exactly that pair (or the serde `StoreRepo` once a web helper lands) — not a new concept.
---
## 8. Per-document subscription — `subscribeDoc`
### Today — `@ng-eventually/client`
```ts
// subscribe.ts:47,60,79
export type DocChange = unknown;
export type DocChangeType = string | undefined;
export type Unsubscribe = () => void;
// subscribe.ts:69
export function docChangeType(resp: DocChange): DocChangeType;
// subscribe.ts:104
export function subscribeDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
// subscribe.ts:184
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
```
### Target
**PASSTHROUGH (level 2, VERIFIED) with two deliberate ergonomic deltas:**
```ts
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
declare function doc_subscribe(repo_o: string, session_id: any, callback: Function): Promise<any>;
```
- The real call is `async` and resolves to an unsubscribe function; the wrapper returns the unsubscribe **synchronously** and honours an early cancel when the promise settles. A consumer coding against the sync return will keep working against the real SDK only through an adapter — a small, known unlearn, traded for not forcing `await` on every subscription site.
- The real callback receives one argument, the serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | … } }`); the wrapper adds a second, pre-extracted `type`. `docChangeType` is a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API.
- `subscribeDocs` has **NO COUNTERPART and needs none**: it is client-side composition (a set of `doc_subscribe` with per-doc error isolation). The upstream fan-out primitive that looks like it (`orm_start_graph(graph_scope, …)`, `index.d.ts:243`) aborts wholesale on one `RepoNotFound` (`sdk/js/orm``engine/verifier/src/request_processor.rs:53-66`) — the reason this composition exists.
---
## 9. Inbox — deposits, and cap delivery
### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`)
```ts
// inbox.ts:48,58
export interface Deposit {
from: PrincipalId | null;
payload: unknown;
ts: number;
}
export interface PostOptions {
from?: PrincipalId | null;
payload: unknown;
ts?: number;
}
// inbox.ts:140
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:215
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:282
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>;
// inbox.ts:339
export async function read(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:419
export const materialize = read;
// inbox.ts:441
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:466
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:492
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void;
```
### Target
**LEVEL-1 SHAPE throughout — the model is VERIFIED, every JS signature here is this library's invention.** There is no inbox method in `@ng-org/web` (none in the 77 `index.d.ts` exports, re-verified), and the verifier's dispatch has no `InboxPost` arm (arms actually handled listed at `engine/verifier/src/request_processor.rs:53-1444`, re-verified). The engine model that constrains the shape:
- An inbox is a keypair on **exactly one repo**: `pub inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); routing is `inboxes: HashMap<PubKey, RepoId>` on the verifier (`engine/verifier/src/verifier.rs:105`, looked up at `:1677`, inserted at `:1928`).
- A message is sealed to the inbox pubkey and carries **no target document**`InboxMsgBody { to_overlay, to_inbox: PubKey, from_overlay: Option<OverlayId>, from_inbox: Option<PubKey>, … }` (`engine/net/src/types.rs:4265`). The address identifies the recipient repo; nothing else is needed. This is why `Deposit` has no document field and why `post` takes only the inbox NURI.
- `from` optional upstream (`from_inbox: Option<PubKey>`) — the "identified if known, anonymous otherwise" behaviour `PostOptions.from` mirrors, including the `null`-means-anonymous case.
- The recipient's own verifier unseals and **applies** queued messages when it processes its inbox (`engine/verifier/src/verifier.rs:1674-1690``process_inbox`); an inbox is a consumed queue, not a store you re-read.
Consequences per function:
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
- `read` / `materialize` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
---
## 10. Capabilities — possession, not ACL
### Today
```ts
// @ng-eventually/client — nuri.ts:50,60 (type guards; the only doors from string to typed)
export function isNuri(s: string): s is Nuri;
export function hasReadCap(s: string): s is ReadCap;
// @ng-eventually/client — types.ts:11,34
export type Nuri = `did:ng:${string}`;
export type ReadCap = `did:ng:${string}:r:${string}`;
// @ng-eventually/client/polyfill — polyfill.ts:205
export function capFor(nuri: Nuri): ReadCap | undefined;
// polyfill.ts:193 — hands out the registry itself
export function getCaps(): CapRegistry;
// polyfill.ts:215 — tests / fresh wallet only
export function resetCaps(): void;
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
constructor(holder?: () => PrincipalId | null);
mint(nuri: Nuri): ReadCap;
learn(cap: ReadCap): void;
capFor(nuri: Nuri): ReadCap | undefined;
publishRepoLink(nuri: Nuri): ReadCap;
isPublished(nuri: Nuri): boolean;
open(nuri: Nuri, scope: Scope): ReadCap;
isEnforcing(): boolean;
onChange(listener: () => void): () => void;
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until P1b
governsWrite(doc: Nuri): boolean; // decorative until P1b
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until P1b
hasWritePolicy(): boolean; // decorative until P1b
clear(): void;
```
### Target
**LEVEL-1 SHAPE.** There is no capability API at level 2 or 3 (no cap method in `index.d.ts`, none in the ORM), and there is **nothing to introspect upstream**: reading is key possession. The model, VERIFIED:
- A ReadCap is the serialized `ObjectRef``format!("r:{}", base64_url::encode(&ser))` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`). The lib's `ReadCap` template-literal grammar (`…:r:{cap}`) is upstream's, with the stand-in constant `OK` in place of the key material (P1b swaps the value, not the shape).
- Caps live in two durable registers by origin: created documents → `AddRepo { read_cap }` on the store's Store branch (`engine/repo/src/types.rs:1890-1899`, committed by `doc_create` via `send_add_repo_to_store`, `engine/verifier/src/request_processor.rs:698`); received caps → `AddLink { read_cap }` on the private store's User branch (`types.rs:1939-1948`).
- The one path that loads a repo from a cap is `pub(crate)``Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`) — unexposed to JS.
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15.
---
## 11. NURI and SPARQL string utilities
### Today — `@ng-eventually/client`
```ts
// sparql.ts:32,66,95
export function escapeLiteral(value: string): string;
export function escapeIri(value: string): string;
export function assertNuri<T extends string>(nuri: T): T;
```
(`isNuri` / `hasReadCap` are in § 10; `targetOf`, `parseNuri`, `mintCap` exist in `nuri.ts` but are **not** exported from either entry point — deliberately: nothing on the surface turns a bare reference into a cap.)
### Target
**NO COUNTERPART at any level, and none expected.** Neither `@ng-org/web` nor the ORM exposes SPARQL escaping helpers (re-verified against `index.d.ts` and `sdk/js/orm/src`); the engine does its own ad-hoc literal escaping internally where it builds SPARQL (e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). These are generic injection-safety utilities, not SDK anticipation: they stay useful to any app that builds SPARQL by interpolation, against this lib or the real SDK. Nothing to unlearn; also nothing that migration replaces.
---
## 12. Scope resolution, per-entity documents, and the store registry
### Today — `@ng-eventually/client` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/surface/placement.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `VirtualUserRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/client`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly.
```ts
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
export type Scope = "public" | "protected" | "private";
// store-registry.ts:90,234
export interface VirtualUserRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
export interface RegistrySession {
sessionId: string;
privateStoreId: string;
protectedStoreId?: string;
publicStoreId?: string;
}
// consumer-facing, designed to survive migration (store-registry.ts:917, 1358, 1079, 735, 698)
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
// inbox-side (store-registry.ts:772, 837, 1133, 1203, 1286)
export async function userInbox(id: string): Promise<Nuri>;
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
export async function myInboxes(): Promise<Nuri[]>;
// User-branch registers (store-registry.ts:1307, 1331)
export async function addLink(cap: ReadCap): Promise<void>;
export async function readLinks(): Promise<ReadCap[]>;
// shim machinery (store-registry.ts:542, 631, 213, 278)
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
export function reservedAccount(name: string): string;
export function resetRegistryCache(): void;
```
### Target — split by what each piece maps to
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter disappears (the session IS the identity); expect `createEntityDoc(id, scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify``load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears.
- **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` leaves `inbox: None`, `repo.rs:574`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
- **`addLink(cap)` / `readLinks()` → level 1, VERIFIED model, no JS surface.** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:1939-1948`*"so that a user can share with all its device a new Link they received"*, external repos only). Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
- **`resolveAccount` / `ensureAccount` / `VirtualUserRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
- **`isOwnInbox` / `myInboxes` → NO COUNTERPART as API.** Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
---
## 13. Identity and connection
### Today
```ts
// @ng-eventually/client — virtualUsers.ts (namespace accounts)
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
export interface VirtualUserStorage { // :26
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export class IdentityStore { // :37
constructor(storage: VirtualUserStorage | null, key?: string);
get(): string | null;
set(id: string): string | null;
clear(): void;
}
export function browserIdentityStore(key?: string): IdentityStore; // :89
// @ng-eventually/client/polyfill
export function setCurrentUser(id: PrincipalId | null): void; // polyfill.ts:171
export function getCurrentUser(): PrincipalId | null; // polyfill.ts:187
export async function connectedUser(): Promise<void>; // connect.ts:52
```
### Target
**PASSTHROUGH-to-be at level 2, VERIFIED signatures.** In the target the identity is established by opening one's own wallet and starting a per-user session — there is no "set the current user" call because the session IS the user:
```ts
// index.d.ts:276, 280, 313, 315
declare function session_start(wallet_name: string, user_id: any): Promise<any>;
declare function session_stop(user_id: string): Promise<void>;
declare function user_connect(client_info: any, user_id: string, location?: string | null): Promise<any>;
declare function user_disconnect(user_id: string): Promise<void>;
```
- `virtualUsers.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15).
- `setCurrentUser` / `getCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet.
- `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
---
## 14. Type re-exports
`@ng-eventually/client` re-exports, type-only (erased at build, `index.ts:48-50`):
```ts
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
export type { NG } from "@ng-org/web";
```
**PASSTHROUGH (levels 2/3, VERIFIED)**`ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
---
## 15. Machinery on the surface — what a consumer should NOT code against
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
- ~~**`virtualUsers.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
### Places the current surface teaches something to unlearn
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
---
## Appendix — full export inventory (for diffing)
*Generated from the `export` statements, and pinned by `packages/client/test/vocabulary.test.ts` — if this list and the code disagree, that test fails. It went stale once, still listing `storeRegistry`'s shim internals after the entry had been narrowed, which is what a hand-maintained inventory does.*
### `@ng-eventually/client` — `src/index.ts`
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, assertNuri, docChangeType, ensureIdentity, escapeIri, escapeLiteral, hasReadCap, init, initNg, isNuri, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: depositInto, docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, shareCap, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
```
### `@ng-eventually/client/polyfill` — `src/polyfill.ts`
```text
direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, capFor, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, shareCap
```