fix: un échec de lecture ne fabrique plus d'état durable et faux

userInbox avalait deux échecs, et chacun laissait des dégâts sur le disque.

À la lecture : la requête « quelle inbox ce compte possède-t-il » levait, on
journalisait, et on créait une inbox de plus. Deux associations pour un même
couple utilisateur/portée — vérifié en conditions, sdoc5 et sdoc6 coexistants.

À l'écriture : l'INSERT levait, on retournait quand même la référence en la
mettant en cache. Le propriétaire tenait sdoc5 pendant qu'un déposant résolvait
sdoc6. Il lit une boîte où nul n'écrit, ils écrivent dans une boîte que nul ne
lit.

Et un troisième que je n'avais pas vu : recordInbox avalait son propre INSERT
puis marquait son index en mémoire — l'inbox était associée mais refusée à la
session suivante, donc tous les dépôts rebondissaient, définitivement.

Le balayage demandé a trouvé la famille entière : onze sites de cette forme, un
catch qui journalise puis une exécution qui continue comme si la chose cherchée
était absente. Les huit autres corrigés vont d'une seconde racine de registre
créée sur budget épuisé, à un document public qui ne sert plus jamais sa clé.

La règle appliquée partout : seule une absence VÉRIFIÉE autorise à créer, et une
référence n'est remise à personne avant que son association ait atterri.

Les sites laissés échouent en fermeture — un refus, une liste vide — sans rien
écrire. Ils sont listés, pas oubliés.
This commit is contained in:
Sylvain Duchesne
2026-08-13 13:21:54 +02:00
parent 0f1e493e93
commit e32b6d04fc
6 changed files with 796 additions and 11 deletions
@@ -150,6 +150,14 @@ export function fileOwnInbox(id: string, inbox: Nuri): void {
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for — and a
* depositor picking the stale one writes where nobody reads.
*
* **Propagates a failed write.** Publishing is the ONLY way a third party learns where to
* deposit for a document (we publish because an emulation has no message channel — see the
* module header), and `openDocumentInbox` short-circuits on the inbox it already recorded,
* so nothing ever tries again. Swallowing therefore produced an inbox its owner drains
* forever while no one can reach it — and worse when the `DELETE` landed and the `INSERT`
* did not: the address that WAS published is gone, and every future deposit on that
* document is refused as "this document has no inbox".
*/
export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
@@ -172,6 +180,7 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
throw error;
}
}
@@ -543,7 +552,15 @@ export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
"openDocumentInbox",
);
} catch (error) {
// This record is what makes the inbox DRAINABLE: `myInboxes` builds the connection's
// drain list from it, and `readInboxCapsFor` answers "have I already opened one"
// from it. Swallowing here went straight on to PUBLISH the address, so depositors
// were invited to write into a queue its own owner never enumerates — every message
// delivered and none ever applied, permanently. Failing before the address is
// published is the honest state: nobody is told where to deposit, and asking again
// opens a fresh inbox.
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
throw error;
}
}
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
@@ -122,6 +122,13 @@ export function resetPublicStoreFetches(): void {
*
* Replacement, not addition, like every Header-branch register: one document has one
* current cap, and two would leave a fetcher picking between them.
*
* **Propagates a failed write.** This runs exactly once, at creation, and nothing exposes
* the cap afterwards — so a swallowed failure leaves a document that IS in a public store
* and serves nothing: every third party's {@link fetchReadCap} answers "no cap", the read
* guard refuses, and the document is unreadable by anyone but its creator, for good. Its
* caller `createEntityDoc` already refuses to hand back a reference whose bookkeeping did
* not land, for the same reason and in the same words.
*/
export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
const s = await session();
@@ -143,6 +150,7 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
);
} catch (error) {
console.error(accessLogPrefix() + " exposeReadCap failed:", error);
throw error;
}
}
@@ -459,15 +459,27 @@ async function resolvePointer(): Promise<MaybeNuri> {
const maxStepMs = budget.maxStepMs ?? 2000;
let step = baseMs;
// Did ANY attempt come back with an answer — even an empty one? The guard retries a
// read that could not be made, and `""` means "there is no pointer yet", which is the
// signal `resolveShimDoc` MINTS a doc-shim on. A budget exhausted without a single
// answer has established nothing at all, so answering `""` there would fabricate a
// second registry root: a new doc-shim, a second pointer beside the one that already
// exists, and every account record split between two documents that only
// `canonicalDoc` reconciles — the ones written into the loser simply disappear. Only a
// read that ANSWERED may report an absence.
let answered = false;
let lastError: unknown = null;
for (let i = 0; i < attempts; i++) {
try {
const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer");
answered = true;
const doc = canonicalDoc(readBindings(result), "shimDoc");
if (doc) {
logStage("resolvePointer → 1 target: " + shortNuri(doc));
return doc;
}
} catch (error) {
lastError = error;
console.error(accessLogPrefix() + " resolvePointer failed:", error);
}
if (i < attempts - 1) {
@@ -475,13 +487,21 @@ async function resolvePointer(): Promise<MaybeNuri> {
step = Math.min(step * 2, maxStepMs);
}
}
if (!answered) throw lastError;
logStage("resolvePointer → 0 targets");
return "";
}
/** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in
* practice (only called when no pointer was found); a concurrent double-write is
* reconciled by canonicalDoc on read. */
* reconciled by canonicalDoc on read.
*
* **Propagates a failed write.** The pointer is the ONLY way back to the doc-shim — it is
* the one NURI a fresh session can name without a lookup. A session that carried on over a
* pointer that never landed would go on writing every account record into a document no
* later session can find, and the next login, finding no pointer, would mint a second
* doc-shim and re-provision every account from scratch. There is nothing to recover
* afterwards, so the failure has to be seen at the moment it happens. */
async function writePointer(doc: Nuri): Promise<void> {
const s = await session();
const root = await rootNuri();
@@ -496,6 +516,7 @@ async function writePointer(doc: Nuri): Promise<void> {
await physicalUpdate(s.sessionId, update, root, "writePointer");
} catch (error) {
console.error(accessLogPrefix() + " writePointer failed:", error);
throw error;
}
}
@@ -640,7 +661,14 @@ export async function resolveAccount(id: string): Promise<VirtualUserRecord | nu
}
/** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the
* canonical always-safe shape — same convention as createEntityDoc). */
* canonical always-safe shape — same convention as createEntityDoc).
*
* **Propagates a failed write.** `ensureAccount` caches and returns the record right
* after this call, so swallowing here handed back three scope documents that the shim
* never heard of: the session writes its entities into them, and the NEXT session,
* reading the shim and finding nothing, provisions the account again — a fork, with every
* document of the first set orphaned and no error anywhere. This is the same event as a
* failed pointer write one level down, and it gets the same answer. */
async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void> {
const s = await session();
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
@@ -661,6 +689,7 @@ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void>
await physicalUpdate(s.sessionId, update, doc, "writeRecord");
} catch (error) {
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
throw error;
}
}
@@ -705,11 +734,21 @@ export async function ensureAccount(id: string): Promise<VirtualUserRecord> {
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
//
// Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State`
// Barrier-AUTHORITATIVE: the lookup reads the doc-shim behind its first-`State`
// barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
const existing = await resolveAccount(id);
//
// `lookupAccount`, NOT the tolerant `resolveAccount`: what follows an absence here is
// a PROVISION. The tolerant form answers `null` for a read that failed exactly as for
// one that found nothing, so a broker that could not answer minted a second set of
// three store documents and a second account record — the very account FORK the
// barrier was introduced to end, walking straight back in through the error path. The
// session then writes its entities into the new set while the old one holds
// everything the user had, and only `canonicalDoc` decides afterwards which of the
// two a later session sees. Absence is a reason to create; ignorance is not
// (`lookupAccount` exists for exactly this distinction, 2026-08-10).
const existing = await lookupAccount(id);
if (existing) {
fileOwnStructure(id, existing);
return existing;
@@ -757,6 +796,15 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
*
* Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's
* business, exactly like the account records beside it.
*
* **Propagates a failed write, and only marks the session's index once the write landed.**
* This record is what the emulation puts in place of a fact the network knows by
* construction, and `inbox.post` refuses to deposit into anything it cannot confirm
* ({@link isKnownInbox}). Swallowing left an inbox that its owner resolves and addresses
* normally while every depositor is turned away — permanently, since nothing ever records
* it a second time. Marking `knownInboxes` regardless made it worse by hiding the damage
* from the only session able to see it: the one that opened the inbox believed the record
* existed, and every other session did not.
*/
export async function recordInbox(nuri: Nuri): Promise<void> {
const s = await session();
@@ -770,6 +818,7 @@ export async function recordInbox(nuri: Nuri): Promise<void> {
);
} catch (error) {
console.error(accessLogPrefix() + " recordInbox failed:", error);
throw error;
}
knownInboxes.add(nuri);
}
@@ -947,6 +996,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
// One triple per (user, scope): the two inboxes are distinct documents, as the two
// store repos that carry them are distinct upstream.
const pred = `${P.docInbox}:${scope}`;
let existing: MaybeNuri;
try {
// The doc-shim is machinery: this reads WHICH inbox a virtual user owns,
// which is exactly the kind of question that cannot be confined to that user.
@@ -957,18 +1007,29 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
shimDoc,
"userInbox",
);
const existing = canonicalDoc(readBindings(res), "d");
if (existing) {
inboxCache.set(key, existing);
fileOwnInbox(id, existing);
return existing;
}
existing = canonicalDoc(readBindings(res), "d");
} catch (error) {
// ONLY A VERIFIED ABSENCE MAY MINT. This catch logged and let execution fall
// through to the mint below, so a read that could not ANSWER was treated as "this
// user owns no inbox" — and a second document was created for one (user, scope).
// Two `docInbox` triples then sit on the same subject, and which one wins
// afterwards is decided by `canonicalDoc` picking among them: the owner can drain
// one while a depositor writes into the other, each of them right, with nothing
// anywhere to see. Same fault as `connect.connectedUser` (2026-08-13), except this
// one writes the mistake down.
console.error(accessLogPrefix() + " userInbox read failed:", error);
throw error;
}
if (existing) {
inboxCache.set(key, existing);
fileOwnInbox(id, existing);
return existing;
}
// The broker answered, and it answered nothing: this user genuinely owns no inbox for
// this scope yet. THAT is what mints one — and the three writes it takes all have to
// land before anyone is handed the result.
const doc = await createDoc();
fileOwnInbox(id, doc);
await recordInbox(doc);
try {
await physicalUpdate(
@@ -978,8 +1039,18 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
"userInbox",
);
} catch (error) {
// This triple IS the answer to "which inbox does this user own". Caching and
// returning the document over a failed write handed the owner a reference nobody
// else can resolve: a depositor reading the shim finds nothing and mints yet
// another. The owner then reads a box nobody writes to, and depositors write to a
// box nobody reads — the exact shape sharing broke in before.
console.error(accessLogPrefix() + " userInbox persist failed:", error);
throw error;
}
// Filed and cached only now, and in that order: until the association was written
// this document was not yet anyone's inbox, and holding a cap for it (or answering
// with it for the rest of the session) would outlive the failure that decided it.
fileOwnInbox(id, doc);
inboxCache.set(key, doc);
logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc));
return doc;
@@ -197,3 +197,15 @@ test("a failed key write is reported too, and names that half", async () => {
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
});
// The third write a PUBLIC creation makes, and the one that was still silent: exposing the
// cap on the document is what lets anyone else read it (`public-store.exposeReadCap`, the
// emulated `expose_outer`), it happens once at creation and nothing ever redoes it. A
// swallowed failure therefore left a document sitting in a public store serving nothing —
// unreadable by everyone but its creator, permanently, with a reference handed back as
// though it had worked.
test("a public document whose cap could not be exposed is reported, not returned", async () => {
inject(/shim:exposedReadCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("public")).rejects.toThrow(/broker refused/i);
});
@@ -0,0 +1,670 @@
/**
* Only a VERIFIED ABSENCE may mint an inbox — the whole case space of `userInbox`.
*
* `userInbox(id, scope)` answers *which inbox document does this virtual user own for this
* scope*, and mints one the first time. Everything downstream is addressed through that
* answer: `share` deposits into it, `connect.connectedUser` drains it, `isOwnInbox` guards
* reads with it. So the one thing it must never do is treat a failure as an absence — where
* `connect.ts` lost a restore for the same fault (2026-08-13), this one WRITES:
*
* - a read that could not answer, taken for "no inbox exists", MINTS a second document for
* one (user, scope). Two `shim:docInbox` triples then sit on the same subject and which
* one wins later is decided by `canonicalDoc` picking among them — the owner and a
* depositor can resolve different documents;
* - a persist that failed, followed by handing the document back anyway, gives the owner a
* reference whose triple was never written. Nobody else can resolve it: a depositor
* finding nothing mints yet another. The owner reads a box nobody writes to, depositors
* write to boxes nobody reads.
*
* The rule this file pins, one line: **a failure surfaces; only an absence the broker
* actually confirmed may mint.** Resolving from the cache, joining a run in flight, and
* minting on a confirmed 0 are unchanged — they are the states that are true.
*
* ── Why every branch is here, not just the interesting ones ───────────────
* A swallowed failure is invisible by construction, so a suite covering the happy path and
* one error leaves exactly the places a bug hides. Every path through the function is
* asserted below: the cache, a run in flight (both outcomes), each of the four collaborators
* it awaits before deciding (session, doc-shim, account, the read), the read answering and
* the read finding nothing, and each of the three writes the mint performs.
*
* ── The faults are the broker's, not the test's ───────────────────────────
* Every failure is injected at the `ng` boundary — a read that throws `RepoNotFound` (what
* the engine hard-errors when a repo is not in `self.repos`,
* `engine/verifier/src/request_processor.rs:264`), a write or a `doc_create` that cannot
* reach the broker — or at the consumer's injected `getSession`, which is the other edge of
* the library. Nothing reaches into the library to make one of its own functions reject: a
* fake that fabricates a state the real system never produces goes green while leaving the
* real state untested.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure } from "../src/index";
import {
adoptCurrentUser,
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import {
createEntityDoc,
ensureAccount,
isKnownInbox,
resetRegistryCache,
userInbox,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {
documentInboxAddress,
openDocumentInbox,
} from "../src/emulated-verifier/branch-registers";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import type { InboxScope, Nuri } from "../src/model/types";
const SESSION: RegistrySession = { sessionId: "sid-inbox", privateStoreId: "PRIV-INBOX" };
const SHIM = "urn:ng-eventually:shim";
interface Quad { g: string; s: string; p: string; o: string }
/**
* What the broker refuses to do, and when. Every field arms a REAL failure of the
* corresponding platform call; `false` means "answer normally". Mutable after `inject`, so
* a test can build a healthy world first and only then break the one call it is about.
*/
interface Faults {
/** The consumer's `getSession` thunk rejects — the wallet cannot answer. */
session: boolean;
/** `doc_create` throws — the broker cannot mint a document. */
docCreate: boolean;
/** The doc-shim read that answers "which inbox does this user own" throws. */
inboxLookup: boolean;
/** The shim write that records "this NURI IS an inbox" throws. */
inboxRecord: boolean;
/** The shim write that associates the inbox with its (user, scope) throws. */
inboxPersist: boolean;
/** The store-root read that answers "where is the doc-shim" throws. */
pointerRead: boolean;
/** The store-root write that publishes the doc-shim's address throws. */
pointerWrite: boolean;
/** The doc-shim read that answers "does this account exist" throws. */
accountLookup: boolean;
/** The doc-shim write that records an account's three scope documents throws. */
accountRecord: boolean;
/** The User-branch write that records "I opened this document's inbox" throws. */
inboxCapPersist: boolean;
/** The Header-branch write that publishes WHERE to deposit for a document throws. */
addressPublish: boolean;
}
function noFaults(): Faults {
return {
session: false,
docCreate: false,
inboxLookup: false,
inboxRecord: false,
inboxPersist: false,
pointerRead: false,
pointerWrite: false,
accountLookup: false,
accountRecord: false,
inboxCapPersist: false,
addressPublish: false,
};
}
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/**
* A stateful fake `ng` over an in-memory quad store, modelling the pointer → doc-shim
* indirection the registry is built on: the store-root graph carries the write-once pointer,
* the doc-shim carries the account records, the inbox index and the (user, scope) → inbox
* associations. Same shape as the one `anti-fork.test.ts` drives the registry with, plus the
* fault switches above.
*
* `doc_subscribe` pushes a first `State` so the barrier `ensureRepoOpen` waits on resolves
* at once — the platform's real behaviour, and it keeps every failure below attributable to
* the call that was armed rather than to a missing primitive.
*/
function makeFakeNg(faults: Faults) {
const quads: Quad[] = [];
let docCounter = 0;
/** How many times the (user, scope) → inbox association was READ. */
let inboxLookups = 0;
const doc_create = mock(async () => {
if (faults.docCreate) throw new Error("BrokerError: cannot create document");
return `did:ng:o:idoc${++docCounter}`;
});
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (query.includes(`${SHIM}:isInbox`) && faults.inboxRecord) {
throw new Error("BrokerError: cannot write the inbox index");
}
if (query.includes(`${SHIM}:docInbox`) && faults.inboxPersist) {
throw new Error("BrokerError: cannot write the inbox association");
}
if (query.includes(`${SHIM}:shimDoc`) && faults.pointerWrite) {
throw new Error("BrokerError: cannot write the pointer");
}
if (query.includes(`${SHIM}:docPublic`) && faults.accountRecord) {
throw new Error("BrokerError: cannot write the account record");
}
if (query.includes(`${SHIM}:inboxCap`) && faults.inboxCapPersist) {
throw new Error("BrokerError: cannot write the inbox cap");
}
// Only the INSERT half: the `DELETE` that clears the previous address lands, which is
// the state that bites — the old address gone and the new one never written.
if (
query.includes("INSERT DATA") && query.includes(`${SHIM}:inboxAddress`) &&
faults.addressPublish
) {
throw new Error("BrokerError: cannot publish the inbox address");
}
// `DELETE WHERE { <s> <p> ?x }` — the clear half of a Header-branch replacement.
const dm = query.match(/DELETE WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?\w+\s*\}/);
if (dm) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === dm[1] && q.p === dm[2]) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses;
// everything else writes the anchored default graph.
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const g = gm ? gm[1]! : anchor;
if (!g) return undefined;
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${SHIM}:Account`;
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const rows = (anchor: string | undefined, pred: string, name: string) => ({
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred)
.map((q) => ({ [name]: { value: q.o } })),
},
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Store-root pointer → the doc-shim.
if (query.includes(`<${SHIM}:shimDoc>`)) {
if (faults.pointerRead) throw new Error("RepoNotFound");
return rows(anchor, `${SHIM}:shimDoc`, "shimDoc");
}
// The account record — the read `lookupAccount` issues, bounded to one subject.
if (query.includes(`<${SHIM}:id>`)) {
if (faults.accountLookup) throw new Error("RepoNotFound");
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
// Which inbox does this (user, scope) own — the read the whole file is about.
if (query.includes(`${SHIM}:docInbox`)) {
inboxLookups += 1;
if (faults.inboxLookup) throw new Error("RepoNotFound");
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return {
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })),
},
};
}
if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i");
// The registers a document and a store carry — enough for `createEntityDoc`,
// `ownsDocument` and `openDocumentInbox` to run against this wallet.
if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c");
if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c");
if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e");
if (query.includes(`<${SHIM}:inboxCap>`)) return rows(anchor, `${SHIM}:inboxCap`, "c");
if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a");
// Anchored per-doc read (`SELECT ?s ?p ?o`).
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
// Push a `State` on subscribe — the sync barrier `open-repo.ts` waits for.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: unknown) => {
if (typeof cb === "function") (cb as (r: unknown, t?: string) => void)({ V0: { State: {} } });
return () => {};
});
return {
doc_create,
sparql_query,
sparql_update,
doc_subscribe,
/** Documents minted so far — what "did it mint a second one" reads. */
created: (): number => docCounter,
/** Times the (user, scope) → inbox association was read. */
lookups: (): number => inboxLookups,
/** The inbox NURIs DURABLY associated with `scope`, across every account subject. */
associated: (scope: InboxScope): string[] =>
quads.filter((q) => q.p === `${SHIM}:docInbox:${scope}`).map((q) => q.o),
/** Every triple written with `pred`, whatever its subject or graph. */
written: (pred: string): string[] =>
quads.filter((q) => q.p === `${SHIM}:${pred}`).map((q) => q.o),
};
}
/** Wire a clean world: fresh fake broker, fresh caches, nobody connected. */
function inject(faults: Faults) {
const ng = makeFakeNg(faults);
configure({ ng: ng as never, useShape: (() => {}) as never });
configureStoreRegistry({
getSession: async (): Promise<RegistrySession> => {
if (faults.session) throw new Error("WalletError: the session cannot answer yet");
return SESSION;
},
normalizeId: (id: string) => id.trim().toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
resetCaps();
setCurrentUser(null);
return ng;
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetCaps();
});
/** A later session over the SAME wallet: every in-memory cache gone, the quads kept. */
function freshSession(): void {
resetRegistryCache();
resetOpenedRepos();
}
// --- resolving without asking: the two states that are already true ---------
test("A — an inbox already resolved comes back from the cache, untouched", async () => {
const ng = inject(noFaults());
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
const createdBefore = ng.created();
const lookupsBefore = ng.lookups();
const again = await userInbox("bob", "public");
expect(again).toBe(inbox);
expect(ng.lookups()).toBe(lookupsBefore); // no read at all
expect(ng.created()).toBe(createdBefore); // and nothing minted
});
test("B — a concurrent caller joins the resolution in flight instead of racing a second inbox", async () => {
// Two independent callers ask at once on a cold cache — the ordinary shape on a fresh
// page. Without the join each would see "no inbox" and mint one, forking the (user,
// scope) association in-session.
const ng = inject(noFaults());
adoptCurrentUser("bob");
await ensureAccount("bob");
const [first, second] = await Promise.all([
userInbox("bob", "public"),
userInbox("bob", "public"),
]);
expect(second).toBe(first);
expect(ng.associated("public")).toEqual([first]); // ONE association, not two
});
test("B — a concurrent caller joins the resolution in flight and inherits its FAILURE", async () => {
// The join is only safe if what it hands out is the run's real outcome. A joiner that
// inherits a resolved promise over a failed run is the sibling defect fixed in
// `connect.ts`: a caller that did nothing wrong carries on over work that never happened.
const faults = noFaults();
inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxPersist = true;
// Both handlers attached in the same tick, as two real concurrent callers would: awaiting
// one and only THEN the other leaves the second rejection momentarily unobserved, which
// the runtime reports as an unhandled rejection rather than as the outcome under test.
const outcomes = await Promise.allSettled([
userInbox("bob", "public"),
userInbox("bob", "public"),
]);
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]);
});
// --- the collaborators awaited before any decision is taken -----------------
test("C — a session that cannot answer fails the call", async () => {
// The consumer's `getSession` is the other edge of the library. A wallet that cannot
// answer says nothing about which inbox a user owns, so there is nothing to conclude.
const faults = noFaults();
inject(faults);
adoptCurrentUser("bob");
faults.session = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/the session cannot answer/);
});
test("D — a doc-shim that cannot be reached fails the call", async () => {
// No pointer yet (a first login), so resolving the doc-shim mints it. A broker that
// cannot create it leaves the registry itself unresolved — every account record and every
// inbox association lives in that document.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.created()).toBe(0);
});
test("E — an account that cannot be provisioned fails the call", async () => {
// A user must exist before it can own an inbox. `ensureAccount` mints its three scope
// documents on first sight; a broker that cannot returns no record, and an inbox filed
// under a user with no stores is filed under nothing.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("alice");
await ensureAccount("alice"); // the doc-shim now exists, so the fault lands on the account
const createdBefore = ng.created();
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.associated("public")).toEqual([]);
expect(ng.created()).toBe(createdBefore);
});
// --- the read: an answer, an absence, or a failure --------------------------
test("F — a lookup that could not answer is not an absent inbox: no second one is minted", async () => {
// The shipped defect. The read that asks *which inbox does this user own* was wrapped in a
// catch that only logged, and execution fell through to the mint — so a broker that could
// not answer produced a SECOND document for one (user, scope). Which of the two later wins
// is then decided by `canonicalDoc` picking among the triples: the owner drains one, a
// depositor may write to the other.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
freshSession();
const createdBefore = ng.created();
faults.inboxLookup = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/RepoNotFound/);
// The durable consequence: the wallet still holds exactly ONE inbox for (bob, public),
// and no document was minted behind the failure.
expect(ng.associated("public")).toEqual([inbox]);
expect(ng.created()).toBe(createdBefore);
// …so once the broker answers again, it is that same inbox that comes back.
faults.inboxLookup = false;
freshSession();
expect(await userInbox("bob", "public")).toBe(inbox);
});
test("G — an inbox the shim already records is resolved, never re-minted", async () => {
const ng = inject(noFaults());
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
freshSession(); // a later session over the same wallet: nothing warm
const createdBefore = ng.created();
const resolved = await userInbox("bob", "public");
expect(resolved).toBe(inbox);
expect(ng.created()).toBe(createdBefore);
expect(ng.associated("public")).toEqual([inbox]);
});
test("H — a first ask on a VERIFIED absence mints the inbox, records it, and associates it", async () => {
// The normal case, and the only one entitled to write: the broker answered, and it
// answered nothing. The three writes that make the answer usable all have to land — the
// document, the shim's "this IS an inbox", and the (user, scope) association.
const ng = inject(noFaults());
adoptCurrentUser("bob");
const publicInbox = await userInbox("bob", "public");
const protectedInbox = await userInbox("bob", "protected");
expect(protectedInbox).not.toBe(publicInbox); // two store repos upstream, two documents
expect(ng.associated("public")).toEqual([publicInbox]);
expect(ng.associated("protected")).toEqual([protectedInbox]);
// Durably an inbox, not merely one this session happens to remember.
freshSession();
expect(await isKnownInbox(publicInbox)).toBe(true);
expect(await isKnownInbox(protectedInbox)).toBe(true);
});
// --- the mint: three writes, and none of them may fail in silence -----------
test("I — a broker that cannot mint the document fails the call", async () => {
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob"); // exists, and has never opened an inbox
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.associated("public")).toEqual([]);
});
test("J — an inbox the shim could not record as one is not handed back", async () => {
// `recordInbox` is what stands in for the broker's `inboxes: PubKey → RepoId` table: it is
// how a depositor learns that a NURI is an inbox at all, and `inbox.post` refuses anything
// it cannot confirm. It swallowed its own write failure, so the call went on to associate a
// document that no depositor will ever be allowed to write to — the owner's inbox, silently
// closed to everyone, forever.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxRecord = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox index/);
expect(ng.associated("public")).toEqual([]); // nothing points at a box deposits bounce off
});
test("K — an association that could not be persisted is not returned as an inbox", async () => {
// The other shipped defect. The `INSERT DATA` that records WHICH inbox belongs to (user,
// scope) was wrapped in a catch that only logged, and the function cached and returned the
// document anyway. The owner then holds a reference whose triple was never written: nobody
// else resolves it, so a depositor finding nothing mints yet another. The owner reads a box
// nobody writes to, depositors write to a box nobody reads.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxPersist = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox association/);
expect(ng.associated("public")).toEqual([]);
// …and the reference a healthy resolution DOES hand back is one everybody resolves the
// same. Alice obtains Bob's inbox the way `inbox.share` does — by asking the registry
// under her own identity — rather than being handed a value across the boundary.
faults.inboxPersist = false;
freshSession();
const ownersView: Nuri = await userInbox("bob", "protected");
freshSession();
adoptCurrentUser("alice");
const depositorsView: Nuri = await userInbox("bob", "protected");
expect(depositorsView).toBe(ownersView);
expect(ng.associated("protected")).toEqual([ownersView]);
});
// --- the same shape, swept out of the rest of the registry -------------------
//
// `userInbox` was the third member of this family found by accident, so the sweep that
// followed the fix looked for every other `catch` that logs and lets execution carry on as
// though the thing looked for was absent — especially where what follows creates or writes.
// These are the ones whose consequence was a DURABLE false state; each is pinned here,
// because a swallow that comes back is invisible again by construction.
test("SWEEP — a pointer read that never answered does not mint a second registry root", async () => {
// `resolvePointer` retries a bounded number of times and answered `""` when the budget ran
// out — the same value it uses for "no pointer yet", which is what makes `resolveShimDoc`
// CREATE one. A store-root that could not be read therefore forked the registry itself: a
// second doc-shim, a second pointer, and every account record afterwards split between two
// documents, the loser's simply invisible.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
const pointersBefore = ng.written("shimDoc");
const createdBefore = ng.created();
freshSession();
faults.pointerRead = true;
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
expect(ng.written("shimDoc")).toEqual(pointersBefore); // still ONE registry root
expect(ng.created()).toBe(createdBefore); // and nothing minted behind the failure
});
test("SWEEP — a pointer that could not be written fails the first login", async () => {
// The pointer is the only NURI a fresh session can name without a lookup. Carrying on
// without it means writing every account into a doc-shim no later session can find — and
// the next login, seeing no pointer, mints another one and re-provisions everybody.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
faults.pointerWrite = true;
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the pointer/);
expect(ng.written("id")).toEqual([]); // no account was filed into an unreachable shim
});
test("SWEEP — an account read that could not answer does not provision a second account", async () => {
// `ensureAccount` asked through the TOLERANT resolver, which answers `null` for a read
// that failed exactly as for one that found nothing — and what follows an absence here is
// a PROVISION. So a broker hiccup minted a second set of three store documents and a
// second record: the account fork the doc-shim barrier was introduced to end, walking back
// in through the error path.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const first = await ensureAccount("bob");
const createdBefore = ng.created();
freshSession();
faults.accountLookup = true;
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
expect(ng.created()).toBe(createdBefore); // no second set of scope documents
faults.accountLookup = false;
freshSession();
expect(await ensureAccount("bob")).toEqual(first); // and the one account is intact
});
test("SWEEP — an account record that could not be persisted is not handed back", async () => {
// `ensureAccount` cached and returned the record right after the write. The session then
// wrote its entities into three documents the shim never heard of, and the next session,
// finding no record, provisioned the account again — everything created in between
// orphaned, with no error anywhere.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("alice"); // the doc-shim exists, so the fault lands on the record
faults.accountRecord = true;
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the account record/);
// Nothing remembers a user that was never filed: asking again re-provisions from scratch
// rather than handing back the set the first attempt abandoned.
faults.accountRecord = false;
const createdBefore = ng.created();
const record = await ensureAccount("bob");
expect(ng.created()).toBe(createdBefore + 3);
expect(record.docPrivate).not.toBe("");
});
test("SWEEP — a document inbox the owner's branch could not record is never published", async () => {
// `openDocumentInbox` writes twice: the pair on the owner's User branch (what makes the
// inbox DRAINABLE — `myInboxes` builds the connection's drain list from it) and the
// address on the document (what makes it REACHABLE). Swallowing the first went on to
// publish the second, inviting depositors into a queue its owner never enumerates: every
// message delivered, none ever applied, permanently.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const note = await createEntityDoc("bob", "protected");
faults.inboxCapPersist = true;
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot write the inbox cap/);
expect(ng.written("inboxAddress")).toEqual([]); // nobody was told where to deposit
expect(await documentInboxAddress(note)).toBeUndefined();
});
test("SWEEP — an address that could not be published fails opening the inbox", async () => {
// The other half. Publishing is the only way a third party learns where to deposit here,
// and the address is a REPLACEMENT: the `DELETE` lands, the `INSERT` does not, and the
// document is left with no address at all while its owner is handed an inbox and told
// nothing.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const note = await createEntityDoc("bob", "protected");
faults.addressPublish = true;
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot publish the inbox address/);
expect(ng.written("inboxAddress")).toEqual([]);
});