docs(brief): spécifier P1a (la surface) et corriger l'inventaire des contournements

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🆖` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
Sylvain Duchesne
2026-07-27 14:48:48 +02:00
parent ead5aececf
commit d7e0ee6a4b
@@ -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<string, 'DocRef'> // did:ng:o:{id}:v:{overlay}
/** Un DocRef + la clé. SUFFISANT et REQUIS pour lire. */
type DocCap = Brand<string, 'DocCap'> // …: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<void> // livrer UNE fois
receivedCaps(): Promise<DocCap[]> // 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**,