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);
}