From 60a9fd3ede328d8686053c226de26eea692d5f46 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 27 Jul 2026 16:08:02 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20r=C3=A9=C3=A9crire=20P1a=20apr=C3=A8s?= =?UTF-8?q?=20double=20revue=20adverse,=20corriger=20le=20verdict=20Q1=20s?= =?UTF-8?q?ur-lu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux adversaires à mandats disjoints (alignement NextGraph / économie conceptuelle). Résultat : P1a fond de 8 notions nouvelles à 2, et un fait que j'avais consigné comme VÉRIFIÉ était sur-lu. CORRECTION DE FOND — le fetch keyless n'est PAS constructible. Le spike P0 concluait « Q1 OUI partiel, seul garde : l'overlay ». Il s'arrêtait au contrôle d'accès sans regarder l'ADRESSAGE : aucune commande d'existence au niveau SDK ; la seule sonde est interne au crate, exige des BlockId ET un repo chargé, et vise l'overlay inner dérivé du secret de lecture. Une référence cap-less porte un RepoId et l'overlay outer — ni BlockId, ni le bon overlay. L'adressage présuppose le cap. Note corrigée sur place (pas empilée), avec la leçon transposable : vérifier qu'une garde laisse passer ne prouve pas qu'une opération est atteignable — encore faut-il pouvoir NOMMER ce qu'on demande. P1a réécrite : - UN seul type nouveau, `ReadCap`, le nom de l'amont. `Nuri` reste ce qu'il est déjà (~90 usages) : la forme cap-less. Les types de marque disparaissent — le SDK réel prend `nuri: String` et enforce au RUNTIME par la crypto ; une garantie de compilation est un concept que NextGraph n'a pas, et un consommateur qui typerait tout devrait dé-typer plus tard. - `capFor(nuri) → ReadCap | undefined` : le trousseau. Comble un trou fatal du premier jet — `doc_create` renvoie un NURI cap-less, donc l'invariant « on ne va jamais d'une référence nue à un cap » empêchait le créateur d'obtenir le cap de son propre document. Le trousseau existe déjà : la branche de store, où chaque création commite AddRepo{read_cap}. En amont c'est le wallet. - `shareCap(cap, toInbox)` : on partage UN DOCUMENT, à une ou plusieurs inboxes. Pas le store — donner un cap de store livrerait tout son contenu présent et futur. Les caps reçus arrivent comme dépôts d'inbox, consommés par le inbox.watch existant (ce qui règle le point 7 de la revue adverse). - Durabilité : ne PAS la promettre. Verbatim amont, les caps sont « not durable » et qui ne reste pas abonné perd l'accès ; PermaCap est un TODO. La surface doit exposer l'obligation d'abonnement, sinon le consommateur retient des caps morts. - `PrincipalId` sort de la surface caps : n'existe pas en amont, et le brief le supprimait de canRead en le qualifiant d'inversion ACL avant de le réintroduire dans sealCapTo. On adresse des inboxes, comme inbox.post le fait déjà. - Section 0 conservant les erreurs du premier jet : elles sont instructives. - Exception publique actée : un lien de repo public n'a PAS de read_cap (il se télécharge depuis l'outer overlay) — pour du public, « référence nue → contenu » est bien la forme cible. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-20-caps-emulation-alignment.md | 157 +++++++++++------- docs/readcap-and-nuri-model.md | 26 ++- 2 files changed, 123 insertions(+), 60 deletions(-) diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index be4676c..4ffe89c 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -189,99 +189,144 @@ par une vérification de signature — voir le brief Festipod « inscriptions » | | 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**. | +| **Q1 — existence/fetch sans cap** | **NON** *(corrigé le 2026-07-27 — le verdict initial « OUI partiel » sur-lisait la preuve)* | Le contrôle d'accès en lecture laisse effectivement passer (les lectures ne sont pas cap-gatées) — **mais l'adressage présuppose le cap** : aucune commande d'existence au niveau SDK ; la seule sonde est interne au crate, exige des `BlockId` **et** un repo chargé, et vise l'overlay **inner** dérivé du secret de lecture. Une référence cap-less n'a ni `BlockId` ni l'overlay requis. Voir `readcap-and-nuri-model.md`. | | **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. +- **Tranché par la correction de Q1** : le compteur anonyme ne peut **pas** s'appuyer sur une validation d'existence — elle n'est pas constructible. Il repose donc sur du **déclaratif**, ce qui est acceptable (hors périmètre sécurité) tant que la **forme exposée ne ment pas** : ne pas exposer de primitif d'existence que la cible n'offrira pas. -## P1a — la surface (spécification, 2026-07-27) +## P1a — la surface (spécification, révisée 2026-07-27 après double revue adverse) -**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. +**Le découpage.** P1a = la **forme** exposée aux consommateurs ; P1b = l'**enforcement** +(chiffrement, fermeture de l'inventaire ci-dessus). Seul **P1a bloque Festipod**. +**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 +**Critère directeur, énoncé par le PO** : être **au plus près des concepts de +NextGraph et de son SDK**, pour réduire la complexité de développement et le +nombre de notions à découvrir pour qui connaît déjà le SDK. Toute notion inventée +ici est une **dette de vocabulaire** — le lecteur doit maintenir une table de +traduction dans sa tête. -Aujourd'hui `Nuri = string`, aucun parseur, aucune notion de segment de clé : la -distinction est purement conventionnelle. Elle doit devenir un **type**. +### 0. Ce que la première version de cette spec avait faux + +Conservé délibérément : ces erreurs sont instructives et faciles à refaire. + +| Erreur | Le réel | +|---|---| +| Types `DocRef` / `DocCap` | **Deux noms pour un seul objet.** En amont c'est **un** type, `NuriV0 { target, access }` : la clé est un **champ**, pas un autre type. Un NURI cap-less = `access` vide. `did:ng:` est le préfixe de **schéma** (inbox `d:`, branche `b:`, overlay `v:`…), pas un marqueur de « sans cap » — le discriminant est le segment **`:k:`**. | +| `DocCap` comme nom | S'appelle **`ReadCap`** en amont, et ce brief l'écrivait déjà ainsi. Un `WriteCap` étant prévu, un nom générique ne dirait même pas *quel* cap on manipule. | +| Types de marque TypeScript | Le SDK réel prend `nuri: String`. L'enforcement y est **runtime, par la crypto**, pas à la compilation. Une garantie de compilation est un concept que NextGraph **n'a pas** — et un consommateur qui typerait tout devrait *dé-typer* le jour où le vrai SDK arrive. L'inverse du but. | +| `recipient: PrincipalId` | `Principal` n'existe nulle part en amont. On adresse une **inbox** — et `inbox.post(targetInbox: Nuri)` le fait **déjà** ainsi dans ce paquet. | +| `sealCapTo` promis **durable** | Verbatim amont : « *Those capabilities are **not durable**… if they don't subscribe, they will lose access after the refresh. For durable capabilities, see PermaCap* » — et `PermaCap` est un TODO. | +| `resolveCapLess(ref)` | **Inconstruisible** : aucune commande d'existence au niveau SDK ; la seule sonde est interne au crate, exige des `BlockId` **et** un repo chargé, et adresse l'overlay **inner** (dérivé du secret de lecture). L'**adressage présuppose le cap**. | +| `parseNuri`, `refOf` | Doublons — `assertNuri` existe déjà à l'entrée SDK ; dégrader un cap se lit dans le champ `target`. | + +### 1. Les types — un seul, la clé est un champ + +`Nuri` **reste ce qu'il est déjà** dans ce paquet (une chaîne, ~90 usages) : la forme +**cap-less**, fidèle à la surface du SDK réel. On ajoute **un seul** nom, celui de +l'amont : ```ts -/** Nomme et localise un document. Ne donne AUCUN droit de lire. */ -type DocRef = Brand // did:ng:o:{id}:v:{overlay} -/** Un DocRef + la clé. SUFFISANT et REQUIS pour lire. */ -type DocCap = Brand // …: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 +type Nuri = string // did:ng:o:{doc}:v:{overlay} — nomme, ne lit pas +type ReadCap = string // …:k:{clé} — nomme ET lit ``` -**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. +La forme analysée `{ target, readCap? }` — miroir 1:1 de `NuriV0 { target, access }` — +sert **à l'intérieur** du polyfill ; elle ne remonte pas dans les signatures de +l'entrée SDK. **Un seul type nouveau, zéro churn, zéro cast aux frontières +ORM/SPARQL.** -### 2. Résoudre un cap-less +### 2. Le trousseau — d'où viennent les caps + +Le premier jet posait « aucune fonction ne va d'une référence nue vers un cap », ce +qui rendait la spec **inapplicable** : `doc_create` renvoie un NURI **cap-less**, donc +le créateur ne pouvait jamais obtenir le cap de son propre document. + +Le mécanisme réel était sous nos yeux : à chaque création, un `AddRepo { read_cap }` +est commité **sur la branche du store**. Cette branche **est le trousseau du +propriétaire** — c'est ainsi qu'il retrouve les caps de ses documents. *(Ce n'est +PAS le mécanisme de partage : voir §3.)* ```ts -resolveCapLess(ref: DocRef): Promise<{ exists: boolean }> +capFor(nuri: Nuri): ReadCap | undefined // cherche dans le trousseau ``` -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. +L'invariant se reformule, et devient à la fois vrai et plus simple à dire : -*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**. +> **On ne dérive pas un cap depuis une référence nue. On le cherche dans son +> trousseau — ou on l'a reçu.** -### 3. Le partage — sceller, pas déclarer +Le trousseau, en amont, c'est le **wallet** : un concept que tout lecteur du SDK +connaît déjà. `capFor` absorbe `canRead(doc)` (`capFor(n) !== undefined`) et supprime +son verbe d'ACL. + +### 3. Le partage — un DOCUMENT, à un ou plusieurs destinataires + +**L'unité de partage est le document**, pas le store. Donner le cap d'un store +livrerait tout son contenu, présent **et futur** — ce n'est pas le geste voulu, et +c'est cohérent avec la doctrine consommateur (« le document est l'unité de partage +et de droits »). ```ts -sealCapTo(cap: DocCap, recipient: PrincipalId): Promise // livrer UNE fois -receivedCaps(): Promise // ce qu'on m'a livré +shareCap(cap: ReadCap, toInbox: Nuri): Promise ``` -Trois propriétés qui font le delta réel avec l'ACL (et non « l'ACL renommée ») : +On adresse une **inbox**, comme `inbox.post` le fait déjà. Les caps **reçus** +n'ont pas besoin d'une opération dédiée : ils arrivent comme **dépôts d'inbox** +d'un genre `cap`, consommés par l'`inbox.watch` **existant**. *(Ce qui résout au +passage le point 7 de la revue adverse : la livraison d'un cap déclenche alors +naturellement une re-lecture, au lieu de laisser des vues périmées.)* -- **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. +**Statut amont — c'est un MANQUE, pas un désaccord** : le champ existe +(`ContactDetails.read_cap`, « *if user wants to share the content of profile* ») +mais le chemin est `unimplemented!()` et le récepteur **jette** le cap reçu. Le +polyfill l'émule donc en attendant → fiche dans le bug-inbox, avec ce qu'il faudra +en retirer le jour où l'amont l'implémente. -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. La durabilité — exposer l'obligation, pas la masquer -### 4. Ce qui disparaît ou change de sens +Un cap partagé est **rafraîchissable** : après un refresh, qui n'est pas resté +abonné **perd l'accès**. Le cap durable (`PermaCap`) n'existe pas encore. + +La surface ne doit donc **pas** promettre la permanence — sinon le consommateur +n'implémente jamais « rester abonné ou perdre l'accès » et conserve silencieusement +des caps morts. À exposer explicitement : un cap peut **expirer**, et une +re-livraison arrive par le même canal que la livraison initiale. + +### 5. Ce qui disparaît ou change de nom | 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. | +| `grantRead(doc, grantee)` | `shareCap(cap, toInbox)` | +| `canRead(doc, principal)` | absorbé par `capFor(nuri)` — le paramètre `principal` **était** l'inversion ACL | +| `protectedDocsOf(owner)` | **supprimé** — la boucle de re-dérivation disparaît | +| `makePublic(doc)` | `publishRepoLink` — le lien partageable a un nom en amont (`RepoLinkV0`) | +| `grantWrite` / `canWrite` | traités en P1b — aujourd'hui **décoratifs** (garde jamais déclenché) | +| `resetCaps()` au changement d'identité | **basculer** de trousseau, **pas effacer** | +| `PrincipalId` dans la surface caps | **sort** — on adresse des inboxes | -### 5. Le premier changement de comportement observable +### 6. L'exception publique, à ne pas raboter + +Un lien de repo **public** n'a **pas** de read_cap : le cap se télécharge depuis +l'outer overlay. Pour du contenu public, la forme cible **est** donc bien +« référence nue → contenu ». L'invariant du §2 admet cette exception — elle n'est +pas une entorse, c'est un second mécanisme. *(Type sans consommateur en amont : +DIRECTION, pas mécanisme vérifié.)* + +### 7. Recette de P1a — vérifiable sans une ligne de crypto `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 `DocRef` → **ne 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. +Après P1a : une référence nue moissonnée **ne rend rien** faute de cap au trousseau +— ce que fait exactement le vrai NextGraph. Le test tient sans chiffrement, ce qui +rend le découpage P1a/P1b honnête plutôt que cosmétique. ## Esquisse de phases diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index c356c9f..1b89d16 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -133,10 +133,28 @@ client↔broker et le stockage local utilisent l'**inner** — valeur différent 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 -sans lire son **contenu**. Le chemin d'autorisation de fetch broker pour un -détenteur sans clé n'a **pas** été tracé — à confirmer avant de s'en servir. +**CORRIGÉ le 2026-07-27 — cette hypothèse était FAUSSE.** On avait inféré, puis +cru vérifier, qu'un détenteur **sans clé** pouvait récupérer les blocs chiffrés et +donc prouver l'**existence** d'un document. Une revue adverse a montré que le +raisonnement s'arrêtait au *contrôle d'accès* sans regarder l'**adressage** : + +- Il n'existe **aucune commande d'existence au niveau SDK**. +- La seule sonde (`BlocksExist`) est **interne au crate**, exige des `BlockId` + **et** un repo déjà **chargé**, et adresse l'overlay **inner** — lequel est + dérivé du **secret de lecture**. +- Une référence cap-less porte un RepoId et l'overlay **outer** : aucun `BlockId` + à sonder. Et l'outer n'est de toute façon jamais enregistré (`expose_outer` + codé en dur à `false`, sans paramètre SDK). +- Le seul primitif accessible à un non-membre (`ExtObjectGet`) exige les ObjectIds + **et leurs clés**. + +> **L'adressage lui-même présuppose le cap.** Prouver l'existence d'un document +> sans détenir sa clé n'est pas constructible aujourd'hui, et rien n'indique que +> ce soit prévu. + +Leçon transposable : vérifier qu'une garde d'accès **laisse passer** ne prouve pas +qu'une opération est atteignable — encore faut-il pouvoir **nommer** ce qu'on +demande. ## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge