docs: passer vision, readcap-and-nuri-model et l'incident en anglais

Le reste du dossier docs/ était déjà en anglais ; ces trois fichiers avaient été
rédigés en français par erreur. Traduction fidèle, sans changement de fond :
mêmes sections, mêmes tableaux, mêmes blocs de code. Le retour à la ligne dur à
78 colonnes est levé (une ligne par paragraphe, convention du projet).

Marqueurs épistémiques préservés et rendus aussi visibles : VERIFIED / INFERRED /
CORRECTED / DIRECTION / GAP. Les citations verbatim de commentaires amont restent
intactes.

Deux incohérences de FOND signalées par la traduction et corrigées ici — elles
étaient invisibles tant qu'on lisait chaque section isolément :

- readcap-and-nuri-model, section « Caveats / gaps » : elle listait encore le
  fetch keyless comme hypothèse INFÉRÉE à confirmer, alors que le bloc CORRIGÉ du
  §4bis la déclare fausse et non constructible. Contradiction interne née de ma
  correction partielle. Conservée barrée plutôt que supprimée : l'hypothèse est
  intuitive et se reformera sinon.
- incident write-loss : l'intro affirmait en fait établi que « l'écriture
  n'atteint jamais durablement le broker », alors que la réserve épistémique plus
  bas dit explicitement que l'alternative (perte d'écriture vs réhydratation à
  froid) n'est pas tranchée. L'intro ne rapporte plus que le symptôme observé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
Sylvain Duchesne
2026-07-28 15:51:22 +02:00
parent 518292498a
commit 0d52c82ba9
3 changed files with 135 additions and 285 deletions
@@ -1,57 +1,57 @@
# Perte d'écriture lors d'une mort de socket (`SerializationError`) # Write loss on socket death (`SerializationError`)
**Post-mortem — 2026-07-14 · Statut : OUVERT (non traité).** **Post-mortem — 2026-07-14 · Status: OPEN (not addressed).**
Une entité écrite juste avant une période d'inactivité peut être **perdue silencieusement** : l'écriture n'atteint jamais durablement le broker, et l'entité est absente à la reconnexion. Le **compte / l'identité survit** (pas de fork). Observé en conditions réelles (Festipod, Firefox) lors d'une pause après login/création. 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.
## Symptôme ## Symptom
1. L'utilisateur se connecte, l'app crée une entité (un événement Festipod). 1. The user logs in, the app creates an entity (a Festipod event).
2. Une période d'inactivité suit (idle, onglet en arrière-plan…). 2. A period of inactivity follows (idle, tab in the background…).
3. Le socket broker meurt spontanément avec `SOCKET IS CLOSED Some(Left(SerializationError))`. 3. The broker socket dies spontaneously with `SOCKET IS CLOSED Some(Left(SerializationError))`.
4. À la reconnexion, l'entité créée a disparu ; l'app relit son propre scope **vide**. 4. On reconnection, the created entity has disappeared; the app reads back its own scope **empty**.
## Preuves (VÉRIFIÉ — logs Firefox en direct, verbatim) ## Evidence (VERIFIED — live Firefox logs, verbatim)
``` ```
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay> … REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
… NEED REPLAY true … NEED REPLAY true
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound) … SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
[user1][polyfill] resolveAccount(user1) → 1 record ← le compte SURVIT (pas de fork) [user1][polyfill] resolveAccount(user1) → 1 record ← the account SURVIVES (no fork)
[user1][polyfill] readScopeIndex(…) → 0 entities ← mais le scope est VIDE [user1][polyfill] readScopeIndex(…) → 0 entities ← but the scope is EMPTY
… set reçu: 0 objets Event (public) … set reçu: 0 objets Event (public)
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …] … SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]
``` ```
Lecture (**mécanisme plausible, non tranché**) : l'écriture a été poussée dans l'**outbox** local, mais le socket est mort avant qu'elle ne soit **flushée durablement** dans le topic broker ; à la reconnexion, le replay de l'outbox échoue (`Err(TopicNotFound)`) parce que le topic n'a **jamais été créé côté broker**l'événement est abandonné. Le compte, lui, avait déjà été résolu durablement (`resolveAccount → 1 record`) : il n'est ni perdu ni forké. 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.
> **Réserve épistémique.** Les preuves établissent le *symptôme* (perte + `Err(TopicNotFound)` + `readScopeIndex → 0`). Le *mécanisme* exact n'est pas tranché entre **(i) perte à l'écriture** (l'écriture n'atteint jamais durablement le broker) et **(ii) échec de réhydratation à froid** (l'écriture *est* sur le broker mais une session fraîche ne rouvre pas le scope propre). Le `Err(TopicNotFound)` sur le replay outbox penche pour **(i) dans ce cas Firefox**. Voir la repro @data ci-dessous, qui expose un symptôme voisin mais **ne tranche pas** (i) vs (ii). > **Epistemic caveat.** The evidence establishes the *symptom* (loss + `Err(TopicNotFound)` + `readScopeIndex → 0`). 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). The `Err(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).
## Chaîne causale (TRACÉlecture du core NextGraph, à re-vérifier) ## Causal chain (TRACEDreading of the NextGraph core, to be re-verified)
- La `SerializationError` ferme le socket. Le core émet la déconnexion : `broker.rs``LocalBrokerMessage::Disconnected``disconnections_sender.send(...)` (≈ `broker.rs:1051`, à re-vérifiernuméro volatil, se repérer par le symbole). - The `SerializationError` closes the socket. The core emits the disconnection: `broker.rs``LocalBrokerMessage::Disconnected``disconnections_sender.send(...)` (≈ `broker.rs:1051`, to be re-verifiedvolatile number, navigate by symbol).
- Cette déconnexion est **poussée** aux abonnés via `disconnections_subscribe(cb)` (flux PUSH). - This disconnection is **pushed** to subscribers via `disconnections_subscribe(cb)` (PUSH stream).
- **La reconnexion NextGraph est un `// TODO` non implémenté** (≈ `broker.rs:1051-1076`) : rien ne rétablit le socket ni ne re-flushe l'outbox. - **NextGraph reconnection is an unimplemented `// TODO`** (≈ `broker.rs:1051-1076`): nothing re-establishes the socket nor re-flushes the outbox.
- `user_connect` renvoie un **instantané** `{ server_id, server_ip, error, since }` au moment de l'appel — pas un flux, inutilisable pour détecter une chute ultérieure. - `user_connect` returns a **snapshot** `{ server_id, server_ip, error, since }` at call time — not a stream, unusable for detecting a later drop.
- **Aucune API de confirmation de durabilité d'écriture** : un appelant ne peut pas `await` la garantie qu'une écriture a atteint le broker. - **No write-durability confirmation API**: a caller cannot `await` the guarantee that a write has reached the broker.
## Ce que le SDK expose mais ne consomme pas ## What the SDK exposes but does not consume
`disconnections_subscribe` **se déclenche** sur cette panne — mais ni le polyfill (`@ng-eventually/client`) ni l'app consommateur ne s'y abonnent. Le signal existe, personne ne l'écoute ; côté app, aucun mécanisme ne re-tente ni n'avertit l'utilisateur. `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.
## Portée & non-reproduit ## Scope & not reproduced
- **Observé Firefox uniquement** à ce jour. Un test manuel sur un autre navigateur n'a pas déclenché la `SerializationError` ni ses conséquences. - **Observed on Firefox only** to date. A manual test on another browser did not trigger the `SerializationError` nor its consequences.
- **Reproduction @data (Chromium, broker réel) — 2026-07-14, décisive.** Le test de reconnexion @data existant (`reconnexion-meme-identite`) était **faux-vert** : il relisait les repos de A depuis l'**IndexedDB local** du profil persistant, jamais depuis le broker. Un lecteur **réellement à froid** (contexte non-persistant `freshBrowser`, **me** wallet/compte A, aucun état local — seedé du wallet capturé avant l'événement) lit **0** événement de A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). Signature **différente** du cas Firefox (pas de mort de socket ; l'`OUTBOX empty` est celui du lecteur, trivialement vide) et **ne tranche pas** (i) vs (ii) — un barrier vide est compatible avec les deux. Établi en revanche : **@data n'a jamais vérifié la durabilité broker des lectures propres de A**, et la réhydratation à froid depuis le broker échoue. Repro : `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod). - **@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-persistent `freshBrowser` context, 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; the `OUTBOX empty` is 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).
- **Pour trancher (i) vs (ii)** : vérifier indépendamment que l'écriture de A atteint le broker — p.ex. un lecteur *chaud* / une seconde identité lit le doc public de l'événement (le scénario d'isolation deux-identités). S'il le voit → l'écriture est durable → le 0 du lecteur à froid est un **(ii)** (réhydratation). Sinon**(i)**. - **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)**.
## Pistes de correction (non arbitré) ## Fix leads (not arbitrated)
1. **Core**corriger la `SerializationError` **et** implémenter le TODO de reconnexion (rétablir le socket + re-flusher l'outbox). 1. **Core**fix the `SerializationError` **and** implement the reconnection TODO (re-establish the socket + re-flush the outbox).
2. **SDK / polyfill** — consommer `disconnections_subscribe` → reconnexion + re-flush outbox comme mitigation, indépendamment du core. 2. **SDK / polyfill** — consume `disconnections_subscribe` → reconnection + outbox re-flush as a mitigation, independently of the core.
3. **API de durabilité** — exposer une confirmation qu'une écriture a atteint le broker, pour que l'appelant puisse l'`await`. 3. **Durability API** — expose a confirmation that a write has reached the broker, so that the caller can `await` it.
## Liens ## Links
- `docs/nextgraph-current-state.md`état courant du core (déconnexion / reconnexion à cross-référencer ici). - `docs/nextgraph-current-state.md`current state of the core (disconnection / reconnection to be cross-referenced here).
- Impact produit + caveat côté consommateur : concept Festipod `data-layer``caveat_write-durability-across-disconnect`. - Product impact + consumer-side caveat: Festipod concept `data-layer``caveat_write-durability-across-disconnect`.
+84 -205
View File
@@ -1,268 +1,147 @@
# Modèle ReadCap & NURI de NextGraph — et l'émulation caps du polyfill # NextGraph's ReadCap & NURI model — and the polyfill's caps emulation
**Établi 2026-07-20**, VÉRIFIÉ par lecture directe du cœur Rust `nextgraph-rs` **Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex.
(sauf points marqués INFÉRÉ). Les `file:line` sont datés — les numéros de ligne
sont volatils, se repérer par symbole/regex.
But : donner la vérité-terrain du modèle de droits d'accès NextGraph, pour Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
aligner l'émulation `caps.ts` du polyfill (aujourd'hui une ACL — l'inverse du
modèle réel). C'est la base de l'item « aligner ReadCap/WriteCap avec NextGraph ».
--- ---
## 1. Un ReadCap = possession d'une clé, PAS une ACL par-identité ## 1. A ReadCap = possession of a key, NOT a per-identity ACL
Un **ReadCap est fondamentalement une clé cryptographique que l'on détient**, pas A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
une entrée d'ACL liée à un wallet. « Qui détient la clé peut lire. »
- Structure : `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` - Structure: `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`).
(`engine/repo/src/types.rs:461, 463-471, 557, 565`). - `id: BlockId` = **BLAKE3** digest (address of the encrypted object).
- `id: BlockId` = digest **BLAKE3** (adresse de l'objet chiffré). - `key: SymKey = ChaCha20Key([u8;32])` = the **decryption key**.
- `key: SymKey = ChaCha20Key([u8;32])` = la **clé de déchiffrement**. Holding the pair → the broker serves the encrypted blocks by `id`, and one decrypts **locally** with `key`.
Détenir le couple → le broker sert les blocs chiffrés par `id`, on déchiffre - Granularity: per commit/object the `ObjectRef` **is** the cap; for a branch → its defining commit; for a repo → RootBranch; for a store → the root repo's cap (`types.rs:559-565`). `ReadCapSecret` = the key half (`:567-570`).
**localement** avec `key`. - **There is NO read-ACL.** A repo's membership/permissions (`RootBranch`, `AddMember`, `AddPermission`) govern **writing/admin**, not reading. Reading is guarded only by key possession.
- Granularité : par commit/objet l'`ObjectRef` **est** le cap ; pour une branche
→ commit de définition ; pour un repo → RootBranch ; pour un store → cap du
repo racine (`types.rs:559-565`). `ReadCapSecret` = la moitié clé (`:567-570`).
- **Il n'y a PAS de read-ACL.** L'appartenance/permissions d'un repo
(`RootBranch`, `AddMember`, `AddPermission`) gouvernent l'**écriture/admin**,
pas la lecture. La lecture n'est gardée que par la possession de la clé.
## 2. Accorder la lecture = sceller la clé au destinataire ## 2. Granting read access = sealing the key to the recipient
« Grant » = livrer le cap **scellé** (`crypto_box seal`, chiffrement à clé "Grant" = delivering the cap **sealed** (`crypto_box seal`, anonymous public-key encryption) to the recipient's **inbox pubkey** — only they can open it with their private key.
publique anonyme) à la **pubkey d'inbox** du destinataire — seul lui l'ouvre avec
sa clé privée.
- Message d'inbox scellé : `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, - Sealed inbox message: `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, opened with the inbox secret key (`engine/net/src/types.rs:4272, 4299, 4319`).
ouvert avec la clé secrète d'inbox (`engine/net/src/types.rs:4272, 4299, 4319`). - The payload can carry a cap: `ContactDetails.read_cap: Option<ReadCap>` ("if user wants to share the content of profile") (`net/types.rs:4232-4233`) → **directed grant** (sealed to one recipient).
- Le payload peut porter un cap : `ContactDetails.read_cap: Option<ReadCap>` - **Undirected** variant: `RepoLinkV0.read_cap` = a shareable link that **whoever receives it** can open (`net/types.rs:5061-5078`).
(« if user wants to share the content of profile ») (`net/types.rs:4232-4233`)
**grant dirigé** (scellé à un destinataire).
- Variante **non-dirigée** : `RepoLinkV0.read_cap` = un lien partageable que
**quiconque le reçoit** peut ouvrir (`net/types.rs:5061-5078`).
Le « ciblage wallet » vit donc dans **l'enveloppe de scellage**, pas dans le cap : So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap remains `{id, key}`, possession-based.
le cap reste `{id, clé}`, possession-based.
> **État courant (2026-07-27) — le chemin est un MANQUE, pas un désaccord.** Le > **Current state (2026-07-27) — the path is a GAP, not a disagreement.** The `ContactDetails.read_cap` field exists, but the construction of the message is `unimplemented!()` (its only caller passes "without read_cap") and the receiver **discards** the cap it would receive. The *shape* is therefore the right one; the implementation is not there. The polyfill emulates it in the meantime — filed in the bug-inbox.
> champ `ContactDetails.read_cap` existe, mais la construction du message est
> `unimplemented!()` (son unique appelant passe « sans read_cap ») et le récepteur
> **jette** le cap qu'il recevrait. La *forme* est donc la bonne ; l'implémentation
> n'est pas là. Le polyfill l'émule en attendant — fiche dans le bug-inbox.
## 3. Révocation = re-key (grossier, non-rétroactif) ## 3. Revocation = re-key (coarse, non-retroactive)
On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders.
clé et ne la re-sceller qu'aux autorisés restants.
- « Capabilities are not durable: they can be refreshed by members and previously - "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`).
shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose - Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; perms `types.rs:1748-1749`).
access after the refresh » (`net/types.rs:5055-5058`). - Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh).
- Mécanisme : `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; - **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`).
perms `types.rs:1748-1749`).
- Conséquences : **grossier** (échelle repo/branche), **non-rétroactif** (ce qui a
été lu avant reste connu de l'ex-détenteur ; il ne déchiffre que les versions
**antérieures** au refresh).
- Livraison **durable** d'un cap = `PermaCap` — encore **TODO** (`repo/types.rs:578`).
### DIRECTION — la rotation ne fait PAS perdre l'accès (confirmé PO, 2026-07-27) ### DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27)
**Ne pas lire le commentaire ci-dessus comme l'intention.** « *if they don't **Do not read the comment above as the intent.** "*if they don't subscribe, they lose access after the refresh*" describes **the current state**, not the target. What NextGraph is aiming for:
subscribe, they lose access after the refresh* » décrit **l'état courant**, pas la
cible. Ce que NextGraph vise :
> Quand une clé tourne, la nouvelle est **envoyée dans l'inbox** des utilisateurs > When a key is rotated, the new one is **sent to the inbox** of the users who retain the access right. That inbox is **processed automatically** as soon as one of the user's clients connects.
> qui conservent le droit d'accès. Cette inbox est **traitée automatiquement** dès
> qu'un client de l'utilisateur se connecte.
Donc l'accès n'est **pas perdu**, il est **différé** jusqu'à la prochaine connexion So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive.
— cohérent avec le local-first. Conséquences de forme : **aucune obligation
d'abonnement** à exposer au consommateur ; une re-livraison emprunte **le même
canal** que la livraison initiale, donc le mécanisme de partage couvre les deux
sans cas particulier. La **révocation** reste « cesser de re-livrer », non
rétroactive.
## 4. Grammaire NURI : cap-less vs cap-porteur (le segment `:k:`) ## 4. NURI grammar: cap-less vs cap-bearing (the `:k:` segment)
**Lever la confusion d'abord** : `did:ng:` n'est **pas** un marqueur de « sans **Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`.
cap », c'est le **préfixe de schéma d'URI** — présent partout (inbox `did:ng:d:…`,
branche `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). Un NURI **est**
un `did:ng:…`. Il n'y a donc pas « le did » d'un côté et « le NURI » de l'autre :
c'est **un seul objet**, avec ou sans la clé dedans — un seul type en amont,
`NuriV0 { target, access }`, où un NURI cap-less a simplement `access` vide.
Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent = The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
cap-less** (nomme/localise **sans** donner le droit de lire). C'est de **première
classe** dans le type : `NuriV0.target` (des ids) et `access`/`objects` (le cap)
sont des **champs séparés** — un NURI d'id parse avec `access: vec![]`
(`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
**Cap-less** (id + overlay éventuel, pas de clé) — formatters `app_protocol.rs`, **Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`:
regexes `net/types.rs` :
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52) - `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
- `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55) - `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55)
- `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58) - `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58)
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) - `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) - `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
**Cap-porteur** (embarque la clé) : **Cap-bearing** (embeds the key):
- `did:ng:j:{id}:k:{clé}`read cap d'objet/fichier (`repo/types.rs:511`, - `did:ng:j:{id}:k:{key}` — object/file read cap (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49)
`RE_FILE_READ_CAP` types.rs:49) - `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:73)
- `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73) - list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:64)
- liste `RE_OBJECTS` `…:[cj]:{id}:k:{clé}…:l:{locator}` (types.rs:64)
Le segment `:v:` est l'**overlay**, qui a sa propre section ci-dessous — c'est le The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
point le plus lourd de conséquences pour les modèles de présence anonyme.
## 4bis. L'overlay est l'espace réseau d'un STORE — jamais d'un document ## 4bis. The overlay is the network space of a STORE — never of a document
**L'overlay est l'unité d'adressage réseau d'un store.** Chez le broker, les blocs **The overlay is a store's unit of network addressing.** At the broker, blocks are filed under a `(overlay, block_id)` key, and peers synchronize *within* an overlay. Two forms per store:
sont rangés sous une clé `(overlay, block_id)`, et les pairs se synchronisent
*dans* un overlay. Deux formes par store :
| | Dérivation | Qui peut le calculer | | | Derivation | Who can compute it |
|---|---|---| |---|---|---|
| **outer** | `OverlayId::outer(store_id)` = BLAKE3 **public** | tout le monde (le store_id suffit) | | **outer** | `OverlayId::outer(store_id)` = **public** BLAKE3 | everyone (the store_id is enough) |
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = BLAKE3 **keyed** | seulement qui détient la clé de lecture du store | | **inner** | `OverlayId::inner(store_id, readcap_secret)` = **keyed** BLAKE3 | only whoever holds the store's read key |
Corent avec le reste du modèle : pas de rôle ni de liste, seulement « détiens-tu Consistent with the rest of the model: no role and no list, only "do you hold the key that lets you derive this identifier". `outer` = the store's public name, `inner` = its private name.
la clé qui permet de dériver cet identifiant ». `outer` = le nom public du store,
`inner` = son nom privé.
**Le `:v:` d'un NURI de DOCUMENT porte l'overlay de son STORE** (VÉRIFIÉ, chaîne **The `:v:` of a DOCUMENT NURI carries the overlay of its STORE** (VERIFIED, chain read end to end): `NuriV0::repo_graph_name(repo_id, overlay_id)` formats `o:{repo_id}:v:{overlay_id}`; in `doc_create` the value injected is `store.outer_overlay()` — the **containing** store, never the `repo_id`. A `Repo` carries **no** overlay field (only `store: Arc<Store>`); it is `Store` that carries `overlay_id`. **Mechanical counter-proof**: in `Store`, `get`/`put`/`del`/`has` all pass `&self.overlay_id` to the block storage — every document of a store shares the same block namespace, so a per-document overlay is structurally impossible.
lue de bout en bout) : `NuriV0::repo_graph_name(repo_id, overlay_id)` formate
`o:{repo_id}:v:{overlay_id}` ; dans `doc_create` la valeur injectée est
`store.outer_overlay()` — le store **contenant**, jamais le `repo_id`. Un `Repo` ne
porte **aucun** champ overlay (seulement `store: Arc<Store>`) ; c'est `Store` qui
porte `overlay_id`. **Contre-preuve mécanique** : dans `Store`, `get`/`put`/`del`/`has`
passent tous `&self.overlay_id` au block storage — tous les documents d'un store
partagent le namespace de blocs, donc un overlay par-document est structurellement
impossible.
### La conséquence à connaître : le `:v:` est un pseudonyme stable ### The consequence to know about: the `:v:` is a stable pseudonym
**Tous les documents d'une même personne dans son store protected portent le MÊME **All of one person's documents in their protected store carry the SAME `:v:`** = `outer(protected_store_id)`. So a cap-less reference — precisely the one used to "name without granting read" — **exposes store membership**, that is to say a **stable and permanent pseudonymous identifier of the person**. The store_id itself does not leak (BLAKE3 is not invertible), so it does not say *who*; but it is a **constant handle**, the same everywhere and forever, correlatable by anyone who collects cap-less references.
`:v:`** = `outer(protected_store_id)`. Donc une référence cap-less — précisément
celle qu'on utilise pour « nommer sans donner à lire » — **expose l'appartenance
au store**, c'est-à-dire un **identifiant pseudonyme stable et permanent de la
personne**. Le store_id lui-même ne fuit pas (BLAKE3 non inversible), donc ça ne
dit pas *qui* ; mais c'est un **handle constant**, le même partout et pour
toujours, corrélable par quiconque collecte des références cap-less.
**Le couplage qui en résulte, et qui contraint tout modèle de présence anonyme** : **The coupling that results, and that constrains any anonymous-presence model**: that same `:v:` is *simultaneously* (a) what makes it possible to **deduplicate** references without reading them — two references with the same `:v:` come from the same person — and (b) what makes it possible to **track** that person from one context to another. **It is the same bit of information.** You cannot get the dedup without conceding the tracking, nor remove the tracking without losing the dedup — short of changing how the stores are carved up, which moves the cursor but does not remove the trade-off.
ce même `:v:` est *simultanément* (a) ce qui permet de **dédupliquer** des
références sans les lire — deux références de même `:v:` viennent de la même
personne — et (b) ce qui permet de **tracer** cette personne d'un contexte à
l'autre. **C'est le même bit d'information.** On ne peut pas obtenir la dédup sans
concéder le traçage, ni supprimer le traçage sans perdre la dédup — sauf à changer
le découpage en stores, ce qui déplace le curseur mais ne supprime pas l'arbitrage.
*Nuances.* Le `:v:` du NURI est l'overlay **outer**, alors que le trafic *Nuances.* The NURI's `:v:` is the **outer** overlay, whereas client↔broker traffic and local storage use the **inner** one — a different value, but derived from the store as well, so the property holds in both cases. A `Dialog` store returns an `Inner`, still store-scoped.
client↔broker et le stockage local utilisent l'**inner** — valeur différente, mais
tirée du store elle aussi, donc la propriété tient dans les deux cas. Un store
`Dialog` renvoie un `Inner`, toujours store-scopé.
**CORRIGÉ le 2026-07-27 — cette hypothèse était FAUSSE.** On avait inféré, puis **CORRECTED on 2026-07-27 — this hypothesis was FALSE.** We had inferred, then believed we had verified, that a holder **without a key** could fetch the encrypted blocks and therefore prove a document's **existence**. An adversarial review showed that the reasoning stopped at *access control* without looking at **addressing**:
cru vérifier, qu'un détenteur **sans clé** pouvait récupérer les blocs chiffrés et
donc prouver l'**existence** d'un document. Une revue adverse a montré que le
raisonnement s'arrêtait au *contrôle d'accès* sans regarder l'**adressage** :
- Il n'existe **aucune commande d'existence au niveau SDK**. - There is **no existence command at the SDK level**.
- La seule sonde (`BlocksExist`) est **interne au crate**, exige des `BlockId` - The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**.
**et** un repo déjà **chargé**, et adresse l'overlay **inner** — lequel est - A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter).
dérivé du **secret de lecture**. - The only primitive accessible to a non-member (`ExtObjectGet`) requires the ObjectIds **and their keys**.
- Une référence cap-less porte un RepoId et l'overlay **outer** : aucun `BlockId`
à sonder. Et l'outer n'est de toute façon jamais enregistré (`expose_outer`
codé en dur à `false`, sans paramètre SDK).
- Le seul primitif accessible à un non-membre (`ExtObjectGet`) exige les ObjectIds
**et leurs clés**.
> **L'adressage lui-même présuppose le cap.** Prouver l'existence d'un document > **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned.
> sans détenir sa clé n'est pas constructible aujourd'hui, et rien n'indique que
> ce soit prévu.
Leçon transposable : vérifier qu'une garde d'accès **laisse passer** ne prouve pas Transferable lesson: verifying that an access guard **lets you through** does not prove that an operation is reachable — you still have to be able to **name** what you are asking for.
qu'une opération est atteignable — encore faut-il pouvoir **nommer** ce qu'on
demande.
## 4ter. Le store public : lisible par l'URL, et NON récursif ## 4ter. The public store: readable by URL, and NOT recursive
Principe cible (confirmé PO, 2026-07-27) : Target principle (confirmed by the PO, 2026-07-27):
> **Un élément du store public est public : qui a l'URL lit le contenu.** > **An element of the public store is public: whoever has the URL reads the content.**
> Mais **pas récursivement** — un contenu public peut *référencer* du contenu > But **not recursively** — public content can *reference* private content, and the reference does **not** give access to the referenced.
> privé, et la référence ne donne **pas** accès au référencé.
C'est un **second mécanisme**, à côté de la possession de clé (§1) — pas une This is a **second mechanism**, alongside key possession (§1) — not a breach of it. And it is the **non-recursiveness** that carries the value: it allows a public object that **points** to private identity, without divulging it. That is exactly the pattern an anonymous-presence model needs.
entorse. Et c'est la **non-récursivité** qui porte la valeur : elle autorise un
objet public qui **pointe** vers de l'identité privée, sans la divulguer. C'est
exactement le motif dont un modèle de présence anonyme a besoin.
*Détail d'implémentation, à ne PAS faire porter par la forme* : NextGraph s'oriente *Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer.
vers un **non-chiffrement** du contenu du store public (les données restant
**signées**). Une surface ne doit pas en dépendre. Et si le store public ne se
comporte pas comme ce principe le décrit, c'est **le polyfill** qui s'adapte, pas
le consommateur.
## 4quater. Le trousseau : d'où le propriétaire tire les caps de SES documents ## 4quater. The keyring: where the owner gets the caps for THEIR OWN documents
À chaque création de document, un `AddRepo { read_cap }` est commité sur une On every document creation, an `AddRepo { read_cap }` is committed to a **store branch** — the store being itself a repo, endowed with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role). That branch lists **the store's documents, each with its read key**.
**branche du store** — le store étant lui-même un repo, doté de branches **typées**
(le mot « branche » n'a rien de git : c'est un compartiment à rôle défini). Cette
branche liste **les documents du store, chacun avec sa clé de lecture**.
Elle **est** donc le **trousseau du propriétaire** : le mécanisme par lequel il So it **is** the **owner's keyring**: the mechanism by which they find the caps of their own documents. Upstream of that, the keyring is the **wallet**.
retrouve les caps de ses propres documents. En amont, le trousseau, c'est le
**wallet**.
**Ce n'est PAS le mécanisme de partage.** Confusion facile et coûteuse : en déduire **This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2). The keyring is a private index, not an act of sharing.
« on partage au niveau du store » est faux — livrer un cap de store donnerait accès
à **tout** son contenu, présent et futur. **L'unité de partage est le document**
(§2). Le trousseau est un index privé, pas un geste de partage.
*(VÉRIFIÉ pour le mécanisme `AddRepo { read_cap }` ; le **nom exact** des branches *(VERIFIED for the `AddRepo { read_cap }` mechanism; the **exact name** of the branches and the enumeration of their types have not been re-traced — to be confirmed if this point becomes load-bearing.)*
et l'énumération de leurs types n'ont pas été re-tracés — à confirmer si ce point
devient porteur.)*
## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge ## 5. What the polyfill emulates (caps.ts) — and where it diverges
`packages/client/src/caps.ts` modélise `readers: Map<Nuri, Set<PrincipalId>>` + `packages/client/src/caps.ts` models `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` (`:29-30, 41-42`) — **a per-document ACL of principals, that is the exact INVERSION of the real model** (key). Divergences:
`grantRead(doc, grantee)` (`:29-30, 41-42`) — **une ACL de principals par
document, soit l'INVERSION exacte du modèle réel** (clé). Divergences :
| | Réel NextGraph | Émulation caps.ts | | | Real NextGraph | caps.ts emulation |
|---|---|---| |---|---|---|
| Nature | possession de **clé** | **ACL** (set de principals) | | Nature | possession of a **key** | **ACL** (set of principals) |
| Grant | sceller la clé (crypto_box) à l'inbox | ajouter un principal au set | | Grant | seal the key (crypto_box) to the inbox | add a principal to the set |
| Durabilité | **durable** (clé livrée une fois) | **éphémère** (Map vide à chaque session → re-déclarée) | | Durability | **durable** (key delivered once) | **ephemeral** (Map empty every session → re-declared) |
| Révocation | **re-key** grossier, non-rétroactif | retrait du set : **instantané et total** | | Revocation | coarse **re-key**, non-retroactive | removal from the set: **instantaneous and total** |
| Granularité | repo / branche / commit / objet | **un cap par doc-NURI** | | Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
| Réf. sans droit | **NURI cap-less** (sans `:k:`) | pas de notion (l'ACL dit qui peut) | | Ref. without rights | **cap-less NURI** (no `:k:`) | no such notion (the ACL says who may) |
**Face app** : `declareConnections` (côté consommateur) qui re-déclare « mes **App-facing**: `declareConnections` (on the consumer side), which re-declares "my connections read my protected entities" **every session**, is an **artifact of this ephemeral ACL** — moot in the real model (there the seals are durable; one seals per-doc at share time, not per-session).
connexions lisent mes entités protected » **à chaque session** est un **artefact
de cette ACL éphémère** — sans objet dans le modèle réel (les scellages y sont
durables ; on scelle par-doc au partage, pas par-session).
## 6. Implications pour les consommateurs (ex. Festipod) ## 6. Implications for consumers (e.g. Festipod)
- « **scope protected = mon réseau peut lire** » n'est **pas** une ACL vérifiée - "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
par le broker : c'est « j'ai **scellé ma read key** à chacune de mes - **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
connexions ». Le modèle mental « scope = ACL » est faux au niveau NextGraph. - **Alignment to do**: when the real cap operations become available, replace the emulated ACL with durable per-doc key sealing, and `declareConnections`-as-a-re-declared-ACL disappears.
- **Références anonymes possibles** : mettre un **NURI cap-less** dans une
collection tierce laisse le tiers **nommer/compter** sans **lire l'identité** ;
sceller le cap-porteur séparément aux seuls autorisés. (Base d'un modèle de
présence « participation auto-possédée + Set curé cap-less + cap scellé aux
connexions ».)
- **Alignement à faire** : quand les vraies opérations de cap seront disponibles,
remplacer l'ACL émulée par du scellage de clé durable par-doc, et
`declareConnections`-comme-ACL-ré-déclarée disparaît.
## Réserves / lacunes ## Caveats / gaps
- `file:line` datés (2026-07) — re-vérifier par symbole ; le core bouge. - `file:line` references are dated (2026-07) — re-verify by symbol; the core moves.
- INFÉRÉ : fetch broker keyless (existence sans clé) — non tracé au runtime. - ~~INFERRED: keyless broker fetch (existence without a key)~~ — **RESOLVED and REFUTED, 2026-07-27**: not constructible. See the CORRECTED block in §4bis. Kept struck through because the hypothesis is intuitive and will otherwise be re-formed.
- Non tracé : exécution complète de `RootCapRefresh` côté verifier - Not traced: the full execution of `RootCapRefresh` on the verifier side (`verifier/src/commits/mod.rs:616`), wallet storage of `private_store_read_cap` (`repo/types.rs:945,976`).
(`verifier/src/commits/mod.rs:616`), stockage wallet de `private_store_read_cap`
(`repo/types.rs:945,976`).
+19 -48
View File
@@ -1,61 +1,32 @@
# Vision & principes du polyfill `@ng-eventually/client` # Vision & principles of the `@ng-eventually/client` polyfill
## Raison d'être ## Purpose
Un **stand-in fidèle en FORME** des primitives futures de NextGraph. Objectif A **stand-in faithful in SHAPE** to NextGraph's future primitives. **Single** objective: that consumers (Festipod) be **coded against the CORRECT mental model** — the one of finished NextGraph — and have **NOTHING to rewrite** when NextGraph provides the real primitives.
**unique** : que les consommateurs (Festipod) soient **codés contre le modèle
mental CORRECT** — celui de NextGraph fini — et **n'aient RIEN à réécrire** quand
NextGraph fournira les vraies primitives.
## Ce que le polyfill n'est PAS ## What the polyfill is NOT
Une couche de **sécurité**. Le **wallet partagé** (tout le monde partage les mêmes A **security** layer. The **shared wallet** (everyone shares the same keys) plus the absence of real crypto make the emulation **infinitely less secure** than a wallet-per-user — it is a **dev/staging vehicle**, not a goal. **Insecurity is ACCEPTED.** An attacker who bypasses the emulation is not our problem.
clés) + l'absence de vraie crypto rendent l'émulation **infiniment moins
sécurisée** qu'un wallet-par-utilisateur — c'est un **véhicule de dev/staging**,
pas un but. **L'insécurité est ACCEPTÉE.** Un attaquant qui contourne l'émulation
n'est pas notre problème.
## Le seul critère : shape-fidelity, avec RIGUEUR ## The only criterion: shape-fidelity, with RIGOR
Les **surfaces exposées** doivent matcher **exactement la FORME** des primitives The **exposed surfaces** must match the **exact SHAPE** of the future primitives, **even where enforcement is simulated**. The **failure mode to avoid**: exposing the **wrong shape** → the consumer codes against a model that will not exist → rewrite. The **ACL** inversion of ReadCaps was exactly that defect (an ACL where the real thing is **key possession**) — a lack of rigor.
futures, **même là où l'enforcement est simulé**. Le **mode d'échec à éviter** :
exposer la **mauvaise forme** → le consommateur code contre un modèle qui
n'existera pas → réécriture. L'inversion **ACL** des ReadCaps était exactement ce
défaut (une ACL là où le réel est **possession de clé**) — un manque de rigueur.
## Simuler la crypto pour EMPÊCHER les raccourcis ## Simulating crypto to PREVENT shortcuts
Sans un minimum de simulation crypto, des raccourcis préjudiciables sont pris (on Without a minimum of crypto simulation, damaging shortcuts get taken (reading the plaintext, falling back on ACLs). The polyfill therefore **simulates** the final mechanism, enough to hold this **invariant**:
lit le clair, on retombe sur des ACLs). Le polyfill **simule** donc le mécanisme
final, assez pour tenir cet **invariant** :
> **Un `did` (id nu, SANS ReadCap) et un NURI (AVEC ReadCap) sont traités > **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.**
> VRAIMENT différemment : le premier ne permet PAS de lire la donnée ; le second
> est SUFFISANT et REQUIS.**
Concrètement : la donnée d'un document est **stockée chiffrée** (chiffrement Concretely: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model.
symétrique par-doc, même léger) ; le **ReadCap = la clé** ; sans elle, **impossible
de déchiffrer/lire**. Pas d'ACL, pas de clair accessible « à côté ». Obtenir la
lecture = **détenir la clé**, exactement comme en cible.
## Conséquences de forme (à respecter partout) ## Shape consequences (to respect everywhere)
- **Tout est clés et URLs.** Il n'y a **pas** de notion d'appartenance, de rôle ni - **Everything is keys and URLs.** There is **no** notion of membership, role, or authorization list in the model: only symmetric and asymmetric cryptography, URIs, and who holds which key. Any exposed shape that looks like an ACL, a `member`, a `role`, or a `permission` is a **wrong shape**, whatever scaffolding one may otherwise read in the current state of NextGraph.
de liste d'autorisation dans le modèle : uniquement de la cryptographie - **Reading = possession of the read key** (ReadCap = `{id, key}`). A bare id (a `did` without a ReadCap) does not read.
symétrique et asymétrique, des URIs, et qui détient quelle clé. Toute forme - **Writing = possession of the write key** — a key **distinct** from the read key, hence a distinct axis, but **possession too**.
exposée qui ressemble à une ACL, un `member`, un `role` ou une `permission` est - **Sharing a cap = sealing it to a recipient** (**durable** delivery, at share time — NOT an ACL re-declared every session).
une **mauvaise forme**, quel que soit l'échafaudage qu'on peut lire par ailleurs - **Revocation = re-key** (new key; former holders keep the old state). Non-retroactive.
dans l'état courant de NextGraph. - **Cap-less reference** (naming/pointing without reading) **distinct** from the cap-bearing reference.
- **Lecture = possession de la clé de lecture** (ReadCap = `{id, clé}`). Un id nu
(un `did` sans ReadCap) ne lit pas.
- **Écriture = possession de la clé d'écriture** — une clé **distincte** de celle
de lecture, donc un axe distinct, mais **de la possession elle aussi**.
- **Partage d'un cap = le sceller à un destinataire** (livraison **durable**, au
moment du partage — PAS une ACL re-déclarée à chaque session).
- **Révocation = re-key** (nouvelle clé ; les anciens détenteurs gardent l'ancien
état). Non-rétroactif.
- **Référence cap-less** (nommer/pointer sans lire) **distincte** de la référence
cap-porteuse.
Voir `readcap-and-nuri-model.md` (le vrai modèle, vérifié dans `nextgraph-rs`) et See `readcap-and-nuri-model.md` (the real model, verified in `nextgraph-rs`) and `briefs/2026-07-20-caps-emulation-alignment.md` (the alignment effort).
`briefs/2026-07-20-caps-emulation-alignment.md` (le chantier d'alignement).