Files
ng-eventually/docs/readcap-and-nuri-model.md
T
Sylvain Duchesne 60a9fd3ede docs: réécrire P1a après double revue adverse, corriger le verdict Q1 sur-lu
Deux adversaires à mandats disjoints (alignement NextGraph / économie
conceptuelle). Résultat : P1a fond de 8 notions nouvelles à 2, et un fait que
j'avais consigné comme VÉRIFIÉ était sur-lu.

CORRECTION DE FOND — le fetch keyless n'est PAS constructible. Le spike P0
concluait « Q1 OUI partiel, seul garde : l'overlay ». Il s'arrêtait au contrôle
d'accès sans regarder l'ADRESSAGE : aucune commande d'existence au niveau SDK ;
la seule sonde est interne au crate, exige des BlockId ET un repo chargé, et vise
l'overlay inner dérivé du secret de lecture. Une référence cap-less porte un
RepoId et l'overlay outer — ni BlockId, ni le bon overlay. L'adressage
présuppose le cap. Note corrigée sur place (pas empilée), avec la leçon
transposable : vérifier qu'une garde laisse passer ne prouve pas qu'une
opération est atteignable — encore faut-il pouvoir NOMMER ce qu'on demande.

P1a réécrite :
- UN seul type nouveau, `ReadCap`, le nom de l'amont. `Nuri` reste ce qu'il est
  déjà (~90 usages) : la forme cap-less. Les types de marque disparaissent — le
  SDK réel prend `nuri: String` et enforce au RUNTIME par la crypto ; une
  garantie de compilation est un concept que NextGraph n'a pas, et un
  consommateur qui typerait tout devrait dé-typer plus tard.
- `capFor(nuri) → ReadCap | undefined` : le trousseau. Comble un trou fatal du
  premier jet — `doc_create` renvoie un NURI cap-less, donc l'invariant « on ne
  va jamais d'une référence nue à un cap » empêchait le créateur d'obtenir le cap
  de son propre document. Le trousseau existe déjà : la branche de store, où
  chaque création commite AddRepo{read_cap}. En amont c'est le wallet.
- `shareCap(cap, toInbox)` : on partage UN DOCUMENT, à une ou plusieurs inboxes.
  Pas le store — donner un cap de store livrerait tout son contenu présent et
  futur. Les caps reçus arrivent comme dépôts d'inbox, consommés par le
  inbox.watch existant (ce qui règle le point 7 de la revue adverse).
- Durabilité : ne PAS la promettre. Verbatim amont, les caps sont « not durable »
  et qui ne reste pas abonné perd l'accès ; PermaCap est un TODO. La surface doit
  exposer l'obligation d'abonnement, sinon le consommateur retient des caps morts.
- `PrincipalId` sort de la surface caps : n'existe pas en amont, et le brief le
  supprimait de canRead en le qualifiant d'inversion ACL avant de le réintroduire
  dans sealCapTo. On adresse des inboxes, comme inbox.post le fait déjà.
- Section 0 conservant les erreurs du premier jet : elles sont instructives.
- Exception publique actée : un lien de repo public n'a PAS de read_cap (il se
  télécharge depuis l'outer overlay) — pour du public, « référence nue → contenu »
  est bien la forme cible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 16:08:02 +02:00

11 KiB

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<ReadCap> (« 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<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

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<Nuri, Set<PrincipalId>> + 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).