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