readcap-and-nuri-model — nouvelle section sur l'OVERLAY, le concept qui manquait à la référence. C'est l'espace réseau d'un STORE : deux formes (outer = BLAKE3 public du store_id, calculable par tous ; inner = BLAKE3 keyed par le ReadCapSecret, réservé aux détenteurs de la clé). Le `✌️` d'un NURI de DOCUMENT porte l'overlay de son store — VÉRIFIÉ de bout en bout, avec une contre-preuve mécanique : dans Store, get/put/del/has passent tous `&self.overlay_id`, donc tous les documents d'un store partagent le namespace de blocs et un overlay par-document est structurellement impossible. Conséquence documentée, qui contraint tout modèle de présence anonyme : le `✌️` est un pseudonyme stable et permanent de la personne, présent dans toute référence cap-less vers n'importe lequel de ses documents protected. Le même bit d'information sert à dédupliquer sans lire ET à tracer — indissociables. vision — correction d'une forme fausse. Le document affirmait « écriture = membership/permissions », en miroir de « lecture = possession de clé ». Faux : il n'y a pas de notion d'appartenance dans le modèle, uniquement des clés et des URLs. Toute forme en member/role/permission est une MAUVAISE forme. brief caps — la section « périmètre élargi : WriteCap = membership » est retirée, conservée barrée comme garde-fou, avec la leçon de méthode qui vaut plus qu'elle : lire l'état courant de nextgraph-rs pour en DÉDUIRE la forme cible est une erreur — le source contient de l'échafaudage inerte (AddMember, PermissionV0, verify_sig jamais appelé hors tests). Le source sert à vérifier un mécanisme, jamais à inférer une intention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
17 KiB
Brief — aligner l'émulation des caps sur le vrai modèle NextGraph
Brief (incubation) — 2026-07-20. Voir la référence docs/readcap-and-nuri-model.md.
Problème
caps.ts émule les droits de lecture comme une ACL (Map<Nuri, Set<PrincipalId>>,
grantRead(doc, grantee)) — l'inversion du vrai modèle NextGraph (possession
de clé). Conséquences : pas de notion de référence cap-less, grant/révocation
instantanés et totaux (au lieu de scellage durable + re-key), et une API
(declareConnections) que les consommateurs doivent re-déclarer à chaque
session. Cet écart empêche de bâtir correctement des modèles qui reposent sur la
vraie sémantique — notamment la présence anonyme (nommer/compter sans lire).
Objectif : shape-fidelity, PAS sécurité
Le polyfill n'égale PAS la sécurité de NextGraph fini, et n'essaie pas. Le wallet partagé + l'absence de crypto rendent l'émulation volontairement non-sécurisée (tout est en clair, tout marqueur est forgeable) — véhicule de dev/staging, pas un but. Seul objectif : exposer la BONNE FORME des primitives futures pour que les consommateurs (Festipod) soient codés contre le modèle mental correct et n'aient pas à être réécrits quand NextGraph sera fini.
Corollaire : « pas de crypto » n'est pas un problème ; ce qui compte est d'être dans la même logique, avec RIGUEUR. Une critique « un attaquant lit le clair / forge un marqueur » est exacte mais hors-scope. Ce qui est inacceptable = exposer la mauvaise forme (ex. une ACL là où le réel est possession de clé) → le consommateur code contre un modèle qui n'existera pas. L'inversion ACL des ReadCaps EST ce manque de rigueur — le défaut central à corriger.
Mécanisme d'enforcement : simulation crypto LÉGÈRE (anti-ACL, anti-raccourci)
Pour que la forme soit réellement possession-de-clé (et pas une ACL déguisée),
la donnée d'un doc est stockée chiffrée (chiffrement symétrique par-doc, même
léger) et le ReadCap = la clé. Invariant (cf. docs/vision.md) :
un
didnu (sans ReadCap) ne permet PAS de lire ; un NURI avec ReadCap est suffisant et requis.
Ça empêche les raccourcis que l'adversaire a relevés (#4/#6 : lire le clair,
sparqlQuery/inbox.read qui bypass le filtre) et interdit de retomber sur
une ACL — c'est le cœur de « même logique, avec rigueur ».
Cible — PAS l'état actuel : toutes les surfaces de lecture (read-model,
read-filter, sparqlQuery, inbox.read) devront passer par le
déchiffrement-avec-clé. Aujourd'hui c'est FAUX : inbox.read (inbox.ts:198)
et sparqlQuery brut (docs.ts:82) bypass le filtre, et le seul garde
(caps.canRead, caps.ts:95) est une ACL set-membership — l'inversion même
que la vision interdit. Fermer ces voies = le cœur de P1.
- Deux formes de référence distinctes : cap-less (nomme/localise sans lire —
aligné sur le NURI sans
:k:) vs cap-porteur (id + clé/token). Aujourd'hui absent. - Grant = livrer un cap-token à un destinataire (émuler le scellage : le destinataire reçoit le token dans son inbox ; c'est la possession du token qui autorise la lecture — pas une ligne d'ACL vérifiée par principal).
- Enforcement par possession : les lecteurs (
read-filter,use-shape) ne voient que ce dont ils détiennent le token, pas « ce dont ils sont dans le set de readers ». - Résoudre un cap-less = nommer / prouver l'existence / compter, sans exposer le contenu (support de la présence anonyme).
- Révocation = re-key émulé : invalider l'ancien token, re-livrer un nouveau aux autorisés restants ; non-rétroactif.
Périmètre élargi : le WriteCap (= membership) — RETIRÉ (2026-07-21)
Cette section était fausse et est conservée barrée comme garde-fou. Elle
importait une notion de membership lue dans l'état courant de nextgraph-rs
(AddMember, PermissionV0, member_pubkey) et la promouvait en forme cible.
Or (a) ces types sont un échafaudage inerte au runtime — verify_sig /
verify_perm ne sont appelés que dans des tests unitaires, les Repo sont
construits avec members: HashMap::new() ; et (b) le modèle cible n'a pas de
notion d'appartenance du tout : uniquement des clés et des URLs, symétrique
et asymétrique. Une forme en member/role/permission est donc exactement la
mauvaise forme que ce brief existe pour empêcher.
La leçon de méthode, qui vaut plus que la section retirée : lire l'état courant de NextGraph pour en déduire la forme cible est une erreur — l'état courant contient de l'inachevé qu'il ne faut pas figer dans le polyfill. Le source sert à vérifier un mécanisme existant, jamais à inférer une intention.
Contenu erroné conservé ci-dessous à titre de trace :
Section retirée
Pourquoi c'est ici et pas ailleurs. Le brief était d'abord centré ReadCap ; un constat adverse a montré qu'il ne compose pas avec son consommateur : le brief Festipod « inscriptions par Set » a besoin de dédupliquer les participations (un user = une participation par événement), et la seule base non-applicative disponible est la signature d'auteur du commit — donc un primitif d'écriture, pas de lecture. Un polyfill qui n'expose que la forme du ReadCap laisse le consommateur inventer sa propre dédup applicative → exactement la mauvaise forme.
La forme réelle (VÉRIFIÉE, cf. readcap-and-nuri-model.md §1) — et elle est
asymétrique de la lecture, ce qui est le point le plus facile à rater :
- Lecture = possession d'une clé. Pas d'ACL. Qui détient, lit.
- Écriture = membership + permissions (
AddMember,AddPermissionsur leRootBranch). C'est bel et bien une liste d'autorisation — pas de la possession. Émuler l'écriture « par possession de token » serait aussi faux que l'ACL de lecture actuelle, en miroir. - Les commits SONT signés par un
UserId(clé technique, distincte du profil) — donc un identifiant de dédup existe nativement, sans pseudonyme applicatif. - Mais vérifier une signature exige d'être membre du repo (accès au
member_pubkey). Un tiers non-membre voit un commit signé sans pouvoir l'attribuer. - Le dépôt d'inbox n'est PAS authentifié (sealed box anonyme) : un
fromdéclaré est du contenu, pas une preuve.
Ce que ça impose au polyfill. Exposer membership comme primitif distinct
de la possession de cap, avec au minimum : ajouter/retirer un membre d'un repo,
lire la members map quand on est membre, et vérifier l'auteur d'un commit
(→ un digest d'auteur, par-overlay donc par-store). C'est ce dernier point qui
débloque la dédup côté Festipod.
La conséquence de forme, à documenter explicitement (sinon le consommateur se trompe de modèle) : le digest d'auteur étant par-store, le choix du découpage en stores est le choix du niveau de corrélation. Un store stable par-user donne un identifiant traçable cross-événement ; un store par-événement donne un pseudonyme local à l'événement — dédup possible, corrélation impossible. Festipod a besoin du second. Le polyfill doit donc rendre ce découpage exprimable, pas le figer.
Reste ouvert : « le créateur d'un événement peut-il être membre du store qui contient les participations, sans pour autant en détenir la clé de lecture ? » — c'est-à-dire membership (écriture/vérification) et possession (lecture) réellement orthogonales. Si NextGraph les couple, la dédup vérifiée et l'anonymat s'excluent, et c'est le brief Festipod qui doit trancher ce qu'il sacrifie. À vérifier avant de façonner l'API.
(Question devenue sans objet : il n'y a pas de membership. La dédup ne passe pas par une vérification de signature — voir le brief Festipod « inscriptions ».)
Questions ouvertes
- TRANCHÉ (directive PO, 2026-07-21) : on simule la crypto (donnée chiffrée par-doc, cap = clé). La « sémantique seule » (registre de tokens) est écartée — elle redevient une ACL et laisse lire le clair. Reste à trancher le niveau de simulation (chiffrement réel léger vs projection read-model masquée), avant P1.
- Représentation NURI cap-less vs cap-porteur dans l'émulation (calquer
:k:). - Faut-il permettre le fetch keyless (résoudre un cap-less en existence/compte sans le contenu) — dépend de ce que le vrai broker autorise (INFÉRÉ, non tracé).
- Migration d'API :
declareConnections/grantRead→seal(cap, recipient)+inbox → caps reçus. Casse les consommateurs (declareConnectionsapp disparaît).
P0 — Spike « keyless-resolve » (le verrou, AVANT tout P1)
Question porteuse : un détenteur d'une référence cap-less (did:ng:o:{id}:v:{overlay}, sans :k:) peut-il, sans jamais lire le contenu :
- Q1 — Existence / fetch : prouver/récupérer la présence des blocs (chiffrés) auprès du broker ? Ou le broker exige-t-il un ReadCap/membership pour servir les blocs ?
- Q2 — Suppression : distinguer « existe » de « supprimé » ? (Le point FRAGILE : NextGraph est append-only CRDT — une désinscription = un commit tombstone qu'il faudrait lire pour connaître → potentiellement la clé est requise. Or le décrément au leave en dépend.)
- Q3 — Confidentialité : la clé (
:k:) reste-t-elle requise pour déchiffrer (le keyless ne donne jamais le contenu) ?
Pourquoi c'est le verrou : tout le compteur anonyme (compter/valider des réfs cap-less sans lire) ET le décrément au leave en dépendent. Si NON → « compteur anonyme par réf cap-less » est inconstruisible en cible → Festipod ne doit pas coder cette forme (réécriture garantie). Si OUI → P1 expose resolveCapLess(nuri) → {exists|deleted} (jamais de contenu), et l'émulation la simule fidèlement.
Méthode (bon marché, décisif) :
- Tracer dans
nextgraph-rsle chemin d'autorisation de fetch du broker/verifier : qui sert les blocs (BlocksGet/TopicSync/OverlaySync) ? un cap/membership est-il vérifié, ouid+overlaysuffit ? l'overlay outer est-il public ? une suppression est-elle observable sans clé ? - (Optionnel) test e2e décisif (façon
e2e/reactivity-doc-subscribe.ts) : B détient la réf cap-less, tente fetch/existence sans la clé, vérifie qu'il n'accède pas au contenu. Preuve empirique > source. - (Ou) confirmer avec le dev NextGraph — le plus rapide.
Livrable : YES/NO/PARTIAL par Q1/Q2/Q3 + le primitif exact (file:line) + la forme d'API à exposer (si OUI), ou le constat que le compteur change (si NON). Décision gatée : OUI → P1 ; NON → le brief inscriptions revoit le compteur (pas anonyme, ou autre primitif).
Verdict du spike (2026-07-21) — VÉRIFIÉ dans nextgraph-rs
| Réponse | Preuve | |
|---|---|---|
| Q1 — existence/fetch sans cap | OUI, partiel | Les lectures ne sont pas cap-gatées : blocks_get.rs, blocks_exist.rs, topic_sync_req.rs servent les blocs sans exiger ReadCap ni membership. Seul garde : l'overlay. Nuance non levée : overlay inner vs outer (expose_outer, défaut false) — sonde en cours. |
| Q2 — détecter une suppression sans clé | NON | Broker append-only ; une suppression est un commit tombstone chiffré (RemoveRepo), no-op côté verifier. Sans clé on observe « une activité », jamais « une suppression ». |
| Q3 — confidentialité | OUI | Blocs stockés en ciphertext ; la clé est #[serde(skip)] (types.rs), dérivée du ReadCapSecret. Le keyless ne donne jamais le contenu. |
Ce que ça décide.
- P1 est débloqué :
resolveCapLess(nuri) → { exists }est la bonne forme — mais{ exists | deleted }ne l'est PAS. Ne pas exposer d'étatdeleted, ce serait inventer une capacité que la cible n'aura jamais (précisément le mode d'échec que ce brief combat). - Le retrait doit être un message, pas une observation. Côté consommateur : un nudge explicite. Le polyfill n'a rien à émuler pour ça — juste à ne pas prétendre le contraire.
- Reste gaté : la nuance d'overlay (Q1). Si un non-membre ne peut pas rejoindre l'overlay d'un store protected tiers, le fetch keyless est inatteignable en pratique malgré un chemin d'autorisation ouvert — et le compteur anonyme retombe sur du déclaratif.
Esquisse de phases
- P1 — distinction cap-less / cap-porteur dans les NURI + le read-model
(résoudre un cap-less = nommer/compter/prouver l'existence, pas lire, pas
de
deleted). Inclut la fermeture des bypass (inbox.read,sparqlQuery) sans laquelle la distinction n'est pas tenue. - P2 — remplacer l'ACL par un modèle de possession de token (grant = livrer à un destinataire ; enforcement = possession). Requalifié par la revue adverse : le vrai contenu de P2 est durabilité + cap-less + re-partage par le détenteur, pas « inverser l'ACL » — sans crypto, inverser ne produit aucun delta observable.
- P3 — révocation par re-key (invalidation + re-livraison, non-rétroactive).
- PW — WriteCap = membership : primitif distinct (add/remove member, members map lisible par un membre, vérification d'auteur de commit → digest par-store), et découpage en stores exprimable par le consommateur. Indépendant de P1–P3 ; bloquant pour la dédup Festipod, donc à ordonnancer tôt si c'est ce besoin-là qui presse.
- P4 — adapter l'API consommateur +
migration-guide.md. La revue adverse requalifie ce lot : ce n'est pas un swap d'API mais une re-architecture consommateur (le grant se déplace vers l'acceptation de connexion et devient persistant ;declareConnectionsdisparaît).
Revue adverse (2026-07-20) — à intégrer
Un adversaire a réfuté le brief (6 constats). À lire au filtre de l'Objectif ci-dessus (forme, pas sécurité). Les critiques purement sécurité — contenu en clair lisible (#4), marqueurs forgeables — sont ACCEPTÉES / hors-scope : le polyfill ne cherche pas à les empêcher. Restent les vrais défauts de FORME / rigueur (à corriger), et une question de modèle futur (#5) :
- WriteCap oublié, et « possession » y est FAUX. L'écriture est membership/permissions (
AddMember) — une liste d'autorisation, pas de la possession de clé (réf §1) ;ng-proxy.ts:28-48garde chaquesparql_update. → garder une piste WriteCap = membership ; la possession ne concerne QUE la lecture. - P2 « possession sans crypto » = l'ACL renommée. Sans crypto, « qui détient quel token » =
Map<doc, Set<holder>>= lereadersactuel : aucun delta observable. Les vrais deltas sont durabilité + cap-less + re-partage par le détenteur — c'est ÇA le contenu de P2, pas « inverser l'ACL ». - Révocation non-rétroactive INÉMULABLE sans versioning :
read-model.ts:112-118ne lit que l'état courant → « invalider l'ancien token » = retrait total = l'inverse du réel (l'ex-détenteur déchiffre les versions antérieures). → n'émuler que « plus de nouvelles lectures après re-key » + documenter la non-rétroactivité comme non-émulable. - cap-less « sans exposer le contenu » ILLUSOIRE dans l'émulation : contenu en clair dans le wallet partagé ;
sparqlQuery/inbox.readbypass le filtre ;read-filter.ts:30-35est tout-ou-rien. → l'anonymat cap-less exige soit du vrai crypto, soit une projection read-model masquée (compter sans lire). « Remplacement pas refonte » est surévalué. - Keyless-fetch = INFÉRÉ et load-bearing : ajouter un spike P0 qui le vérifie avant P1 (sinon le modèle — polyfill ET Festipod — est inconstruisible).
- Migration ≠ swap d'API.
declareConnectionsse re-joue chaque session parce que la map est éphémère ; des scellages durables déplacent le grant à l'acceptation de connexion + persistent « déjà scellé » — pas d'analogue deprotectedDocsOf+ la boucle de re-dérivation. Re-architecture consommateur. - (Plausible) livrer un cap par inbox async ne re-déclenche pas
watchShape(souscrit aux docs de données, pas aux caps) → vues illisibles périmées jusqu'à un autre changement. → prévoir un signal de mutation de caps.
Conséquence : ajouter en tête P0 (spike keyless-fetch) et une piste WriteCap distincte ; requalifier P2 (le vrai contenu = durabilité + cap-less + re-partage, pas « inverser l'ACL ») ; acter que sans crypto, la privacy de lecture n'est pas applicable (choisir : vrai crypto vs projection masquée).
Liens : readcap-and-nuri-model.md, packages/client/src/caps.ts. Côté consommateur,
le brief Festipod « réaligner les inscriptions » dépend de ce chantier.