diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index 310d1b0..fc44df1 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -1,259 +1,145 @@ -# Brief — aligner l'émulation des caps sur le vrai modèle NextGraph +# Brief — align the caps emulation with the real NextGraph model -**Brief (incubation) — 2026-07-20.** Voir la référence `docs/readcap-and-nuri-model.md`. +**Brief (incubation) — 2026-07-20.** See the reference `docs/readcap-and-nuri-model.md`. -## Problème +## Problem -`caps.ts` émule les droits de lecture comme une **ACL** (`Map>`, -`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). +`caps.ts` emulates read rights as an **ACL** (`Map>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading). -## Objectif : shape-fidelity, PAS sécurité +## Objective: shape-fidelity, NOT security -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. +The polyfill does **NOT match** the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation **deliberately insecure** (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. **Sole objective**: expose the **RIGHT SHAPE** of the future primitives so that consumers (Festipod) are coded against the **correct mental model** and **do not have to be rewritten** when NextGraph is finished. -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. +Corollary: **"no crypto" is not a problem**; what matters is being **in the same logic, with RIGOR**. A criticism of the form "an attacker reads the plaintext / forges a marker" is **correct but out of scope**. What is **unacceptable** = exposing the **wrong shape** (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. **The ACL inversion of ReadCaps IS that lack of rigor** — the central defect to fix. -## Mécanisme d'enforcement : simulation crypto LÉGÈRE (anti-ACL, anti-raccourci) +## Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut) -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`) : +For the shape to be **really** key-possession (and not an ACL in disguise), a doc's data is **stored encrypted** (per-doc symmetric encryption, however lightweight) and the **ReadCap = the key**. Invariant (cf. `docs/vision.md`): -> un **`did` nu (sans ReadCap)** ne permet **PAS** de lire ; un **NURI avec -> ReadCap** est **suffisant et requis**. +> a **bare `did` (without a ReadCap)** does **NOT** allow reading; a **NURI with a ReadCap** is **sufficient and required**. -Ç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 ». +This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the plaintext, `sparqlQuery`/`inbox.read` bypassing the filter) and **forbids** falling back on an ACL — that is the heart of "same logic, with rigor". -**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 : +**Target — NOT the current state**: every surface that returns data will have to go through decryption-with-key. **Today this is FALSE, and far more broadly than this brief first stated** — mapping of 2026-07-27, VERIFIED: **only 4 sites consult the caps** (`use-shape`, `read-filter`, `read-model.readUnion`, `discovery.submitToIndex`). Everything else returns data with no guard: -| Surface | État | +| Surface | State | |---|---| -| `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) | +| `docs.sparqlQuery` / `sparqlUpdate` | **bypasses** — they call the injected `ng` **directly** (an accepted constraint, to avoid a double-Proxy `DataCloneError`). **The widest breach**: a session id + a NURI are enough to read everything. | +| `inbox` (`read` / `readSynced` / `materialize` / `watch`) | **bypasses** — no cap consulted; the drops go to whoever asks for them | +| `store-registry` (**zero** reference to caps in the whole file) | **bypasses** — the account→NURI root of trust is universally readable | +| `discovery.readIndex` | **bypasses** on read (caps checked on write only) | +| `subscribe`, `open-repo` | **bypass** — the subscription push carries the doc state with no check | +| `watch-shape` | deliberately delegates to `readUnion` (does not re-filter) | -**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.)* +**And the WRITE guard is already stillborn**: `ng-proxy` guards `sparql_update`, but `docs` bypasses the proxy **by design**, and **all** internal writers go through `docs`. So the guard only fires for an app calling `ng.sparql_update` on the exported `ng` — which Festipod does not do. `grantWrite` / `canWrite` are **decorative**. *(This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)* -**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. +**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids. -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**. +1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:k:`) vs cap-bearing (id + key/token). Absent today. +2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal). +3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for". +4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence). +5. **Revocation = re-key** emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; **non-retroactive**. -## ~~Périmètre élargi : le WriteCap (= membership)~~ — RETIRÉ (2026-07-21) +## ~~Widened scope: the WriteCap (= membership)~~ — DROPPED (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. +**This section was wrong and is kept struck through as a guardrail.** It imported a notion of *membership* read from the **current state** of `nextgraph-rs` (`AddMember`, `PermissionV0`, `member_pubkey`) and promoted it into a **target shape**. But (a) those types are **inert scaffolding** at runtime — `verify_sig` / `verify_perm` are only called in unit tests, and `Repo`s are built with `members: HashMap::new()`; and (b) the target model **has no notion of membership at all**: only **keys and URLs**, symmetric and asymmetric. A shape in terms of `member`/`role`/`permission` is therefore exactly the **wrong shape** that this brief exists to prevent. -**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**. +**The methodological lesson, which is worth more than the dropped section**: reading NextGraph's current state in order to **deduce** the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing **mechanism**, never to infer an **intent**. -Contenu erroné conservé ci-dessous à titre de trace : +Erroneous content kept below as a record:
-Section retirée +Dropped section -**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. +**Why this is here and not elsewhere.** The brief was at first ReadCap-centric; an adversarial finding showed that it **does not compose** with its consumer: the Festipod brief on "Set-based sign-ups" needs to **deduplicate** participations (one user = one participation per event), and the only non-application-level basis available is the commit's **author signature** — hence a **write** primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape. -**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 : +**The real shape (VERIFIED, cf. `readcap-and-nuri-model.md` §1)** — and it is **asymmetric** with reading, which is the easiest point to miss: -- **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. +- **Reading = possession of a key.** No ACL. Whoever holds, reads. +- **Writing = membership + permissions** (`AddMember`, `AddPermission` on the `RootBranch`). It really **is an authorization list** — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image. +- **Commits ARE signed** by a `UserId` (a **technical** key, distinct from the profile) — so a dedup identifier **exists** natively, with no application-level pseudonym. +- **But verifying a signature requires being a member of the repo** (access to the `member_pubkey`). A non-member third party sees a signed commit without being able to attribute it. +- **The inbox drop is NOT authenticated** (anonymous sealed box): a declared `from` is content, not proof. -**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. +**What that imposes on the polyfill.** Expose `membership` as a primitive **distinct** from cap possession, with at minimum: adding/removing a member of a repo, reading the members map **when one is a member**, and **verifying the author of a commit** (→ an author digest, **per-overlay hence per-store**). It is this last point that unblocks the dedup on the Festipod side. -**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. +**The shape consequence, to be documented explicitly** (otherwise the consumer picks the wrong model): the author digest being **per-store**, the choice of how stores are carved up **is** the choice of the correlation level. A **per-user stable** store gives an identifier traceable **across events**; a **per-event** store gives a pseudonym **local to the event** — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving **expressible**, not freeze it. -**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.** +**Still open**: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely **orthogonal**. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. **To be verified before shaping the API.**
-*(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 ».)* +*(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)* -## Questions ouvertes +## Open questions -- **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). +- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**. +- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:k:`). +- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed. +- API migration: `declareConnections`/`grantRead` → `seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears). -## P0 — Spike « keyless-resolve » (le verrou, AVANT tout P1) +## P0 — "keyless-resolve" spike (the blocker, BEFORE any 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) ? +**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:k:`), **without ever reading the content**: +- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks? +- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)* +- **Q3 — Confidentiality**: does the key (`:k:`) remain **required** in order to decrypt (keyless never gives the content)? -**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. +**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully. -**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. +**Method** (cheap, decisive): +1. **Trace** in `nextgraph-rs` the broker/verifier **fetch authorization** path: who serves the blocks (`BlocksGet`/`TopicSync`/`OverlaySync`)? is a cap/membership checked, or is `id+overlay` enough? is the *outer* overlay public? is a deletion observable without the key? +2. *(Optional)* **decisive e2e test** (in the style of `e2e/reactivity-doc-subscribe.ts`): B holds the cap-less ref, attempts fetch/existence **without** the key, verifies that it **does not reach** the content. Empirical proof > source. +3. *(Or)* confirm with the NextGraph dev — the fastest. -**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). +**Deliverable**: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO). +**Gated decision**: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive). -### Verdict du spike (2026-07-21) — VÉRIFIÉ dans `nextgraph-rs` +### Spike verdict (2026-07-21) — VERIFIED in `nextgraph-rs` -| | Réponse | Preuve | +| | Answer | Evidence | |---|---|---| -| **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. | +| **Q1 — existence/fetch without a cap** | **NO** *(corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence)* | Read access control does indeed let you through (reads are not cap-gated) — **but addressing presupposes the cap**: no existence command at the SDK level; the only probe is internal to the crate, requires `BlockId`s **and** a loaded repo, and targets the **inner** overlay derived from the read secret. A cap-less reference has neither `BlockId` nor the required overlay. See `readcap-and-nuri-model.md`. | +| **Q2 — detect a deletion without the key** | **NO** | Append-only broker; a deletion is an **encrypted tombstone commit** (`RemoveRepo`), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion". | +| **Q3 — confidentiality** | **YES** | Blocks stored as ciphertext; the key is `#[serde(skip)]` (`types.rs`), derived from the `ReadCapSecret`. Keyless **never** gives the content. | -**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. +**What that decides.** +- **P1 is unblocked**: `resolveCapLess(nuri) → { exists }` is the right shape — but **`{ exists | deleted }` is NOT**. Do not expose a `deleted` state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights). +- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise. +- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer. -## P1a — la surface +## P1a — the surface -**Extrait dans son propre brief : [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).** +**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).** -Ce lot est **spécifié et prêt à implémenter** ; il a sa propre fiche pour qu'on -puisse coder depuis elle sans traverser la matière rétractée de ce document-ci. +This batch is **specified and ready to implement**; it has its own note so that one can code from it without wading through the retracted material of this document. -En deux lignes : un seul type nouveau (`ReadCap`), un trousseau (`capFor`), un -partage par document vers une inbox (`shareCap`) — et rien d'autre. Les types de -marque, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` et `PrincipalId` -ont été **écartés** après double revue adverse ; les raisons sont dans la fiche. +In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note. -Ce brief-ci reste le **chantier d'ensemble** : verdicts P0, périmètre de P1b, -lots P2–P4, et les revues adverses. +This brief remains the **overall effort**: P0 verdicts, P1b scope, P2–P4 batches, and the adversarial reviews. -## Esquisse de phases +## Phase sketch -- **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~~ **RETIRÉ (2026-07-27)**. Ce lot reposait sur - une notion d'appartenance qui **n'existe pas** dans le modèle (tout est clés et - URLs) ; voir la section barrée plus haut. Il était en outre justifié par un - besoin de **dédup par vérification de signature** que le consommateur n'a - finalement pas : sa dédup s'appuie sur l'overlay, store-scopé. - *Pour mémoire, deux faits vérifiés au passage, à ne pas re-découvrir* : la - vérification de signature d'auteur **n'est pas appelée au runtime** ; et le - digest d'auteur n'est **pas** clé sous le secret de lecture — il est clé par - l'overlay **outer**, public *(c'est le **contenu** du commit qui est chiffré, - d'où le fait que vérifier suppose quand même de pouvoir lire)*. Détail dans - `nextgraph-current-state.md`. -- **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).* +- **P1a** — **the surface**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). **The only batch that blocks Festipod.** Specified in its own note: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md). *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)* +- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. +- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.* +- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive). +- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`. +- **P4** — adapt the consumer API + `migration-guide.md`. *The adversarial review requalifies this batch: it is not an API swap but a **consumer re-architecture** (the grant moves to connection acceptance and becomes persistent; `declareConnections` disappears).* -## Revue adverse (2026-07-20) — à intégrer +## Adversarial review (2026-07-20) — to be integrated -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) : +An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **To be read through the filter of the Objective above** (shape, not security). The purely **security** criticisms — readable plaintext content (#4), forgeable markers — are **ACCEPTED / out of scope**: the polyfill does not seek to prevent them. What remains are the real **SHAPE / rigor** defects (to be fixed), and a question of **future model** (#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>` = 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. +1. **WriteCap forgotten, and "possession" is FALSE there.** Writing is **membership/permissions** (`AddMember`) — an **authorization list**, not key possession (ref. §1); `ng-proxy.ts:28-48` guards every `sparql_update`. → keep a **WriteCap = membership track**; **possession concerns ONLY reading**. +2. **P2 "possession without crypto" = the ACL renamed.** Without crypto, "who holds which token" = `Map>` = the current `readers`: **no observable delta**. The real deltas are **durability + cap-less + re-sharing by the holder** — THAT is the content of P2, not "inverting the ACL". +3. **Non-retroactive revocation NOT EMULABLE** without versioning: `read-model.ts:112-118` only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the **prior** versions). → emulate only "no new reads after re-key" + **document non-retroactivity as non-emulable**. +4. **cap-less "without exposing the content" ILLUSORY in the emulation**: content in **plaintext** in the shared wallet; `sparqlQuery`/`inbox.read` **bypass** the filter; `read-filter.ts:30-35` is all-or-nothing. → cap-less anonymity requires either **real crypto** or a **masked read-model projection** (counting without reading). "Replacement not overhaul" is **overstated**. +5. **Keyless-fetch = INFERRED and load-bearing**: add a **P0 spike** that verifies it **before** P1 (otherwise the model — polyfill AND Festipod — is not constructible). +6. **Migration ≠ API swap.** `declareConnections` is replayed every session because the map is ephemeral; durable seals move the grant to **connection acceptance** + persist "already sealed" — no analogue of `protectedDocsOf` + the re-derivation loop. **Consumer re-architecture.** +7. *(Plausible)* delivering a cap through the async inbox **does not re-trigger** `watchShape` (which subscribes to data docs, not to caps) → unreadable views left **stale** until another change. → plan for a cap-mutation signal. -**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). +**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection). -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. +Links: `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.