From 518292498abe10fdaf64a03bd5bda9fe65c83a87 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 28 Jul 2026 15:44:24 +0200 Subject: [PATCH] docs(brief): extraire P1a dans son propre brief, en anglais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1a n'était qu'une section d'un brief de ~370 lignes charriant beaucoup de matière rétractée (lot PW barré, section membership en
, verdicts corrigés). Coder depuis ce fichier aurait été pénible et risqué. docs/briefs/2026-07-27-p1a-cap-surface.md — le lot actionnable, lisible seul : - un seul type nouveau, ReadCap, le nom de l'amont ; - capFor(nuri) sur le trousseau (la branche de store), avec l'avertissement que le trousseau n'est PAS le mécanisme de partage ; - shareCap(cap, toInbox) — un document, vers une ou plusieurs inboxes ; - rotation de clé : re-livraison automatique, rien à implémenter côté consommateur ; - contenu public : lisible par l'URL, non récursif ; - la frontière index.ts / polyfill, tranchée : signatures sur des chaînes, comme le vrai SDK ; - le test de recette sans crypto (watch-shape moissonne aujourd'hui toute chaîne did:ng: et la replie dans l'ensemble LU) ; - et ce que le lot ne fait PAS, pour ne pas le croire fini. Chaque écart écarté y est justifié plutôt que tu : types de marque, resolveCapLess, receivedCaps, refOf, parseNuri, PrincipalId. Le premier jet introduisait 8 notions nouvelles ; il en reste 2, et le critère est écrit noir sur blanc — toute notion inventée est une dette de vocabulaire. Le brief d'origine reste le chantier d'ensemble et pointe vers la fiche. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-20-caps-emulation-alignment.md | 159 ++---------------- docs/briefs/2026-07-27-p1a-cap-surface.md | 129 ++++++++++++++ 2 files changed, 139 insertions(+), 149 deletions(-) create mode 100644 docs/briefs/2026-07-27-p1a-cap-surface.md diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index 24861dc..310d1b0 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -198,159 +198,20 @@ 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. - **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) +## P1a — la surface -**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. +**Extrait dans son propre brief : [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).** -**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. +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. -### 0. Ce que la première version de cette spec avait faux +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. -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 -``` - -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 rotation de clé — re-livraison automatique, pas perte d'accès - -**CORRIGÉ le 2026-07-27 (direction confirmée par le PO).** Une version antérieure -de cette section disait « qui n'est pas resté abonné perd l'accès » et demandait -d'exposer une obligation d'abonnement. **C'est faux comme cible.** Le commentaire -amont dont ça venait (« *if they don't subscribe, they will lose access after the -refresh* ») décrit **l'état courant**, pas l'intention — l'erreur de méthode que ce -brief met justement en garde de commettre. - -La **direction** : quand une clé tourne, la nouvelle est **envoyée dans l'inbox** -des utilisateurs qui conservent le droit d'accès, et cette inbox est **traitée -automatiquement** dès qu'un client de l'utilisateur se connecte. - -Conséquences pour la surface : - -- **Aucune obligation d'abonnement à exposer.** Le consommateur n'a rien à - implémenter pour « garder » un accès. -- L'accès n'est pas perdu, il est **différé** jusqu'à la prochaine connexion — - cohérent avec le reste du modèle local-first. -- Une re-livraison emprunte **le même canal** que la livraison initiale : l'inbox. - Donc le mécanisme de §3 couvre les deux, sans cas particulier. -- La **révocation** reste ce qu'elle est : on cesse de re-livrer à qui ne doit plus - lire, et c'est non rétroactif. - -### 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. Le public — lisible par l'URL, et NON récursif - -Principe cible (confirmé par le PO, 2026-07-27), à exposer tel quel : - -> **Un élément du store public est public : qui a l'URL lit le contenu.** -> Mais **pas récursivement** — un contenu public peut *référencer* du contenu -> privé, et la référence ne donne pas le contenu référencé. - -C'est un **second mécanisme** à côté de la possession de clé du §2, pas une -entorse à l'invariant. Et c'est la non-récursivité qui porte la valeur : elle -permet un objet public qui **pointe** vers de l'identité privée — le cas exact du -consommateur. - -*Détail d'implémentation à ignorer côté forme* : NextGraph s'oriente vers un -**non-chiffrement** du contenu du store public (les données restant signées). La -surface ne doit pas en dépendre. **Si le store public ne fonctionne pas** comme ce -principe le décrit, c'est **le polyfill** qui s'adapte — pas le consommateur. - -### 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. +Ce brief-ci reste le **chantier d'ensemble** : verdicts P0, périmètre de P1b, +lots P2–P4, et les revues adverses. ## Esquisse de phases diff --git a/docs/briefs/2026-07-27-p1a-cap-surface.md b/docs/briefs/2026-07-27-p1a-cap-surface.md new file mode 100644 index 0000000..9454d7d --- /dev/null +++ b/docs/briefs/2026-07-27-p1a-cap-surface.md @@ -0,0 +1,129 @@ +# Brief — P1a: the capability surface + +**Status: specified, ready to implement.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement. + +Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`). + +## Why this lot exists + +`caps.ts` currently models read rights as an **ACL** — a `Map>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere. + +This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten. + +## Scope: shape only, not enforcement + +- **P1a (this brief)** — the surface consumers see. +- **P1b (separate)** — per-doc encryption and closing the read paths that bypass the guard. + +Only P1a blocks the consumer, because the consumer must be written as if NextGraph were finished. P1b can follow. + +> **After P1a the shape is right and the isolation is still fake.** Nothing may be claimed as "anonymous" or "private" until P1b lands. Say so in the README if it helps. + +## Guiding constraint: stay close to NextGraph's concepts + +Stated by the PO, and it is the acceptance criterion for the design as much as for the code: + +> Stay as close as possible to NextGraph's concepts — and to its SDK's — to keep development simple and to keep the number of notions someone must discover small when they already know NextGraph and open this library. + +Every invented name is **vocabulary debt**: the reader has to carry a translation table in their head. The first draft of this spec introduced eight new notions; adversarial review reduced it to two. Hold that line. + +## The design + +### 1. Types — one new name + +A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:k:` segment**. + +```ts +type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read +type ReadCap = string // …:k:{key} — names AND reads +``` + +`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another. + +A parsed form `{ target, readCap? }` — a 1:1 mirror of `NuriV0 { target, access }` — may be used **inside** the library. It must not surface in the SDK-identical entry's signatures. + +**Do not use branded types.** They were in the first draft and were dropped deliberately: the real SDK takes `nuri: String` and enforces at **runtime, through cryptography**. A compile-time guarantee is a concept NextGraph does not have, and a consumer who typed everything would have to *un-type* it when the real SDK arrives — the opposite of the goal. The cost was also measured: branded types force a cast at every ORM and SPARQL boundary. + +### 2. The keyring — where caps come from + +`doc_create` returns a **cap-less** NURI. So a rule like "no function ever goes from a bare reference to a cap" is wrong: it would leave a document's own creator unable to obtain that document's cap. + +The real mechanism: on every document creation, an `AddRepo { read_cap }` is committed to a **branch of the store** (the store is itself a repo, with typed branches — "branch" here has nothing to do with git). That branch lists the store's documents, each with its read key. **It is the owner's keyring.** Upstream, the keyring is the **wallet**. + +```ts +capFor(nuri: Nuri): ReadCap | undefined +``` + +The invariant, correctly stated: + +> **You do not derive a cap from a bare reference. You look it up in your keyring — or you were given it.** + +`capFor` absorbs `canRead(doc)` (`capFor(n) !== undefined`) and drops its ACL verb. + +**The keyring is not the sharing mechanism.** Handing over a store cap would give away everything the store contains, present and future. That is not the gesture (see §3). This confusion is easy and expensive — it was made once already during design. + +### 3. Sharing — one document, to one or more recipients + +**The unit of sharing is the document**, consistent with the consumer's own doctrine ("the document is the unit of sharing and of rights"). + +```ts +shareCap(cap: ReadCap, toInbox: Nuri): Promise +``` + +Recipients are addressed as **inboxes** — which `inbox.post(targetInbox: Nuri)` already does in this package. There is no `PrincipalId` here: that notion exists nowhere upstream, and the first draft removed `principal` from `canRead` (calling it the ACL inversion) only to reintroduce it here. + +**Caps received need no dedicated operation.** They arrive as inbox deposits of kind `cap`, consumed by the **existing** `inbox.watch`. This also fixes a known gap: a cap delivered asynchronously now triggers a re-read naturally, instead of leaving stale views. + +> **Upstream status: this is a GAP, not a disagreement.** The field exists (`ContactDetails.read_cap`, commented "*if user wants to share the content of profile*") but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver **discards** the cap. The shape is right; the implementation is absent. We emulate it meanwhile — filed as `orm-tests/INBOX/2026-07-27-inbox-cap-delivery-not-implemented.md`, including what to remove from this library once upstream lands it. + +### 4. Key rotation — automatic redelivery, not loss of access + +When a key is rotated, the new one is **sent to the inbox** of users who keep access, and that inbox is **processed automatically** as soon as one of the user's clients connects. + +So access is not lost, it is **deferred** until the next connection — consistent with local-first. Consequences for the surface: + +- **No subscription obligation to expose.** The consumer implements nothing to "keep" an access. +- Redelivery uses **the same channel** as the initial delivery, so §3 covers both with no special case. +- **Revocation** stays what it is: stop redelivering, non-retroactive. + +> An earlier draft said the opposite ("whoever does not stay subscribed loses access"). That came from an upstream comment describing the **current state**, read as if it gave the **intention**. It does not. Source verifies a mechanism; it never states a direction. + +### 5. Public content — readable by URL, and NOT recursive + +> **An item in the public store is public: whoever has the URL reads the content.** But **not recursively** — public content may *reference* private content, and the reference does not grant access to what it references. + +This is a **second mechanism** alongside key possession, not an exception to it. The non-recursiveness is what carries the value: it allows a public object that **points at** private identity without disclosing it — exactly the pattern the consumer needs. + +*Implementation detail the shape must not depend on*: NextGraph is moving toward **not encrypting** public store content (data still signed). And if the public store does not behave as this principle describes, **this library adapts** — not the consumer. + +### 6. What disappears or is renamed + +| Today | Becomes | +|---|---| +| `grantRead(doc, grantee)` | `shareCap(cap, toInbox)` | +| `canRead(doc, principal)` | absorbed by `capFor(nuri)` — the `principal` parameter **was** the ACL inversion | +| `protectedDocsOf(owner)` | **removed** — the re-derivation loop disappears | +| `makePublic(doc)` | `publishRepoLink` — the shareable link has an upstream name (`RepoLinkV0`) | +| `grantWrite` / `canWrite` | deferred to P1b — currently **decorative** (the guard never fires) | +| `resetCaps()` on identity change | **switch** keyrings, do **not** wipe | +| `PrincipalId` in the cap surface | **removed** — recipients are inboxes | + +`resetCaps()` is the trap that can make this lot look finished while it is not: if switching identity still wipes, durability is a lie and the per-session re-declaration comes back under another name. + +### 7. Boundary: SDK-identical entry vs `/polyfill` + +Caps live under `/polyfill` today; `index.ts` is the SDK-identical entry. Keep it that way, and keep `index.ts` signatures on plain strings — that **is** what the real SDK does. The discrimination lives in what you can **obtain** (the keyring), not in what the compiler permits. + +### 8. Acceptance test — no cryptography required + +`watch-shape` currently harvests **every** `did:ng:` string it finds in a discovery reference and folds those documents into the **read** set. A bare reference therefore grants **full read** today — the semantics exactly inverted. + +After P1a: a harvested bare reference yields **nothing**, for want of a cap in the keyring — which is what real NextGraph does. The test holds without a line of encryption, which is what makes the P1a/P1b split honest rather than cosmetic. + +## Consumer impact + +`declareConnections` **disappears**. This is not an API swap: today it re-declares every grant on every session because the ACL is in-memory. With delivered caps, the grant moves to the moment a connection is **accepted**, and persists. Plan for consumer re-architecture, and update `../migration-guide.md`. + +## What this lot does NOT do + +Closing the read paths that bypass the guard — `docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`. Only four sites consult caps today. That inventory is P1b's scope and is listed in `2026-07-20-caps-emulation-alignment.md`.