Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.
e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).
Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :
- « chaque document a une inbox native » est FAUX. Seuls les repos de store
public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
`open_branch_`, après `RepoNotFound` ; charger par cap, c'est
`load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
(77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.
Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
5.8 KiB
Write loss on socket death (SerializationError)
Post-mortem — 2026-07-14 · Status: OPEN (not addressed).
An entity written just before a period of inactivity can be silently lost: it is absent on reconnection. (Whether the write never durably reached the broker, or reached it and is not read back on a cold reconnection, is not settled — see Epistemic caveat below. The wording here deliberately states only the observed symptom.) The account / identity survives (no fork). Observed in real conditions (Festipod, Firefox) during a pause after login/creation.
Symptom
- The user logs in, the app creates an entity (a Festipod event).
- A period of inactivity follows (idle, tab in the background…).
- The broker socket dies spontaneously with
SOCKET IS CLOSED Some(Left(SerializationError)). - On reconnection, the created entity has disappeared; the app reads back its own scope empty.
Evidence (VERIFIED — live Firefox logs, verbatim)
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
… NEED REPLAY true
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
[user1][polyfill] resolveAccount(user1) → 1 record ← the account SURVIVES (no fork)
[user1][polyfill] readScopeIndex(…) → 0 entities ← but the scope is EMPTY
… set reçu: 0 objets Event (public)
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]
Interpretation (plausible mechanism, not settled): the write was pushed into the local outbox, but the socket died before it was durably flushed into the broker topic; on reconnection, the outbox replay fails (Err(TopicNotFound)) because the topic was never created on the broker side → the event is abandoned. The account, for its part, had already been durably resolved (resolveAccount → 1 record): it is neither lost nor forked.
Epistemic caveat. The evidence establishes the symptom (loss +
Err(TopicNotFound)+ the scope read returning 0 — logged above asreadScopeIndex, since renamedreadUserStore). The exact mechanism is not settled between (i) loss at write time (the write never durably reaches the broker) and (ii) cold-rehydration failure (the write is on the broker but a fresh session does not reopen its own scope). TheErr(TopicNotFound)on the outbox replay leans toward (i) in this Firefox case. See the @data repro below, which exhibits a neighboring symptom but does not settle (i) vs (ii).
Causal chain (TRACED — reading of the NextGraph core, to be re-verified)
- The
SerializationErrorcloses the socket. The core emits the disconnection in two places, and they are not the same file:engine/net/src/broker.rs:1074sendsLocalBrokerMessage::Disconnected, which the SDK turns intodisconnections_sender.send(...)atsdk/rust/src/local_broker.rs:648. Navigate by symbol — the line numbers are volatile, and the earlier note in this file put thesendinbroker.rs, which it never was. - This disconnection is pushed to subscribers via
disconnections_subscribe(cb)(PUSH stream). - NextGraph reconnection is an unimplemented
// TODO(≈broker.rs:1051-1076): nothing re-establishes the socket nor re-flushes the outbox. user_connectreturns a snapshot{ server_id, server_ip, error, since }at call time — not a stream, unusable for detecting a later drop.- No write-durability confirmation API: a caller cannot
awaitthe guarantee that a write has reached the broker.
What the SDK exposes but does not consume
disconnections_subscribe does fire on this failure — but neither the polyfill (@ng-eventually/client) nor the consumer app subscribes to it. The signal exists, nobody listens to it; on the app side, no mechanism retries or warns the user.
Scope & not reproduced
- Observed on Firefox only to date. A manual test on another browser did not trigger the
SerializationErrornor its consequences. - @data reproduction (Chromium, real broker) — 2026-07-14, decisive. The existing @data reconnection test (
reconnexion-meme-identite) was a false green: it read A's repos back from the persistent profile's local IndexedDB, never from the broker. A genuinely cold reader (non-persistentfreshBrowsercontext, the same wallet/account A, no local state — seeded from the wallet captured before the event) reads 0 events from A (BARRIER timed-out (8000ms),CONNECTION ESTABLISHED). A different signature from the Firefox case (no socket death; theOUTBOX emptyis the reader's, trivially empty) and it does not settle (i) vs (ii) — an empty barrier is compatible with both. Established on the other hand: @data has never verified the broker durability of A's own reads, and cold rehydration from the broker fails. Repro:src/modules/event/features/reconnexion-froide-sans-local.feature(Festipod). - To settle (i) vs (ii): independently verify that A's write reaches the broker — e.g. a warm reader / a second identity reads the event's public doc (the two-identity isolation scenario). If it sees it → the write is durable → the cold reader's 0 is a (ii) (rehydration). Otherwise → (i).
Fix leads (not arbitrated)
- Core — fix the
SerializationErrorand implement the reconnection TODO (re-establish the socket + re-flush the outbox). - SDK / polyfill — consume
disconnections_subscribe→ reconnection + outbox re-flush as a mitigation, independently of the core. - Durability API — expose a confirmation that a write has reached the broker, so that the caller can
awaitit.
Links
docs/nextgraph-current-state.md— current state of the core (disconnection / reconnection to be cross-referenced here).- Product impact + consumer-side caveat: Festipod concept
data-layer→caveat_write-durability-across-disconnect.