Files
ng-eventually/packages/polyfill/src/surface/inbox.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

922 lines
49 KiB
TypeScript

/**
* 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<string | number> {
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<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
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<void> {
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 <plainNuri>` 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<PrivKey>`, 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<void> {
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<Nuri, StoredHere[]>();
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<ReadCap>`
* (`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<ReadCap>` 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<void> {
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<Deposit[]>,
): Promise<Deposit[]> {
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<Deposit[]> {
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<void> {
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<Deposit[]> {
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<Deposit[]> {
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<Deposit[]> {
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<Deposit[]> {
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<void> {
// 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<void> => {
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();
};
}