fix: un dépôt est délivré une fois et retiré, comme en amont
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user