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**,