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