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
@@ -9,11 +9,20 @@
* is *"the app works but the documents shared with me never appear"* — the worst kind,
* because it looks like a permission decision and is a swallowed error.
*
* The rule this file pins, one line: **any failure must surface; only "there was nothing to
* do" may resolve quietly.** Nothing to do means exactly two things — no identity is
* connected, or the identity has no account yet (connecting must never PROVISION one, see
* `connect.ts`) — plus abandoning when the identity changed under the run, which is not a
* failure either: the next connection picks it up.
* The rule this file pins, one line: **failing to ESTABLISH the session surfaces; failing
* to apply one of its queues is reported and does not deny the session; only "there was
* nothing to do" resolves quietly.** Nothing to do means exactly two things — no identity
* is connected, or the identity has no account yet (connecting must never PROVISION one,
* see `connect.ts`) — plus abandoning when the identity changed under the run, which is not
* a failure either: the next connection picks it up.
*
* The queue clause was added 2026-08-16, and it is a correction rather than a softening.
* Rejecting on an undrained inbox looked like the same rigour as the rest, and it was not:
* a queue that cannot be applied is not consumed by failing, so it is still there at the
* next connection and the one after — one unapplicable item denied a live application's
* user their sign-in, permanently, three times out of three. Reporting it and carrying on
* is what makes the failure recoverable instead of terminal; nothing about it is silent
* (the branch below asserts the report, and that the OTHER queues still ran).
*
* ── Why every branch is here, not just the interesting ones ───────────────
* A swallowed failure is invisible by construction, so a suite that covers "the happy path
@@ -408,21 +417,76 @@ test("a document-inbox list that cannot be read fails the connection", async ()
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
});
test("an inbox that cannot be drained fails the connection, and the rest stay undrained", async () => {
// Draining is what APPLIES a share. An inbox that could not be read may hold the cap this
// very reconnection was for, so a silent skip loses it with no trace — and it took the
// remaining inboxes down with it, silently too. It still stops at the first failure; the
// difference is that stopping is now audible.
/**
* The one branch where a failure does NOT reject — and the four things that have to hold
* at once for that to be honest. Rewritten 2026-08-16, when the previous contract
* ("stop at the first failure, reject") was reported doing this to a live application:
* one queue it could not apply denied its user the application, at every sign-in, because
* an inbox that fails is not consumed and is still there next time. See `connect.ts`.
*
* A queue is not the session. Failing to REACH the queues still rejects — that case is
* the test above, and it is untouched.
*/
test("an inbox that cannot be drained is reported, the rest are drained, and the session stands", async () => {
const faults = noFaults();
const ng = inject(faults);
const note = await aliceSharesANoteWithBob();
adoptCurrentUser("bob");
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
const protectedInbox = await userInbox("bob", "protected"); // where Alice's share landed
faults.inboxRead = publicInbox;
const reported: string[] = [];
const realError = console.error;
console.error = (...args: unknown[]): void => void reported.push(args.map(String).join(" "));
try {
// 1. The person gets their session. This is what the previous contract denied them.
await connectedUser();
} finally {
console.error = realError;
}
// 2. The queue after the failing one was still drained — a failure that took the others
// down with it lost shares that had nothing to do with it.
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
// 3. …so the share this reconnection was for actually arrived.
expect(holds(note)).toBe(true);
// 4. And it is NOT silence: the failure names the queue and carries the broker's error.
// Ungated — this suite never turns the access log on.
expect(reported.some((line) => line.includes("RepoNotFound"))).toBe(true);
expect(reported.some((line) => line.includes("could not be drained"))).toBe(true);
});
// The property the live report was actually about: not one refused sign-in, but EVERY one
// of them. An inbox is not consumed by failing to be read, so a fault that persists is
// still on the drain list at the next connection — which, under the previous contract, made
// the first refusal permanent rather than transient. Signing in twice over the same
// standing fault is the cheapest way to pin that it no longer is.
test("a queue that keeps failing does not deny the session at the NEXT connection either", async () => {
const faults = noFaults();
const ng = inject(faults);
await aliceSharesANoteWithBob();
adoptCurrentUser("bob");
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
faults.inboxRead = publicInbox;
const publicInbox = await userInbox("bob", "public");
faults.inboxRead = publicInbox; // a standing fault: nothing about it heals
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
expect(ng.inboxReads).toEqual([publicInbox]); // the protected one was never reached
const realError = console.error;
console.error = (): void => undefined;
try {
await connectedUser();
// A second connection, the way a reload produces one: the caches go, the wallet stays.
resetRegistryCache();
resetCaps();
adoptCurrentUser("bob");
await connectedUser();
} finally {
console.error = realError;
}
// Both connections went all the way through the list rather than stopping at the fault.
expect(ng.inboxReads.filter((r) => r === publicInbox).length).toBe(2);
});
// --- abandoning on an identity switch: not a failure ------------------------