Files
ng-eventually/docs/internal-contract.md
Sylvain Duchesne 2726f4a26f docs: nommer par la fonction, et n'annoncer qu'un point d'entrée
Deux corrections indépendantes dans la doc vivante, les briefs et décisions
datés restant tels qu'écrits.

P1a et P1b ne disaient rien à personne. Six mois plus tard il aurait fallu lire
le code pour savoir de quoi on parle, et le coût de la recherche se repaie à
chaque lecture. Ils deviennent cap-surface — la forme des capacités, livrée le
2026-07-28 — et cap-enforcement — ce qui reste : le chiffrement par document et
les gardes d'écriture aujourd'hui décoratives. 28 occurrences.

Et api-contract.md se contredisait à quatre lignes d'intervalle : il annonçait
deux points d'entrée en tête, et en bas qu'il n'y en a qu'un depuis la fusion du
2026-08-07. Vérifié dans package.json avant d'écrire — exports mappe exactement
{".": "./src/index.ts"} et src/polyfill.ts n'existe pas.

Ce qui identifie un symbole polyfill-era ne change pas : le bloc marqué dans
src/index.ts et le test de vocabulaire, plus aucun chemin d'import.
2026-08-11 19:10:34 +02:00

32 KiB

Internal contract — what @ng-eventually/polyfill keeps off its surface, and what NextGraph does or would do about each 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 complement of docs/api-contract.md: every module export under packages/polyfill/src/ that is NOT reachable from the published entry point (package.json maps exactly .src/index.ts). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the nextgraph-rs clone (HEAD 213338f6) and the installed @ng-org/web@0.1.2-alpha.13 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 the boundary was computed — mechanically, from the export statements. index.ts re-exports wholesale (export * / export * as ns) from types.ts, inbox.ts, docs.ts, surface/read-model.ts, and by name everything surface/use-shape.ts, surface/watch-shape.ts, lifecycle.ts, sparql.ts export, and subscribeDoc/subscribeDocs/docChangeType (+ types) from subscribe.ts; its storeRegistry namespace is the surface/placement.ts slice only (createEntityDoc, listMyEntityDocs, resolveScopeGraph, resolveWriteGraph, openDocumentInbox). model/nuri.ts is now internal in full — the guards isNuri/hasReadCap stopped being published when the entries became permissive-in (NuriLike validated at the door). (The second entry, polyfill.ts, was merged into index.ts on 2026-08-07, and the polyfill-era block was then cut to ONE published call, configure. configureStoreRegistry, setCurrentUser and connectedUser became internal the same day — folded, replaced by the gate, and awaited inside it respectively; the machinery accessors and test resets are internal too. All four are inventoried below.) Everything else that carries export in a src/ module is internal and inventoried here. Eight modules are internal in their entirety: shared-wallet/access-log.ts, emulated-verifier/machinery.ts, surface/ng-proxy.ts, emulated-verifier/open-repo.ts, shared-wallet/outbox-log.ts, shared-wallet/physical.ts, emulated-verifier/reach.ts, emulated-verifier/read-filter.ts. Four are internal in part: nuri.ts, emulated-verifier/connect.ts, subscribe.ts, shared-wallet/account-registry.ts.

Labels are those of docs/api-contract.md: PASSTHROUGH (level 3/2, VERIFIED), LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED), ASSUMPTION, NO COUNTERPART. Level numbers per README.md § The three references: 3 = JS ORM, 2 = wasm binding (@ng-org/web), 1 = Rust engine. One label recurs here that the surface contract rarely needs: NO COUNTERPART, shared-wallet machinery — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.


1. The wrapped ng factory — surface/ng-proxy.ts

// ng-proxy.ts:10
export function makeNg(): Record<string, any>;

Builds the published ng Proxy (consumed once, index.ts:61): forwards every property to the injected real ng, overriding login/session_start (passthrough with a shared-wallet-credentials TODO) and sparql_update (the emulated write-cap guard).

  • The factory itself is NO COUNTERPART, by design — the target has no "wrap the SDK" step; at migration ng IS @ng-org/web's and makeNg is deleted.
  • The sparql_update guard stands in for the engine's write-permission model (verify_perm inside Commit::verify, engine/repo/src/commit.rs:892-899) — same analysis as docs/api-contract.md § 3.
  • Defect — the login arm fabricates a member (see Findings F1). @ng-org/web has no login: none among the exports of index.d.ts (re-verified), and no fn login in sdk/js/lib-wasm/src/lib.rs. The proxy nevertheless returns a function for prop === "login" (ng-proxy.ts:16-22), so typeof ng.login === "function" on the wrapper while the real SDK yields undefined — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (ng[prop] is undefined). ASSUMPTION with no provenance — no target layer names a login.
  • Disappears at migration (the whole module).

2. NURI internals — the unexported slice of nuri.ts, plus the minting point

// model/nuri.ts
export function targetOf(nuri: Nuri): Nuri;
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
// emulated-verifier/caps.ts — NOT `model/nuri.ts`; it moved when the source layout was
// reorganised by migration fate, and this block said `nuri.ts` until 2026-08-10.
export function mintCap(nuri: Nuri): ReadCap;

targetOf strips a :r: cap segment to the naming form; parseNuri is the parsed pair; mintCap builds the cap-bearing form with the stand-in value OK (STAND_IN_CAP). Kept off the surface deliberately: nothing published turns a bare reference into a cap.

  • targetOf / parseNuriLEVEL-1 SHAPE, model VERIFIED: they transcribe upstream's one-type-with-optional-access NURI, on the two fields this library uses. Not a "1:1 mirror" of NuriV0, as this line claimed until 2026-08-10: that type has TEN fields — identity, target, entire_store, objects, signature, branch, overlay, access, topic, locator (engine/net/src/app_protocol.rs:181-194) — of which parseNuri carries target and the cap half of access. The other eight have no counterpart here (the missing locator is a stated gap, docs/readcap-and-nuri-model.md § 4sexies). The ReadCap encoding they discriminate on is r:{base64url(serde_bare(ObjectRef))} (BlockRef::readcap_nuri, engine/repo/src/types.rs:518-521), distinct from the :k: object/commit forms (object_nuri/commit_nuri, types.rs:510-514). No JS surface parses NURIs at level 2 or 3 — the real SDK takes plain strings — so these helpers never surface in signatures and survive only as internals.
  • mintCapNO COUNTERPART as an operation, and that is the point: upstream a ReadCap is produced by the engine when a repo is created, never derived from a bare reference by a caller. mintCap exists solely because the emulation needs a cap VALUE at creation time and cap-enforcement has not yet supplied real key material; the constant OK pretends nothing (STAND_IN_CAP, emulated-verifier/caps.ts). It has exactly two call sites (shared-wallet/account-registry.ts createEntityDoc; emulated-verifier/caps.ts internals) — the minting points of the emulation. With cap-enforcement the constant becomes a real key; at migration the function is deleted (the engine mints).

3. The reach boundary — emulated-verifier/reach.ts

// reach.ts:66
export function declareInfrastructure(nuri: Nuri): void;
// reach.ts:71
export function isInfrastructure(nuri: Nuri): boolean;
// reach.ts:76
export function resetInfrastructure(): void;
// reach.ts:93
export function mayReach(nuri: Nuri): boolean;
// reach.ts:110
export function assertMayReach(nuri: Nuri, op: string): void;
// reach.ts:131
export function mustNotAttempt(nuri: Nuri): boolean;

The single predicate deciding whether the CONNECTED virtual user may touch a document at all: cap possession, or explicitly-declared infrastructure (the store-root and doc-shim). assertMayReach guards the passage points (rule 1, throw on refusal); mustNotAttempt guards the callers (rule 2, do not even issue the operation). Inert until the first cap exists (caps.isEnforcing()).

  • NO COUNTERPART, shared-wallet machinery — the emulated stand-in for the wallet boundary itself. In the target the boundary is cryptographic, not a predicate: a repo whose cap the wallet does not hold is never decrypted, a targeted read of it errors RepoNotFound (resolve_target_for_sparql, engine/verifier/src/request_processor.rs:264,269), and the only path that loads a repo from a cap is pub(crate) (Verifier::load_repo_from_read_cap, engine/verifier/src/verifier.rs:2237). The two-rules split (refuse at the gate AND do not attempt) is redundancy this lib chose; upstream only "cannot" exists — there is nothing to refuse because the request cannot be formed.
  • The infrastructure exemption (declareInfrastructure, registered by shared-wallet/account-registry.ts for the store-root and doc-shim only) has no image in the target: there is no shim to exempt. Registration-not-pattern-matching is a lib-internal safety choice.
  • Everything here disappears at migration; the durable lesson it protects (naming a document does not grant access) is the target's own model.

4. The physical user's primitives — shared-wallet/physical.ts

// physical.ts:54
export async function physicalCreate(sessionId: string, crdt = "Graph", cls = "data:graph", dest = "store", store?: unknown): Promise<Nuri>;
// physical.ts:80
export async function physicalQuery(sessionId: string, query: string, base: string | undefined, anchor: Nuri, label = "physicalQuery"): Promise<unknown>;
// physical.ts:94
export async function physicalUpdate(sessionId: string, query: string, anchor: Nuri, label = "physicalUpdate"): Promise<void>;

The unguarded counterparts of docs.docCreate / sparqlQuery / sparqlUpdate, callable only by the library's own machinery on the shim's documents (store-root pointer, doc-shim, provisioning). Separated as FUNCTIONS rather than as an exemption list so machinery never gets "waved through" a guard (module header, physical.ts:19-27).

  • As wire calls: PASSTHROUGH (level 2, VERIFIED) — the same doc_create / sparql_query / sparql_update the published docs.* forwards to (index.d.ts:60,295,297; sources sdk/js/lib-wasm/src/lib.rs:1575 web / :1618 nodejs, :352/:555), minus the lib-internal label.
  • As a CONCEPT: NO COUNTERPART, shared-wallet machinery. The physical/virtual user split exists only because one wallet hosts many identities; the target has exactly one user per wallet and no privileged "machinery caller". The module disappears with the shim.

5. Physical subscription — the unexported slice of subscribe.ts

// subscribe.ts:118
export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void): Unsubscribe;

subscribeDoc minus the reach guard — the machinery's door to doc_subscribe, used by emulated-verifier/open-repo.ts to hold shim repos open. Same wire behaviour as the published subscribeDoc (analysed in docs/api-contract.md § 8, target doc_subscribe, index.d.ts:66, sdk/js/lib-wasm/src/lib.rs:1908).

  • NO COUNTERPART, shared-wallet machinery — the guarded/unguarded pair collapses to one call when the wallet is the boundary. Disappears with shared-wallet/physical.ts.

6. Bootstrap repo opening — emulated-verifier/open-repo.ts

// open-repo.ts:75
export type SyncState = "syncing" | "synced" | "timed-out";
// open-repo.ts:104 — TEST-ONLY
export function setOpenTimeoutForTests(ms: number): void;
// open-repo.ts:110
export function resetOpenedRepos(): void;
// open-repo.ts:135
export function getSyncState(nuri: Nuri): SyncState | "unknown";
// open-repo.ts:167
export async function ensureRepoOpen(nuri: Nuri): Promise<void>;
// open-repo.ts:184
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void>;
// open-repo.ts:259
export async function ensureReposOpen(nuris: Nuri[]): Promise<void>;

Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (subscribePhysicalDoc) and awaiting the first State push — the sync barrier — with a bounded timeout. The subscription is held for the session; per-nuri SyncState keeps synced and timed-out apart.

  • The opening mechanism is level 2, VERIFIED as a composition: doc_subscribe exists (sdk/js/lib-wasm/src/lib.rs:1908), and the push variants TabInfo/State/Patch are the engine's AppResponseV0 (engine/net/src/app_protocol.rs:1354-1358). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
  • "Hold a live subscription to keep the repo open" — ASSUMPTION (nothing upstream documents subscription lifetime as what retains a repo in self.repos); observed to work, bounded by the same probe.
  • Defect — the header's mechanism claim is contradicted at the source (see Findings F2). open-repo.ts:10-12 says an anchored sparql_query on a repo absent from self.repos "silently returns 0 rows (never a RepoNotFound)". Verified upstream: absence from self.repos yields Err(NgError::RepoNotFound) (request_processor.rs:264,269), the ReadQuery arm converts it into AppResponse::error (:1293-1296), and the web binding REJECTS the JS promise with it (sdk/js/lib-wasm/src/lib.rs:606). The observed 0-rows-no-error behaviour has two candidate explanations that the source does support: a persistent verifier reloads every known repo into self.repos at Verifier::load (engine/verifier/src/verifier.rs:535-560) so the repo is present-but-unsynced (a genuine 0-row read), and/or the lib's own per-doc tolerance (readUserStore, surface/read-model.ts per-doc catch) converts a rejection into an empty result. The healed symptom is real and the fix correct; the stated mechanism is not established, and a maintainer reasoning from it would mispredict behaviour whenever the verifier is not persistent.
  • SyncState and getSyncState are lib-invented vocabulary — NO COUNTERPART (upstream has no consumer-facing "sync state of a repo" API at any level; OrmSubscription.readyPromise, sdk/js/orm/src/connector/GraphOrmSubscription.ts:260, is the closest level-3 signal, per-subscription not per-repo).
  • At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.

7. The read filter — emulated-verifier/read-filter.ts

// read-filter.ts:47
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[];
// read-filter.ts:60
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S;

The polyfill of capability-based read access: a Proxy view over the reactive set keeping only items whose @graph document the current holder holds; applied by surface/use-shape.ts once caps.isEnforcing().

  • NO COUNTERPART, by design — it stands in for cryptographic non-delivery. In the target the broker/verifier simply never yields what the wallet holds no cap for (targeted read errors, request_processor.rs:264,269; union read yields nothing for undecrypted repos — the § 4 analysis of docs/api-contract.md). There is no post-hoc filter to migrate to; the module is deleted.
  • The @graph key it filters on is level 3, VERIFIED: the ORM annotates every object with its graph NURI (sdk/js/orm/src/frontendAdapters/react/useShape.ts:41, sdk/js/orm/src/model/types.ts:19). Items with no @graph are kept — a lib policy choice (they name no document), not an upstream rule.
  • Access unit = the DOCUMENT, not the item — faithful to the model (a ReadCap opens a repo, types.rs:518-521), and the reason the filter is all-or-nothing per document.

8. Connection trigger — the unexported slice of emulated-verifier/connect.ts

// connect.ts:91
export function startConnect(): void;

Fire-and-forget wrapper over connectedUser() (internal since 2026-08-07, awaited inside ensureIdentity) (restore Links, then drain every inbox), called by setCurrentUser so inbox processing is the library's job, not the app's.

  • LEVEL-1 SHAPE for the timing, VERIFIED: upstream the recipient's verifier processes inbox messages as they arrive, with no consumer call (Verifier::inboxprocess_inbox, engine/verifier/src/verifier.rs:1674-1690); firing on connection is the emulation's equivalent moment. The restore-before-drain order is a lib choice; upstream "restore" does not exist as a step (applied caps are already in the User branch replay).
  • startConnect itself disappears at migration; the automatic-processing behaviour it fabricates is native.

9. The shim registry — the unexported slice of shared-wallet/account-registry.ts

The sharpest boundary case: surface/placement.ts publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The type RegistrySession is published by the entry (a consumer types its injected getSession with it) and covered by the surface contract; VirtualUserRecord is internal.

9a. Account shim — provision, resolve, reserved names, cache

// store-registry.ts:213
export function reservedAccount(name: string): string;
// store-registry.ts:278
export function resetRegistryCache(): void;
// store-registry.ts:542
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
// store-registry.ts:631
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;

resolveAccount — barrier-authoritative O(1) lookup of one account's record in the doc-shim; ensureAccount — resolve-or-provision (creates the three scope docs on first sight, concurrency-deduped); reservedAccount — NUL-prefixed sentinel namespace for lib-internal accounts; resetRegistryCache — test/wallet-switch reset.

  • NO COUNTERPART, shared-wallet machinery — the whole group. The target has no directory of identities to resolve or provision: a user's site (three stores + their inboxes) is created once at wallet creation (engine/verifier/src/site.rs — the site-creation flow committing the stores and the two store-inbox AddInboxCaps at :128,149), and "which user" is the session. ensureAccount's provision-on-first-sight has no target analogue and is exactly what connectedUser refuses to trigger (connect.ts:60-65). All of it disappears with the shim (docs/migration-guide.md § 3).
  • reservedAccount's collision-safety rests on an ASSUMPTION about a consumer-injected function: the comment (store-registry.ts:200-206) asserts the injected normalizeId can never produce a U+0000-prefixed key, but normalizeId is injected by the consumer and the lib's own default is a bare trim() (shared-wallet/bootstrap.ts), which does not strip U+0000. Bound: a consumer id would have to begin with a literal NUL to collide — implausible from any UI, but the property is the consumer's to keep, not the lib's (see Findings F5).

9b. Scope-index resolution

// store-registry.ts:1079
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;

The scope-INDEX document of one account (what watchShape subscribes to for container changes). Same resolution as the published resolveWriteGraph, without doubling as a write target.

  • Level 2, VERIFIED counterpart with the indirection removed: in the target the container IS the store, and its id is on the session (Session.private_store_id | protected_store_id | public_store_id, index.d.ts:264-272); the listing a subscriber watches is the store's ldp:contains graph, written natively by doc_create (request_processor.rs:706-708). The function's question survives; the per-account parameter and the index-document indirection disappear.

9c. Inbox ownership predicates

// store-registry.ts:837
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
// store-registry.ts:1286
export async function myInboxes(): Promise<Nuri[]>;

isOwnInbox — the read guard's question (may the current identity READ this inbox); myInboxes — the drain list for connectedUser (own wallet inbox + one per document this user opened an inbox on).

  • LEVEL-1 SHAPE for the underlying record, VERIFIED; NO COUNTERPART as API. Upstream "which inboxes may I read" is answered inside the verifier by the User branch's AddInboxCap records (AddInboxCapV0 { repo_id, overlay, priv_key }, engine/repo/src/types.rs:1969-1981, applied at verifier.rs:1916-1932) and consulted implicitly when a message arrives (verifier.rs:1674-1690); nothing suggests a JS API for the question at any level. Both functions exist only because the emulation must ANSWER it in JS (the read guard, the drain); they disappear with the emulated inbox read side.
// store-registry.ts:1307
export async function addLink(cap: ReadCap): Promise<void>;
// store-registry.ts:1331
export async function readLinks(): Promise<ReadCap[]>;

File / read back a received cap on the emulated User branch of the private store — what makes a received cap durable across sessions.

  • LEVEL-1 SHAPE, model VERIFIED; no JS surface anywhere. The record emulated is AddLink { read_cap } on the User branch — "so that a user can share with all its device a new Link they received", external repos only (engine/repo/src/types.rs:1934-1950). Upstream the filing happens inside the verifier while processing the inbox; the future SDK most likely never exposes these as calls (the surface contract's § 12 finding, restated here because these are now internal-only: the one caller is emulated-verifier/connect.ts / inbox.ts, which is exactly where upstream's verifier sits).
  • At migration both are deleted; the verifier files and replays.

10. The machinery namespace — emulated-verifier/machinery.ts

// machinery.ts:32
export const MACHINERY_NS = "urn:ng-eventually:";
// machinery.ts:40
export function isMachinerySubject(subject: string | undefined): boolean;

The URN prefix every triple the lib writes for itself lives under, and the one predicate read paths use to keep machinery out of consumer data.

  • NO COUNTERPART, by design — the seam where the emulation pays for having no branches. Upstream the separation is structural: a compartment is a different BRANCH with its own CRDT and topic (BranchType, engine/repo/src/types.rs:1536-1551; the Store/User/Overlay branches carry no triples at all, BranchCrdt::None, types.rs:1420), so machinery cannot appear in a content read and no subject filter exists to write. The namespace, the filter, and the four shim:* compartment subjects it protects all disappear at migration.

11. Diagnostics — shared-wallet/access-log.ts and shared-wallet/outbox-log.ts

// access-log.ts:25,45,50,63,76,87,99,116
export type AccessOp = "READ" | "WRITE";
export function setAccessLog(on: boolean): void;
export function enabled(): boolean;
export function activeIdentity(): string;
export function accessLogPrefix(): string;
export function logStage(line: string): void;
export function shortNuri(nuri: string): string;
export function logAccess(op: AccessOp, nuri: string, label: string, extra?: string): void;

// outbox-log.ts:62
export function inspectOutbox(): void;

shared-wallet/access-log.ts — the off-by-default per-identity access trace for the shared-wallet isolation leak (toggled by configure({ debugAccessLog }) or NG_EVENTUALLY_ACCESS_LOG=1). shared-wallet/outbox-log.ts — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.

  • shared-wallet/access-log.tsNO COUNTERPART, shared-wallet machinery: the leak it makes visible cannot exist in the target (isolation is per-wallet), and the "active identity" it prefixes is the relayed virtual id that disappears with setCurrentUser. Deleted at migration.
  • shared-wallet/outbox-log.tsNO COUNTERPART as API, but every fact it relies on is level-2 VERIFIED in the clone: the outbox is persisted through JsStorageConfig (sdk/rust/src/local_broker.rs:89-100), keyed ng_peer_last_seq@<peerId> (:119,141) and ng_outboxes@<peerId>@start / @<idx> with zero-padded {:05} indexes (:163-213, pad at :183,210); the real outbox_read_function DRAINS on read (session_del per key plus the start key, :218-224) — which is why the probe only counts and never touches; and the storage callbacks land in browser sessionStorage (sdk/js/api-web/main.ts:47,57,66), whose access-denied error string is the one convert_error handles (main.ts:18-22). The probe reads a private persistence format of the injected SDK — acknowledged in its header as out-of-contract, hence count-only. Deleted with the rest of the trace instrumentation at migration.

Findings — defects and migration risks

F1 — surface/ng-proxy.ts fabricates a login member the real SDK does not have. ng-proxy.ts:16-22 intercepts prop === "login", but @ng-org/web exports no login (none in index.d.ts, re-verified against the full declare function list; no fn login in sdk/js/lib-wasm/src/lib.rs). On the wrapper ng.login is a function; on the real SDK it is undefined; calling it throws. This contradicts the module's own "surface stays identical" header and docs/api-contract.md § 3's "the proxy adds no member and removes none". No target layer names a login — the arm is an unprovenanced assumption. Cheap fix: drop the login case (keep session_start), or gate it on typeof ng.login === "function".

F2 — emulated-verifier/open-repo.ts's stated mechanism is contradicted at the source. The header (open-repo.ts:10-12) asserts an anchored sparql_query on a repo absent from self.repos "silently returns 0 rows (never a RepoNotFound)". Upstream, absence from self.repos errors RepoNotFound (engine/verifier/src/request_processor.rs:264,269), the ReadQuery arm wraps it as AppResponse::error (:1293-1296), and the web binding rejects the JS promise (sdk/js/lib-wasm/src/lib.rs:606). The observed behaviour is real but its cause is one (or both) of: the repo WAS in self.repos (a persistent verifier reloads all known repos at Verifier::load, engine/verifier/src/verifier.rs:535-560) and read 0 rows because unsynced; or the lib's own catch-and-continue layers absorbed the rejection. The fix (open before reading) is correct either way; the diagnosis in the header should not be relied on, and mispredicts non-persistent-verifier behaviour.

F3 — incomplete citation in subscribe.ts. subscribe.ts:31 cites the ORM fan-out abort as "initialize.rs:125-128" with no path. The file is engine/verifier/src/orm/graph/initialize.rs; lines 125-128 are the graph loop calling self.open_for_target(&nuri.target, true).await? — verified, the ? propagates RepoNotFound and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.

F4 — docs/api-contract.md lags the surface/placement.ts split. Its § 12 and appendix still list resolveAccount, ensureAccount, userStoreDoc, isOwnInbox, myInboxes, addLink, readLinks, reservedAccount, resetRegistryCache as the SDK entry's storeRegistry namespace, and § 13/§ 15 place virtualUsers.* on the SDK entry — since the split (index.ts routes through surface/placement.ts) those are internal. That file is being edited concurrently; noted here, deliberately not fixed by this document.

F5 — reservedAccount's collision guarantee is asserted about code the lib does not own. store-registry.ts:200-206 states the injected normalizeId "strips a leading @, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is id.trim() (shared-wallet/bootstrap.ts), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on StoreRegistryDeps.normalizeId, or reject NUL-prefixed raw ids at accountKey.

Migration-risk flags (shapes that will not travel):

  • The (document, inbox) pair is persisted as a space-joined string literal ("${doc} ${inbox}", written store-registry.ts:1174, parsed by split(" ") at :1268). Upstream the record is the typed AddInboxCapV0 { repo_id, overlay, priv_key } (engine/repo/src/types.rs:1969-1981). Internal-only and replaced wholesale at migration, but it is the one shim record with an ad hoc micro-format a future reader must know to parse.
  • isOwnInbox / myInboxes encode questions the target answers only inside the verifier (§ 9c) — any new internal caller added to them deepens a dependency that has no successor API; keep callers to the read guard and the connection drain.
  • The sync barrier is empirical (§ 6): "TabInfo before the first State" and "held subscription keeps the repo open" are pinned by the in-repo e2e probe, not by any upstream statement. If upstream changes push ordering or repo retention, emulated-verifier/open-repo.ts is the module that breaks first; the probe is the tripwire.
  • ensureAccount's provision-on-first-sight (§ 9a) is a behaviour with no target image; emulated-verifier/connect.ts already refuses to trigger it. Any future internal path that provisions as a side effect of resolving would be teaching the emulation something the target contradicts (creation is an explicit act at wallet/site creation, engine/verifier/src/site.rs).

Appendix — full internal export inventory (for diffing)

Fully internal modules: shared-wallet/access-log.ts (AccessOp, setAccessLog, enabled, activeIdentity, accessLogPrefix, logStage, shortNuri, logAccess); emulated-verifier/machinery.ts (MACHINERY_NS, isMachinerySubject); surface/ng-proxy.ts (makeNg); emulated-verifier/open-repo.ts (SyncState, setOpenTimeoutForTests, resetOpenedRepos, getSyncState, ensureRepoOpen, ensurePhysicalRepoOpen, ensureReposOpen); shared-wallet/outbox-log.ts (inspectOutbox); shared-wallet/physical.ts (physicalCreate, physicalQuery, physicalUpdate); emulated-verifier/reach.ts (declareInfrastructure, isInfrastructure, resetInfrastructure, mayReach, assertMayReach, mustNotAttempt); emulated-verifier/read-filter.ts (filterReadable, makeReadFilteredView).

Internal slices of partially-published modules: model/nuri.ts (targetOf, parseNuri) and emulated-verifier/caps.ts (mintCap); emulated-verifier/connect.ts (startConnect); subscribe.ts (subscribePhysicalDoc); shared-wallet/account-registry.ts (reservedAccount, resetRegistryCache, resolveAccount, ensureAccount, userStoreDoc, isOwnInbox, myInboxes, addLink, readLinks).

Modules with no internal exports (everything they export is published): types.ts, docs.ts, inbox.ts, surface/read-model.ts, shared-wallet/virtualUsers.ts, emulated-verifier/caps.ts, sparql.ts, lifecycle.ts, surface/use-shape.ts, surface/watch-shape.ts, surface/placement.ts, and the entry point.