Files
ng-eventually/docs/readcap-and-nuri-model.md
T
Sylvain Duchesne b2cb774124 docs: état courant NextGraph enrichi + modèle cible aligné + retrait du lot PW
MODÈLE CIBLE (readcap-and-nuri-model) — trois ajouts, deux corrections :
- Store public : lisible par l'URL, et NON récursif — un contenu public peut
  référencer du contenu privé sans y donner accès. C'est la non-récursivité qui
  porte la valeur (objet public pointant vers de l'identité privée).
- Le trousseau : la branche de store, où chaque création commite AddRepo{read_cap}
  — avec l'avertissement explicite que ce n'est PAS le mécanisme de partage.
  Confondre l'index privé et le geste de partage mène à « on partage le store »,
  ce qui livrerait tout son contenu présent et futur.
- Rotation de clé : re-livraison par inbox, traitée automatiquement à la
  connexion. Écrit comme DIRECTION, en signalant que le commentaire amont dont ça
  partait décrit l'état courant.
- Levée de la confusion did/NURI en tête de la section grammaire : `did🆖` est
  un préfixe de schéma présent partout, pas un marqueur de « sans cap ». C'est un
  seul objet, avec ou sans la clé dedans.
- Livraison de cap par inbox signalée comme MANQUE (forme bonne, chemin absent).

ÉTAT COURANT (nextgraph-current-state) — 218 lignes ajoutées, structure intacte :
livraison de cap par inbox non implémentée ; vérification de signature d'auteur
jamais appelée au runtime (members map vide, //TODO) ; aucune sonde d'existence
au niveau SDK ; expose_outer codé en dur à false, absent du SDK ; protocole Ext
sans aucun contrôle. Plus trois constats d'exploitation : heal cold-start,
fork de compte sur provision concurrente, et l'abort du flush outbox sur
TopicNotFound. La mort du socket est seulement référencée (déjà couverte).

CORRECTION D'UN FAIT QUE J'AVAIS ÉNONCÉ FAUX : le digest d'auteur n'est PAS clé
sous le secret de lecture — il est clé par l'overlay outer, public. C'est le
CONTENU du commit qui est chiffré. La conclusion « vérifier suppose de pouvoir
lire » tient, le mécanisme diffère.

Lot PW (WriteCap = membership) RETIRÉ de la liste des phases : il restait planifié
alors que le brief déclare plus haut qu'il n'y a pas de membership. Il était en
outre justifié par un besoin de dédup par signature que le consommateur n'a pas —
sa dédup s'appuie sur l'overlay.

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

15 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.

État courant (2026-07-27) — le chemin est un MANQUE, pas un désaccord. Le 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)

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).

DIRECTION — la rotation ne fait PAS perdre l'accès (confirmé PO, 2026-07-27)

Ne pas lire le commentaire ci-dessus comme l'intention. « if they don't 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 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 — 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:)

Lever la confusion d'abord : did:ng: n'est pas un marqueur de « sans 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 = 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.

4ter. Le store public : lisible par l'URL, et NON récursif

Principe cible (confirmé PO, 2026-07-27) :

Un élément du store public est public : qui a l'URL lit le contenu. Mais pas récursivement — un contenu public peut référencer du contenu 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 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 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

À chaque création de document, un AddRepo { read_cap } est commité sur une 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 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 « 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 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

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).