From ead5aececf78b26a12a40dae56ab7550a512d2f1 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 27 Jul 2026 12:07:24 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20l'overlay=20est=20store-scop=C3=A9=20;?= =?UTF-8?q?=20retrait=20de=20la=20fausse=20piste=20"membership"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `:v:` 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 `:v:` 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 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-20-caps-emulation-alignment.md | 104 +++++++++++++++++- docs/readcap-and-nuri-model.md | 52 ++++++++- docs/vision.md | 12 +- 3 files changed, 161 insertions(+), 7 deletions(-) diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index 4b945fa..4dbed75 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -62,6 +62,79 @@ que la vision interdit. **Fermer ces voies = le cœur de P1.** 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 @@ -91,14 +164,39 @@ que la vision interdit. **Fermer ces voies = le cœur de P1.** **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, pas lire). + (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). + à 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). -- **P4** — adapter l'API consommateur + `migration-guide.md`. +- **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 ; `declareConnections` disparaît).* ## Revue adverse (2026-07-20) — à intégrer diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index b6316ca..c356c9f 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -82,8 +82,56 @@ regexes `net/types.rs` : - `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73) - liste `RE_OBJECTS` `…:[cj]:{id}:k:{clé}…:l:{locator}` (types.rs:64) -L'`overlay` est **dérivé publiquement** de l'id du repo (`OverlayId::outer(store_id)`, -`:259`) → id+overlay ne fuitent **aucun secret**. +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`) ; 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é. INFÉRÉ : un détenteur **sans clé** peut vraisemblablement **récupérer les blocs chiffrés** (avec l'overlay, toujours cap-less) → vérifier l'**existence** d'un doc diff --git a/docs/vision.md b/docs/vision.md index 29570ad..0011370 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -40,8 +40,16 @@ lecture = **détenir la clé**, exactement comme en cible. ## Conséquences de forme (à respecter partout) -- **Lecture = possession d'une clé** (ReadCap = `{id, clé}`). Un id nu ne lit pas. -- **Écriture = membership/permissions** (primitif **distinct** — PAS de la possession). +- **Tout est clés et URLs.** Il n'y a **pas** de notion d'appartenance, de rôle ni + de liste d'autorisation dans le modèle : uniquement de la cryptographie + symétrique et asymétrique, des URIs, et qui détient quelle clé. Toute forme + exposée qui ressemble à une ACL, un `member`, un `role` ou une `permission` est + une **mauvaise forme**, quel que soit l'échafaudage qu'on peut lire par ailleurs + dans l'état courant de NextGraph. +- **Lecture = possession de la clé de lecture** (ReadCap = `{id, clé}`). Un id nu + (un `did` sans ReadCap) ne lit pas. +- **Écriture = possession de la clé d'écriture** — une clé **distincte** de celle + de lecture, donc un axe distinct, mais **de la possession elle aussi**. - **Partage d'un cap = le sceller à un destinataire** (livraison **durable**, au moment du partage — PAS une ACL re-déclarée à chaque session). - **Révocation = re-key** (nouvelle clé ; les anciens détenteurs gardent l'ancien