Files
ng-eventually/docs/briefs/2026-07-20-caps-emulation-alignment.md
T
Sylvain Duchesne d7e0ee6a4b docs(brief): spécifier P1a (la surface) et corriger l'inventaire des contournements
Découpage de P1 en deux natures de travail : P1a = la FORME exposée aux
consommateurs, P1b = l'ENFORCEMENT. Seul P1a bloque Festipod, puisque l'app doit
être écrite comme si NextGraph était fini. Après P1a la forme est juste et
l'isolation reste fausse — le brief le dit explicitement pour qu'on n'affirme
rien d'anonyme avant P1b.

P1a spécifié :
- Types DocRef / DocCap distincts À LA COMPILATION (aujourd'hui `Nuri = string`,
  aucun parseur, aucune notion de segment de clé). L'invariant central : AUCUNE
  fonction ne va de DocRef vers DocCap — on n'obtient pas un cap en le demandant,
  seulement en le recevant. Le compilateur refuse alors de lire depuis un
  identifiant nu, et le consommateur ne PEUT PLUS écrire le modèle mental faux.
- resolveCapLess(ref) → { exists }, jamais de contenu et jamais d'état `deleted`
  (vérifié : la cible ne pourra pas l'offrir).
- sealCapTo(cap, recipient) durable + receivedCaps(), qui remplacent grantRead.
  Trois deltas réels vs l'ACL : durabilité, livraison-chez-le-destinataire,
  re-partage par le détenteur. C'est ce qui fait disparaître declareConnections.
- Table de ce qui disparaît : le paramètre `principal` de canRead EST l'inversion
  ACL ; resetCaps doit BASCULER de trousseau, pas effacer, sinon la durabilité
  est un mensonge.
- Test de recette naturel : watch-shape moissonne aujourd'hui toute chaîne
  `did🆖` et la replie dans l'ensemble LU — sémantique exactement inversée.
  Avec les types, elle ne peut plus qu'être résolue en existence. Vérifiable sans
  une ligne de crypto.

Corrigé aussi : l'inventaire des contournements était écrit beaucoup trop
doucement. Cartographie vérifiée — seuls 4 sites consultent les caps ; l'inbox
entière, store-registry (racine de confiance compte→NURI), discovery.readIndex,
subscribe et open-repo rendent de la donnée sans garde. Et le garde d'ÉCRITURE
est déjà mort-né : docs contourne ng-proxy par conception et tous les écrivains
internes passent par docs — grantWrite/canWrite ne se déclenchent jamais.

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

23 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 : toute surface rendant de la donnée devra passer par le déchiffrement-avec-clé. Aujourd'hui c'est FAUX, et bien plus largement que ce brief ne l'écrivait d'abord — cartographie du 2026-07-27, VÉRIFIÉE : seuls 4 sites consultent les caps (use-shape, read-filter, read-model.readUnion, discovery.submitToIndex). Tout le reste rend de la donnée sans garde :

Surface État
docs.sparqlQuery / sparqlUpdate contourne — appellent le ng injecté directement (contrainte assumée, pour éviter un DataCloneError de double-Proxy). La brèche la plus large : un id de session + un NURI suffisent à tout lire.
inbox (read / readSynced / materialize / watch) contourne — aucun cap consulté ; les dépôts vont à qui les demande
store-registry (zéro référence aux caps dans tout le fichier) contourne — la racine de confiance compte→NURI est en lecture universelle
discovery.readIndex contourne en lecture (caps vérifiés à l'écriture seulement)
subscribe, open-repo contournent — la poussée d'abonnement transporte l'état du doc sans contrôle
watch-shape délègue volontairement à readUnion (ne re-filtre pas)

Et le garde d'ÉCRITURE est déjà mort-né : ng-proxy garde sparql_update, mais docs contourne le proxy par conception, et tous les écrivains internes passent par docs. Le garde ne se déclenche donc que pour une app appelant ng.sparql_update sur le ng exporté — ce que Festipod ne fait pas. grantWrite / canWrite sont décoratifs. (Ce constat renforce §1 de la revue adverse : l'écriture n'est pas un axe « à ajouter », c'est un axe qu'on croyait couvert et qui ne l'est pas.)

Cet inventaire EST le périmètre de P1b. Le seul garde existant (caps.canRead) est par ailleurs une ACL set-membership — l'inversion même que la vision interdit.

  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.

P1a — la surface (spécification, 2026-07-27)

Le découpage. P1 mélangeait deux natures de travail. P1a = la forme exposée aux consommateurs ; P1b = l'enforcement (chiffrement, fermeture de l'inventaire ci-dessus). Seul P1a bloque Festipod : rule_app-uses-sdk-surface-only veut que l'app soit écrite comme si NextGraph était fini, donc contre la bonne surface — l'enforcement peut suivre. Après P1a la forme est juste et l'isolation reste fausse : ne rien affirmer d'anonyme avant P1b.

1. Les types — deux formes de référence, distinctes à la COMPILATION

Aujourd'hui Nuri = string, aucun parseur, aucune notion de segment de clé : la distinction est purement conventionnelle. Elle doit devenir un type.

/** Nomme et localise un document. Ne donne AUCUN droit de lire. */
type DocRef = Brand<string, 'DocRef'>   // did:ng:o:{id}:v:{overlay}
/** Un DocRef + la clé. SUFFISANT et REQUIS pour lire. */
type DocCap = Brand<string, 'DocCap'>   // …:k:{clé}

parseNuri(s: string): DocRef | DocCap   // le discriminant est la présence de la clé
refOf(cap: DocCap): DocRef              // dégrader est toujours possible

L'invariant central, et toute la valeur de P1a : aucune fonction ne va de DocRef vers DocCap. On n'obtient pas un cap en le demandant — on ne l'obtient qu'en le recevant. Le compilateur refuse alors de lire depuis un identifiant nu, et le consommateur ne peut plus écrire le modèle mental faux.

2. Résoudre un cap-less

resolveCapLess(ref: DocRef): Promise<{ exists: boolean }>

Jamais de contenu. Et surtout jamais d'état deleted : VÉRIFIÉ, la cible ne pourra pas l'offrir (append-only, tombstone chiffré). Exposer deleted inventerait une capacité qui n'existera pas — le mode d'échec exact que ce brief combat.

Réserve : côté NextGraph ce service repose sur un protocole dont la garde d'autorisation n'est pas branchée (fiche INBOX). Elle peut l'être un jour → ne pas rendre resolveCapLess porteur d'une garantie, c'est un durcissement optionnel.

3. Le partage — sceller, pas déclarer

sealCapTo(cap: DocCap, recipient: PrincipalId): Promise<void>  // livrer UNE fois
receivedCaps(): Promise<DocCap[]>                              // ce qu'on m'a livré

Trois propriétés qui font le delta réel avec l'ACL (et non « l'ACL renommée ») :

  • Durable — livré une fois, ça persiste. C'est ce qui fait disparaître declareConnections côté app : le grant se déplace au moment où l'on accepte une connexion.
  • Livraison, pas inscription — le cap va chez le destinataire ; c'est sa possession qui autorise, pas une ligne dans un registre central.
  • Re-partageable — qui détient un cap peut le sceller plus loin. Aucun équivalent dans une ACL.

Le véhicule est l'inbox (comme en cible : scellé à la clé d'inbox du destinataire). Le point d'accroche existe déjà : ng-proxy porte un TODO(anticipated API): inbox_post_link + capability operations.

4. Ce qui disparaît ou change de sens

Aujourd'hui Devient
grantRead(doc, grantee) sealCapTo(cap, recipient)
canRead(doc, principal) canRead(doc) — « est-ce que JE détiens un cap ? ». Le paramètre principal est l'inversion ACL : il disparaît.
protectedDocsOf(owner) supprimé — plus de boucle de re-dérivation
makePublic(doc) conservé, requalifié : publier le cap en clair (analogue du lien partageable réel), pas « marquer un drapeau »
grantWrite / canWrite à traiter en P1b — aujourd'hui décoratifs (garde jamais déclenché)
resetCaps() au changement d'identité basculer vers le trousseau de l'autre identité, pas effacer. Sinon la durabilité est un mensonge et declareConnections renaît.

5. Le premier changement de comportement observable

watch-shape moissonne aujourd'hui toute chaîne did:ng: trouvée dans une référence de découverte et replie ces documents dans l'ensemble lu — une référence nue y donne donc lecture complète, la sémantique exactement inversée.

Avec les types, une chaîne moissonnée parse en DocRefne peut plus entrer dans l'ensemble lu ; seul resolveCapLess l'accepte. C'est le test de recette naturel de P1a, et il est vérifiable sans une ligne de crypto.

Esquisse de phases

  • P1ala surface : types DocRef / DocCap, resolveCapLess, sealCapTo durable. Spécifié ci-dessus. Le seul lot qui bloque Festipod.
  • P1bl'enforcement : chiffrement par-doc (cap = clé) et fermeture de l'inventaire des contournements. Sans lui la forme est juste mais l'isolation reste fausse — donc rien d'« anonyme » ne peut être affirmé.
  • 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.