60a9fd3ede
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
369 lines
26 KiB
Markdown
369 lines
26 KiB
Markdown
# Brief — aligner l'émulation des caps sur le vrai modèle NextGraph
|
||
|
||
**Brief (incubation) — 2026-07-20.** Voir la référence `docs/readcap-and-nuri-model.md`.
|
||
|
||
## Problème
|
||
|
||
`caps.ts` émule les droits de lecture comme une **ACL** (`Map<Nuri, Set<PrincipalId>>`,
|
||
`grantRead(doc, grantee)`) — **l'inversion** du vrai modèle NextGraph (possession
|
||
de clé). Conséquences : pas de notion de **référence cap-less**, grant/révocation
|
||
**instantanés et totaux** (au lieu de scellage durable + re-key), et une API
|
||
(`declareConnections`) que les consommateurs doivent **re-déclarer à chaque
|
||
session**. Cet écart empêche de bâtir correctement des modèles qui reposent sur la
|
||
vraie sémantique — notamment la **présence anonyme** (nommer/compter sans lire).
|
||
|
||
## Objectif : shape-fidelity, PAS sécurité
|
||
|
||
Le polyfill **n'égale PAS** la sécurité de NextGraph fini, et n'essaie pas. Le
|
||
wallet partagé + l'absence de crypto rendent l'émulation **volontairement
|
||
non-sécurisée** (tout est en clair, tout marqueur est forgeable) — véhicule de
|
||
dev/staging, pas un but. **Seul objectif** : exposer la **BONNE FORME** des
|
||
primitives futures pour que les consommateurs (Festipod) soient codés contre le
|
||
**modèle mental correct** et **n'aient pas à être réécrits** quand NextGraph sera fini.
|
||
|
||
Corollaire : **« pas de crypto » n'est pas un problème** ; ce qui compte est d'être
|
||
**dans la même logique, avec RIGUEUR**. Une critique « un attaquant lit le clair /
|
||
forge un marqueur » est **exacte mais hors-scope**. Ce qui est **inacceptable** =
|
||
exposer la **mauvaise forme** (ex. une ACL là où le réel est possession de clé) →
|
||
le consommateur code contre un modèle qui n'existera pas. **L'inversion ACL des
|
||
ReadCaps EST ce manque de rigueur** — le défaut central à corriger.
|
||
|
||
## Mécanisme d'enforcement : simulation crypto LÉGÈRE (anti-ACL, anti-raccourci)
|
||
|
||
Pour que la forme soit **réellement** possession-de-clé (et pas une ACL déguisée),
|
||
la donnée d'un doc est **stockée chiffrée** (chiffrement symétrique par-doc, même
|
||
léger) et le **ReadCap = la clé**. Invariant (cf. `docs/vision.md`) :
|
||
|
||
> un **`did` nu (sans ReadCap)** ne permet **PAS** de lire ; un **NURI avec
|
||
> ReadCap** est **suffisant et requis**.
|
||
|
||
Ça **empêche les raccourcis** que l'adversaire a relevés (#4/#6 : lire le clair,
|
||
`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** : 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
|
||
absent.
|
||
2. **Grant = livrer un cap-token à un destinataire** (émuler le scellage :
|
||
le destinataire *reçoit* le token dans son inbox ; c'est la **possession** du
|
||
token qui autorise la lecture — pas une ligne d'ACL vérifiée par principal).
|
||
3. **Enforcement par possession** : les lecteurs (`read-filter`, `use-shape`) ne
|
||
voient que ce dont ils **détiennent le token**, pas « ce dont ils sont dans le
|
||
set de readers ».
|
||
4. **Résoudre un cap-less** = nommer / prouver l'existence / compter, **sans**
|
||
exposer le contenu (support de la présence anonyme).
|
||
5. **Révocation = re-key** émulé : invalider l'ancien token, re-livrer un nouveau
|
||
aux autorisés restants ; **non-rétroactif**.
|
||
|
||
## ~~Périmètre élargi : le WriteCap (= membership)~~ — RETIRÉ (2026-07-21)
|
||
|
||
**Cette section était fausse et est conservée barrée comme garde-fou.** Elle
|
||
importait une notion de *membership* lue dans l'**état courant** de `nextgraph-rs`
|
||
(`AddMember`, `PermissionV0`, `member_pubkey`) et la promouvait en **forme cible**.
|
||
Or (a) ces types sont un **échafaudage inerte** au runtime — `verify_sig` /
|
||
`verify_perm` ne sont appelés que dans des tests unitaires, les `Repo` sont
|
||
construits avec `members: HashMap::new()` ; et (b) le modèle cible **n'a pas de
|
||
notion d'appartenance du tout** : uniquement des **clés et des URLs**, symétrique
|
||
et asymétrique. Une forme en `member`/`role`/`permission` est donc exactement la
|
||
**mauvaise forme** que ce brief existe pour empêcher.
|
||
|
||
**La leçon de méthode, qui vaut plus que la section retirée** : lire l'état
|
||
courant de NextGraph pour en **déduire** la forme cible est une erreur — l'état
|
||
courant contient de l'inachevé qu'il ne faut pas figer dans le polyfill. Le source
|
||
sert à vérifier un **mécanisme** existant, jamais à inférer une **intention**.
|
||
|
||
Contenu erroné conservé ci-dessous à titre de trace :
|
||
|
||
<details>
|
||
<summary>Section retirée</summary>
|
||
|
||
**Pourquoi c'est ici et pas ailleurs.** Le brief était d'abord centré ReadCap ; un
|
||
constat adverse a montré qu'il **ne compose pas** avec son consommateur : le brief
|
||
Festipod « inscriptions par Set » a besoin de **dédupliquer** les participations
|
||
(un user = une participation par événement), et la seule base non-applicative
|
||
disponible est la **signature d'auteur du commit** — donc un primitif d'**écriture**,
|
||
pas de lecture. Un polyfill qui n'expose que la forme du ReadCap laisse le
|
||
consommateur inventer sa propre dédup applicative → exactement la mauvaise forme.
|
||
|
||
**La forme réelle (VÉRIFIÉE, cf. `readcap-and-nuri-model.md` §1)** — et elle est
|
||
**asymétrique** de la lecture, ce qui est le point le plus facile à rater :
|
||
|
||
- **Lecture = possession d'une clé.** Pas d'ACL. Qui détient, lit.
|
||
- **Écriture = membership + permissions** (`AddMember`, `AddPermission` sur le
|
||
`RootBranch`). C'est **bel et bien une liste d'autorisation** — pas de la
|
||
possession. Émuler l'écriture « par possession de token » serait aussi faux que
|
||
l'ACL de lecture actuelle, en miroir.
|
||
- **Les commits SONT signés** par un `UserId` (clé **technique**, distincte du
|
||
profil) — donc un identifiant de dédup **existe** nativement, sans pseudonyme
|
||
applicatif.
|
||
- **Mais vérifier une signature exige d'être membre du repo** (accès au
|
||
`member_pubkey`). Un tiers non-membre voit un commit signé sans pouvoir
|
||
l'attribuer.
|
||
- **Le dépôt d'inbox n'est PAS authentifié** (sealed box anonyme) : un `from`
|
||
déclaré est du contenu, pas une preuve.
|
||
|
||
**Ce que ça impose au polyfill.** Exposer `membership` comme primitif **distinct**
|
||
de la possession de cap, avec au minimum : ajouter/retirer un membre d'un repo,
|
||
lire la members map **quand on est membre**, et **vérifier l'auteur d'un commit**
|
||
(→ un digest d'auteur, **par-overlay donc par-store**). C'est ce dernier point qui
|
||
débloque la dédup côté Festipod.
|
||
|
||
**La conséquence de forme, à documenter explicitement** (sinon le consommateur se
|
||
trompe de modèle) : le digest d'auteur étant **par-store**, le choix du découpage
|
||
en stores **est** le choix du niveau de corrélation. Un store **stable par-user**
|
||
donne un identifiant traçable **cross-événement** ; un store **par-événement**
|
||
donne un pseudonyme **local à l'événement** — dédup possible, corrélation
|
||
impossible. Festipod a besoin du second. Le polyfill doit donc rendre ce découpage
|
||
**exprimable**, pas le figer.
|
||
|
||
**Reste ouvert** : « le créateur d'un événement peut-il être membre du store qui
|
||
contient les participations, sans pour autant en détenir la clé de lecture ? » —
|
||
c'est-à-dire membership (écriture/vérification) et possession (lecture)
|
||
réellement **orthogonales**. Si NextGraph les couple, la dédup vérifiée et
|
||
l'anonymat s'excluent, et c'est le brief Festipod qui doit trancher ce qu'il
|
||
sacrifie. **À vérifier avant de façonner l'API.**
|
||
|
||
</details>
|
||
|
||
*(Question devenue sans objet : il n'y a pas de membership. La dédup ne passe pas
|
||
par une vérification de signature — voir le brief Festipod « inscriptions ».)*
|
||
|
||
## Questions ouvertes
|
||
|
||
- **TRANCHÉ (directive PO, 2026-07-21)** : on **simule la crypto** (donnée chiffrée
|
||
par-doc, cap = clé). La « sémantique seule » (registre de tokens) est **écartée** —
|
||
elle redevient une ACL et laisse lire le clair. Reste à trancher le **niveau** de
|
||
simulation (chiffrement réel léger vs projection read-model masquée), **avant P1**.
|
||
- Représentation NURI cap-less vs cap-porteur dans l'émulation (calquer `:k:`).
|
||
- Faut-il permettre le **fetch keyless** (résoudre un cap-less en existence/compte
|
||
sans le contenu) — dépend de ce que le vrai broker autorise (INFÉRÉ, non tracé).
|
||
- Migration d'API : `declareConnections`/`grantRead` → `seal(cap, recipient)` +
|
||
`inbox → caps reçus`. Casse les consommateurs (`declareConnections` app disparaît).
|
||
|
||
## P0 — Spike « keyless-resolve » (le verrou, AVANT tout P1)
|
||
|
||
**Question porteuse** : un détenteur d'une **référence cap-less** (`did:ng:o:{id}:v:{overlay}`, sans `:k:`) peut-il, **sans jamais lire le contenu** :
|
||
- **Q1 — Existence / fetch** : prouver/récupérer la présence des blocs (chiffrés) auprès du broker ? Ou le broker exige-t-il un ReadCap/membership pour servir les blocs ?
|
||
- **Q2 — Suppression** : distinguer « existe » de « supprimé » ? *(Le point FRAGILE : NextGraph est append-only CRDT — une désinscription = un **commit tombstone** qu'il faudrait **lire** pour connaître → potentiellement **la clé est requise**. Or le **décrément au leave** en dépend.)*
|
||
- **Q3 — Confidentialité** : la clé (`:k:`) reste-t-elle **requise** pour déchiffrer (le keyless ne donne jamais le contenu) ?
|
||
|
||
**Pourquoi c'est le verrou** : tout le **compteur anonyme** (compter/valider des réfs cap-less sans lire) ET le **décrément au leave** en dépendent. **Si NON** → « compteur anonyme par réf cap-less » est **inconstruisible en cible** → Festipod ne doit **pas** coder cette forme (réécriture garantie). **Si OUI** → P1 expose `resolveCapLess(nuri) → {exists|deleted}` (jamais de contenu), et l'émulation la simule fidèlement.
|
||
|
||
**Méthode** (bon marché, décisif) :
|
||
1. **Tracer** dans `nextgraph-rs` le chemin d'**autorisation de fetch** du broker/verifier : qui sert les blocs (`BlocksGet`/`TopicSync`/`OverlaySync`) ? un cap/membership est-il vérifié, ou `id+overlay` suffit ? l'overlay *outer* est-il public ? une suppression est-elle observable sans clé ?
|
||
2. *(Optionnel)* **test e2e décisif** (façon `e2e/reactivity-doc-subscribe.ts`) : B détient la réf cap-less, tente fetch/existence **sans** la clé, vérifie qu'il **n'accède pas** au contenu. Preuve empirique > source.
|
||
3. *(Ou)* confirmer avec le dev NextGraph — le plus rapide.
|
||
|
||
**Livrable** : YES/NO/PARTIAL par Q1/Q2/Q3 + le primitif exact (file:line) + la forme d'API à exposer (si OUI), ou le constat que le compteur change (si NON).
|
||
**Décision gatée** : OUI → P1 ; NON → le brief inscriptions revoit le compteur (pas anonyme, ou autre primitif).
|
||
|
||
### Verdict du spike (2026-07-21) — VÉRIFIÉ dans `nextgraph-rs`
|
||
|
||
| | Réponse | Preuve |
|
||
|---|---|---|
|
||
| **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.
|
||
- **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, révisée 2026-07-27 après double revue adverse)
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
### 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
|
||
type Nuri = string // did:ng:o:{doc}:v:{overlay} — nomme, ne lit pas
|
||
type ReadCap = string // …:k:{clé} — nomme ET lit
|
||
```
|
||
|
||
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. 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
|
||
capFor(nuri: Nuri): ReadCap | undefined // cherche dans le trousseau
|
||
```
|
||
|
||
L'invariant se reformule, et devient à la fois vrai et plus simple à dire :
|
||
|
||
> **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.**
|
||
|
||
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
|
||
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
|
||
```
|
||
|
||
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.)*
|
||
|
||
**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.
|
||
|
||
### 4. La durabilité — exposer l'obligation, pas la masquer
|
||
|
||
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)` | `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 |
|
||
|
||
### 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.
|
||
|
||
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
|
||
|
||
- **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**,
|
||
pas « inverser l'ACL » — sans crypto, inverser ne produit aucun delta observable.*
|
||
- **P3** — révocation par re-key (invalidation + re-livraison, non-rétroactive).
|
||
- **PW** — **WriteCap = membership** : primitif distinct (add/remove member,
|
||
members map lisible par un membre, **vérification d'auteur de commit** → digest
|
||
par-store), et découpage en stores **exprimable** par le consommateur.
|
||
*Indépendant de P1–P3 ; **bloquant pour la dédup Festipod**, donc à ordonnancer
|
||
tôt si c'est ce besoin-là qui presse.*
|
||
- **P4** — adapter l'API consommateur + `migration-guide.md`. *La revue adverse
|
||
requalifie ce lot : ce n'est pas un swap d'API mais une **re-architecture
|
||
consommateur** (le grant se déplace vers l'acceptation de connexion et devient
|
||
persistant ; `declareConnections` disparaît).*
|
||
|
||
## Revue adverse (2026-07-20) — à intégrer
|
||
|
||
Un adversaire a réfuté le brief (6 constats). **À lire au filtre de l'Objectif ci-dessus** (forme, pas sécurité). Les critiques purement **sécurité** — contenu en clair lisible (#4), marqueurs forgeables — sont **ACCEPTÉES / hors-scope** : le polyfill ne cherche pas à les empêcher. Restent les vrais défauts de **FORME / rigueur** (à corriger), et une question de **modèle futur** (#5) :
|
||
|
||
1. **WriteCap oublié, et « possession » y est FAUX.** L'écriture est **membership/permissions** (`AddMember`) — une **liste d'autorisation**, pas de la possession de clé (réf §1) ; `ng-proxy.ts:28-48` garde chaque `sparql_update`. → garder une **piste WriteCap = membership** ; la **possession ne concerne QUE la lecture**.
|
||
2. **P2 « possession sans crypto » = l'ACL renommée.** Sans crypto, « qui détient quel token » = `Map<doc, Set<holder>>` = le `readers` actuel : **aucun delta observable**. Les vrais deltas sont **durabilité + cap-less + re-partage par le détenteur** — c'est ÇA le contenu de P2, pas « inverser l'ACL ».
|
||
3. **Révocation non-rétroactive INÉMULABLE** sans versioning : `read-model.ts:112-118` ne lit que l'état courant → « invalider l'ancien token » = retrait total = l'inverse du réel (l'ex-détenteur déchiffre les versions **antérieures**). → n'émuler que « plus de nouvelles lectures après re-key » + **documenter la non-rétroactivité comme non-émulable**.
|
||
4. **cap-less « sans exposer le contenu » ILLUSOIRE dans l'émulation** : contenu en **clair** dans le wallet partagé ; `sparqlQuery`/`inbox.read` **bypass** le filtre ; `read-filter.ts:30-35` est tout-ou-rien. → l'anonymat cap-less exige soit du **vrai crypto**, soit une **projection read-model masquée** (compter sans lire). « Remplacement pas refonte » est **surévalué**.
|
||
5. **Keyless-fetch = INFÉRÉ et load-bearing** : ajouter un **spike P0** qui le vérifie **avant** P1 (sinon le modèle — polyfill ET Festipod — est inconstruisible).
|
||
6. **Migration ≠ swap d'API.** `declareConnections` se re-joue chaque session parce que la map est éphémère ; des scellages durables déplacent le grant à l'**acceptation de connexion** + persistent « déjà scellé » — pas d'analogue de `protectedDocsOf` + la boucle de re-dérivation. **Re-architecture consommateur.**
|
||
7. *(Plausible)* livrer un cap par inbox async **ne re-déclenche pas** `watchShape` (souscrit aux docs de données, pas aux caps) → vues illisibles **périmées** jusqu'à un autre changement. → prévoir un signal de mutation de caps.
|
||
|
||
**Conséquence** : ajouter en tête **P0 (spike keyless-fetch)** et une **piste WriteCap distincte** ; requalifier P2 (le vrai contenu = durabilité + cap-less + re-partage, pas « inverser l'ACL ») ; acter que **sans crypto, la privacy de lecture n'est pas applicable** (choisir : vrai crypto vs projection masquée).
|
||
|
||
Liens : `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. Côté consommateur,
|
||
le brief Festipod « réaligner les inscriptions » dépend de ce chantier.
|