fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable : le propriétaire surveille une inbox pendant que les dépôts arrivent dans une autre. La concurrence. openDocumentInbox ne partageait rien avec userInbox — module différent, registre propre, aucune coalescence. Reproduit pire que rapporté : quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur, document), et le corps déplacé pour que l'invariant soit porté par la composition plutôt que par la position d'une vérification. La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte, chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici — une branche est en ajout seul, et ça ne se réconcilie pas après coup, le propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du document. Le contrat porte donc une garantie positive ET une non-garantie. La page froide. readInboxCapPairs était le seul lecteur de store sans barrière, correct uniquement parce qu'une autre fonction s'exécutait avant lui à la connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur une page froide il lisait le store privé non synchronisé, répondait « aucune inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La barrière est désormais dans la lecture, et elle ne coûte rien aux chemins connectés, la connexion ayant déjà ouvert les trois stores. Et la lecture qui manquait. readSynced donnait la garantie, readForDocument l'adressage, pas leur intersection — si bien que matérialiser des dépôts obligeait une application à résoudre une adresse d'inbox elle-même, ce que le contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne. Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien. Au passage, le compteur d'identifiants de la doublure était par page : une page rechargée refrappait le même identifiant PAR-DESSUS une inbox existante, aliasant deux dépôts en silence. Il est monotone.
This commit is contained in:
@@ -403,6 +403,22 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
|
||||
* half of the drain list `connect.connectedUser` works from, and an empty answer would
|
||||
* make a document's queue silently un-drained — a share that was delivered and never
|
||||
* applied, with nothing to see anywhere.
|
||||
*
|
||||
* **Barrier-AUTHORITATIVE, since 2026-08-17.** This was the only reader of a user's store
|
||||
* in this file with no {@link ensureRepoOpen} of its own — `readLinks` next door,
|
||||
* `restoreOwnCaps` and `readUserStore` all carry one — and on a fresh page over the same
|
||||
* persistent wallet an anchored read of a not-yet-synced repo returns no rows, no error
|
||||
* (`open-repo.ts`). What made that survive was caller ORDER: connecting opens the three
|
||||
* stores ({@link restoreOwnCaps}) before anything asks. Order is not a guarantee, and the
|
||||
* one caller that decides on the answer proved it — {@link openDocumentInbox} MINTS when
|
||||
* this reads empty, so on a page that had settled an identity without connecting it yet,
|
||||
* one call, no race, gave a note a SECOND inbox: two `AddInboxCap` records, and the
|
||||
* address published on the document replaced by the new one, so later deposits land where
|
||||
* none of the earlier ones are. Same ruling as the family around it (`e32b6d0`): only a
|
||||
* VERIFIED absence may mint, and a read that could not answer is not one.
|
||||
*
|
||||
* Costs nothing on the paths that already connected — the open registry is per-session and
|
||||
* a repo already open is a map hit (`open-repo.ts`).
|
||||
*/
|
||||
// @provenance readInboxCapPairs kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the record is keyed by `repo_id` and `update_inbox_cap_v0` applies it with no is-store check — but the engine only ever commits one for the two STORE repos, never for a plain document
|
||||
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||||
@@ -413,6 +429,8 @@ export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nur
|
||||
if (!store) return [];
|
||||
const s = await session();
|
||||
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
|
||||
// The sync barrier, before the read that decides — see the note above.
|
||||
await ensureRepoOpen(store);
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
@@ -585,6 +603,45 @@ export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The `openDocumentInbox` calls currently in flight, keyed by `(holder, document)` —
|
||||
* mirrors `account-registry.userInbox`'s `inboxInFlight`, which had the same read-then-
|
||||
* mint shape and was made concurrency-safe first. This one was not, and the gap was
|
||||
* reported from an application: four simultaneous calls on ONE document minted three
|
||||
* inboxes, after which the owner drained one while deposits arrived in another.
|
||||
*
|
||||
* Note what did NOT happen: nothing failed. Every caller read the register, every read
|
||||
* ANSWERED, and every answer was honestly "no inbox recorded" — because none of the writes
|
||||
* had landed yet. So this is not the "a failure resolved like a success" family the rest of
|
||||
* this file guards against; it is a read-then-write with no coalescing, and only the
|
||||
* coalescing closes it.
|
||||
*
|
||||
* Keyed by the HOLDER as well as the document, because the answer is the holder's: the
|
||||
* register lives on their User branch, and a non-owner asking gets a refusal, never an
|
||||
* inbox. Keyed on `accountKey` so `@Alice` and `alice ` — one person — share one entry,
|
||||
* and joined with `\u0000` for the reason `userInbox` uses it: a separator no identifier
|
||||
* can contain is the only one that cannot make two different pairs share a key.
|
||||
*
|
||||
* Deliberately holds no RESOLVED value, unlike the `inboxCache` beside its counterpart:
|
||||
* entries are dropped the instant the call settles, whether it answered or threw, so the
|
||||
* next ask re-reads the durable register rather than trusting a memo — and a refusal
|
||||
* never lingers as one. That is also why this map needs no reset hook: nothing in it can
|
||||
* go stale, because nothing in it has finished.
|
||||
*
|
||||
* **Its reach is one JS realm, and that is the whole of what it promises.** Two browser
|
||||
* tabs, or two sessions, share no map: each reads the register, each finds nothing, and
|
||||
* each mints — the durable fork this cannot prevent. Preventing it needs a conditional
|
||||
* write ("insert only if absent") that no layer of the target offers: a branch is an
|
||||
* add-only CRDT, so two `AddInboxCap` records simply merge. Nor can it be reconciled
|
||||
* after the fact the way `canonicalDoc` reconciles a forked account pointer: the owner
|
||||
* resolves from the User branch and a depositor from the document's published address,
|
||||
* two different records, and the second is last-write-wins by construction — upstream
|
||||
* `inboxes: PubKey → RepoId` is a function and `repo.inbox` a single `Option`, so
|
||||
* accumulating two addresses to pick a canonical one is a state the model has no meaning
|
||||
* for. The contract says one realm; see `contract_polyfill-surface.md`.
|
||||
*/
|
||||
const openInboxInFlight = new Map<string, Promise<Nuri>>();
|
||||
|
||||
/**
|
||||
* The inbox of a document this user owns — resolved, and created on first ask.
|
||||
*
|
||||
@@ -635,6 +692,12 @@ export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
|
||||
* reading half, so it would silently divert to itself the deposits meant for the
|
||||
* owner. To deposit into someone else's document, resolve
|
||||
* {@link documentInboxAddress} and `inbox.post` into it.
|
||||
*
|
||||
* **Idempotent, including under concurrency — within ONE JS realm.** Simultaneous asks
|
||||
* for the same document by the same holder are coalesced onto a single call
|
||||
* ({@link openInboxInFlight}), which is what stops N callers each reading "no inbox" and
|
||||
* each minting one. Two TABS still fork, and cannot be stopped from here — read the note
|
||||
* on that map before assuming otherwise.
|
||||
*/
|
||||
// @provenance storeRegistry.openDocumentInbox kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — every `Repo` carries `inbox: Option<PrivKey>` and the record is keyed by any `repo_id`, but `new_store_default` attaches one only to non-private STORES and `doc_create` leaves `inbox: None`. PUBLISHING the address is a separate, divergent act — see `publishInboxAddress`
|
||||
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||||
@@ -643,6 +706,35 @@ export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||||
const doc = toNuri(docLike, "openDocumentInbox");
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
|
||||
|
||||
// Everything below is one read-then-write: it asks the register whether an inbox is
|
||||
// already recorded, and mints when the answer is no. A dozen awaits separate the two,
|
||||
// so callers that arrive together all read before any of them writes — each finds
|
||||
// nothing, each mints, and the document ends up with several. Coalescing them onto ONE
|
||||
// call is the whole fix, and it is where the guarantee is enforced rather than merely
|
||||
// hoped for: a second caller never runs the body at all, it awaits the first.
|
||||
const key = `${accountKey(holder)}\u0000${doc}`;
|
||||
const pending = openInboxInFlight.get(key);
|
||||
if (pending) return pending;
|
||||
const p = resolveOrMintDocumentInbox(doc, holder);
|
||||
openInboxInFlight.set(key, p);
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
// Dropped whether it resolved or threw. A refusal (not the owner) or a failed persist
|
||||
// must not linger as an answer: the next ask has to look again, exactly as it would
|
||||
// have if it had arrived a moment later.
|
||||
openInboxInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of {@link openDocumentInbox}, minus the coalescing — a separate function so
|
||||
* the "one call per (holder, document)" invariant is carried by the composition rather
|
||||
* than by where a check sits inside a long block. Never call it directly: it is the
|
||||
* un-coalesced path, and reaching it twice concurrently is the bug.
|
||||
*/
|
||||
async function resolveOrMintDocumentInbox(doc: Nuri, holder: PrincipalId): Promise<Nuri> {
|
||||
const known = (await readInboxCapsFor(doc)) ?? null;
|
||||
if (known) return known;
|
||||
|
||||
|
||||
@@ -429,18 +429,39 @@ export async function share(doc: NuriLike, toUser: string): Promise<void> {
|
||||
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, 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");
|
||||
const address = await documentInboxAddress(doc);
|
||||
return address ? read(address) : [];
|
||||
return depositsForDocument(doc, read);
|
||||
}
|
||||
|
||||
// --- the read guard ------------------------------------------------------
|
||||
@@ -598,6 +619,43 @@ export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]>
|
||||
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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user