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
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 parid, on déchiffre localement aveckey.
- Granularité : par commit/objet l'
ObjectRefest 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_capexiste, mais la construction du message estunimplemented!()(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; permstypes.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_Otypes.rs:52)did:ng:o:{repo_id}:v:{overlay_id}(:263,RE_REPOtypes.rs:55)did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}(RE_BRANCHtypes.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_CAPtypes.rs:49)did:ng:o:{repo}:c:{commit}:k:{clé}(RE_COMMITtypes.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 desBlockIdet 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_outercodé 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:linedaté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
RootCapRefreshcôté verifier (verifier/src/commits/mod.rs:616), stockage wallet deprivate_store_read_cap(repo/types.rs:945,976).