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
9.9 KiB
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<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.
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.
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").
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 isunimplemented!(), 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 asorm-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.