From d7e0ee6a4bbfeeb98aabeebc0da2cd9a0dff48cc Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 27 Jul 2026 14:48:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(brief):=20sp=C3=A9cifier=20P1a=20(la=20sur?= =?UTF-8?q?face)=20et=20corriger=20l'inventaire=20des=20contournements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:ng:` 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 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-20-caps-emulation-alignment.md | 127 ++++++++++++++++-- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index 4dbed75..be4676c 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -41,12 +41,33 @@ léger) et le **ReadCap = la clé**. Invariant (cf. `docs/vision.md`) : `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.** +**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 @@ -177,12 +198,98 @@ par une vérification de signature — voir le brief Festipod « inscriptions » - **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**. + +```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 +``` + +**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 + +```ts +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 + +```ts +sealCapTo(cap: DocCap, recipient: PrincipalId): Promise // livrer UNE fois +receivedCaps(): Promise // 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 `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. + ## 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. +- **P1a** — **la surface** : types `DocRef` / `DocCap`, `resolveCapLess`, + `sealCapTo` durable. Spécifié ci-dessus. **Le seul lot qui bloque Festipod.** +- **P1b** — **l'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**,