/** * storeRegistry — resolves (account, scope) → document NURI. * * Stopgap / polyfill-era. Emulates the target infrastructure — where each * user owns their own public/protected/private stores — on top of one shared * wallet. It creates one document per (account × scope) inside that shared * wallet (via `physical.physicalCreate` — the UNGUARDED primitive, since a store is * machinery and its cap is filed here, where it is known whose it is; the public * `docs.docCreate` files the creator's cap itself), so the `scope` * (`public|protected|private`) is a logical attribute tracked here, not a * physical NextGraph store. Isolation is enforced by the app layer + the * emulated cap registry, not by crypto. * * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It * is persisted as RDF, but NOT in the store-root graph anymore — see the * indirection below. That makes login cross-device: another device opening the * same wallet reads the same shim and finds the same virtualUsers. * * ── The indirection: pointer (store-root) → doc-shim (subscribable) ───────── * On the real NextGraph platform "findable-without-lookup" and "subscribable" * are DISJOINT (verified at the source, see docs/nextgraph-current-state.md * § *Findable vs subscribable*): * - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root * `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync * BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could * be truly empty); * - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:` * doc from `doc_create` — but its RepoID is RANDOM, so a fresh session * cannot GUESS it; it must be looked up. * So a purely-barrier shim resolution is impossible: you cannot have a doc that * is both guessable and authoritative on a cold read. The indirection bridges * this: a well-known, write-ONCE **pointer** triple in the store-root names a * **doc-shim** (`did:ng:o:...`) that holds all account records and IS * subscribable. Resolution reads the pointer (a single oldest write-once triple, * near-always synced), then opens the doc-shim through its `ensureRepoOpen` * BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent). * * A pointer FORK (two devices writing the pointer before either synced) is * reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every * device converges on the SAME doc-shim. This is why the OLD account-level retry * (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now * barrier-authoritative, so it never needs to be retried to distinguish sync-lag * from absence. A micro-guard remains ONLY on the pointer read (one write-once * triple) — see resolvePointer. * * ── Generic by construction ────────────────────────────────────────────── * This module knows only the three native scopes; it knows no application * entity kind. The consumer maps its entities to a scope and calls * `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native * scope. No application domain here. * * ── What disappears at migration ───────────────────────────────────────── * At the real multi-store migration the shim vanishes entirely: `(account, * scope)` maps to the user's REAL store NURI instead of a document in the * shared wallet, `docCreate` targets the real per-user store, and the * per-scope index document (the store-container emulation) is replaced by the * store itself. The consumer-facing surface (`createEntityDoc`, * `listEntityDocs`, resolvers) is designed to survive that swap unchanged. * * All NextGraph I/O routes through the T01.a `docs` primitive (real injected * `ng`), so this module imports **no** `@ng-org` package. */ import { sparqlQuery } from "../surface/docs"; import { registerUpdate } from "../emulated-verifier/register-write"; import { physicalCreate, physicalQuery, physicalUpdate } from "./physical"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap"; // Cross-fate edge, deliberate: resolving a user is also when its own structure gets // filed, and creating a document is when its cap does. See branch-registers.ts. import { fileOwnStructure, fileOwnInbox, holdOwnCap, publishInboxAddress, readStoreCaps, readInboxCapsFor, ownsDocument, documentInboxAddress, } from "../emulated-verifier/branch-registers"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { exposeReadCap } from "../emulated-verifier/public-store"; import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical"; import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql"; import { hasReadCap, isNuri } from "../model/nuri"; import { mintCap } from "../emulated-verifier/caps"; import { mustNotAttempt } from "../emulated-verifier/reach"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import type { InboxScope, Nuri, StoreType } from "../model/types"; // --- sharedWalletShim model ---------------------------------------------- /** * A NURI as read back from the shim, where `""` means "absent or corrupt". * * The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing * field, and callers have always had to test for it — but with {@link Nuri} typed it * stops hiding inside a `string`. It is kept confined to the shim-reading functions * below: `VirtualUserRecord` still promises real NURIs, because a record with an empty * scope document is a corrupt record, not a valid state to spread through the API. * Tightening that (reject the record rather than let it flow) is a change of * behaviour and belongs to its own lot — see `recordFromRows`. */ type MaybeNuri = Nuri | ""; /** One account's three scope-document NURIs, as recorded in the shim. */ export interface VirtualUserRecord { id: string; docPublic: Nuri; docProtected: Nuri; docPrivate: Nuri; } const SHIM = "urn:ng-eventually:shim"; export const P = { type: `${SHIM}:Account`, id: `${SHIM}:id`, docPublic: `${SHIM}:docPublic`, docProtected: `${SHIM}:docProtected`, docPrivate: `${SHIM}:docPrivate`, contains: `${SHIM}:contains`, // scope-index → entity document NURI docInbox: `${SHIM}:docInbox`, // (user, inboxScope) → ITS inbox document link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`) inboxOwner: `${SHIM}:inboxOwner`, // shim → WHOSE inbox this NURI is (see `recordInbox`) } as const; // Fixed subject of the per-(account×scope) index document. The index doc plays // the role of the future store-container: it lists the NURIs of the entity // documents (one per entity) that live "in" that scope. const MAIN_BRANCH_SUBJECT = `${SHIM}:index`; /** * Fixed subject of the **User branch** emulation, inside a user's PRIVATE store * document. Upstream, `AddLink { read_cap }` is committed to the User branch of the * private store — *"so that a user can share with all its device a new Link they * received"*, and *"only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`). * That is where a cap received from someone else durably lives. * * We have no branches, so the compartment is a distinct SUBJECT in the same * document, kept separate from `MAIN_BRANCH_SUBJECT` (which emulates the store's Main * branch, the `ldp:contains` listing). Two compartments, two subjects — the * separation upstream makes with two branches. */ export const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; /** * Fixed subject of the **Store branch** emulation, inside a user's store document. * * `doc_create` upstream writes TWICE (`engine/verifier/src/request_processor.rs:697-710`): * `ldp:contains` on the store's **Main** branch — the listing — and * `AddRepo { read_cap }` on its **Store** branch — the key. Two branches, two * purposes, deliberately separate; replaying the Store branch is what reloads a * store's documents WITH their caps (`AddRepo::verify` → `load_repo_from_read_cap`). * * We have no branches, so this is a distinct SUBJECT beside {@link MAIN_BRANCH_SUBJECT} * in the same document — the same shape already used for {@link USER_BRANCH_SUBJECT}. * * **Honest about the emulation**: upstream the Store branch carries NO triples at all * (`BranchCrdt::None`, `engine/repo/src/types.rs:1420`) — it is a stream of service * commits. Representing it as RDF is our invention; what is faithful is *that the cap * is stored beside the document rather than recomputed*, and that the listing and the * keys stay separate. */ export const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; /** * Fixed subject of the **Header branch** emulation, inside an ENTITY document — the * first compartment we put in a document the consumer also reads, hence the filter in * `read-document.ts` (every `${SHIM}:` subject is machinery, and the internal read drops it). * * It carries what must be readable by *whoever can read the document*, as opposed to * what belongs to its owner alone. Today that is one thing: the ADDRESS of the * document's inbox. * * Why the address must live here and not on the owner's User branch. Upstream an inbox * is a KEYPAIR (`repo.inbox: Option`, `engine/repo/src/repo.rs:126`) and the * two halves have opposite audiences: a depositor seals with the PUBLIC key * (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`, * `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the * private half (`AddInboxCap`, on the User branch). An address is therefore public by * nature — upstream it travels with the profile (`ContactDetails` carries * `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`). * Keeping it only on the owner's User branch, as this lib first did, made the deposit * side unreachable: a third party had no way to learn where to deposit. * * **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`) * but is CLOSED: `update_header` writes only `title`/`about` * (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back * only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The * name is borrowed for the shape — a compartment of the document that is not its * content — not for the upstream branch's contract. */ export const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`; // --- pointer (store-root → doc-shim indirection) -------------------------- // // The pointer is a SINGLE well-known triple written ONCE into the store-root // graph on the very first login, then IMMUTABLE. Its object is the NURI of the // doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The // store-root is NOT subscribable (no first-`State` barrier), but the pointer is // the OLDEST triple in that graph and is write-once, so it is near-always synced // on a cold read — and even a transient miss is bounded by a small guard // (resolvePointer), NOT by an account-level retry. const POINTER_SUBJECT = `${SHIM}:root`; const POINTER_PRED = `${SHIM}:shimDoc`; function accountSubject(id: string): string { // The id is UNTRUSTED and lands in an IRI position. Percent-encode it // (escapeIri) so no `>` / `"` / whitespace / control char can break out of // the `<...>` and inject triples into the shim graph (the account→doc trust // root). accountKey() runs first so the subject stays stable per shim key. return `${SHIM}:account:${escapeIri(accountKey(id))}`; } // --- reserved accounts ----------------------------------------------------- // // Some accounts are internal to the lib (e.g. the discovery index owner) and // must NOT collide with any user-chosen id. A reserved account is created // via {@link reservedAccount}, which marks the name with a sentinel PREFIX that // `normalizeId` (consumer-injected) can never produce: it strips a leading // `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved // keys therefore live in a disjoint namespace from every normalized id — // a real user named "index"/"@index" can never resolve to the reserved // `reservedAccount("index")` account. const RESERVED_PREFIX = "\u0000reserved:"; /** * Wrap an internal account name so it occupies a key that no user input can * produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount} * (and the other registry calls) instead of a bare id. */ export function reservedAccount(name: string): string { return `${RESERVED_PREFIX}${name}`; } /** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ function isReserved(id: string): boolean { return id.startsWith(RESERVED_PREFIX); } /** * The shim/cache key for an account. Reserved accounts bypass `normalizeId` * entirely and key on their sentinel-prefixed name, so they cannot collide with * a normalized id; everyone else normalizes as usual. */ export function accountKey(id: string): string { return isReserved(id) ? id : normalize(id); } // --- session / normalization access (injected by the consumer) ------------ /** Minimal session shape the registry needs — provided by the consumer. */ export interface RegistrySession { /** Relayed untouched to `ng` — upstream declares it `string | number` (`index.d.ts:266`). */ sessionId: string | number; /** The shared wallet's private store id — the pointer anchor. */ privateStoreId: string; /** The shared wallet's protected store id (native store). Optional: only the * scope resolvers need it; the shim only needs the private anchor. */ protectedStoreId?: string; /** The shared wallet's public store id (native store). Optional. */ publicStoreId?: string; } function normalize(id: string): string { const key = getStoreRegistryDeps().normalizeId(id); // The reserved namespace's whole guarantee is that no user id can land in it, and the // rule that produces the key does not enforce it: the package's own `normalizeIdentityId` // trims, strips a leading `@` and lowercases — nothing stops an id that already starts // with the sentinel — and the internal wiring path lets a suite or the e2e harness // substitute another rule entirely. A collision here is not a cosmetic clash: a user // would key onto an infrastructure account and read or write documents that are not // theirs. So it is checked rather than assumed. if (isReserved(key)) { throw new Error( "[ng-eventually] account-registry: `normalizeId` produced a key inside the " + `reserved namespace, which no user id may occupy: ${JSON.stringify(key)}`, ); } return key; } export async function session(): Promise { return getStoreRegistryDeps().getSession(); } /** The pointer lives in the shared wallet's private STORE-ROOT graph (the only * always-known-without-lookup anchor). NOT subscribable — hence the pointer is * a write-once triple, and the actual account records live in the doc-shim it * names (see rootNuri vs the doc-shim). */ async function rootNuri(): Promise { const s = await session(); return `did:ng:${s.privateStoreId}`; } // --- cache ---------------------------------------------------------------- // Per-account cache, keyed by account key. Populated by the TARGETED resolver // (resolveAccount) and by loadShim(). Independent of `cache` so a single // targeted resolve never forces a full shim scan. Both are cleared together. const accountCache = new Map(); // The resolved doc-shim NURI for the current session (cached: the pointer read + // barrier open happen once, then every account read reuses this doc). Cleared on // resetRegistryCache / wallet switch. let shimDocNuri: Nuri | null = null; // De-dupe concurrent pointer-resolutions so a fresh page firing many parallel // ensureAccount/resolveAccount calls opens the doc-shim exactly once. let shimDocInFlight: Promise | null = null; /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ export function resetRegistryCache(): void { accountCache.clear(); inboxCache.clear(); inboxInFlight.clear(); shimDocNuri = null; shimDocInFlight = null; // The inbox index is session state like the rest: a NURI confirmed as an inbox in one // session must not be taken for one in the next. (Left out at first, and the full unit // suite went red on a NURI collision between two files — a memo that outlives its // session is exactly the fault this review found elsewhere in the tests.) knownInboxes.clear(); // …and so is who owns which inbox: it is read from the same index, in the same session. inboxOwners.clear(); } // --- SPARQL result helpers ------------------------------------------------ /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ export function readBindings(result: unknown): Array> { if (!result) return []; const anyRes = result as { results?: { bindings?: Array> }; }; if (Array.isArray(result)) return result as Array>; if (anyRes.results?.bindings) return anyRes.results.bindings; return []; } export function bindingValue(row: Record, key: string): string { return row[key]?.value ?? ""; } /** * DETERMINISTIC resolution of an account's scope docs from a set of SPARQL * bindings (all bindings for ONE account subject). * * ── Why this is load-bearing (residual fork residue) ─────────────────────── * A corrupted shim can carry the SAME account subject with MULTIPLE values for a * scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS * (each stray provision appended another doc NURI). A query then returns several * bindings (the cross-product of the duplicate values). Picking `rows[0]` is * NON-DETERMINISTIC (binding order is not stable across sessions), so the session * that WROTE an entity into one docPublic and a later fresh page that RESOLVED a * DIFFERENT docPublic would disagree → the anchored `readUserStore` returns 0 → * the home reads empty. When both happen to pick the same doc, it "works". * * The fix: for each scope field, collect EVERY distinct value across the bindings * and choose the SAME one every time — the lexicographically-smallest NURI. NURIs * are content-addressed and stable, so lexicographic order is a total, stable, * session-independent order: writer and reader converge on the SAME canonical doc * even on a wallet already corrupted by duplicates. (No creation timestamp is * recorded in the shim, so lexicographic-min is the available deterministic key.) * * With the barrier-authoritative doc-shim read, account FORKS no longer occur (a * fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is * definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to * stay robust against the residue of PAST forks already persisted in a wallet, and * to reconcile a benign pointer fork the same content-addressed way. */ function canonicalDoc(rows: Array>, key: string): MaybeNuri { let chosen: MaybeNuri = ""; const distinct = new Set(); for (const row of rows) { const v = bindingValue(row, key); // The SPARQL boundary: a binding is an untrusted string. Narrowing here (rather // than casting) also discards a value that is not a NextGraph reference at all — // shim corruption that used to flow straight through as a "document NURI". if (!v || !isNuri(v)) continue; distinct.add(v); if (chosen === "" || v < chosen) chosen = v; } // Stage trace: which doc got picked, and out of how many DISTINCT candidate // values — >1 flags residual fork residue (see the module doc above) even // when resolution still converges correctly on the canonical (smallest) one. logStage( "canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") + " (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"), ); return chosen; } /** Build an VirtualUserRecord by picking the canonical (lexicographically-smallest) * doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */ function recordFromRows( rows: Array>, fallbackId: string, ): VirtualUserRecord { let id = ""; for (const row of rows) { const v = bindingValue(row, "id"); if (v) { id = v; break; } } // The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises // real NURIs; a shim missing a scope document yields `""` here, exactly as it // always has, and the cast records that this is a KNOWN gap rather than a proven // invariant. Callers already test for the empty value (`listMyEntityDocs` reads // nothing out of a falsy container). Rejecting such a record outright would be the right fix and is // a behaviour change — its own lot, not this one. return { id: id || fallbackId, docPublic: canonicalDoc(rows, "docPublic") as Nuri, docProtected: canonicalDoc(rows, "docProtected") as Nuri, docPrivate: canonicalDoc(rows, "docPrivate") as Nuri, }; } // --- pointer resolution + doc-shim bootstrap ------------------------------ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); /** * Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or * `""` if no pointer exists yet. * * The store-root is NOT subscribable, so this read has no first-`State` barrier: * a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in * that graph), so it is near-always synced. The ONLY residual guard is a small * bounded re-read here (NOT an account retry): a handful of quick re-reads of that * single triple. It is bounded, benign, and — crucially — it can never re-provision * an account or fork data; the worst it can do is take a couple extra reads to see a * pointer that is still landing. The account records themselves are read * authoritatively through the doc-shim barrier, never through this guard. * * If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to * their own freshly-created doc-shim before either synced), reconcile to the * canonical (lexicographically-smallest) doc-shim NURI — content-addressed and * stable, so every device converges on the SAME doc-shim. */ async function resolvePointer(): Promise { const s = await session(); const root = await rootNuri(); // COLD-START heal: open the store-root repo before the anchored read, so a fresh // wallet whose store-root isn't yet in `self.repos` resolves instead of throwing // `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no // barrier, so this open cannot make the read authoritative — the guard below does. await ensurePhysicalRepoOpen(root); const query = ` SELECT ?shimDoc WHERE { GRAPH <${assertNuri(root)}> { <${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc . } }`; // Micro-guard (POINTER only): a small bounded re-read to bridge the store-root // sync-lag window on the ONE write-once pointer triple. Bounded, and it can only // ever DELAY seeing an existing pointer — never provision, never fork. Uses the // injected pointerGuard budget (defaults to a single read when unset, so unit // fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry. const budget = getStoreRegistryDeps().pointerGuard; const attempts = Math.max(1, budget.attempts ?? 1); const baseMs = budget.baseMs ?? 150; const maxStepMs = budget.maxStepMs ?? 2000; let step = baseMs; // Did ANY attempt come back with an answer — even an empty one? The guard retries a // read that could not be made, and `""` means "there is no pointer yet", which is the // signal `resolveShimDoc` MINTS a doc-shim on. A budget exhausted without a single // answer has established nothing at all, so answering `""` there would fabricate a // second registry root: a new doc-shim, a second pointer beside the one that already // exists, and every account record split between two documents that only // `canonicalDoc` reconciles — the ones written into the loser simply disappear. Only a // read that ANSWERED may report an absence. let answered = false; let lastError: unknown = null; for (let i = 0; i < attempts; i++) { try { const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer"); answered = true; const doc = canonicalDoc(readBindings(result), "shimDoc"); if (doc) { logStage("resolvePointer → 1 target: " + shortNuri(doc)); return doc; } } catch (error) { lastError = error; console.error(accessLogPrefix() + " resolvePointer failed:", error); } if (i < attempts - 1) { await sleep(step); step = Math.min(step * 2, maxStepMs); } } if (!answered) throw lastError; logStage("resolvePointer → 0 targets"); return ""; } /** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in * practice (only called when no pointer was found); a concurrent double-write is * reconciled by canonicalDoc on read. * * **Propagates a failed write.** The pointer is the ONLY way back to the doc-shim — it is * the one NURI a fresh session can name without a lookup. A session that carried on over a * pointer that never landed would go on writing every account record into a document no * later session can find, and the next login, finding no pointer, would mint a second * doc-shim and re-provision every account from scratch. There is nothing to recover * afterwards, so the failure has to be seen at the moment it happens. */ async function writePointer(doc: Nuri): Promise { const s = await session(); const root = await rootNuri(); await ensurePhysicalRepoOpen(root); const update = ` INSERT DATA { GRAPH <${assertNuri(root)}> { <${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> . } }`; try { await physicalUpdate(s.sessionId, update, root, "writePointer"); } catch (error) { console.error(accessLogPrefix() + " writePointer failed:", error); throw error; } } /** Create one graph document in the shared wallet's private store (→ a NURI). */ export async function createDoc(): Promise { const s = await session(); // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", // store_repo=undefined → shared wallet's private store. return physicalCreate(s.sessionId); } /** * Resolve (or on first login, create) the doc-shim NURI for this session — the * `did:ng:o:...` repo that holds every VirtualUserRecord and IS subscribable. * * Steps (cached; runs at most once per session, concurrent callers share one): * 1. Read the pointer from the store-root (resolvePointer). If present → that is * the doc-shim; open it through its first-`State` BARRIER so subsequent account * reads are AUTHORITATIVE. * 2. No pointer → FIRST login: * a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the * session (`self.repos`) — so it is already open/synced in-session; * b. publish the pointer (store-root → the new doc-shim), once; * c. open it (barrier — trivially satisfied for a just-created in-session repo). * The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim); * on first-login creation it is a no-op, so the pointer is published first. */ async function resolveShimDoc(): Promise { if (shimDocNuri) return shimDocNuri; if (shimDocInFlight) return shimDocInFlight; const p = (async (): Promise => { const existing = await resolvePointer(); if (existing) { // Open the doc-shim through its first-`State` barrier BEFORE any account read, // so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag. await ensurePhysicalRepoOpen(existing); shimDocNuri = existing; logStage("resolveShimDoc → " + shortNuri(existing)); return existing; } // FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish // the pointer, then open (no-op barrier for a just-created repo). const doc = await createDoc(); await writePointer(doc); await ensurePhysicalRepoOpen(doc); shimDocNuri = doc; logStage("resolveShimDoc → " + shortNuri(doc)); return doc; })(); shimDocInFlight = p; try { return await p; } finally { shimDocInFlight = null; } } // --- shim load / account bootstrap ---------------------------------------- /** * Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * of the number of accounts in the shim. This is the HOT-PATH lookup: it hits * the account record at its known subject (`accountSubject(id)`) directly in the * doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the * account's record or `null` if it does not exist yet. * * ── Barrier-AUTHORITATIVE (the reconnection fix) ──────────────────────────── * The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened * through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account * genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or * used. This is what replaced the old `resolveAccountReliably` / `provisionRetry` * loop: the store-root ambiguity that forced the retry is gone once the read moves * behind the doc-shim barrier. * * Cached per account (in `accountCache`); a hit skips the query entirely, so * repeated resolves of the same account are free. `resetRegistryCache` clears it. */ /** * Does this account exist? **Propagates** a read failure instead of turning it into * "no such account". * * `resolveAccount` below is tolerant by design — most callers want "carry on without a * record". But a caller that REFUSES on absence needs the two apart: telling a user * "nobody has signed in as bob" because one query timed out is a lie, and it made * `inbox.share` reject a recipient who exists (found adversarially, 2026-08-10). */ export async function lookupAccount(id: string): Promise { const key = accountKey(id); const cached = accountCache.get(key); if (cached) return cached; const s = await session(); const doc = await resolveShimDoc(); // `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to // the doc-shim's default graph (opened through its barrier by resolveShimDoc), so // it is authoritative. The query is bounded to this one subject. const subj = accountSubject(id); const query = ` SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { <${subj}> a <${P.type}> ; <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; <${P.docProtected}> ?docProtected ; <${P.docPrivate}> ?docPrivate . }`; try { const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount"); const rows = readBindings(result); if (rows.length === 0) { logStage("resolveAccount(" + key + ") → null"); return null; } // DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one // account subject (duplicate scope-doc values from past forks). Pick the // canonical (lexicographically-smallest) doc per scope so writer and reader // always resolve the SAME docPublic (robustness against PAST fork residue). const record = recordFromRows(rows, id); accountCache.set(key, record); logStage("resolveAccount(" + key + ") → 1 record"); return record; } catch (error) { console.error(accessLogPrefix() + " resolveAccount failed:", error); throw error; } } /** * The account, or `null` when there is none — and `null` too when the read failed. The * tolerant form every internal caller uses: none of them can act on the difference, and * connecting must not break because a query did not come back. Use {@link lookupAccount} * where absence is a REASON TO REFUSE. */ export async function resolveAccount(id: string): Promise { try { return await lookupAccount(id); } catch { return null; } } /** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the * canonical always-safe shape — same convention as createEntityDoc). * * **Propagates a failed write.** `ensureAccount` caches and returns the record right * after this call, so swallowing here handed back three scope documents that the shim * never heard of: the session writes its entities into them, and the NEXT session, * reading the shim and finding nothing, provisions the account again — a fork, with every * document of the first set orphaned and no error anywhere. This is the same event as a * failed pointer write one level down, and it gets the same answer. */ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise { const s = await session(); const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`; // `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position → // escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so // they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>` // wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the // canonical, always-safe shape the anchored default-graph read queries match. const update = ` INSERT DATA { <${subj}> a <${P.type}> ; <${P.id}> "${escapeLiteral(record.id)}" ; <${P.docPublic}> "${escapeLiteral(record.docPublic)}" ; <${P.docProtected}> "${escapeLiteral(record.docProtected)}" ; <${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" . }`; try { await physicalUpdate(s.sessionId, update, doc, "writeRecord"); } catch (error) { console.error(accessLogPrefix() + " writeRecord persist failed:", error); throw error; } } /** * In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT * `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision. * * ── Why this is load-bearing (concurrent-provision de-dup) ────────────────── * On a fresh page over the persistent wallet, SEVERAL independent callers hit * `ensureAccount(A)` near-simultaneously (a listing per store, an inbox enumeration, the * app's own effects). With the barrier-authoritative * resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the * account is GENUINELY new, N concurrent callers would still each see 0 and each * provision a set of scope docs (an in-session fork). De-duping concurrent provisions * collapses those N into ONE: the first caller resolves-or-provisions; every * concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a * bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant * it settles. */ const ensureInFlight = new Map>(); /** * Ensure an account exists in the shim, creating its 3 scope documents on * first sight. Idempotent — returns the existing record if already present. * Concurrency-safe: concurrent calls for the same account share one provision * (see {@link ensureInFlight}) so a fresh page never FORKS the account. */ export async function ensureAccount(id: string): Promise { const key = accountKey(id); // A completed provision/resolve is cached → no query, no fork risk. const cached = accountCache.get(key); if (cached) { fileOwnStructure(id, cached); return cached; } // A concurrent provision for the SAME account is already running → await it, // instead of racing a second (forking) provision. This is the anti-fork guard. const pending = ensureInFlight.get(key); if (pending) return pending; const p = (async (): Promise => { // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. // // Barrier-AUTHORITATIVE: the lookup reads the doc-shim behind its first-`State` // barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY // absent — not sync-lag. No account-level retry: the store-root ambiguity that // forced the old provisionRetry loop is gone once the read moves behind the barrier. // // `lookupAccount`, NOT the tolerant `resolveAccount`: what follows an absence here is // a PROVISION. The tolerant form answers `null` for a read that failed exactly as for // one that found nothing, so a broker that could not answer minted a second set of // three store documents and a second account record — the very account FORK the // barrier was introduced to end, walking straight back in through the error path. The // session then writes its entities into the new set while the old one holds // everything the user had, and only `canonicalDoc` decides afterwards which of the // two a later session sees. Absence is a reason to create; ignorance is not // (`lookupAccount` exists for exactly this distinction, 2026-08-10). const existing = await lookupAccount(id); if (existing) { fileOwnStructure(id, existing); return existing; } const doc = await resolveShimDoc(); const [docPublic, docProtected, docPrivate] = await Promise.all([ createDoc(), createDoc(), createDoc(), ]); const record: VirtualUserRecord = { id, docPublic, docProtected, docPrivate }; // Persist the record INTO the doc-shim (not the store-root anymore). await writeRecord(doc, record); // Feed the per-account cache, and the full-shim cache if it is already loaded // (so allAccounts / the fan-out see the freshly-created account too). accountCache.set(key, record); fileOwnStructure(id, record); return record; })(); ensureInFlight.set(key, p); try { return await p; } finally { ensureInFlight.delete(key); } } // --- resolvers ------------------------------------------------------------ /** Fixed subject of the shim's inbox INDEX — see {@link recordInbox}. */ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`; /** * Record that `nuri` IS an inbox, in the shim — the emulated counterpart of what the * broker knows by construction upstream. * * Upstream you cannot address a repo with a deposit: `InboxPost` seals to an inbox * PUBKEY, and the broker routes it through `inboxes: PubKey → RepoId` * (`engine/verifier/src/verifier.rs:105,1677`). Depositing into a plain document is not * refused there — it is unrepresentable. Here an inbox is a document like any other, so * "is this an inbox?" has to be asked of something, and the shim is where the emulation * keeps what the network would know. * * Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's * business, exactly like the account records beside it. * * ── And WHOSE inbox it is, recorded beside it ───────────────────────────── * Same table, same reason. Upstream the routing entry `inboxes: PubKey → RepoId` * (`engine/verifier/src/verifier.rs:105`) does not merely say "this is an inbox": it names * the REPO, and the repo's own verifier — its owner's — is what unseals and applies the * message (`verifier.rs:1677`). "Which inbox" and "whose" are one fact there, so they are * one record here. Written for BOTH kinds of inbox, and by the same call: a user's own * ({@link userInbox}) and a document's (`branch-registers.openDocumentInbox`), each of * which knows its owner at the moment it mints one. * * Two `INSERT DATA`s rather than one: the fact keyed by the index subject and the fact * keyed by the inbox are two subjects, and one statement carrying two subjects is a shape * this library writes nowhere else. Both must land before the inbox is handed back, for the * reason spelled out below. * * **Propagates a failed write, and only marks the session's index once the write landed.** * This record is what the emulation puts in place of a fact the network knows by * construction, and `inbox.post` refuses to deposit into anything it cannot confirm * ({@link isKnownInbox}). Swallowing left an inbox that its owner resolves and addresses * normally while every depositor is turned away — permanently, since nothing ever records * it a second time. Marking `knownInboxes` regardless made it worse by hiding the damage * from the only session able to see it: the one that opened the inbox believed the record * existed, and every other session did not. */ export async function recordInbox(nuri: Nuri, ownerId: string): Promise { const s = await session(); const shimDoc = await resolveShimDoc(); try { await physicalUpdate( s.sessionId, `INSERT DATA { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> "${escapeLiteral(nuri)}" }`, shimDoc, "recordInbox", ); // The inbox NURI is the subject here — the routing entry is keyed by the inbox, exactly // as upstream's table is keyed by its pubkey. It is a NURI (`did:ng:` + base64url and // `:` segments), so it carries nothing that could break out of the `<…>`. await physicalUpdate( s.sessionId, `INSERT DATA { <${assertNuri(nuri)}> <${P.inboxOwner}> "${escapeLiteral(ownerId)}" }`, shimDoc, "recordInbox:owner", ); } catch (error) { console.error(accessLogPrefix() + " recordInbox failed:", error); throw error; } knownInboxes.add(nuri); inboxOwners.set(nuri, ownerId); } /** Inboxes this session has already confirmed — the index only ever grows. */ const knownInboxes = new Set(); /** inbox → the id of the user it belongs to, as this session has resolved it. */ const inboxOwners = new Map(); /** * WHOSE inbox is this? The routing question, answered from the shim — see * {@link recordInbox}. `null` when nothing records an owner for it, which for a NURI that * IS an inbox means the record predates this association or its write did not land. * * Asked through the physical door, like {@link isKnownInbox} beside it: routing is not one * virtual user's business, and the caller is precisely the one that is not the owner. * * **Propagates a failed read.** Its caller (`emulated-verifier/inbox-processor.ts`) decides * on the answer whether to drain an inbox and, if so, for whom — so "the broker could not * answer" and "nobody owns this" must not arrive as the same value. Answering `null` on a * failed read would file a deposit under nobody and say nothing. */ export async function inboxOwner(nuri: Nuri): Promise { const memo = inboxOwners.get(nuri); if (memo !== undefined) return memo; const s = await session(); const shimDoc = await resolveShimDoc(); const res = await physicalQuery( s.sessionId, `SELECT ?u WHERE { <${assertNuri(nuri)}> <${P.inboxOwner}> ?u }`, undefined, shimDoc, "inboxOwner", ); for (const row of readBindings(res)) { const id = bindingValue(row, "u"); if (id) { inboxOwners.set(nuri, id); return id; } } return null; } /** * Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not * the inbox's owner and holds nothing of it, which is the whole point of an inbox. */ export async function isKnownInbox(nuri: Nuri): Promise { if (knownInboxes.has(nuri)) return true; const s = await session(); const shimDoc = await resolveShimDoc(); try { const res = await physicalQuery( s.sessionId, `SELECT ?i WHERE { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> ?i }`, undefined, shimDoc, "isKnownInbox", ); for (const row of readBindings(res)) { const v = bindingValue(row, "i"); if (v && isNuri(v)) knownInboxes.add(v); } } catch (error) { console.error(accessLogPrefix() + " isKnownInbox failed:", error); } return knownInboxes.has(nuri); } /** The index document NURI of an account for a scope (the store-container). */ export function storeOf(record: VirtualUserRecord, scope: StoreType): Nuri { return scope === "public" ? record.docPublic : scope === "protected" ? record.docProtected : record.docPrivate; } /** * NURI of the document where `id` writes GROUPED entities of `scope` (a single * per-scope index document, for entities that need no per-entity document / no * inbox). For per-entity scopes use {@link createEntityDoc} instead. */ export async function resolveWriteGraph(id: string, scope: StoreType): Promise { const record = await ensureAccount(id); return storeOf(record, scope); } // --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ---------- // // The consumer asks by SCOPE ("give me the graph to write/read entities of // scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}` // store NURI itself. The lib owns the physical placement — which is the whole // point of the SDK boundary. In THIS polyfill the placement is the shared // wallet's native stores (Axis A, per the two-axes doctrine in // docs/simulation.md): a scope maps to a native store NURI resolved from the // injected session. `public` currently co-locates with `protected` because // `doc_create`/ORM cannot target a non-private/protected native store today // (the SDK blocker recorded in migration-guide.md); at migration each scope // resolves to the user's REAL per-scope store and this mapping changes here, // in the lib, with no consumer change. /** The native store NURI backing `scope`, resolved from the injected session. * Requires `protectedStoreId` on the session for the non-private scopes. */ async function scopeStoreNuri(scope: StoreType): Promise { const s = await session(); if (scope === "private") return `did:ng:${s.privateStoreId}`; // public + protected → the protected native store (see note above). Falls // back to the private store if the session didn't carry a protected id. const store = s.protectedStoreId ?? s.privateStoreId; return `did:ng:${store}`; } /** * The graph NURI where the current session WRITES entities of `scope`, and * whose repo `useShape` must subscribe to read them back. SDK-shaped: the * consumer passes a logical scope and gets an opaque graph NURI — it holds no * store-id and builds no NURI. Use the returned value as both the read scope * (`useShape(shape, nuri)`) and the `@graph` write target. */ // @provenance storeRegistry.resolveScopeGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — `did:ng:` + `private_store_id | protected_store_id | public_store_id`, straight off the session export async function resolveScopeGraph(scope: StoreType): Promise { return scopeStoreNuri(scope); } /** * In-flight `userInbox` resolutions, keyed by account key — so concurrent callers * for the SAME wallet share ONE resolve-or-create instead of racing two documents * into existence (mirrors {@link ensureInFlight}). */ const inboxInFlight = new Map>(); /** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */ const inboxCache = new Map(); /** * The inbox of a virtual user — where caps and messages addressed to THEM arrive. * * **Renamed from `walletInbox` on 2026-08-03, and the old name was wrong twice.** * A *wallet* upstream is only a keyring; what owns stores — and therefore what an inbox * belongs to — is a **user** (a *site*): `SensitiveWalletV0.sites: HashMap` * (`engine/wallet/src/types.rs:456`), `SiteV0` carrying `public`/`protected`/`private` * (`engine/verifier/src/site.rs:31-37`). The library had corrected that vocabulary * everywhere else and this name survived the pass — and it did cost: reasoning about * "the inbox per wallet" hid that upstream a user has **two**. * * **Known divergence, deliberate.** Upstream a user has TWO inboxes, one on its public * store repo and one on its protected store repo — the only two `AddInboxCap` commits in * the engine (`engine/verifier/src/site.rs:128,149`; `new_store_default` attaches one * only `if !private`, `engine/verifier/src/verifier.rs:2994`). They are distinguished * right down to the predicates a contact record uses (`ng:site_inbox` vs * `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:374-375`). This function * exposes ONE. Collapsing them is a simplification this library has not yet had a reason * to undo; the day a caller needs to address a user's public inbox distinctly from its * protected one, this is the seam that has to split in two. * * The NURI of a virtual user's OWN inbox — where deposits addressed to that * identity land, ReadCaps among them. * * ── Why a wallet owns an inbox, and why that is load-bearing ─────────────── * You cannot discover in NextGraph; you can only follow links. So a link crosses * from one wallet to another through exactly one channel: a deposit into the * recipient's inbox. That makes the inbox the **bootstrap of the whole * reachability graph** rather than a side feature — and it is why an inbox has to * BELONG to someone. Before this existed, an inbox was any NURI a caller passed, * so "read the inbox" meant "read anyone's inbox", and since cap-surface routes * caps through it, reading someone else's collected the caps addressed to them. * * Created on first sight and stable thereafter. Recorded in the doc-shim under its * own predicate, read by its OWN query rather than added to the account SELECT: an * account record written before this existed must keep resolving, which it would * not if the fixed account pattern grew a fourth required field. * * Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same * content-addressed way as everything else ({@link canonicalDoc}). * * ── TWO inboxes, because upstream a user has two ───────────────────────── * A *site* carries an inbox on its **public** store repo and another on its * **protected** one — the only two `AddInboxCap` commits in the engine * (`engine/verifier/src/site.rs:127-152`), `new_store_default` attaching one solely * `if !private` (`engine/verifier/src/verifier.rs:2994`). They are addressed * separately right down to the contact records, which pick their predicate from the * profile being reached: `ng:site_inbox` for a public profile, `ng:protected_inbox` * otherwise (`engine/verifier/src/inbox_processor.rs:787,823-824`). * * This function used to expose ONE, which was a cardinality this library invented. * Corrected 2026-08-03 by walking the cascade: neither the JS ORM nor the wasm binding * says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the * session exposes none — so the engine's model is what decides, and it says two. * * **The private store has none**, hence {@link InboxScope} rather than `StoreType`: asking * for a private inbox is not a lookup that returns nothing, it is a question the model * has no meaning for. * * At migration these become the site's native store inboxes and the resolution moves * there — the consumer-facing act (deposit to an inbox, process my own) is unchanged. */ // @provenance userInbox kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's inboxes are its public and protected STORE repos' — the engine's only two `AddInboxCap` commits export async function userInbox(id: string, scope: InboxScope): Promise { const key = `${accountKey(id)}\u0000${scope}`; const cached = inboxCache.get(key); if (cached) { fileOwnInbox(id, cached); return cached; } const pending = inboxInFlight.get(key); if (pending) return pending; const p = (async (): Promise => { const s = await session(); const shimDoc = await resolveShimDoc(); await ensureAccount(id); // the account must exist before it can own an inbox const subj = accountSubject(id); // One triple per (user, scope): the two inboxes are distinct documents, as the two // store repos that carry them are distinct upstream. const pred = `${P.docInbox}:${scope}`; let existing: MaybeNuri; try { // The doc-shim is machinery: this reads WHICH inbox a virtual user owns, // which is exactly the kind of question that cannot be confined to that user. const res = await physicalQuery( s.sessionId, `SELECT ?d WHERE { <${subj}> <${pred}> ?d }`, undefined, shimDoc, "userInbox", ); existing = canonicalDoc(readBindings(res), "d"); } catch (error) { // ONLY A VERIFIED ABSENCE MAY MINT. This catch logged and let execution fall // through to the mint below, so a read that could not ANSWER was treated as "this // user owns no inbox" — and a second document was created for one (user, scope). // Two `docInbox` triples then sit on the same subject, and which one wins // afterwards is decided by `canonicalDoc` picking among them: the owner can drain // one while a depositor writes into the other, each of them right, with nothing // anywhere to see. Same fault as `connect.connectedUser` (2026-08-13), except this // one writes the mistake down. console.error(accessLogPrefix() + " userInbox read failed:", error); throw error; } if (existing) { inboxCache.set(key, existing); fileOwnInbox(id, existing); return existing; } // The broker answered, and it answered nothing: this user genuinely owns no inbox for // this scope yet. THAT is what mints one — and the three writes it takes all have to // land before anyone is handed the result. const doc = await createDoc(); await recordInbox(doc, id); try { await physicalUpdate( s.sessionId, `INSERT DATA { <${subj}> <${pred}> "${escapeLiteral(doc)}" }`, shimDoc, "userInbox", ); } catch (error) { // This triple IS the answer to "which inbox does this user own". Caching and // returning the document over a failed write handed the owner a reference nobody // else can resolve: a depositor reading the shim finds nothing and mints yet // another. The owner then reads a box nobody writes to, and depositors write to a // box nobody reads — the exact shape sharing broke in before. console.error(accessLogPrefix() + " userInbox persist failed:", error); throw error; } // Filed and cached only now, and in that order: until the association was written // this document was not yet anyone's inbox, and holding a cap for it (or answering // with it for the rest of the session) would outlive the failure that decided it. fileOwnInbox(id, doc); inboxCache.set(key, doc); logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc)); return doc; })(); inboxInFlight.set(key, p); try { return await p; } finally { inboxInFlight.delete(key); } } /** * Create a dedicated document for ONE entity — mirrors the target, where each * such entity is its own document/repo (addressable, future inbox). The new * document's NURI is appended to the account's scope index document (the * store-container). Returns the entity document NURI (use it as `@graph`) — a * CAP-LESS reference, exactly like `doc_create` upstream: it names the document, * it does not carry its key. The key goes to what the creator holds (see * {@link holdOwnCap}), which is where you look it up. */ export async function createEntityDoc(id: string, scope: StoreType): Promise { const record = await ensureAccount(id); const indexDoc = storeOf(record, scope); const entityNuri = await createDoc(); const s = await session(); const cap = mintCap(entityNuri); // TWO writes, both attempted, and the failure reported only after. They are separate // because upstream they are two commits on two branches — `ldp:contains` on the store's // Main branch and `AddRepo { read_cap }` on its Store branch // (`engine/verifier/src/request_processor.rs:697-710`) — and because either is worth // having without the other: the cap alone lets the creator re-open its document, the // listing alone makes it findable. // // A first attempt at surfacing the failure (2026-08-07) threw on the FIRST one, and an // adversarial pass showed it was wrong in both directions: throwing on the listing // skipped the cap write, orphaning the document entirely — destroying the very recovery // the comment above describes — and throwing on the cap left the document LISTED with // no key, which is precisely the "reads empty forever" state it claimed to prevent. // Attempt both, keep what lands, and say exactly what state the document is in. const failures: string[] = []; try { await registerUpdate( s.sessionId, // NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the // `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the anchored // default-graph read queries (readUserStore below, same as read-document.ts). // entityNuri is a NURI stored as a literal → escapeLiteral. `INSERT DATA { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`, indexDoc, "createEntityDoc", ); } catch (error) { console.error(accessLogPrefix() + " createEntityDoc index append failed:", error); failures.push(`it is not listed in its store (${String(error)})`); } try { await registerUpdate( s.sessionId, `INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`, indexDoc, "createEntityDoc:addRepo", ); } catch (error) { console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error); failures.push(`its key is not recorded (${String(error)})`); } if (failures.length > 0) { // The document EXISTS on the broker; what failed is the bookkeeping that makes it // findable and re-openable. Returning its NURI would hand back a reference that reads // empty on the next session, silently — so the caller is told instead. throw new Error( `[ng-eventually] createEntityDoc: the document ${entityNuri} was created but ` + `${failures.join(", and ")}. It will not survive this session as it stands.`, ); } // …and the creator holds THAT cap for this session. holdOwnCap(id, scope, entityNuri, cap); // A document in a PUBLIC store hands its cap to whoever asks — upstream because the // broker exposes the outer overlay and the ReadCap is downloaded from it // (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`), here because the cap is put // where the machinery can fetch it. This is what makes a BARE reference enough for a // public document, so nothing in this library ever has to put a key into a link. if (scope === "public") await exposeReadCap(entityNuri, cap); // NO inbox here, and NOT the owner's own inbox published as this document's address. // Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming // message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) // and unseals it with THAT repo's private half, while `InboxMsgBody` carries no // target document at all (`engine/net/src/types.rs:4265`) — because it needs none, // the address IS the identification. Pointing several documents at one inbox would // emulate a many-to-one relation the model cannot express, and would teach consumers // to tag deposits with their document, a habit that has to be unlearned at migration. // // So a document gets an inbox only when its owner opens one // ({@link openDocumentInbox}), which is also what keeps the cost proportional: only // documents meant to RECEIVE pay for one (see // `docs/briefs/2026-08-03-document-inbox-addressing.md`). return entityNuri; } /** * Read the entity-document NURIs contained in ONE scope index document. * * **Propagates a failed read**, like {@link readStoreCaps} and `readLinks` next to it, and * for the same reason: empty and unreadable are the same value here — "this account put * nothing in this store" against "the store did not answer" — and the first is a fact an * application acts on while the second is a breakdown it must be told about. Swallowing the * difference is what {@link listMyEntityDocs} exists downstream of, and answering an empty * listing for a read that never happened is the failure-as-absence this library was bitten * by twice. * * A caller that genuinely prefers to carry on catches it itself, and one does: `ownsDocument`, * whose answer is a REFUSAL either way (see it). */ export async function readUserStore(indexDoc: Nuri): Promise { const s = await session(); const out: Nuri[] = []; // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the // scope-index repo (public OR protected — the protected one carries participations // and is the one that most often reads empty) is not yet in `self.repos`, so this // anchored read would return 0 NURIs → nothing gets listed → nothing gets // subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE // before reading it. Idempotent per session; no-op with the unit fake ng. See // open-repo.ts. await ensureRepoOpen(indexDoc); const res = await sparqlQuery( s.sessionId, // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see // the note in createEntityDoc). The `indexDoc` anchor scopes the query. `SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`, undefined, indexDoc, "readUserStore", ); for (const row of readBindings(res)) { // SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored // value that is not a NextGraph reference is not an entity document. const v = bindingValue(row, "e"); if (v && isNuri(v)) out.push(v); } logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities"); return out; } // `userStoreDoc(id, scope)` was REMOVED in `1.0.0-dev.6`. It handed out the scope-INDEX // document so that a reactive reader could SUBSCRIBE to it and re-resolve its document set // when the index changed - and the reactive reader was the only thing that ever wanted it. // `useShapeQuery` now reads through the SDK's own shape query, which follows the data // rather than a container this package maintains, so the last caller went with the // pipeline. `storeOf(await ensureAccount(id), scope)` is the two-line body, for whoever // needs the value again. /** * The entity-document NURIs of `scope` belonging to ONE account (`id`) — * the read-by-need path for one account's own entities. Bounded to a SINGLE * account: it resolves only that account's scope index doc (via `ensureAccount`) * and reads the contained NURIs — NO cross-account fan-out, so it never touches * another account's unsynced docs. This is the helper a consumer application uses * for its own my-entities path, instead of the all-accounts `listEntityDocs`. * * **Only a VERIFIED answer comes back as a listing.** Both reads it stands on propagate — * the store's Main branch, which says WHICH documents are there, and its Store branch, * which hands back the KEY of each. A listing is a promise that what it names can be * opened, and until 2026-08-17 the key read was tolerated here: the caller got the * documents without the means to read them, and nothing on the surface told the two apart. * From the outside a listing you can open and one you cannot are the same array — the * difference only appears at the next read, coming back empty, long after the cause is * gone. That is the shape of the outage this library shipped twice * (`resolveAccount` answering "no such account" for a read that failed, `connectedUser` * resolving over a restore that never ran), so the tolerance goes with it. * * Nothing to list is NOT a failure and stays silent: a store that answers with zero * documents means this account created none, which is a fact. */ export async function listMyEntityDocs(id: string, scope: StoreType): Promise { const record = await ensureAccount(id); const store = storeOf(record, scope); const docs = await readUserStore(store); // Recover the caps by READING the Store branch, never by recomputing them from // the NURIs — that is the whole point of storing them. A fresh session gets back // exactly what was recorded, and the day the stand-in value becomes a real key // (cap-enforcement) this path needs no change at all. // // Scoped to the current holder: another user's store caps are not ours to hold. const holder = getCurrentUser(); if (holder !== null && accountKey(holder) === accountKey(id)) { const caps = getCaps(); // The holder was decided just above; `readStoreCaps` awaits, and filing resolves the // holder when it runs — so hand the captured key back rather than trust that the // identity has not moved. See `caps.holderKey`. const holderKey = caps.holderKey(); // PROPAGATES, like `readStoreCaps` itself and like `restoreOwnCaps` next door. Holding // the listing already is exactly why this must not swallow: the array is in hand, so // returning it costs nothing and says something false — these are yours, here they are. // A caller has no second question to ask that would reveal the keys are missing. for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap); // Which store a document sits in is a registry fact, not one recorded beside the // caps, so it is re-applied here. Marking only — the caps just came from the Store // branch above, and minting a second one beside them is the trap `holdOwnCap` warns // about. if (scope === "public") for (const d of docs) caps.markInPublicStore(d); } return docs; }