La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en a **deux** en amont (repos de store public et protected, les deux seuls `AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un test non. D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`, `polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer pour faire passer le test », c'est une question : la cible a-t-elle un mot pour ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ? Ce que le test a trouvé, et les réponses : - `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc plutôt que caché par le nom. - `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` → `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour l'utilisateur virtuel, et le marqueur le dit désormais. - `readModel` → la fonction `readUnion`, exposée directement. « model » n'était ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction. - Le reste était du vocabulaire légitime à déclarer (`subject`, `base`, `schema`, `connected`, le modèle réactif de l'ORM). Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo` justifiait son correctif par un mécanisme que le source contredit. Un repo absent de `self.repos` lève bien `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc` attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à côté ne l'était pas. 159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
34 KiB
Brief — P1a: the capability surface
Status: IMPLEMENTED 2026-07-28, awaiting review. 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.
The spec below is unchanged — read it first. Everything from here to Why this lot exists is the implementation report: what landed, the exact surface a consumer codes against, the decisions taken, and what is not verified.
Implementation report (2026-07-28)
Superseded in places by later lots — read with
2026-07-30-virtual-wallet-boundary.md. This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written:
- "ReadCaps are NOT persisted as caps anywhere. There is no key store" — no longer true. Both durable registers are now emulated:
shim:readCapon the store's Store branch (AddRepo) andshim:linkon its User branch (AddLink). Caps are read back, not recomputed.- "Processing inboxes … Not started" — done (
src/emulated-verifier/connect.ts), at both levels, including per-document inboxes.- "
NuriandReadCapare plain strings" — superseded the same week: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The Typing section below records the change; the earlier sentences were not rewritten.- The
:k:segment throughout — a ReadCap isr:(BlockRef::readcap_nuri,engine/repo/src/types.rs:518-521), reported by NextGraph's developer and verified.:k:belongs to objects, files and commits.- "That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet" — wrong when written, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See
../readcap-and-nuri-model.md§4quater. The word keyring is left standing everywhere below because this report is kept as written; read it as "what the holder holds", which is what the code now calls it.fileOwnCaps— renamed and split. Writing a created document's cap isholdOwnCap, reading them back isreadStoreCaps, and a user's own structure (three stores + inbox) isfileOwnStructure. Searching the code forfileOwnCapsfinds nothing.
What landed
| Spec | Where |
|---|---|
Nuri / ReadCap (plain strings, :r: discriminant) |
packages/client/src/model/types.ts, src/model/nuri.ts (internal parse/mint/derive) |
Keyring, one per identity — capFor |
src/emulated-verifier/caps.ts (CapRegistry), surfaced as capFor in src/polyfill.ts |
Caps of my OWN documents (the emulated AddRepo { read_cap }) |
src/shared-wallet/account-registry.ts fileOwnCaps, called from createEntityDoc and listMyEntityDocs |
shareCap(cap, toInbox) + reception with no dedicated operation |
src/surface/inbox.ts (shareCap, and the inline absorption in read) |
publishRepoLink |
src/emulated-verifier/caps.ts. (The published-only guard it fed lived in src/discovery.ts, removed 2026-07-30 — see the boundary brief.) |
| Possession gate on reads | src/surface/read-model.ts (readUnion), src/emulated-verifier/read-filter.ts, src/surface/use-shape.ts |
| Cap-mutation signal (a delivered cap re-triggers reads) | CapRegistry.onChange → src/surface/watch-shape.ts |
| Acceptance test (§8) | test/cross-user-access.test.ts (see below); isolation end-to-end in test/isolation-active.test.ts. Originally test/watch-shape.test.ts (e), on the discovery fold — dropped 2026-07-30 with discovery.ts; the property it proved is covered on the model's own terms by the cross-user scenario. |
| Cross-user scenario (§5 non-recursiveness) | test/cross-user-access.test.ts — see below |
The cross-user scenario, as the PO specified it (test/cross-user-access.test.ts)
Alice owns a protected document holding a secret, and a public document that carries a reference to it — a bare NURI. Then:
- Bob holds the public document's link. He reads it, finds the reference, and can NAME Alice's protected document while reading nothing of it. Publication is not recursive.
- Charlie holds the same link, plus the protected document's cap — delivered by Alice to his inbox. Same document, same reference, same path: he reads through it.
- The only difference between them is what their keyring holds. Nobody was named to the registry; Alice addressed an inbox.
- Dynamic: Bob is refused, Alice delivers the cap to his inbox, his client processes it — and the read that was empty yields the content. Filing the cap fires
CapRegistry.onChange, so a reader wired to that signal (which is whatwatchShapedoes internally) re-reads on its own.
One property this makes explicit and that is worth confirming: the bare NURI of a PUBLIC document is not enough either — its repo link is. See Publication travels as a link below.
The exact surface a consumer codes against
From @ng-eventually/client/polyfill:
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
getCaps(): CapRegistry
resetCaps(): void // tests / fresh wallet ONLY — never on identity change
setCurrentUser(id: PrincipalId | null): void // selects WHICH keyring is consulted
On CapRegistry (reached via getCaps()):
open(nuri: Nuri, scope: Scope): ReadCap // "this document is mine, in this scope"
mint(nuri: Nuri): ReadCap // …its protected/private half
publishRepoLink(nuri: Nuri): ReadCap // …its public half — returns the SHAREABLE LINK
learn(cap: ReadCap): void // file a cap I was given (throws on a bare reference)
capFor(nuri: Nuri): ReadCap | undefined
isPublished(nuri: Nuri): boolean
isEnforcing(): boolean // false until the first cap exists
onChange(listener: () => void): () => void // keyring mutations
grantWrite / canWrite / governsWrite / hasWritePolicy // unchanged, decorative, P1b
clear(): void
Plus the narrowing guards, from the SDK-identical entry:
isNuri(s: string): s is Nuri // an untrusted string → a Nuri
hasReadCap(s: string): s is ReadCap // …→ a ReadCap; the ONLY such narrowing
Types: Nuri, ReadCap, Scope, PrincipalId are all exported from the SDK-identical entry (export * from "./types"). Scope is a literal union, so open(doc, "protected") is compiler-checked. capFor returns ReadCap | undefined, so under strict the consumer is forced to handle "I hold nothing".
Every call accepting a Nuri also accepts the cap-bearing form and normalizes it (targetOf), so passing a cap where a NURI is expected is never a silent mismatch — and it type-checks, because ReadCap is assignable to Nuri.
Typing — template literal types, not string, not branded types
Amended on the PO's instruction (2026-07-30), after the first pass shipped both as type X = string. The types are now:
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
Still strings — assignable to string, JSON-serializable, no wrapper object — so nothing has to be un-typed when the real SDK arrives and takes nuri: String. §1's two documented objections to branded types do not apply: there is nothing to un-type at migration, and the cost at the ORM/SPARQL boundaries was measured at zero (see below). What the template buys is the single asymmetry that matters: a ReadCap is freely usable wherever a Nuri is expected (a cap IS a NURI with the key inside — upstream's one NuriV0), while a bare Nuri where a ReadCap is required is a compile error.
This is the one place the implementation departs from the letter of §7 ("the discrimination lives in what you can obtain, not in what the compiler permits"). It was an explicit PO decision: the consumer app benefits from the distinction, and possession is still what actually decides — the compiler only stops the app from writing a call the model has no meaning for.
Cost, measured on the whole repo: typing both aliases produced 16 errors, all of them at genuine boundaries, and every one resolved by narrowing rather than casting:
| Boundary | Resolution |
|---|---|
Broker (docs.docCreate, whose ng is any) |
Validates with isNuri and throws — the declared Promise<Nuri> was an unchecked promise every typed NURI downstream rested on |
SPARQL (store-registry canonicalDoc, readScopeIndex) |
Narrow with isNuri; a stored value that is not a reference is now discarded instead of flowing through as a "document NURI" |
ORM (read-filter docOf, an untyped @graph) |
Narrow with isNuri |
| Inbox payload | Free — it already tested hasReadCap, which is now a type guard (s is ReadCap) |
assertNuri |
Made generic (<T extends string>(nuri: T): T) so the caller's type flows through instead of widening to string |
nuri.ts targetOf |
The one cast in the library, in the primitive that defines the contract, so no caller needs one |
| Playwright bridge (e2e) | An asNuri helper that throws — arguments cross the bridge as plain strings |
isNuri and hasReadCap are exported from the SDK-identical entry, so a consumer narrows its own strings (storage, URL, JSON, a form) the same way instead of casting.
The runtime guards stay, and are not redundant: a JavaScript consumer never meets the compiler, and a cap read back from storage and cast rather than narrowed reaches the library just the same. CapRegistry.file — the single door into any keyring — refuses a reference with no :r:, and inbox.shareCap does likewise. That guard was added during implementation after the trap was demonstrated: learn(someBareNuri) filed the bare reference under its own name, capFor returned it, and the document read — "naming is not reading" silently becoming "naming is reading".
(An earlier version of this section flagged a gap around what a consumer put into a discovery ref. Moot since 2026-07-30: discovery.ts was removed — there is no discovery. Circulating a link is now an explicit act, shareCap(link, inbox), whose argument is typed ReadCap and checked at runtime.)
How this articulates with the virtual users
This is the part worth reviewing closely, because P1a puts a NextGraph concept (the keyring = the wallet) on top of an emulation that already fakes wallets.
Upstream, the keyring IS the wallet. Here there is ONE physical user that everybody opens, and an "identity" is a virtual wallet: a shim account in store-registry, mapped to three scope-index documents. So the registry holds one keyring per virtual user — Map<accountKey, Map<Nuri, ReadCap>> — and setCurrentUser(id) selects which one is consulted. Switching identity switches keyrings structurally; there is nothing to reset and nothing is wiped.
Where a virtual user's caps come from, and what makes them survive a reload:
- Its own documents — the scope-index document of the (account × scope) plays the role of the store branch that carries
AddRepo { read_cap }upstream.createEntityDocfiles the cap on creation;listMyEntityDocsrefiles them on any later session. Nothing is persisted as a key store: the emulated key is derived from the NURI, so listing the documents is enough to hold them again. This is why a fresh page reads its own documents with nothing re-declared. - Documents shared with it — the cap lives in the recipient's inbox document, which is persistent in the shared wallet. It re-enters the keyring when the consumer processes that inbox.
Today, caps received are refiled only when the inbox is read — which means the consumer's startup sequence has to do it. The PO has ruled that this is wrong (see Follow-up below): inbox processing belongs to the polyfill, on connection, not to the app.
A defect found while writing this up, and fixed. The keyring was keyed on the raw currentUser, while the shim keys accounts through the consumer-injected normalizeId. So setCurrentUser("@Alice") and setCurrentUser("alice") — ONE shim account, one set of documents — produced two keyrings, and the second one was empty: the identity stopped reading its own documents. The keyring now keys the same way the shim does, so one virtual user has exactly one keyring however its id is spelled. Locked by test/isolation-active.test.ts one keyring per virtual WALLET, not per spelling of its id.
Still per-process, and that is correct: the keyring is in memory, so two tabs have two keyrings. Each rebuilds itself the same way (scope index + inbox), which is exactly how a real wallet behaves on two devices.
Where NURIs and ReadCaps are actually stored
Worth stating plainly, because "the keyring" is in memory and that sounds fragile until you see what backs it.
NURIs are persisted, in RDF, in the shared wallet — they always were:
| What | Where it lives | Written by |
|---|---|---|
| account → its 3 scope-index documents | the doc-shim, itself named by a write-once pointer triple in the private store-root | store-registry.writeRecord |
| scope index → the NURIs of that scope's entity documents | the per-(account × scope) index document, as shim:contains literals |
store-registry.createEntityDoc |
| a document's own content, including any reference to another document | that document's graph | the consumer's write path |
| an inbox deposit (payload, ts, from) | the inbox document's graph | inbox.post |
ReadCaps are NOT persisted as caps anywhere. There is no key store, no trousseau document, nothing on disk that says "this identity holds these keys". The keyring is a plain in-memory Map<accountKey, Map<Nuri, ReadCap>> inside CapRegistry, rebuilt from scratch on every page load out of two persisted sources:
- my own documents →
listMyEntityDocsreads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constantOK. So knowing which documents are mine is knowing their caps. Upstream the key really is stored, on the Store branch (AddRepo { read_cap }); emulating that storage rather than re-minting is a separate lot. - documents shared with me → the cap sits inside an inbox deposit's JSON payload, which IS persisted (it is an ordinary triple in the inbox document's graph). Processing the inbox re-files it. So a shared cap survives a reload because the delivery is durable, not because we stored a key — which is exactly the upstream shape, where the seal sits in the inbox until the verifier applies it.
Consequence to keep in view: a cap is only as durable as its delivery. That is why the PO's follow-up below (the polyfill processing inboxes on connection) matters more than it looks — until it lands, the durability of a shared cap depends on the app remembering to read its inbox.
Second consequence, on the emulated key being derived: anyone can compute any document's cap from its NURI. That is the P1a/P1b line, stated once more — possession is a shape here, not a protection.
Publication travels as a link — a choice to confirm
§5 says a public item is read by "whoever has the URL", and §8 says a harvested bare reference must yield nothing. Both hold only if what circulates for a public document is its repo link (publishRepoLink → did:ng:o:…:r:…), not its bare NURI. That is what was implemented, and the cross-user test pins it: Bob holding only the public document's bare NURI reads nothing; holding its link, he reads it.
The alternative — making a published document readable from its bare NURI — was rejected because the "published" fact would then live only in the local registry: another tab, another process, another user would have no way to know a document was published, and the emulation would stop being portable. Carrying the fact in the data (the link) is what makes it work across processes, and it matches RepoLinkV0 upstream.
This is the point where the emulation is furthest from the eventual target, where the public store may not encrypt at all and a bare NURI would suffice. Per §5 that is fine — "if the public store does not behave as this principle describes, this library adapts, not the consumer" — but it is a deliberate divergence and the PO should confirm it.
A debt this lot created — the unguarded inbox — CLOSED 2026-07-30
inbox.read had no guard and absorbs caps into the reader's keyring, so inbox.read(someoneElsesInbox) pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
Fixed in step 2 of 2026-07-30-virtual-wallet-boundary.md: an inbox now BELONGS to a virtual user (storeRegistry.userInbox), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
Follow-up decided by the PO — to plan, NOT in this lot
Processing inboxes is the polyfill's job, not the app's (PO, 2026-07-30). As soon as the app connects a user/wallet, the polyfill must process that identity's inboxes itself — the consumer should never have to remember to do it for its shared documents to become readable.
And it is inboxes, plural, at two levels:
- the user/wallet inbox — where ReadCaps arrive;
- the inbox of every document the user owns, so deposits land per document too. (The justification originally given here — "a document has its own inbox upstream" — is false; see the correction in
2026-07-30-virtual-wallet-boundary.mdstep 7. The design decision stands on the consumer's need and on the record's per-repo_idshape, not on an upstream document inbox that does not exist.)
What exists today and is reusable: inbox.readSynced (the cold, barrier-gated read meant exactly for "process the inbox at (re)connection"), the inline cap absorption in inbox.read, and CapRegistry.onChange to re-trigger the reads a late cap unblocks. What is missing is the orchestration: a connection hook that enumerates the identity's inboxes (wallet-level + one per owned document, via the scope indexes) and processes them, idempotently and without polling.
Not started. It changes the consumer contract in the right direction (one less obligation), so it should land before the consumer re-architecture settles.
Decisions taken while implementing, none contradicting the spec
open(nuri, scope)was kept (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act —mintfor protected/private,publishRepoLinkfor public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.grantWrite/canWritewere left exactly as they were (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly —openused to set them as a side effect. They belong to P1b.shareCapis implemented ininbox.tsand re-exported from/polyfill, so it is reachable both asinbox.shareCap(SDK-identical entry, viaexport * as inbox) and from the polyfill surface. Deliberate: sharing a cap is an inbox deposit upstream, and at migration this call becomes the native sealed deposit — a real SDK method, whatever it ends up being called (inbox_post_linkwas our own proposed name, not an announced API) — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry,capForandCapRegistrystay on the polyfill side, and every signature is a plain string.- The stand-in key is the constant
OK(nuri.ts; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is do I hold this cap or not, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key. resetCaps()clears in place rather than rebuilding the registry, so awatchShapesubscribed to the change signal does not end up holding a listener on an orphaned instance.- The scope-index feed is holder-scoped (
fileOwnCapscompares through the shim key): the cross-account fan-outlistEntityDocsfiles nothing, because other accounts' caps are emphatically not ours to hold.
Verification status
- Unit suite green — 146 tests, typecheck clean on
src,testand the e2e harness. - The typing was verified from a consumer's point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (
shareCap(bareNuri, …)and passing a rawstringfrom storage) as compile errors, while every correct path —capFor(doc)→shareCap(cap, inbox), and narrowing with the exported guards — needs no cast. - The acceptance test was mutation-checked: reverting both gardes (the discovery fold and the
readUnionpossession gate) makeswatch-shape.test.ts(e) fail with the bare-referenced document reappearing. The test has teeth. - The e2e ran against the live broker (
nextgraph.eu) on 2026-08-03 — 39 passed, 0 failed. The first run was 22/8, and the eight refusals were not test noise: they exposed a real hole in the surface.docs.docCreatefiled no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen —doc_createcommitsAddRepo { read_cap }to the store's Store branch, so the creator holds it from the first instant. Fixed atpackages/client/src/surface/docs.ts:73, and deliberately NOT replicated inshared-wallet/physical.ts: the shim's own documents belong to no user, andstore-registryfiles their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (createEntityDoc(id, …)with someone else connected) or reading an arbitrary document as an inbox; both are nowsetCurrentUser+userInbox, which is what a consumer must do too. - An e2e run against a persistent wallet must use a FRESH identity per run. The second run was green and the third was not, on unchanged code: moving the inbox tests onto
userInbox(id)made the inbox stable for its owner — which is the point of an inbox — so a fixed id accumulates every past run's deposits anddeposits.length === 2drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the user, not the inbox:run.tsnow stamps@inbox-user-/@watcher-/@friend-withDate.now(), as it already did for@alice-. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint. - The cap registry is process-wide and
bun testshares modules across files, so suites that read without declaring caps now reset explicitly (read-model.test.ts,watch-shape.test.ts). Worth knowing before adding a suite.
Documentation state
The permanent documentation was updated in the same pass (root README.md, packages/client/README.md, docs/simulation.md, docs/migration-guide.md §1 + the assumed declareConnections break, docs/read-model.md, docs/readcap-and-nuri-model.md §5, docs/nextgraph-current-state.md, packages/client/docs/sdk-reference.md). If the review changes the surface, those are the files to re-align — they describe the code as it stands now, not a validated state.
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
emulated-verifier/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 :r: segment (the spec said :k:; corrected 2026-07-30 on a report from NextGraph's developer — :k: belongs to objects/files/commits, a ReadCap is r:{base64url(serde_bare(ObjectRef))}, repo/types.rs:518).
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
type ReadCap = string // …:r:{cap} — 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.