# 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`](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:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on 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. > - *"`Nuri` and `ReadCap` are 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 is `r:`** (`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`](../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 is `holdOwnCap`, reading them back is `readStoreCaps`, and a user's own structure (three stores + inbox) is `fileOwnStructure`. Searching the code for `fileOwnCaps` finds 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 what `watchShape` does 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`: ```ts capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup shareCap(cap: ReadCap, toInbox: Nuri): Promise 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()`): ```ts 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: ```ts 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: ```ts 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` 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 (`(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>` — 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. `createEntityDoc` files the cap on creation; `listMyEntityDocs` refiles 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>` inside `CapRegistry`, rebuilt from scratch on every page load out of two persisted sources: - **my own documents** → `listMyEntityDocs` reads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constant `OK`. 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`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), 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.md`](2026-07-30-virtual-wallet-boundary.md) step 7. The design decision stands on the consumer's need and on the record's per-`repo_id` shape, 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 — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do. - **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b. - **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * 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_link` was 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, `capFor` and `CapRegistry` stay 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 a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance. - **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold. ## Verification status - **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and 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 raw `string` from 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 `readUnion` possession gate) makes `watch-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.docCreate` filed 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_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/physical.ts`: the shim's own documents belong to no user, and `store-registry` files 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 now `setCurrentUser` + `walletInbox`, 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 `walletInbox(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 and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.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 test` shares 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>` 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`)*. ```ts 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**. ```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`.