Files
ng-eventually/packages/polyfill/src/shared-wallet/account-registry.ts
T
Sylvain Duchesne 0a2b8b1fbf docs: dire partout que la lecture par lot n'existe plus
Le contrat publie et l'api-contract etaient traites avec le retrait lui-meme ;
restaient les documents qui decrivaient readUnion comme la primitive de
lecture du paquet, ou qui nommaient le module disparu.

Ce que les lecteurs apprennent maintenant : lire un document, c'est
docs.sparqlQuery ancre dessus, et lire plusieurs documents, c'est cet appel
une fois par document -- avec la decision sur une lecture qui echoue ecrite
au point d'appel. Deux consequences sont dites plutot que decouvertes : une
lecture refusee leve au lieu de rendre une liste vide, et les sujets sous le
prefixe reserve urn:ng-eventually: reviennent dans le resultat, a l'appelant
de les ignorer -- sans aucun gain de droits, les deux registres qu'un
document ordinaire porte etant l'adresse de sa boite et, pour un document
public, la cle que son magasin sert deja a qui la demande.

Les briefs dates ne sont pas touches : ils decrivent un etat de juillet, et
le reecrire falsifierait ce qui avait ete decide alors.
2026-09-21 14:11:35 +02:00

1326 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:<RepoID>`
* 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<PrivKey>`, `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<RegistrySession> {
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<Nuri> {
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<string, VirtualUserRecord>();
// 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<Nuri> | 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<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
if (anyRes.results?.bindings) return anyRes.results.bindings;
return [];
}
export function bindingValue(row: Record<string, { value: string }>, 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<Record<string, { value: string }>>, key: string): MaybeNuri {
let chosen: MaybeNuri = "";
const distinct = new Set<string>();
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<Record<string, { value: string }>>,
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<void> => 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<MaybeNuri> {
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<void> {
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<Nuri> {
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<Nuri> {
if (shimDocNuri) return shimDocNuri;
if (shimDocInFlight) return shimDocInFlight;
const p = (async (): Promise<Nuri> => {
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<VirtualUserRecord | null> {
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<VirtualUserRecord | null> {
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<void> {
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<string, Promise<VirtualUserRecord>>();
/**
* 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<VirtualUserRecord> {
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<VirtualUserRecord> => {
// 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<void> {
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<Nuri>();
/** inbox → the id of the user it belongs to, as this session has resolved it. */
const inboxOwners = new Map<Nuri, string>();
/**
* 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<string | null> {
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<boolean> {
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<Nuri> {
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<Nuri> {
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<Nuri> {
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<string, Promise<Nuri>>();
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
const inboxCache = new Map<string, Nuri>();
/**
* 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<String, SiteV0>`
* (`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<Nuri> {
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<Nuri> => {
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<Nuri> {
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<Nuri[]> {
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<Nuri[]> {
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;
}