Files
ng-eventually/docs/briefs/2026-07-20-caps-emulation-alignment.md
T
Sylvain Duchesne ead5aececf docs: l'overlay est store-scopé ; retrait de la fausse piste "membership"
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
2026-07-27 12:07:24 +02:00

17 KiB
Raw Blame History

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 did nu (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.

  1. 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.
  2. 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).
  3. 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 ».
  4. Résoudre un cap-less = nommer / prouver l'existence / compter, sans exposer le contenu (support de la présence anonyme).
  5. 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, AddPermission sur le RootBranch). 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 from dé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/grantReadseal(cap, recipient) + inbox → caps reçus. Casse les consommateurs (declareConnections app 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) :

  1. Tracer dans nextgraph-rs le chemin d'autorisation de fetch du broker/verifier : qui sert les blocs (BlocksGet/TopicSync/OverlaySync) ? un cap/membership est-il vérifié, ou id+overlay suffit ? l'overlay outer est-il public ? une suppression est-elle observable sans clé ?
  2. (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.
  3. (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'état deleted, 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).
  • PWWriteCap = 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 P1P3 ; 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 ; declareConnections disparaî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) :

  1. 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-48 garde chaque sparql_update. → garder une piste WriteCap = membership ; la possession ne concerne QUE la lecture.
  2. P2 « possession sans crypto » = l'ACL renommée. Sans crypto, « qui détient quel token » = Map<doc, Set<holder>> = le readers actuel : 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 ».
  3. Révocation non-rétroactive INÉMULABLE sans versioning : read-model.ts:112-118 ne 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.
  4. cap-less « sans exposer le contenu » ILLUSOIRE dans l'émulation : contenu en clair dans le wallet partagé ; sparqlQuery/inbox.read bypass le filtre ; read-filter.ts:30-35 est 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é.
  5. 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).
  6. Migration ≠ swap d'API. declareConnections se 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 de protectedDocsOf + la boucle de re-dérivation. Re-architecture consommateur.
  7. (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.