Files
ng-eventually/docs/briefs/2026-07-27-p1a-cap-surface.md
T
Sylvain Duchesne 518292498a docs(brief): extraire P1a dans son propre brief, en anglais
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 <details>, 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🆖 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 15:44:24 +02:00

130 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, P2P4, 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<doc, Set<principal>>` 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<void>
```
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`.