fix: à la reconnexion, on retrouve ce qu'on possède — pas seulement ce qu'on a reçu

Une application signalait deux symptômes. Ils sont indépendants, et ils ont une
racine commune : se connecter ne rejouait qu'UN des registres durables du
portefeuille.

connectedUser lisait les AddLink de la branche User — ce qu'on vous a partagé —
et rien d'autre. Les capacités des documents que vous avez FAITS vivent sur la
branche Store, et un seul chemin les relisait : listMyEntityDocs. Une
application qui recharge et va droit à sa note ne tenait donc rien pour elle.
Constaté : capFor(note) vaut undefined juste après une connexion résolue, et
devient défini dès que listMyEntityDocs passe.

Le public survivait en lecture parce que fetchReadCap va rechercher la clé dans
le store ; il échouait quand même à l'écriture, qui ne fait pas cette démarche.

Deuxième défaut : myInboxes énumérait les inbox sans en remettre la clé au
détenteur. Se connecter demandait donc un document qu'on n'avait pas de quoi
lire — sur une inbox, que l'application n'a jamais nommée puisque rien ne le
lui permet.

Troisième défaut, et c'est lui qui rendait tout ça fatal : un drainage refusé
faisait échouer toute la connexion. Une inbox n'étant pas consommée par un
échec, elle refusait à chaque tentative suivante. D'où le « trois fois sur
trois », et d'où un verrouillage plutôt qu'un partage manquant.

C'est ma spécification qui l'a créé. En rendant les échecs visibles j'avais
écrasé une distinction : ne pas ATTEINDRE la file est une panne, et refuser la
session est juste ; ne pas pouvoir APPLIQUER un élément est une donnée, et ça ne
doit priver personne de sa session. Le drainage par inbox est désormais isolé —
signalé haut et fort, les autres files drainées, la session accordée — tandis
qu'énumérer les files et restaurer rejettent toujours.

Le parcours e2e ratait les deux : personne ne se reconnecte après avoir ouvert
son document aux messages. Il s'arrêtait une reconnexion trop tôt.
This commit is contained in:
Sylvain Duchesne
2026-08-16 17:23:27 +02:00
parent b50591f5bd
commit f6d1734679
8 changed files with 1113 additions and 49 deletions
@@ -37,7 +37,7 @@ import { sparqlQuery } from "../surface/docs";
import { readForHolder, registerUpdate } from "./register-write";
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
import { hasReadCap, isNuri, targetOf, toNuri } from "../model/nuri";
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { ensureRepoOpen } from "./open-repo";
@@ -51,6 +51,7 @@ import {
session,
readBindings,
bindingValue,
lookupAccount,
resolveAccount,
storeOf,
readUserStore,
@@ -188,28 +189,97 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
* The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
* what it owns without recomputing anything.
*
* **Propagates a failed read**, like {@link readLinks} beside it and for the same reason:
* empty and unreadable are the same value here and could not mean more different things —
* "I own nothing in this store" against "every document I own is invisible and nothing
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
* its whole contract is that the connection either did the restore or says it did not.
* A caller that genuinely prefers to carry on catches it itself, and one of them does.
*/
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
const s = await session();
const out: ReadCap[] = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
return out;
}
/**
* Put back in the connected user's hands the key of EVERY document it owns — the three
* Store branches read back, which is the emulated replay of `AddRepo { read_cap }`
* (`AddRepo::verify` → `Verifier::load_repo_from_read_cap`,
* `engine/verifier/src/verifier.rs:2237`).
*
* ── Why connecting has to do this, and why the store record is not enough ──
* A document's cap is written in exactly one durable place — the Store branch of the
* store it was created in ({@link holdOwnCap}) — and until 2026-08-16 exactly one path
* read it back: `listMyEntityDocs`. So a creator that reloaded the page and went straight
* to the document it had just made, without listing that scope first, held nothing for it:
* the read was refused, the batch read (`readUnion`) dropped it silently, and the write
* was refused. Its own document, denied to its own author, on the first refresh.
*
* Restoring what a user HOLDS is already what connecting means here — that is what the
* User-branch Links step does for caps RECEIVED from others. Its own documents are the
* other half of the same act, and the more fundamental one: a received cap is a bonus, a
* created document is the user's own content.
*
* The scope stores are filed first ({@link fileOwnStructure}), because the Store branch
* lives INSIDE the store document and reading it is itself a guarded read. Not left to a
* side effect of whatever ran before: the order is carried by this composition rather than
* by where the call happens to sit.
*
* `holderKey` is the ring these land in — captured by the caller when it decided whose
* connection this is, and handed back here, exactly as `readLinks`' caller does. See
* `caps.holderKey`.
*
* Nothing to restore is not a failure: an identity with no account yet owns no store to
* read. A failed READ is one, and it propagates — the caller is `connect.connectedUser`,
* whose contract is that it did the work or said it did not.
*/
export async function restoreOwnCaps(holderKey: string): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
// `lookupAccount`, not `resolveAccount`: a read that could not ANSWER must not arrive
// here as "this identity has no account", which is the one absence entitled to silence.
// Its caller resolved the same account a moment ago, so this is a cache hit.
const record = await lookupAccount(holder);
if (record === null) return;
// The store documents themselves, or the reads below would be refused for want of the
// very structure this user owns by BEING a user.
fileOwnStructure(holder, record);
const caps = getCaps();
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
// COLD-START heal, and it is the whole point of this function running at CONNECTION
// time: on a fresh session the store repo is not yet in the verifier's `self.repos`,
// and an anchored read of a repo that is not open returns 0 rows — it does not fail.
// Without this the restore would answer "you own nothing" on exactly the session that
// needs it, silently, which is the failure-as-absence this whole path exists against.
// Same guard `readUserStore` and `readLinks` already apply to the same documents.
await ensureRepoOpen(store);
for (const cap of await readStoreCaps(store)) {
// `learnFor`, never a fresh mint: the cap must be the value the Store branch
// carries — see the note in `holdOwnCap` on why a second mint locks the owner out
// the day the stand-in value becomes a real key.
caps.learnFor(holderKey, cap);
// Which store a document sits in is a registry fact, not one recorded beside the
// caps, so it is re-applied here — marking only, like `listMyEntityDocs` does.
if (scope === "public") caps.markInPublicStore(targetOf(cap));
}
}
}
/**
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
@@ -348,6 +418,24 @@ export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
/**
* Every inbox this user may READ: its own, plus one per document it opened an
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
*
* ── Listing an inbox PUTS IT IN YOUR HANDS, both levels alike ─────────────
* An `AddInboxCap` record is not a note that an inbox exists — upstream it IS the key:
* `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1973`), which
* `update_inbox_cap_v0` applies straight into the repo (`verifier.rs:1920`). So a session
* that can enumerate an inbox from that record can read it, by construction.
*
* Here the record carries the pair and not the key, so the entitlement it expresses is
* honoured the same way {@link fileOwnInbox} already honours it for a user's OWN two
* inboxes, one line above: the cap is minted from the NURI for the holder the record
* belongs to. Same emulation, same stand-in, and at migration both are replaced by the
* `priv_key` the record carries.
*
* Left out until 2026-08-16 for the DOCUMENT inboxes, and the consequence was not a
* missing feature: connecting enumerated an inbox it then had no key to read, the guarded
* read refused it, and the refusal came back out of `ensureIdentity()` — a rejected
* sign-in, on a document the application had never named, repeating at every connection
* because the record is durable. Reported live, three sign-ins out of three.
*/
export async function myInboxes(): Promise<Nuri[]> {
const holder = getCurrentUser();
@@ -358,7 +446,11 @@ export async function myInboxes(): Promise<Nuri[]> {
if ((await resolveAccount(holder)) !== null) {
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope));
}
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
for (const { inbox } of await readInboxCapPairs()) {
// The record entitles this holder to read it — see the note above.
fileOwnInbox(holder, inbox);
out.push(inbox);
}
return out;
}
@@ -9,14 +9,28 @@
*
* Two steps, in order, and the order matters:
*
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
* emulated `AddLink` records on the User branch of the private store) back into
* what this user holds. This is durable state; it costs one read and needs no inbox.
* 1. **Restore** — replay the durable registers back into what this user holds. All of
* them: the Store branches of its three stores (the emulated `AddRepo { read_cap }` —
* the documents it CREATED, `branch-registers.restoreOwnCaps`) and the User branch's
* Links (the emulated `AddLink` — the caps it was GIVEN, `readLinks`). Durable state,
* a handful of reads, no inbox needed.
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
* new Link durably and puts it among what the user holds.
*
* Restoring first means a reconnecting user can read its shared documents
* immediately, without waiting on the inbox round-trip.
* Restoring first means a reconnecting user can read its documents immediately, without
* waiting on the inbox round-trip.
*
* ── Restoring means ALL the registers, and that is a scar ─────────────────
* Until 2026-08-16 this step read the Links and nothing else, so a fresh page put back the
* caps a person had been GIVEN and not the ones for documents they had MADE. The Store
* branch was read by exactly one other path — `listMyEntityDocs` — so a creator who
* reloaded and went straight back to its own note (a route, a deep link) was refused it:
* the read threw, the batch read dropped it silently, and the write was refused. Its own
* document, denied to its own author, on the first refresh.
*
* The lesson generalises past that one register: what connecting owes is *everything the
* wallet durably says this user holds*, and any register left out of this step is a
* capability that exists on disk and not in the session.
*
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
@@ -36,9 +50,30 @@
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { lookupAccount } from "../shared-wallet/account-registry";
import { accessLogPrefix } from "../shared-wallet/access-log";
import { myInboxes, readLinks } from "./branch-registers";
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
import { myInboxes, readLinks, restoreOwnCaps } from "./branch-registers";
import { processInbox } from "../surface/inbox";
import type { Nuri } from "../model/types";
/**
* Where the failure of ONE queue goes.
*
* Not a rejection — see the rule on {@link connectedUser} for why a queue is not the
* connection — and emphatically not silence. Same channel, same prefix and the same
* ungated `console.error` as {@link startConnect} below and as the deferred drain
* (`emulated-verifier/inbox-processor.ts`, whose header argues this exact point): a
* diagnostic may be opt-in, a failure may not.
*/
function reportUndrained(inbox: Nuri, error: unknown): void {
console.error(
accessLogPrefix() +
" connected, but this queue could not be drained — a share deposited in it is not " +
"applied yet, and connecting again will try it again: " +
shortNuri(inbox) +
":",
error,
);
}
/** The in-flight connection work, per user key — so two calls do not race. */
const inFlight = new Map<string, Promise<void>>();
@@ -55,8 +90,9 @@ const inFlight = new Map<string, Promise<void>>();
* a swallowed error. It swallowed **every** failure until 2026-08-13, offline broker
* included, and that is what this shape replaces.
*
* The rule, one line: **any failure rejects; only "there was nothing to do" resolves
* quietly.** Exactly three cases are nothing to do, and none of them is a failure:
* The rule, one line: **failing to ESTABLISH the session rejects; failing to apply one of
* its queues is reported and does not deny anyone their session.** And "there was nothing
* to do" resolves quietly. Exactly three cases are nothing to do, and none is a failure:
*
* - **no identity connected** — anonymous holds nothing and owns no inbox;
* - **the identity has no account yet** — nothing to restore, no queue to drain, and
@@ -66,9 +102,26 @@ const inFlight = new Map<string, Promise<void>>();
* - **the identity changed under the run** — abandoning is correct, and the next
* connection picks the work up (see `stillConnected`).
*
* A restore that did not happen makes every shared document invisible, and an inbox that
* was not drained leaves a share unapplied. Both are the same event for the person using
* the application, so both are failures — there is no third category here.
* ── Why a queue is NOT the connection, corrected 2026-08-16 ───────────────
* This said "there is no third category here" and rejected on an undrained inbox too. The
* reasoning was that an unapplied share and a lost restore look the same to a person. What
* it missed is what happens NEXT: an inbox is not consumed by failing, so a queue that
* cannot be applied is still on the list at the following connection, and the one after
* that. One unapplicable item therefore did not delay a share — it **locked the person out
* of the application, permanently**, and got worse rather than better with time. Reported
* live by a consuming application, three sign-ins out of three.
*
* That is strictly worse than the silence the 2026-08-13 shape replaced, and it collapsed
* a distinction that has to stay open: **failing to REACH the queues** (the session cannot
* proceed at all — reject, unchanged) against **failing to apply what is in one** (data,
* one queue, one person's share — report it, drain the others, let them in).
*
* The split needs no error classification, and that is what makes it trustworthy: the
* restore step is a plain `await` and still rejects, so a broker that cannot answer fails
* the connection there, before any queue is reached. Only the per-inbox drain is caught.
*
* Not silence: {@link reportUndrained} is loud and ungated, and the caps a share carries
* are not lost — the deposit stays in the queue, so the next connection applies it.
*
* Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a
* broker that cannot answer belongs to the caller, and it can only decide if it is told.
@@ -113,7 +166,12 @@ export async function connectedUser(): Promise<void> {
// the same fault `inbox.share` was fixed for on 2026-08-10.
if ((await lookupAccount(holder)) === null) return;
if (!stillConnected()) return;
// 1. Durable first: what this user has already applied.
// 1. Durable first, and ALL of it — see the header. The documents this user MADE, from
// the Store branches of its own stores, and then the caps it was GIVEN, from the
// User branch. Own documents first: they are the user's own content, and a person
// coming back to the note they wrote should not be waiting on anybody's share.
await restoreOwnCaps(holderKey);
if (!stillConnected()) return;
const links = await readLinks();
if (!stillConnected()) return;
for (const cap of links) getCaps().learnFor(holderKey, cap);
@@ -124,14 +182,24 @@ export async function connectedUser(): Promise<void> {
// applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty.
//
// A queue that cannot be read stops the loop, so the inboxes after it are left
// undrained — as before. What changed is that stopping is now audible: the caller
// is told the connection did not complete instead of being handed a resolved
// promise over a half-drained wallet.
// A queue that fails is reported and the next one is still drained — see the rule
// above for why one unapplicable item must not deny a person their session, nor
// take the OTHER queues down with it. Enumerating them is a different act and is
// not caught: not knowing which queues exist is the session failing to establish.
const inboxes = await myInboxes();
for (const inbox of inboxes) {
if (!stillConnected()) return;
await processInbox(inbox);
try {
await processInbox(inbox);
} catch (error) {
// An identity that moved MID-DRAIN throws here too — `processInbox` resolves the
// holder at each step, so the new one is refused an inbox that is not theirs. That
// is abandoning, which this function has always called "not a failure", and
// reporting it would put a broker-looking error in the log every time a page
// switches user. The loop is about to return for the same reason.
if (!stillConnected()) return;
reportUndrained(inbox, error);
}
}
})();
@@ -1291,7 +1291,18 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
// holder when it runs — so hand the captured key back rather than trust that the
// identity has not moved. See `caps.holderKey`.
const holderKey = caps.holderKey();
for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
// Tolerant HERE, and only here: `readStoreCaps` propagates (see it, and `readLinks`
// beside it), because the connection's restore cannot report success over a read that
// never answered. This caller returns the LISTING, which it already has by now, and a
// listing without its keys is what it answered before this distinction existed —
// unchanged rather than quietly turned into a throw on a published call.
let stored: ReadCap[] = [];
try {
stored = await readStoreCaps(store);
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
}
for (const cap of stored) caps.learnFor(holderKey, cap);
// Which store a document sits in is a registry fact, not one recorded beside the
// caps, so it is re-applied here. Marking only — the caps just came from the Store
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns