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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user