/** * Consuming an inbox — the ONE act that retires a deposit, and the ONE loop that performs it. * * ── Upstream pops, and the order is not an accident ─────────────────────── * Read at all three of its sites, because the order is the whole contract: * * 1. the broker REMOVES the message from the queue — `ClientEvent::InboxPopRequest` → * `inbox_pop_for_user` → `take_first_msg` → `MSGS.take_first_value`, which is * `get_first_key_value` followed by `del` (`engine/repo/src/kcv_storage.rs:557`, * `engine/storage-rocksdb/src/kcv_storage.rs:239-243`). The removal happens in the * STORE, before anything has looked at the content; * 2. only then is it delivered (`ClientMessageContentV0::InboxReceive { from_queue: true }`, * `engine/net/src/actors/client/client_event.rs:68`); * 3. the verifier applies it, and a failure is LOGGED AND SWALLOWED — * `log_err!("Error during process_inbox {e}")` — never re-enqueued. It then asks for * the next one (`engine/verifier/src/verifier.rs:1680-1715`). * * So: **remove, then apply, then log whatever went wrong, then move on.** A deposit is * delivered exactly once whether or not its arm succeeded, and an arm that throws loses it * — upstream that message left the store before the arm ever ran. Removing FIRST is * therefore not a risk this module takes, it is the behaviour it reproduces; and it makes * the reverse mistake impossible, since a removal that fails stops the apply instead of * letting an applied deposit stay in the queue. * * ── Why a deliverability question exists here and not upstream ──────────── * Upstream an inbox holds nothing but typed messages, and WHO may pop one is settled by * reader registration rather than by content: `inbox_pop_for_user(user)` pops only from * inboxes that user is a registered reader of, so a message is never popped by anybody but * its own recipient. Here an inbox holds a MIX — this package's deposits and a consumer's * own payloads, which `surface/inbox.ts` hands back untouched — so "may this be popped * now?" has to be asked of the content. That is `inbox-dispatch.claimsDeposit`, and it is * asked BEFORE the removal for the same reason the broker checks registration before * `take_first_msg`: what is not deliverable to this consumer was never popped for it. * * ── One removal, and it is here ─────────────────────────────────────────── * Both triggers this package drains from — the owner's own session (`inbox.processInbox`) * and the deferred stand-in for an absent owner (`inbox.processForOwner`, scheduled by * `emulated-verifier/inbox-processor.ts`) — call {@link deliverDeposits} and nothing else. * Neither carries a copy of the removal: the triggers differ in WHO drains and WHEN, never * in what consuming means. `grep -rn "DELETE" packages/polyfill/src/` shows the only * deposit removal in the package is {@link removeDeposit} below. */ import { registerUpdate } from "./register-write"; import { applyDeposit, claims, claimsDeposit } from "./inbox-dispatch"; import type { Applying } from "./inbox-dispatch"; import { getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log"; /** * One deposit as it is STORED: the RDF subject `inbox.post` minted for it, and what it * carries. The subject is the removal key — `post` gives every deposit a fresh unique * subject, so naming it retires that deposit and no other, where a `ts` could collide. * * Deliberately NOT the published {@link import("../surface/inbox").Deposit}: a subject is * emulation detail (upstream a queued message has no address of its own, it has a * position), so it stays inside the package and an application never learns it. */ export interface StoredDeposit { /** The RDF subject the deposit lives under, in its inbox document's default graph. */ readonly subject: string; /** What the deposit carries — handed to the dispatch as-is. */ readonly payload: unknown; } /** * Retire ONE deposit from its inbox. **The only removal in this package.** * * `DELETE WHERE { ?p ?o }` — every triple of that one deposit, whichever * predicates it happens to carry (`from` is optional). Two established forms are combined * here, and neither half is invented: * * - `DELETE WHERE` with a VARIABLE predicate (` ?p ?o`) is the shape verified against * the real broker for removing a whole subject * (`docs/decisions/sparql-delete-for-orm-objects.md`); * - it is ANCHORED with no `GRAPH <…>` wrapper, like every other register write in this * package (`public-store.exposeReadCap`, `branch-registers.publishInboxAddress`). That * is not a shortcut, it is where the data IS: `inbox.post` writes the anchored DEFAULT * graph and `DEPOSITS_QUERY` reads it, so a `GRAPH`-wrapped delete would name a graph * the deposits were never written to. The decision above shows the wrapped form because * its subject is an ORM object in a named graph; the anchor plays that role here. * * A `;`-joined compound update is exercised nowhere in this library, so this stays one * statement. * * Through `registerUpdate`, and that is deliberate: retiring a deposit is the verifier's * own bookkeeping, not an application write — upstream it happens inside the broker's * store, and no application makes it. That door skips the OWNERSHIP guard, which a drain * running for an absent owner could not pass, and keeps `assertMayReach` against the named * ring: whoever consumes must hold the inbox's cap, and `processForOwner` names the * owner's ring exactly as it does for every other step it takes. * * A failure PROPAGATES. Nothing is applied for a deposit that could not be retired — the * caller's loop stops and its drain reports itself where drains report — because applying * one that stayed in the queue is the replay this module exists to end. */ // @provenance removeDeposit kind=aligned level=1 ref=engine/broker/src/server_storage/core/inbox.rs:take_first_msg — upstream the store REMOVES the message as it hands it over (`MSGS.take_first_value` = `get_first_key_value` + `del`); here the queue is RDF in a document, so the same act is a DELETE of the deposit's own subject async function removeDeposit(stored: StoredDeposit, ctx: Applying): Promise { const { sessionId } = await getStoreRegistryDeps().getSession(); await registerUpdate( sessionId, `DELETE WHERE { <${stored.subject}> ?p ?o }`, ctx.inbox, "inboxConsume", ctx.ring, ); } /** * Deliver every deposit in `stored` to the dispatch, consuming each as it goes. Answers * how many produced an EFFECT — which is what a drain traces, and all any caller wants. * * The loop both triggers share, so that "what consuming means" cannot come to differ * between the owner's own drain and the deferred one. Per deposit, in this order: * * 1. **claimed?** — a payload no arm answers for is not popped at all, and the loop * moves on leaving it in the inbox. That covers a consumer's own data (not a message, * so nothing is reported) and a deposit whose arm belongs to the owner's own session * while a stranger's is running (see `inbox-dispatch.claimsDeposit`); * 2. **remove** — the pop, before anything is applied, as upstream; * 3. **apply** — the `match`, one arm per declared type. A refusal inside an arm is * reported on the log stream and the deposit is already gone, which is upstream's * `log_err!` + move on, and the end of the re-report-forever loop this package had. * * Sequential rather than parallel, for the reason every drain in this package is: an arm * writes what it applies (`branch-registers.addLink`, on the owner's User branch), so two * in flight over one queue interleave their reads and writes. */ // @provenance deliverDeposits kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream's pop-apply-log-next loop, over a queue that is a document here rather than a column family export async function deliverDeposits( stored: readonly StoredDeposit[], ctx: Applying, ): Promise { let applied = 0; for (const deposit of stored) { // The holder can move between two arms as easily as during the read that found these. // Stopping leaves every deposit not yet reached in the queue for its owner — which is // the whole reason this is asked BEFORE the pop rather than after it. if (!holderStillStands(ctx)) { logStage( "ABANDONED " + shortNuri(ctx.inbox) + " — the identity changed while it was being " + "processed; its remaining deposits stay for their owner", ); return applied; } if (!claimsDeposit(deposit.payload, ctx)) continue; // No subject, no pop. `?d` is bound by the deposits query, so this should be // unreachable — but `depositsFrom` defaults it to "" rather than dropping the row, and // an empty subject would issue `DELETE WHERE { <> ?p ?o }`: a malformed statement whose // effect on a real broker nobody has established. Refusing to APPLY as well is the // load-bearing half — applying without retiring is precisely the replay this module // exists to end, and it would repeat at every drain for as long as the row sat there. if (deposit.subject === "") { console.error( accessLogPrefix() + " a deposit in " + shortNuri(ctx.inbox) + " carries no subject, " + "so it cannot be retired — refusing to apply it rather than applying it again at " + "every drain. Nothing was consumed and nothing was written.", ); continue; } await removeDeposit(deposit, ctx); // Popping is an AWAIT, and the arm below reads AMBIENT state — so the identity can move // across exactly this gap, between the decision to deliver and the delivery itself. // Re-asked rather than assumed: applying now would file this deposit for whoever holds // the page instead of for its owner, which is the mis-filing the whole `Applying` // context exists to prevent. // // BOTH guards, and that matters: `processInbox` names its owner as a constant // (`ownerHoldsTheSession: () => true`) because it only ever runs in that owner's own // session, so the claim check alone cannot see the move — only `stillTheHolder` can. // Checking one and not the other left this hole open, which is how it was missed. // // Reported ungated and then ABANDONED: the deposit is gone and cannot be got back, and // whatever is behind it in the queue belongs to a session that is no longer here. if (!holderStillStands(ctx) || !claims(deposit.payload, ctx)) { console.error( accessLogPrefix() + " a deposit in " + shortNuri(ctx.inbox) + " was consumed but not " + "applied: the identity changed while it was being taken out of the queue, and " + "applying it now would file it for the wrong holder. It cannot be recovered; what " + "is still queued stays for its owner.", ); return applied; } // The arm's own I/O failure is REPORTED HERE, at the source, and only then allowed to // travel. It must stop the drain — everything not yet popped has to survive — but a // throw is at the mercy of whoever catches it, and one caller legitimately swallows: // `connect.connectedUser` drops the error when the identity has moved, because an // ownership refusal there is abandoning and not a failure. That guard cannot tell the // two apart, so an arm that failed WHILE the page switched user destroyed a deposit and // printed nothing at all. Reporting before rethrowing closes that without touching a // policy that is right for its own case: the loss is announced by the only code that // knows a deposit was consumed. try { if (await applyDeposit(deposit.payload, ctx)) applied += 1; } catch (error) { console.error( accessLogPrefix() + " a deposit in " + shortNuri(ctx.inbox) + " was consumed and " + "could not be applied — it cannot be recovered; what is still queued stays for " + "the next attempt:", error, ); throw error; } } return applied; } /** Is the identity this drain was authorised for still the one holding the page? A drain * that NAMED its holder rather than resolving it has nothing to lose and always stands. */ function holderStillStands(ctx: Applying): boolean { return ctx.stillTheHolder === undefined || ctx.stillTheHolder(); }