# Modèle ReadCap & NURI de NextGraph — et l'émulation caps du polyfill **Établi 2026-07-20**, VÉRIFIÉ par lecture directe du cœur Rust `nextgraph-rs` (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 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é Un **ReadCap est fondamentalement une clé cryptographique que l'on détient**, pas une entrée d'ACL liée à un wallet. « Qui détient la clé peut lire. » - Structure : `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`). - `id: BlockId` = digest **BLAKE3** (adresse de l'objet chiffré). - `key: SymKey = ChaCha20Key([u8;32])` = la **clé de déchiffrement**. Détenir le couple → le broker sert les blocs chiffrés par `id`, on déchiffre **localement** avec `key`. - 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 « Grant » = livrer le cap **scellé** (`crypto_box seal`, chiffrement à clé 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 ...)`, ouvert avec la clé secrète d'inbox (`engine/net/src/types.rs:4272, 4299, 4319`). - Le payload peut porter un cap : `ContactDetails.read_cap: Option` (« 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 : le cap reste `{id, clé}`, possession-based. ## 3. Révocation = re-key (grossier, non-rétroactif) On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle clé et ne la re-sceller qu'aux autorisés restants. - « 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`). - Mécanisme : `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; 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`). ## 4. Grammaire NURI : cap-less vs cap-porteur (le segment `:k:`) Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent = 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`, regexes `net/types.rs` : - `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}:b:{branch_id}` (`RE_BRANCH` types.rs:58) - `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) - `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) **Cap-porteur** (embarque la clé) : - `did:ng:j:{id}:k:{clé}` — read cap d'objet/fichier (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49) - `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73) - 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 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 **L'overlay est l'unité d'adressage réseau d'un store.** Chez le broker, les blocs 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 | |---|---|---| | **outer** | `OverlayId::outer(store_id)` = BLAKE3 **public** | tout le monde (le store_id suffit) | | **inner** | `OverlayId::inner(store_id, readcap_secret)` = BLAKE3 **keyed** | seulement qui détient la clé de lecture du store | Cohérent avec le reste du modèle : pas de rôle ni de liste, seulement « détiens-tu 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 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`) ; 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 **Tous les documents d'une même personne dans son store protected portent le MÊME `: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** : 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 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 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**. - La seule sonde (`BlocksExist`) est **interne au crate**, exige des `BlockId` **et** un repo déjà **chargé**, et adresse l'overlay **inner** — lequel est dérivé du **secret de lecture**. - 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 > 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 qu'une opération est atteignable — encore faut-il pouvoir **nommer** ce qu'on demande. ## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge `packages/client/src/caps.ts` modélise `readers: Map>` + `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 | |---|---|---| | Nature | possession de **clé** | **ACL** (set de principals) | | Grant | sceller la clé (crypto_box) à l'inbox | ajouter un principal au set | | Durabilité | **durable** (clé livrée une fois) | **éphémère** (Map vide à chaque session → re-déclarée) | | Révocation | **re-key** grossier, non-rétroactif | retrait du set : **instantané et total** | | Granularité | repo / branche / commit / objet | **un cap par doc-NURI** | | Réf. sans droit | **NURI cap-less** (sans `:k:`) | pas de notion (l'ACL dit qui peut) | **Face app** : `declareConnections` (côté consommateur) qui re-déclare « mes 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) - « **scope protected = mon réseau peut lire** » n'est **pas** une ACL vérifiée par le broker : c'est « j'ai **scellé ma read key** à chacune de mes connexions ». Le modèle mental « scope = ACL » est faux au niveau NextGraph. - **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 - `file:line` datés (2026-07) — re-vérifier par symbole ; le core bouge. - INFÉRÉ : fetch broker keyless (existence sans clé) — non tracé au runtime. - Non tracé : exécution complète de `RootCapRefresh` côté verifier (`verifier/src/commits/mod.rs:616`), stockage wallet de `private_store_read_cap` (`repo/types.rs:945,976`).