fix: un dépôt est délivré une fois et retiré, comme en amont

This commit is contained in:
Sylvain Duchesne
2026-08-26 14:11:56 +02:00
parent abfb619c76
commit f6a48a4507
24 changed files with 1486 additions and 176 deletions
+85 -50
View File
@@ -39,11 +39,12 @@ import {
traceProcessed,
} from "../emulated-verifier/inbox-processor";
import {
applyDeposit,
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,
@@ -142,9 +143,14 @@ function summarizePayload(payload: unknown): string {
* 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 ?payload ?ts ?from WHERE {
SELECT ?d ?payload ?ts ?from WHERE {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
@@ -305,18 +311,30 @@ export async function postToDocument(docLike: NuriLike, opts: PostOptions): Prom
* 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, unknown[]>();
function declaredIn(inbox: Nuri): unknown[] {
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): Deposit[] {
const deposits: Deposit[] = [];
function depositsFrom(result: unknown): StoredHere[] {
const stored: StoredHere[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
@@ -328,10 +346,14 @@ function depositsFrom(result: unknown): Deposit[] {
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
stored.push({
subject: row.d?.value ?? "",
payload,
deposit: { from: fromValue ? fromValue : null, payload, ts },
});
}
deposits.sort((a, b) => a.ts - b.ts);
return deposits;
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
@@ -539,26 +561,35 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
// 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: unknown[] = [];
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 — an inbox is not
// consumed by reading, so the next connection under the right identity files them.
// 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.payload);
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);
delivered.push(d.deposit);
}
if (declared.length > 0) seenByInbox.set(targetInbox, declared);
// 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
@@ -688,9 +719,11 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
*
* 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. Nothing is lost: an inbox
* is not consumed by being read, so the deposit is still there for its owner's next
* connection or its next push. Filing it FOR the absent owner instead would be a write on
* 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.
*/
@@ -710,24 +743,21 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
// 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);
for (const payload of declared) {
// Re-checked per item, not once: an arm reads and writes, so the identity can move
// between two of them just as easily as during the read.
if (getCurrentUser() !== holder) {
logStage(
"ABANDONED " + shortNuri(targetInbox) + " — the identity changed while it was being " +
"processed; its deposits stay for their owner",
);
return deposits;
}
// The `match`, one arm per declared type — `emulated-verifier/inbox-dispatch.ts`.
await applyDeposit(payload, {
owner: holder,
ring,
inbox: targetInbox,
ownerHoldsTheSession: true,
});
}
// 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;
}
@@ -800,20 +830,25 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
const deposits = depositsFrom(
await readForHolder(sid, DEPOSITS_QUERY, targetInbox, ownerRing, "inboxProcess"),
);
let applied = 0;
for (const deposit of deposits) {
// The SAME `match` 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 (`emulated-verifier/inbox-dispatch.ts`).
const done = await applyDeposit(deposit.payload, {
owner: ownerId,
ring: ownerRing,
inbox: targetInbox,
ownerHoldsTheSession: getCurrentUser() === ownerId,
});
if (done) applied += 1;
}
// 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);
}
+19 -2
View File
@@ -125,6 +125,7 @@ async function sessionId(): Promise<string | number> {
async function readDoc(
sid: string | number,
doc: Nuri,
onUnreadable?: (doc: Nuri, error: unknown) => void,
): Promise<Array<Record<string, { value: string } | undefined>>> {
try {
const nuri = assertNuri(doc);
@@ -141,6 +142,16 @@ async function readDoc(
return bindings(res);
} catch (error) {
console.error("[read-model] read failed for", doc, error);
// The swallow STAYS — a batch read has always tolerated one bad document, and a caller
// building a list from optional values must not lose the whole read to one hole. What is
// new (2026-08-26) is that the failure is no longer INDISTINGUISHABLE from an empty
// document: a caller that needs to tell them apart says so by passing `onUnreadable`.
//
// It needed telling apart because of inbox consumption. A deposit is now retired before
// its arm runs, so an arm that reads `[]` and concludes "absent, nothing to do" turns a
// broker hiccup into permanent loss — and one that concludes "must be a failure" lets a
// single unresolvable reference stall every deposit behind it. Only the truth serves.
onUnreadable?.(doc, error);
return [];
}
}
@@ -165,7 +176,10 @@ async function readDoc(
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/
// @provenance readUnion kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_query — one anchored query per document — upstream's own anchoring (`resolve_target_for_sparql`), composed client-side
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
export async function readUnion(
docsLike: NuriLike[],
onUnreadable?: (doc: Nuri, error: unknown) => void,
): Promise<UnionSubject[]> {
const sid = await sessionId();
// Drop the empties BEFORE validating, not after: this call has always tolerated a
// list with holes in it — a scope index can carry a blank entry, and a caller
@@ -196,7 +210,10 @@ export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
reachable.map(async (d) => ({
doc: assertNuri(d),
rows: await readDoc(sid, d, onUnreadable),
})),
);
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`