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

217 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 :
<details>
<summary>Section retirée</summary>
**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.**
</details>
*(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 (`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).
- **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 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.