La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en a **deux** en amont (repos de store public et protected, les deux seuls `AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un test non. D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`, `polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer pour faire passer le test », c'est une question : la cible a-t-elle un mot pour ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ? Ce que le test a trouvé, et les réponses : - `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc plutôt que caché par le nom. - `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` → `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour l'utilisateur virtuel, et le marqueur le dit désormais. - `readModel` → la fonction `readUnion`, exposée directement. « model » n'était ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction. - Le reste était du vocabulaire légitime à déclarer (`subject`, `base`, `schema`, `connected`, le modèle réactif de l'ORM). Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo` justifiait son correctif par un mécanisme que le source contredit. Un repo absent de `self.repos` lève bien `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc` attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à côté ne l'était pas. 159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
42 KiB
API contract — what @ng-eventually/client exposes today, and what the future SDK should expose per subject
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-rsor the installed.d.ts. Level numbers perREADME.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)
// 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
// 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:
// 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
// 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 enclosingCommit::verifyhas no runtime caller — seedocs/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 inREADME.md: the guard fires only on this proxy, and the lib's own writers call the injectedngdirectly, so it is best-effort until P1b.
4. Reactive typed reads — useShape
Today — @ng-eventually/client
// 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:
// 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
// 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:
// 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
// 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:
// 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)
// 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):
// 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 — a nuance this repo's docs understate. docs/nextgraph-current-state.md and docs/migration-guide.md say a public/arbitrary StoreRepo "is not JS-constructible". Verified in the clone, the picture is finer:
- The web wasm variant (
sdk/js/lib-wasm/src/lib.rs:1575,#[cfg(not(wasmpack_target = "nodejs"))]) deserializes its 5th argument asOption<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.tsdocuments this 5-arg form. - The nodejs variant (
lib.rs:1618, 6 args) takesstore_type: Option<String>+store_repo: Option<String>and builds the store viaStoreRepo::from_type_and_repo(store_type, repo_id_str)withstore_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
// 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:
// 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
asyncand 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 forcingawaiton every subscription site. - The real callback receives one argument, the serialized
AppResponse({ V0: { State | Patch | TabInfo | … } }); the wrapper adds a second, pre-extractedtype.docChangeTypeis a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API. subscribeDocshas NO COUNTERPART and needs none: it is client-side composition (a set ofdoc_subscribewith 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 oneRepoNotFound(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)
// 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 isinboxes: 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 whyDeposithas no document field and whyposttakes only the inbox NURI. fromoptional upstream (from_inbox: Option<PubKey>) — the "identified if known, anonymous otherwise" behaviourPostOptions.frommirrors, including thenull-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 routesInboxPostnatively,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:187records 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 (ContactDetailscarriesng:site_inbox/ng:protected_inbox,engine/verifier/src/inbox_processor.rs:778-830; the verifier'sinboxestable is session-local, rebuilt empty —verifier.rs:520,2820). Documented indocs/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 isunimplemented!()(types.rs:3786), its only caller passeswith_readcap: false, and the receiving arm never reads the field (inbox_processor.rs:778-830).InboxMsgContent::Linkis 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 whatconnectedUserautomates, § 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.payloadas app data) maps toInboxMsgContentvariants upstream (types.rs:4249-4260), of which onlyContactDetailsandSocialQueryare 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
// @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'sReadCaptemplate-literal grammar (…:r:{cap}) is upstream's, with the stand-in constantOKin 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 bydoc_createviasend_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
// 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-registrymodule. 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 theVirtualUserRecord/RegistrySessiontypes — is no longer importable from@ng-eventually/clientand is covered bydocs/internal-contract.md. The signatures below are kept for the record, marked accordingly.
// 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 takesstore_type/store_repostrings). The two writes the lib performs by hand are native side effects ofdoc_createupstream: theldp:containslisting on the store's Main branch and theAddRepo { read_cap }on its Store branch (engine/verifier/src/request_processor.rs:697-710). Theidparameter disappears (the session IS the identity); expectcreateEntityDoc(id, scope)to becomedoc_create(sid, …, storeOf(scope))with no listing/cap bookkeeping.listMyEntityDocs(id, scope)→ level 1/2, VERIFIED mechanism. Upstream the listing is the store'sldp:containsgraph (written atrequest_processor.rs:706-708), readable with an anchoredsparql_queryon 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 twoAddInboxCapcommits 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. EveryRepocarriesinbox: Option<PrivKey>(engine/repo/src/repo.rs:126);AddInboxCapV0is keyed byrepo_idwith no is-store restriction (engine/repo/src/types.rs:1973; applied atengine/verifier/src/verifier.rs:1920-1928); but no code path creates one for a plain document (doc_createleavesinbox: 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 emulatedAddLink { 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'sAddInboxCaprecords; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
13. Identity and connection
Today
// @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:
// 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 fromsetCurrentUserfor 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):
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 writeinbox.postuses. It is exported only becauseinbox.tslives in another module; a consumer must always go throughinbox.post/inbox.shareCap. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.getConfig/getStoreRegistryDeps— tagged@internalin source, exported for the lib's own wrappers.resetConfig/resetStoreRegistry/resetCaps/storeRegistry.resetRegistryCache— test/reset machinery. In particularresetCapswipes EVERY holder's caps, which no product flow should ever do.getCaps()and theCapRegistryclass — the registry is the emulation's engine room. The consumer surface iscapFor(possession lookup),inbox.shareCap(grant), and the acts that file caps implicitly (creating a document, processing one's inbox).CapRegistry.grantWrite/governsWrite/canWrite/hasWritePolicyare explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.— RESOLVED 2026-08-03: no longer exported. Shim internals, now instoreRegistry.reservedAccount,resolveAccount,ensureAccount,VirtualUserRecord,RegistrySessiondocs/internal-contract.md. The consumer's legitimate touchpoint isconfigureStoreRegistry(bootstrap) plus the scope/entity resolvers.— 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.storeRegistry.addLink/readLinks— RESOLVED 2026-08-03: moved tovirtualUsers.*on the SDK entry/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
labelparameters ondocs.sparqlUpdate/docs.sparqlQuery/docs.depositInto— lib-internal access-log tags, never forwarded tong. 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/ormexpose" while also shippingaccountsand the wholestore-registrymodule. 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 exportsdocs,readModel,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".shareCapis importable from both entries (inbox.shareCapon the SDK entry viaexport * 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/materializeas 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 "planneduseShapeupgrade" — 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.UnionSubjectproperty bags — polyfill read-model shape, not a target type; map them into app types at the boundary (whichwatchShape's design already assumes).- The sync-returning
subscribeDocunsubscribe 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)
@ng-eventually/client (from index.ts): types Nuri, ReadCap, Scope, PrincipalId, NgLike, UseShapeLike, ShapeQuery, ShapeObservable, DocChange, DocChangeType, Unsubscribe, UnionSubject, VirtualUserRecord, RegistrySession, VirtualUserStorage, Deposit, PostOptions (via namespaces), re-exported ShapeType, BaseType, Schema, DeepSignalSet, NG; values ng, useShape, watchShape, init, initNg, subscribeDoc, subscribeDocs, docChangeType, escapeLiteral, escapeIri, assertNuri, isNuri, hasReadCap; namespaces inbox (post, postToDocument, shareCap, read, materialize, readSynced, processInbox, watch), docs (docCreate, sparqlUpdate, sparqlQuery, depositInto), readModel (readUnion), storeRegistry (reservedAccount, resetRegistryCache, resolveAccount, ensureAccount, resolveWriteGraph, resolveScopeGraph, userInbox, isOwnInbox, createEntityDoc, userStoreDoc, openDocumentInbox, documentInboxAddress, myInboxes, addLink, readLinks, listMyEntityDocs), accounts (ACCOUNT_STORAGE_KEY, IdentityStore, browserIdentityStore).
@ng-eventually/client/polyfill (from polyfill.ts): types StoreRegistryDeps, EventuallyConfig; values configure, getConfig, resetConfig, configureStoreRegistry, getStoreRegistryDeps, resetStoreRegistry, setCurrentUser, getCurrentUser, getCaps, capFor, resetCaps, CapRegistry, shareCap, connectedUser.
Not exported from either entry (internal, listed to preempt "why isn't X documented"): nuri.targetOf / parseNuri / mintCap, subscribePhysicalDoc, machinery.*, open-repo.*, read-filter.*, reach.*, physical.*, access-log.*, outbox-log.*, connect.startConnect.