/** * Inbox — a generic deposit + read/materialize mechanism the consumer reuses for * its own purposes (same `inbox.post` API, same watcher — see the discovery-model * decision). The consumer supplies the inbox document NURI and interprets its own * `payload`: this module knows no application domain, and the only payloads it reads * are the ones that DECLARE themselves to this package — see * `emulated-verifier/inbox-dispatch.ts`, which owns that question and every arm that * answers it. Everything else is handed back untouched. * * ── Real target vs this emulation ───────────────────────────────────────── * In real NextGraph, a message is sealed to the recipient's key and queued into * their inbox; the recipient's own verifier unseals each queued message and * applies it inline as it processes the inbox — there is no separate curator * process. There is NO sender-side JS call for this today: the verifier has no * `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`, * named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` — * no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.) * * Here, on one shared wallet where everything is readable, both sides run in-lib: * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox * document (in the shared wallet) via the `docs.sparqlUpdate` primitive; * - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose * them. This in-lib read stands in for the recipient's own inbox processing * until a sealed-inbox path is exposed to JS. * * All NextGraph I/O routes through the `docs` primitives (the real injected `ng`, * never `makeNg`), so this module imports no `@ng-org` package. */ import { sparqlQuery } from "./docs"; import { depositInto, readForHolder } from "../emulated-verifier/register-write"; import { subscribeDoc } from "./subscribe"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; import { mintCap } from "../emulated-verifier/caps"; import { scheduleInboxProcessing, traceProcessed, } from "../emulated-verifier/inbox-processor"; import { capOfPayload, kindOf, linkPayload, } from "../emulated-verifier/inbox-dispatch"; import { deliverDeposits } from "../emulated-verifier/inbox-consume"; import type { StoredDeposit } from "../emulated-verifier/inbox-consume"; import { accountKey, inboxOwner, userInbox, isKnownInbox, lookupAccount, } from "../shared-wallet/account-registry"; import { escapeLiteral } from "./sparql"; import { toNuri } from "../model/nuri"; import { accessLogPrefix, enabled as accessLogEnabled, logAccess, logStage, shortNuri, } from "../shared-wallet/access-log"; import type { Nuri, NuriLike, PrincipalId } from "../model/types"; // --- deposit model -------------------------------------------------------- /** One deposit as materialized from an inbox document. */ // @provenance inbox.Deposit kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgBody — `from` optional like `from_inbox`, and NO target-document field because the address identifies the recipient repo; `payload`/`ts` have no upstream field export interface Deposit { /** The sender, if identified; `null` when the deposit was anonymous. */ from: PrincipalId | null; /** The consumer-defined payload (opaque here — JSON-serialized in storage). */ payload: unknown; /** Deposit timestamp (ms epoch). Caller may pass one for determinism. */ ts: number; } /** Options for {@link post}. `from` and `ts` are both optional. */ // @provenance inbox.PostOptions kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgBody — same shape as Deposit on the way in; `from: null` mirrors `from_inbox: None` export interface PostOptions { /** * Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a * principal id to identify the sender. Defaults to the current polyfill user * ({@link getCurrentUser}) when the property is entirely absent, so callers * that want anonymity must pass `from: null` explicitly. */ from?: PrincipalId | null; /** The payload to deposit (interpreted only by the consumer). */ payload: unknown; /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it * keeps tests deterministic. */ ts?: number; } const SHIM = "urn:ng-eventually:inbox"; const P = { type: `${SHIM}:Deposit`, from: `${SHIM}:from`, payload: `${SHIM}:payload`, ts: `${SHIM}:ts`, } as const; // --- session access (shared with the storeRegistry) ----------------------- /** The inbox documents live in the shared wallet, so we reuse the registry's * injected session provider for the sessionId. Disappears at migration. */ async function sessionId(): Promise { return (await getStoreRegistryDeps().getSession()).sessionId; } // --- diagnostic logging helper --------------------------------------------- /** * Best-effort, length-capped JSON rendering of a deposit payload for the * inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module * stays domain-agnostic (see module header) — it never interprets payload * fields, it only dumps them verbatim so the consumer's own shape (e.g. a * Festipod participation: `{ participantId, eventId, … }`) is visible in the * log without this module knowing that shape. Capped so one oversized payload * can't blow up a log line; a payload that fails to stringify (e.g. a * circular structure a caller mistakenly passed) falls back to `String()`. */ function summarizePayload(payload: unknown): string { let s: string; try { s = JSON.stringify(payload) ?? String(payload); } catch { s = String(payload); } return s.length > 200 ? s.slice(0, 200) + "…" : s; } // --- SPARQL result helpers ------------------------------------------------ /** * The SELECT that materializes an inbox document's deposits. * * NO explicit `GRAPH <…>` clause — it reads the anchored DEFAULT graph (see the note in * {@link post}). The anchor scopes the query to that repo's default graph, exactly where * `post` writes. * * Shared by the two readers of an inbox — its owner ({@link read}) and the emulated * processor draining it for that owner ({@link processForOwner}) — so the two cannot * come to disagree about what a deposit looks like. * * `?d`, the deposit's own subject, is selected because CONSUMING one names it: `post` * mints a fresh unique subject per deposit, so it is the key that retires that deposit and * no other, where a `ts` two deposits could share is not. It never reaches an application * — `depositsFrom` keeps it beside the {@link Deposit} rather than in it. */ const DEPOSITS_QUERY = ` SELECT ?d ?payload ?ts ?from WHERE { ?d a <${P.type}> ; <${P.payload}> ?payload ; <${P.ts}> ?ts . OPTIONAL { ?d <${P.from}> ?from } }`; /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ function readBindings(result: unknown): Array> { if (!result) return []; if (Array.isArray(result)) return result as Array>; const anyRes = result as { results?: { bindings?: Array> }; }; return anyRes.results?.bindings ?? []; } // --- deposit (client side) ------------------------------------------------ /** * Deposit a payload into `targetInbox`. * * Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate` * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox * graph, so concurrent deposits don't collide. * * `from` is bound to the current identity — it is authenticated, not * caller-supplied. Omit it to stamp the current identity; pass `null` to deposit * anonymously (a legitimate choice — identified if known, anonymous otherwise). * A `from` naming another identity is rejected as a spoof: in the target the * broker seals the sender from the wallet's own key, so a client cannot forge * another's identity. This check is redundant once the seal enforces it, but * until then it closes the spoof the shared wallet would otherwise allow. */ // @provenance inbox.post kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:post_to_inbox — the act exists at level 1 and the broker routes it (`inbox_post`); no JS surface at level 2/3. An arbitrary `payload` is a BET — `InboxMsgContent` has no generic variant export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.post"); const current = getCurrentUser(); let from: PrincipalId | null; if (opts.from === undefined) { from = current; // default: stamp the current identity } else if (opts.from === null) { from = null; // explicit anonymous deposit } else if (opts.from === current) { from = opts.from; // identifying as self — allowed } else { throw new Error( "[ng-eventually] inbox.post: `from` must be the current identity or null " + "(anonymous) — depositing as another principal is a spoof.", ); } const ts = opts.ts ?? Date.now(); const sid = await sessionId(); // A unique subject per deposit (in the inbox graph) — no collisions. const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`; const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null)); const fromTriple = from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`; // NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph: // `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's // default graph (same shape as `read-document.ts`'s anchored read). This is the // CANONICAL, always-safe shape and the one the anchored default-graph read // queries. (Not a round-trip necessity on the current broker: the e2e harness // `packages/polyfill/e2e/` verified that an anchored `GRAPH ` write // ALSO round-trips here — it resolves to the same repo graph, no phantom graph. // The no-GRAPH form is kept as a simplicity/safety convention; re-verify with // that harness if the broker version changes.) const update = ` INSERT DATA { <${subject}> a <${P.type}> ; <${P.payload}> "${payloadLiteral}" ; <${P.ts}> "${ts}"${fromTriple} . }`; // The target must BE an inbox, and this is the one check standing between a deposit // and an arbitrary write into someone else's document. // // Upstream the question does not arise: `InboxPost` seals to an inbox PUBKEY and the // broker routes it by `inboxes: PubKey → RepoId` — addressing a plain repo with a // deposit is not refused, it is unrepresentable. Here an inbox is a document like any // other, so without this `inbox.post(someoneElsesDocument, …)` wrote four triples into // it, through a published door that skips both guards by design. Found by re-running // the adversary on the fix that un-published `depositInto` (2026-08-07) — moving that // function was not enough, because `post` reaches the same door. if (!(await isKnownInbox(targetInbox))) { throw new Error( "[ng-eventually] inbox.post: refused — this is not an inbox. A deposit is addressed " + "to an inbox, never to a document; upstream the two cannot even be confused, " + `because a deposit carries an inbox key and not a document reference. ${JSON.stringify(targetInbox)}`, ); } // A deposit crosses the boundary on purpose — see `register-write.depositInto`. await depositInto(sid, update, targetInbox, "deposit"); // The deposit LANDED, so something has to pick it up. Upstream that is a service; here it // is a one-shot timer on the TARGET's inbox — see `emulated-verifier/inbox-processor.ts` // for what that emulates, what it usurps, and the limit it does not hide. Armed only on a // write that succeeded: a deposit that never landed has nothing to converge. scheduleInboxProcessing(targetInbox, () => processForOwner(targetInbox)); // Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log): // who deposited WHAT into which inbox — the decoded payload, not just the // triple-write. Gated by the same access-log flag; skip the JSON work when off. if (accessLogEnabled()) { logAccess( "WRITE", targetInbox, "inbox deposit", " from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null), ); } } /** * Deposit into the inbox of a DOCUMENT — resolve where, then deposit there. * * The call an app makes to reach a document's owner: it needs the document (which it * must be able to read) and nothing else. Where the inbox is, and whether the owner * ever opened one, are the library's business. * * **No target-document field on the deposit, deliberately.** Upstream an inbox belongs * to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals * with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody` * carries no document (`engine/net/src/types.rs:4265`), because the address already * identifies it. Tagging deposits with their document would be an invention consumers * would have to unlearn at migration, so this resolves the address and stops there. * * @throws if the document has no inbox — its owner never opened one, so there is * nowhere for this to go. Throwing rather than returning quietly is the whole lesson of * this path: a deposit that vanishes without an error is worse than a refusal, and it * is exactly the bug per-document inboxes shipped with * (`docs/briefs/2026-08-03-document-inbox-addressing.md`). When "no inbox" is an expected * case for the caller, catch it — there is deliberately no published way to ask an * address in advance, because an application must name a document or a person, never an * inbox. */ // @provenance inbox.postToDocument kind=declared-not-wired level=1 ref=engine/repo/src/repo.rs:Repo — every repo carries `inbox: Option`, but nothing opens one for a plain document. Resolving the address by READING the document is our divergence — see `publishInboxAddress` export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise { const doc = toNuri(docLike, "inbox.postToDocument"); const target = await documentInboxAddress(doc); if (target === undefined) { throw new Error( "[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " + "never opened one, or you cannot read the document (the address rides on it): " + JSON.stringify(doc), ); } return post(target, opts); } // --- cap delivery --------------------------------------------------------- /** * The payloads the last read of an inbox saw DECLARE themselves to this package — * every variant of the dispatch, not only Links — awaiting the applying pass. * * Kept in memory for this session and nothing more: APPLYING them is * {@link processInbox}'s job, because reading an inbox must not quietly write to a * user's store. Reading the raw deposits a second time would mean re-parsing them, so * what the read already decoded is what the pass works from. */ const seenByInbox = new Map(); function declaredIn(inbox: Nuri): StoredHere[] { return seenByInbox.get(inbox) ?? []; } /** * A deposit as it is STORED — its subject and what an application would see of it. The * subject is what {@link processInbox} and {@link processForOwner} hand to the consuming * primitive; it is emulation detail and stays inside this package. */ interface StoredHere extends StoredDeposit { readonly deposit: Deposit; } /** * The deposits a raw SELECT result carries, sorted by `ts` ascending — the one place the * stored shape is turned back into {@link Deposit}s, for both readers of an inbox. * * Each keeps the subject it was read under, because a drain has to be able to RETIRE the * deposit it just applied and a `ts` does not identify one. */ function depositsFrom(result: unknown): StoredHere[] { const stored: StoredHere[] = []; for (const row of readBindings(result)) { const rawPayload = row.payload?.value ?? "null"; let payload: unknown; try { payload = JSON.parse(rawPayload); } catch { payload = rawPayload; // tolerate a non-JSON literal } const tsRaw = row.ts?.value ?? "0"; const ts = Number.parseInt(tsRaw, 10) || 0; const fromValue = row.from?.value; stored.push({ subject: row.d?.value ?? "", payload, deposit: { from: fromValue ? fromValue : null, payload, ts }, }); } stored.sort((a, b) => a.deposit.ts - b.deposit.ts); return stored; } // What a deposit DECLARES itself to be, and what each declaration means, live in // `emulated-verifier/inbox-dispatch.ts` — the `match` this surface hands its deposits to. /** * Share ONE document with ONE recipient. * * The unit of sharing is the DOCUMENT: never hand over a store's cap, which would * give away everything the store contains, present and future. The recipient needs * no dedicated operation to receive it — the cap arrives as a deposit that their * existing {@link watch} absorbs into what they hold (see {@link read}). * * Reaching several recipients means calling this once per inbox, which is what the * real model does too: each delivery is sealed to one recipient. * * Upstream this path is a GAP, not a disagreement — verified at both ends: * - the field exists, `ContactDetails.read_cap: Option` * (`engine/net/src/types.rs:4233`), but building a message that carries one is * `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`); * - and the receiver ignores it: `InboxMsgContent::ContactDetails` creates a fresh * contact document and writes `ng:site`/`ng:protected` + `ng:*_inbox`, a * `vcard:Individual` type, a `vcard:fn` name and an optional `vcard:hasEmail`, * then sets the header title (`engine/verifier/src/inbox_processor.rs:778-845`) — * but never `details.read_cap`. *(The list was "only the two `ng:` predicates" * until 2026-08-10, which understated what the arm writes; the load-bearing part * is the omission, not the length of the list.)* * * Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit * variant carrying nothing** (`engine/net/src/types.rs:InboxMsgContent::Link`). * * The shape is right; the implementation is absent at both ends, so we emulate it * meanwhile. */ // @provenance inbox.share kind=declared-not-wired level=1 ref=engine/net/src/types.rs:ContactDetails — `read_cap: Option` is defined; building the message is `unimplemented!()`, its only caller passes `with_readcap: false`, and the receiving arm never reads the field export async function share(doc: NuriLike, toUser: string): Promise { const target = toNuri(doc, "inbox.share"); // Names the DOCUMENT and the PERSON — the two things an application has. Neither the // key nor the address appears, because a caller will handle neither once this is // native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox // is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`; // both made the caller hold something it will not hold later. const cap = getCaps().capFor(target); if (!cap) { throw new Error( "[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " + `for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`, ); } // ── KNOWN DIVERGENCE: the protected inbox is hard-coded here ────────────── // Upstream the choice is not fixed. A contact record picks its inbox from the PROFILE // through which the person was reached: `a_or_b = if details.profile.is_public() // { "site" } else { "protected" }` (`engine/verifier/src/inbox_processor.rs:787`, // written as `ng:site_inbox` vs `ng:protected_inbox` at `:823-824`). Reach someone by // their public profile and the deposit goes to their public store's inbox; by their // protected profile, to the protected one. // // This library has no notion of "the profile by which I know this person", so it // always uses the protected one. Minor today — a consumer names a user and gets one // answer — but it flattens a distinction the model makes, and the day an application // shares with someone met through a public profile, this picks the wrong inbox. // // Not fixable in isolation: it needs a notion this library does not have, and about // which nothing has been established here. What IS verified: a wallet holds `sites`, // a `SiteV0` has an `id: PubKey`, a `name`, a `site_type` (Individual | Org) and three // stores (`engine/verifier/src/site.rs:23-40`); the `Identity` enum that would name // the rest is entirely COMMENTED OUT upstream (`engine/repo/src/types.rs:586-595`). // Do not build on an assumed profile model — there is none to read yet. // // (The private store has no inbox at all — `new_store_default` attaches one only // `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private // inbox" unwritable rather than merely empty.) // The recipient must EXIST. `userInbox` provisions on first sight, so sharing with a // name nobody has signed in as used to succeed silently: it minted that name's three // stores and an inbox, and the cap landed where nobody will ever look. A mistyped // recipient is the ordinary case, and it produced no error at all. // // Upstream you cannot address a name you invented: a deposit is sealed to an inbox // PUBKEY (`InboxMsg::new`, `engine/net/src/types.rs:4299`) that reached you through an // inbound `ContactDetails` — someone has to have reached you first. Refusing is the // faithful behaviour; provisioning was the invention. // // `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read // that FAILED as well as for one that found nothing, so it would have told a user // "nobody has signed in as bob" because a query timed out. A refusal must not be // built on a value that conflates absence with ignorance. if ((await lookupAccount(toUser)) === null) { throw new Error( `[ng-eventually] inbox.share: no such recipient — nobody has signed in as ` + `${JSON.stringify(toUser)}. Sharing does not create the person you share with.`, ); } await post(await userInbox(toUser, "protected"), { payload: linkPayload(cap) }); } /** * Turn a DOCUMENT into its deposits — the one place the document-addressed reads resolve * an address, shared by both of them ({@link readForDocument} and * {@link readSyncedForDocument}) so the two cannot come to disagree about what "this * document has no inbox" means, exactly as {@link DEPOSITS_QUERY} is shared by the two * readers of an inbox. * * Empty when the document has no inbox, which is a state and not an error. WHICH read of * the inbox follows is the caller's, and it is the only thing the two differ by. */ async function depositsForDocument( doc: Nuri, readInbox: (inbox: Nuri) => Promise, ): Promise { const address = await documentInboxAddress(doc); return address ? readInbox(address) : []; } /** * The messages left on a document YOU own — the read side of {@link postToDocument}. * * Named by the DOCUMENT, like the deposit side: an owner reading their own messages has * no more reason to handle an inbox address than a depositor does. Empty when the * document has no inbox, which is a state and not an error. * * The WARM read, like {@link read} it delegates to: it gates on no sync barrier, so on a * fresh session over the same persistent wallet it can answer `[]` for a document that * has deposits. {@link readSyncedForDocument} is the same address with that guarantee. */ // @provenance inbox.readForDocument kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — upstream an inbox is a queue the verifier consumes and APPLIES; this enumerates it instead, non-destructively export async function readForDocument(docLike: NuriLike): Promise { const doc = toNuri(docLike, "inbox.readForDocument"); return depositsForDocument(doc, read); } // --- the read guard ------------------------------------------------------ /** * Refuse to READ an inbox that is not the current wallet's. * * Depositing into someone else's inbox is the one legitimate cross-wallet act (it * is how a link reaches another wallet at all — see {@link post} / {@link share}); * READING one is not, and it is not symmetric with it. Since caps travel as * deposits, an unguarded read let anyone who knew an inbox NURI collect the caps * addressed to its owner, which defeats directed sharing entirely. * * Anonymous owns no inbox, so it can read none — an identity has to be established * first. At migration this disappears: an inbox is sealed to its owner's key, and * the guard is the cryptography. */ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise { if (getCurrentUser() === null) { throw new Error( `[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` + "session — call `ensureIdentity()` first. Depositing (post/share) stays open.", ); } if (!(await isOwnInbox(targetInbox))) { throw new Error( `[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` + "the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " + "your own — otherwise the caps addressed to its owner would be collectable by " + `whoever knows its NURI: ${JSON.stringify(targetInbox)}`, ); } } // --- read -------------------------------------------------------------- /** * Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In * real NextGraph the recipient's own verifier applies queued messages inline as * it processes the inbox; here this read stands in for that until the * sealed-inbox path is available. The consumer interprets each deposit's * `payload`. * * Cap deliveries ({@link share}) are applied inline and NOT returned: they land * in what the current holder holds, like the verifier applying a queued message. * That is why receiving a cap needs no dedicated operation — a consumer already * watching its inbox gets them, and the resulting change re-triggers the * reads that were empty for want of that cap. */ // @provenance inbox.read kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — same: a re-readable list where upstream has a consumed queue. Coding a mailbox UI on this is coding against emulation detail export async function read(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.read"); await assertOwnInbox(targetInbox, "read"); // WHO this read belongs to, captured with the guard that authorised it — see the note // beside the filing below, and `caps.holderKey`. const owner = getCurrentUser(); const ownerKey = getCaps().holderKey(); const sid = await sessionId(); // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it // (a cold reader that opens the repo before reading), NOT here — `inbox.watch` // already holds the repo open via its own `subscribeDoc`, so opening a second // bootstrap subscription from inside a watch's re-read would be redundant and can // race the watch's own initial-`State` delivery. Keeping `read` a pure anchored // read leaves both callers correct: the watch path stays event-driven, and the // cold direct-read path opens the repo explicitly before calling `read`. const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead"); const deposits = depositsFrom(result); // A KEY is the one thing that never reaches the caller: it is infrastructure, and the // model's whole point is that an application never handles one. Every other deposit is // handed back exactly as before, INCLUDING the ones that declare themselves to this // package — a reference is public information, so withholding it would buy nothing and // would take from an application something it can already read. // // What every declared payload does get is a place in the session's list, which is what // {@link processInbox} works from. It is only KEPT here — APPLYING is that pass's job, // because reading an inbox must not quietly write to a user's store. Filing fires the // registry's change signal, which is what makes a view that was empty for want of a cap // re-read instead of staying stale. const delivered: Deposit[] = []; const declared: StoredHere[] = []; // The ownership guard ran at entry; the filing happens several awaits later, and filing // resolves WHO is holding at that moment. So an application switching identity in the // gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the // code, not a leak anyone reproduced — see `caps.holderKey`. // // Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's // verifier, and switching user is another session. Nothing is lost, and READING is still // what leaves it that way: this pass only HOLDS caps in memory, and a deposit is retired // by the applying pass alone (`emulated-verifier/inbox-consume.ts`), which this one // never reaches. So an abandoned read leaves the queue exactly as it found it. const stillOwner = getCurrentUser() === owner; for (const d of deposits) { if (kindOf(d.payload) !== null) declared.push(d); const cap = capOfPayload(d.payload); if (cap) { if (stillOwner) getCaps().learnFor(ownerKey, cap); continue; } delivered.push(d.deposit); } // UNCONDITIONAL since 2026-08-26, and the empty case is the whole point: this used to be // written only when something was declared, so a read that found nothing left the PREVIOUS // read's list standing. Harmless while nothing was consumed — the arms are idempotent — but // once deposits are popped it means a later `processInbox` works from a list of deposits // that are already gone, re-popping and RE-APPLYING them. That refutes "delivered exactly // once" outright, and the path is real: `processForOwner` drains through `readForHolder` // and never touches this map, so the owner's own next pass inherits a stale one. seenByInbox.set(targetInbox, declared); // Domain-level diagnostic (on top of docs.ts's generic access-path READ log // of raw triple-rows): how many DEPOSITS were found, and the decoded data of // each — the exact visibility needed to trace materialization at the owner // side. Gated by the same access-log flag; skip the JSON work when off. if (accessLogEnabled()) { logAccess( "READ", targetInbox, "inbox materialize", " → " + delivered.length + " message(s)" + (deposits.length !== delivered.length ? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)" : ""), ); for (const d of delivered) { logAccess( "READ", targetInbox, "inbox message", " ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload), ); } } return delivered; } // `materialize` — a second published name for {@link read} — was REMOVED from the surface // on 2026-08-14. It was an alias and nothing more: no call site anywhere, and upstream has // no such member, so it was a symbol an application could learn and would have to unlearn. // `read` is the name; the word "materialize" survives only as prose in the docs. /** * COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at * (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first * `State` (the deterministic sync barrier — after it, presence is guaranteed and * absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}. * * Why this over a plain {@link read}: on a FRESH session over the persistent * wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's * `self.repos`, so a plain anchored `read` resolves an unopened repo and silently * returns 0 deposits — even for a deposit a remote session already synced to the * broker. Gating on the sync barrier makes the read see the synced deposits. This * is the same cold-read heal any cold direct reader needs. * * NOT for the `watch` path: {@link watch} already holds the repo open via its own * `subscribeDoc`, so opening a second bootstrap subscription from inside a watch * re-read would be redundant and could race the watch's own initial-`State` * delivery. Use this from a COLD reader (materialize-at-connection), like * `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on * the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected. */ // @provenance inbox.readSynced kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — same as `read`, gated on the sync barrier first export async function readSynced(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.readSynced"); // Marks the cold, connection-triggered entry point in the trace — the BARRIER // line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below // (from the read() this wraps) follow right after, so a live session shows // the whole owner-reconnect sequence together. logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)"); await assertOwnInbox(targetInbox, "readSynced"); await ensureRepoOpen(targetInbox); return read(targetInbox); } /** * COLD, BARRIER-GATED read of the messages left on a document YOU own — the guarantee of * {@link readSynced} on the address of {@link readForDocument}, and the one call that * materializes deposits without an application ever holding an inbox address. * * ── Why the two do not compose by themselves ────────────────────────────── * The document-addressed path crosses TWO repos, and a cold session (a reconnection, a new * page) loses the answer at either one: * * 1. the DOCUMENT, whose Header branch carries the address (`documentInboxAddress`) — * unopened, that anchored read matches nothing, so the call concludes "no inbox" and * answers `[]` for a document whose inbox is full; * 2. the INBOX, whose deposits are the answer — the cold-start {@link readSynced} exists * for. * * So this crosses the barrier on both, in that order: the document FIRST, because its * address is what the second open is even for. Past it, an empty result MEANS empty, on * the read an application actually makes. * * Gating only the inbox would fix nothing — that is `readSynced`, and reaching it needs an * address. There is deliberately no published call that hands one out (see * {@link postToDocument}), so composing the two was never the application's to do; the gap * was reported by one that had resolved an address itself to get here. */ // @provenance inbox.readSyncedForDocument kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same divergence as the two halves it composes: upstream an inbox is a queue the verifier consumes and applies, and the address is TOLD to you rather than read off a document. It adds no divergent ACT, it spares the caller one export async function readSyncedForDocument(docLike: NuriLike): Promise { const doc = toNuri(docLike, "inbox.readSyncedForDocument"); // The cold, connection-triggered entry point, marked in the trace before the two // BARRIER lines (open-repo.ts) it is about to produce — the document's, then its // inbox's. A live session shows the whole document-addressed materialization together. logStage("READSYNCEDFORDOCUMENT " + shortNuri(doc) + " (cold, barrier-gated)"); // The DOCUMENT, before the address is asked for. An unsynced document does not answer // "this has no inbox" — it answers nothing, and the two are the same value here. await ensureRepoOpen(doc); return depositsForDocument(doc, readSynced); } /** * PROCESS an inbox: read it, and **apply** what it contains. * * Applying is a DISPATCH on what each deposit declares itself to be, one arm per type, * exactly as `Verifier::process_inbox` is a `match` over `InboxMsgContent` — the arms, * and what a kind with no arm gets, are `emulated-verifier/inbox-dispatch.ts`. Applying a * {@link share} Link, for instance, means filing it durably (`branch-registers.addLink`, * the emulated `AddLink { read_cap }` on the User branch of the private store) so the cap * survives the session. Upstream this is what a verifier does when it processes queued * messages: an inbox is a **queue you consume**, not a store you re-read. Re-reading an * inbox every session to recover caps is using a queue as a database, and it is the thing * this replaces. * * Idempotent, arm by arm — `addLink` ignores a cap it already holds, an object already in * an index is passed over — so processing twice (a second tab, a reconnect) costs nothing. * Returns exactly what {@link read} returned: only a cap is ever withheld. * * ── The holder is captured, not re-resolved after the read ──────────────── * The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a * broker round-trip later, so re-asking `getCurrentUser()` at that point asks a different * question: not "whose inbox is this" but "who is holding the page NOW". A page that * switched identity in that window filed CAROL's cap, deposited for ALICE, durably into * BOB's ring — `documentsGivenTo("bob")` returns Carol's document, Alice gets nothing, and * Bob's next sign-in restores it as a capability he holds. Not a near-miss: one user is * given another's document and the real recipient is left with none. * * So the holder the guard authorised is the one the filing is checked against, and a switch * ABANDONS — the same answer {@link read} gives for its own half of this window, and the * same one `connect.connectedUser` gives for a drain it started. What that saves is * everything NOT yet reached: reading consumes nothing, so those deposits are still there * for the owner's next connection or next push. The one being applied when the identity * moved is not among them — it was popped first, and `inbox-consume.deliverDeposits` * reports it ungated rather than pretending it waits. Filing it FOR the absent owner instead would be a write on * somebody else's store from a published call — which is the deferred processor's * deliberate, unpublished privilege (`emulated-verifier/inbox-processor.ts`), not this one's. */ // @provenance inbox.processInbox kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — unseal-and-apply: Links are filed durably and never surfaced. Returning the remaining deposits is the divergent half it shares with `read` export async function processInbox(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.processInbox"); // WHO this processing is for, captured before the read that authorises it — see above. const holder = getCurrentUser(); const deposits = await readSynced(targetInbox); // `readSynced` already decoded every declared payload for this session; now APPLY them. // Reading the raw deposits again would mean re-parsing, so the pass works from what the // read just observed. const declared = declaredIn(targetInbox); seenByInbox.delete(targetInbox); // `readSynced`'s guard refuses a session with no identity, so the holder captured above // cannot be null past it. Narrowed rather than defaulted: applying "for nobody" is // precisely the mis-filing the capture exists to prevent, and it must not have a value. if (holder === null) return deposits; const ring = accountKey(holder); // Consume-and-apply, through the ONE primitive both triggers share // (`emulated-verifier/inbox-consume.ts`). Each deposit is popped before its arm runs and // is not seen again — this pass no longer relies on being safe to repeat. // // The identity is re-checked per item there as it was here, and for the same reason: an // arm reads and writes, so the holder can move between two of them just as easily as // during the read. Abandoning now leaves only the deposits NOT yet reached; the ones // already popped were applied for the holder that was authorised at the time. await deliverDeposits(declared, { owner: holder, ring, inbox: targetInbox, ownerHoldsTheSession: () => true, stillTheHolder: () => getCurrentUser() === holder, }); return deposits; } /** * Process `targetInbox` on behalf of its OWNER — the body of the deferred drain, run while * a DIFFERENT identity holds the session. Never exported: see the module it is scheduled by * (`emulated-verifier/inbox-processor.ts`) for why this must be unreachable. * * ── Every step names its holder; not one of them reads the ambient one ──── * That is the whole difficulty. {@link processInbox} resolves WHO at each step it takes — * `isOwnInbox`, `readLinks`, `addLink` all ask `getCurrentUser()` when they run — so * calling it here would read ALICE's registers and file BOB's capabilities into Alice's * ring, and the deposit would be lost while looking applied. The same hazard * `connect.connectedUser` carries `stillConnected()` for, one step worse: there the holder * merely might change, here it is known to be somebody else from the start. So the owner is * resolved first, from the shim's routing record, and handed to every step after it. * * What it establishes for that owner is what the owner's OWN verifier holds by * construction: the cap of its private store and the cap of the inbox it is processing — * the same two `caps.open` calls `ensureAccount`/`userInbox` make when the owner connects * (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names * the ring those facts belong to. * * Consumer deposits are left where they are, exactly as {@link read} leaves them — and so * is every deposit whose arm would write application data into the owner's own documents: * an inbox is a queue its OWNER consumes, and what this may apply on somebody else's * behalf is the verifier's own bookkeeping (see `Applying.ownerHoldsTheSession`). */ async function processForOwner(targetInbox: Nuri): Promise { // WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see // `account-registry.recordInbox`). Everything below is decided for this identity. const ownerId = await inboxOwner(targetInbox); if (ownerId === null) { throw new Error( "[ng-eventually] deferred inbox processing: the shim records no owner for this inbox, " + "so there is nobody to process it for — its deposits wait for its owner to connect: " + JSON.stringify(targetInbox), ); } // The ring the owner's own session would use — one rule for both, see // `branch-registers.holderRing`. const ownerRing = accountKey(ownerId); // `lookupAccount`, not `resolveAccount`: this decides whether to drain at all, so a read // that could not ANSWER must not arrive looking like "that user does not exist". const record = await lookupAccount(ownerId); if (record === null) { throw new Error( "[ng-eventually] deferred inbox processing: the owner recorded for this inbox has no " + `account — nothing can be filed for them: ${JSON.stringify(ownerId)}`, ); } const caps = getCaps(); // What the owner's OWN verifier holds by construction: the inbox it is about to process, // and the private store where a Link is filed. Both are what `fileOwnInbox` / // `fileOwnStructure` put in that ring when the owner connects — the same two facts, named // rather than resolved. Nothing new is granted: a holder that may not reach a document is // still refused by `readForHolder` below. // // ONE visible consequence, and it is deliberate: filing a cap fires the registry's change // signal, which is global rather than per-holder, so a `useShapeQuery` open in the connected // session re-reads. It re-reads the SAME data — the connected identity gains nothing — // and the signal cannot be suppressed for the other case this same path serves: an owner // draining an inbox on their own document, where re-reading is exactly the contract // (`caps.onChange`). caps.learnFor(ownerRing, mintCap(targetInbox)); if (record.docPrivate) caps.learnFor(ownerRing, mintCap(record.docPrivate)); const sid = await sessionId(); await ensureRepoOpen(targetInbox, ownerRing); const deposits = depositsFrom( await readForHolder(sid, DEPOSITS_QUERY, targetInbox, ownerRing, "inboxProcess"), ); // The SAME consume-and-apply the owner's own session runs, with the holder named instead // of resolved — and told whether the owner is the one holding the page, because an arm // that would write application data into the owner's documents must not run from a // stranger's session, and what such an arm declines must not be consumed either // (`emulated-verifier/inbox-dispatch.claimsDeposit`). // // ONE primitive, not a copy of it: a trigger decides WHO drains and WHEN, never what // consuming means. `emulated-verifier/inbox-consume.ts` holds the only removal. const applied = await deliverDeposits(deposits, { owner: ownerId, ring: ownerRing, inbox: targetInbox, // Asked PER DEPOSIT, not snapshotted once for the run — the identity can arrive or // leave between two arms, and this decides both what an arm may do and what may be // popped. Hoisting it out of the loop (briefly, on 2026-08-26) meant a drain that // started while the owner happened to be connected went on treating every later // deposit as his, applying application data under whoever held the page by then. ownerHoldsTheSession: () => getCurrentUser() === ownerId, }); traceProcessed(targetInbox, ownerRing, applied); } /** * Subscription over an inbox — **event-driven, not polled**. Subscribes to the * inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push): * `onDeposits` fires once on the initial state push and again on every subsequent * change to the inbox document — a local deposit OR a broker-synced remote one. * Returns an unsubscribe function. * * On each push it re-reads the full deposit list ({@link read}) and invokes * `onDeposits` only when the deposit count changed (grew), keeping the same * "fires on change" contract the polling watcher had — same callback signature * and same behaviour, just event-driven instead of `setInterval`. * * The `intervalMs` option is accepted for signature compatibility but IGNORED: * there is no polling. (The inbox document is a single doc, so this is immune to * the ORM fan-out hang — see {@link subscribeDoc}.) */ // @provenance inbox.watch kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream a message is applied on arrival and there is nothing to subscribe to; this pushes the raw deposit list on every change export function watch( targetInboxLike: NuriLike, onDeposits: (deposits: Deposit[]) => void, _opts?: { intervalMs?: number }, ): () => void { // Permissive in, precise out — like every other public entry. It took a bare `Nuri` // until 2026-08-10, which contradicted the very reason no type guard is published. const targetInbox = toNuri(targetInboxLike, "inbox.watch"); let stopped = false; let lastCount = -1; // Re-read on every push; fire onDeposits only when the set changed (grew). const refresh = async (): Promise => { if (stopped) return; try { const deposits = await read(targetInbox); const changed = deposits.length !== lastCount; // Owner-side processing decision: did this push actually grow the // deposit set (→ onDeposits fires, the polyfill's stand-in for // materialization) or was it a no-op push (→ skipped)? This is the // exact line to check for the "must reconnect an extra time" symptom: // a push whose read still sees the OLD count means the barrier/read // raced the write, not that watch itself failed to fire. if (accessLogEnabled()) { logAccess( "READ", targetInbox, "inbox watch", " → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"), ); } if (!stopped && changed) { lastCount = deposits.length; onDeposits(deposits); } } catch (error) { console.error(accessLogPrefix() + " watch read failed:", error); } }; // Subscribe to the inbox document: the initial State push fires the first read // (so onDeposits fires once immediately, as before), each later Patch a re-read. // The ownership guard runs inside `read`, so a watch on someone else's inbox // yields nothing but logged refusals rather than their deposits. const unsubscribe = subscribeDoc(targetInbox, () => void refresh()); return () => { stopped = true; unsubscribe(); }; }