fix: une connexion qui échoue ne se résout plus comme une réussite

connectedUser() restaure les capacités reçues et draine les inbox. Aucun de ses
chemins ne pouvait échouer : un broker injoignable rendait exactement la même
promesse qu'un succès complet. L'application affichait alors des listes vides,
et rien nulle part ne disait que la restauration n'avait pas eu lieu.

L'énumération m'avait échappé sur deux points, l'agent les a établis.

resolveAccount attrapait tout et rendait null : une lecture qui ÉCHOUAIT
ressortait donc comme « ce compte n'existe pas ». L'échec était déguisé en
absence — c'est la racine du partage cassé trouvé ce matin, dont on n'avait
traité que le déclencheur. lookupAccount le remplace : le silence n'est plus
possible que sur une absence VÉRIFIÉE.

Et readLinks comme readInboxCapPairs avalaient leur propre erreur en rendant un
tableau vide, un étage sous le catch de connect. Une panne n'y parvenait même
pas. Elles relèvent désormais.

La règle est simple : tout échec remonte, seul « il n'y avait rien à faire »
se résout en silence. Ce qui reste silencieux — aucun détenteur, compte
réellement absent, identité changée en route — l'est parce que c'est la vérité.

Le piège consigné hier est fermé par là même : une exécution qui ne peut pas
répondre rejette, et ceux qui la rejoignent en héritent. Sa feuille est
supprimée, la question qu'elle laissait ouverte étant tranchée.

Trois fixtures de test utilisaient un ng vide — une forme qu'aucune plateforme
ne présente, dont le TypeError était mangé par le catch. Remplacées par un
broker au portefeuille vide. Aucune assertion modifiée.
This commit is contained in:
Sylvain Duchesne
2026-08-13 11:58:29 +02:00
parent 55714d0a23
commit 8c8ade7a9e
10 changed files with 733 additions and 75 deletions
@@ -294,6 +294,14 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
return `${doc} ${inbox}`;
}
/**
* The (document, inbox) pairs this user may read — the emulated `AddInboxCap` records.
*
* **Propagates a failed read**, for the reason spelled out on {@link readLinks}: this is
* 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.
*/
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();
if (holder === null) return [];
@@ -318,6 +326,7 @@ export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nur
}
} catch (error) {
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
throw error;
}
return out;
}
@@ -383,6 +392,14 @@ export async function addLink(cap: ReadCap): Promise<void> {
/**
* The caps this user has received and applied — the User branch read back. Called
* at connection to restore what was shared with them, without touching any inbox.
*
* **Propagates a failed read** rather than answering `[]`. Empty and unreadable are the
* same value here and could not be more different: "nobody has shared anything with me"
* against "everything shared with me is invisible and nothing said so". The caller that
* matters is `connect.connectedUser`, whose whole contract is that it either did the
* restore or says it did not (2026-08-13) — an empty answer would let it report success
* over a restore that never happened. Same reason `lookupAccount` exists beside
* `resolveAccount`.
*/
export async function readLinks(): Promise<ReadCap[]> {
const holder = getCurrentUser();
@@ -407,6 +424,7 @@ export async function readLinks(): Promise<ReadCap[]> {
}
} catch (error) {
console.error(accessLogPrefix() + " readLinks failed:", error);
throw error;
}
return out;
}
@@ -35,7 +35,8 @@
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { resolveAccount } from "../shared-wallet/account-registry";
import { lookupAccount } from "../shared-wallet/account-registry";
import { accessLogPrefix } from "../shared-wallet/access-log";
import { myInboxes, readLinks } from "./branch-registers";
import { processInbox } from "../surface/inbox";
@@ -45,10 +46,32 @@ const inFlight = new Map<string, Promise<void>>();
/**
* Restore and drain for the connected user. Idempotent per user while in flight.
*
* Tolerant by construction: it runs on every `setCurrentUser`, including in
* contexts where the store registry was never configured (unit tests, an app
* setting the identity before the session resolves). Those simply have nothing to
* restore, and a failure here must never break connecting.
* ── It either DID THE WORK or SAYS IT DID NOT ─────────────────────────────
* `ensureIdentity()` awaits this, and the contract it publishes is that the call
* *completes the connection work it starts*. So the one thing this must never do is
* resolve after failing: an application then renders, shows empty lists, and nothing
* anywhere says the restore never happened. What a person sees is "the app works but the
* documents shared with me never appear" — which reads like a permission decision and is
* 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:
*
* - **no identity connected** — anonymous holds nothing and owns no inbox;
* - **the identity has no account yet** — nothing to restore, no queue to drain, and
* connecting must not create one (see the note in `run` below). A GENUINE absence:
* `lookupAccount` is used precisely so a lookup that could not ANSWER throws instead
* of borrowing that silence;
* - **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.
*
* 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.
*/
export async function connectedUser(): Promise<void> {
const holder = getCurrentUser();
@@ -75,33 +98,40 @@ export async function connectedUser(): Promise<void> {
const holderKey = getCaps().holderKey();
const run = (async (): Promise<void> => {
try {
// Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a
// background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain.
if ((await resolveAccount(holder)) === null) return;
// Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a
// background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain.
//
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
// that FAILED exactly as for one that found nothing, so an unreachable broker looked
// like "this identity has no account" — the one absence connecting is entitled to
// pass over in silence. The whole restore was skipped and the promise resolved like a
// success. That is how sharing broke once (`.project/concepts/sign-in/`
// `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is
// 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.
const links = await readLinks();
if (!stillConnected()) return;
for (const cap of links) getCaps().learnFor(holderKey, cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it
// 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.
const inboxes = await myInboxes();
for (const inbox of inboxes) {
if (!stillConnected()) return;
// 1. Durable first: what this user has already applied.
const links = await readLinks();
if (!stillConnected()) return;
for (const cap of links) getCaps().learnFor(holderKey, cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it
// applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty.
const inboxes = await myInboxes();
for (const inbox of inboxes) {
if (!stillConnected()) return;
await processInbox(inbox);
}
} catch {
// Not configured yet, or offline. Nothing to restore, and connecting must
// not fail because a queue could not be reached — the next connection, or
// an explicit `connectedUser()`, picks it up.
await processInbox(inbox);
}
})();
@@ -113,7 +143,20 @@ export async function connectedUser(): Promise<void> {
}
}
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
/**
* Fire the connection work without awaiting it. Called by `setCurrentUser`.
*
* There is no caller to reject at here — that is what fire-and-forget means — so this is
* the one place a failure cannot surface as a rejection. It is logged instead, and NOT
* left to become an unhandled rejection: that would take down whatever runtime the
* consumer is in, over a connection the consumer never awaited.
*
* A caller that needs the outcome awaits `connectedUser()` — it joins this very run and
* inherits it, failure included. That is what `ensureIdentity()` does, and it is the path
* on which the guarantee is published.
*/
export function startConnect(): void {
void connectedUser();
void connectedUser().catch((error: unknown) => {
console.error(accessLogPrefix() + " connect failed:", error);
});
}