# NextGraph's ReadCap & NURI model — and the polyfill's caps emulation **Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex. Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph". > ## How to use this document — verify, never infer > > **NextGraph works very differently from what general knowledge of distributed systems suggests.** Assert nothing about it that is not, at minimum, in this repository's docs — and preferably read in `nextgraph-rs` itself, with a `file:line`. Reasoning by analogy with git, with ACL systems, with pub/sub brokers, or with "how this normally works" produces confident, wrong statements. Every correction recorded below started that way. > > **Write down everything you learn about NextGraph, as you learn it** (PO, 2026-07-30) — at least everything that helps move forward or that corrects a direction. Not at the end of an investigation, not only in the brief that happened to need it: a fact read in `nextgraph-rs` and left in a conversation is a fact the next agent will re-derive, and will get wrong. > > It does not all have to land in this file. This is where the **access model** accumulates (caps, NURIs, stores, branches, who can read what); platform behaviour and SDK gaps belong in [`nextgraph-current-state.md`](./nextgraph-current-state.md), and how the polyfill fakes something belongs in [`simulation.md`](./simulation.md). What matters is that it is written down somewhere durable and findable, with a `file:line` — not which file. > > Three traps in particular, all of which have already caught an agent more than once: > > - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not. > - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word. > - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills. --- ## 1. A ReadCap = possession of a key, NOT a per-identity ACL A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read." - Structure: `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`). - `id: BlockId` = **BLAKE3** digest (address of the encrypted object). - `key: SymKey = ChaCha20Key([u8;32])` = the **decryption key**. Holding the pair → the broker serves the encrypted blocks by `id`, and one decrypts **locally** with `key`. - Granularity: per commit/object the `ObjectRef` **is** the cap; for a branch → its defining commit; for a repo → RootBranch; for a store → the root repo's cap (`types.rs:559-565`). `ReadCapSecret` = the key half (`:567-570`). - **There is NO read-ACL.** A repo's membership/permissions (`RootBranch`, `AddMember`, `AddPermission`) govern **writing/admin**, not reading. Reading is guarded only by key possession. ## 2. Granting read access = sealing the key to the recipient "Grant" = delivering the cap **sealed** (`crypto_box seal`, anonymous public-key encryption) to the recipient's **inbox pubkey** — only they can open it with their private key. - Sealed inbox message: `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, opened with the inbox secret key (`engine/net/src/types.rs:4272, 4299, 4319`). - The payload can carry a cap: `ContactDetails.read_cap: Option` ("if user wants to share the content of profile") (`net/types.rs:4232-4233`) → **directed grant** (sealed to one recipient). - **Undirected** variant: `RepoLinkV0.read_cap` = a shareable link that **whoever receives it** can open (`net/types.rs:5061-5078`). So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap remains `{id, key}`, possession-based. > **Current state (2026-07-27) — the path is a GAP, not a disagreement.** The `ContactDetails.read_cap` field exists, but the construction of the message is `unimplemented!()` (its only caller passes "without read_cap") and the receiver **discards** the cap it would receive. The *shape* is therefore the right one; the implementation is not there. The polyfill emulates it in the meantime — filed in the bug-inbox. ## 3. Revocation = re-key (coarse, non-retroactive) A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders. - "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`). - Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; perms `types.rs:1748-1749`). - Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh). - **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`). ### DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27) **Do not read the comment above as the intent.** "*if they don't subscribe, they lose access after the refresh*" describes **the current state**, not the target. What NextGraph is aiming for: > When a key is rotated, the new one is **sent to the inbox** of the users who retain the access right. 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. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive. ## 4. NURI grammar: cap-less vs cap-bearing (the `r:` segment) **Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. The discriminant is the **`r:` segment** (see the correction below — this document said `:k:` until 2026-07-30): present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`). **Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`: - `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52) - `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55) - `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58) - `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) - `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) **Cap-bearing — and `:k:` is NOT the ReadCap segment.** CORRECTED 2026-07-30, on a report from NextGraph's developer, verified in the source. There are **two different encodings**, and confusing them was an error in this document: | Segment | Shape | What it is | |---|---|---| | `:k:` | `{id}:k:{key}` — id and key as **two segments** | an **object / file / commit** ref: `j:{id}:k:{key}` (`repo/types.rs:510`), `c:{id}:k:{key}` (`:514`) | | `r:` | `r:{base64url(serde_bare(ObjectRef))}` — id and key **serialized together into one** | a **ReadCap** — `BlockRef::readcap_nuri()` (`repo/types.rs:518-521`) | ```rust pub fn readcap_nuri(&self) -> String { let ser = serde_bare::to_vec(self).unwrap(); format!("r:{}", base64_url::encode(&ser)) } ``` Used to surface a branch's / root branch's read cap (`engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`). So a ReadCap is **not** "a NURI with `:k:{key}` appended". It is an opaque `r:` segment carrying the whole `ObjectRef { id, key }`. Note also that **no regex matches a cap-bearing repo NURI**: `RE_REPO_O` (`did:ng:o:{id}`) and `RE_REPO` (`…:v:{overlay}`) are both cap-less, and `RE_COMMIT`/`RE_FILE_READ_CAP` are about commits and files, not repos (`net/types.rs:48-73`). The `:k:` forms, for completeness: - `did:ng:j:{id}:k:{key}` — object/file read cap (`RE_FILE_READ_CAP` types.rs:48) - `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:72) - list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:63) The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models. ## 4bis. The overlay is the network space of a STORE — never of a document **The overlay is a store's unit of network addressing.** At the broker, blocks are filed under a `(overlay, block_id)` key, and peers synchronize *within* an overlay. Two forms per store: | | Derivation | Who can compute it | |---|---|---| | **outer** | `OverlayId::outer(store_id)` = **public** BLAKE3 | everyone (the store_id is enough) | | **inner** | `OverlayId::inner(store_id, readcap_secret)` = **keyed** BLAKE3 | only whoever holds the store's read key | Consistent with the rest of the model: no role and no list, only "do you hold the key that lets you derive this identifier". `outer` = the store's public name, `inner` = its private name. **The `:v:` of a DOCUMENT NURI carries the overlay of its STORE** (VERIFIED, chain read end to end): `NuriV0::repo_graph_name(repo_id, overlay_id)` formats `o:{repo_id}:v:{overlay_id}`; in `doc_create` the value injected is `store.outer_overlay()` — the **containing** store, never the `repo_id`. A `Repo` carries **no** overlay field (only `store: Arc`); it is `Store` that carries `overlay_id`. **Mechanical counter-proof**: in `Store`, `get`/`put`/`del`/`has` all pass `&self.overlay_id` to the block storage — every document of a store shares the same block namespace, so a per-document overlay is structurally impossible. ### The consequence to know about: the `:v:` is a stable pseudonym **All of one person's documents in their protected store carry the SAME `:v:`** = `outer(protected_store_id)`. So a cap-less reference — precisely the one used to "name without granting read" — **exposes store membership**, that is to say a **stable and permanent pseudonymous identifier of the person**. The store_id itself does not leak (BLAKE3 is not invertible), so it does not say *who*; but it is a **constant handle**, the same everywhere and forever, correlatable by anyone who collects cap-less references. **The coupling that results, and that constrains any anonymous-presence model**: that same `:v:` is *simultaneously* (a) what makes it possible to **deduplicate** references without reading them — two references with the same `:v:` come from the same person — and (b) what makes it possible to **track** that person from one context to another. **It is the same bit of information.** You cannot get the dedup without conceding the tracking, nor remove the tracking without losing the dedup — short of changing how the stores are carved up, which moves the cursor but does not remove the trade-off. *Nuances.* The NURI's `:v:` is the **outer** overlay, whereas client↔broker traffic and local storage use the **inner** one — a different value, but derived from the store as well, so the property holds in both cases. A `Dialog` store returns an `Inner`, still store-scoped. **CORRECTED on 2026-07-27 — this hypothesis was FALSE.** We had inferred, then believed we had verified, that a holder **without a key** could fetch the encrypted blocks and therefore prove a document's **existence**. An adversarial review showed that the reasoning stopped at *access control* without looking at **addressing**: - There is **no existence command at the SDK level**. - The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**. - A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter). - The only primitive accessible to a non-member (`ExtObjectGet`) requires the ObjectIds **and their keys**. > **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned. Transferable lesson: verifying that an access guard **lets you through** does not prove that an operation is reachable — you still have to be able to **name** what you are asking for. ## 4ter. The public store: readable by URL, and NOT recursive Target principle (confirmed by the PO, 2026-07-27): > **An element of the public store is public: whoever has the URL reads the content.** > But **not recursively** — public content can *reference* private content, and the reference does **not** give access to the referenced. This is a **second mechanism**, alongside key possession (§1) — not a breach of it. And it is the **non-recursiveness** that carries the value: it allows a public object that **points** to private identity, without divulging it. That is exactly the pattern an anonymous-presence model needs. *Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer. ## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links **Stated by the PO, 2026-07-30, as one of NextGraph's foundations.** It bears on more design decisions than any other point in this document, and it is the easiest to violate without noticing, so it is stated before anything is built on top of it: > **You cannot discover. You can only follow links.** NextGraph is **local-first**. There is no global index, no registry, no crawler, no "list everything public" — and nothing of the kind is planned. Nothing exists *to be found*; things exist *to be reached*, and reaching them means someone handed you the way in. So **publishing is two acts, never one**: 1. **Place** the data in your public store — that makes it readable *by whoever reaches it*, not visible; 2. **Circulate the link** — post it into inboxes, or put it somewhere already reachable by the people concerned (a document they already hold). And it is seen **only by those who received the information**, i.e. the link. There is no audience beyond the people you reached, and no way to enumerate one. Private distribution is the same act, plus the ReadCap: place, then circulate — the cap being what turns "reached" into "readable". **The consequences, which are not obvious:** - **A "global list of everything public" is not constructible**, and a surface that offers one exposes a capability the target will never have — precisely the failure mode this whole chantier exists to prevent. Whatever such a surface is emulated on, it teaches the consumer a model that does not exist. - **Reachability is a graph, not a directory.** The only way in is a link somebody gave you: in an inbox, or inside a document you already hold. Which is why the inbox is not a side feature — it is *the* bootstrap of the whole graph, the only channel through which a link crosses from one wallet to another. - **This is what makes §4ter operational.** "Whoever has the URL reads the content" is not a weaker form of public: the URL *is* the access. Having it means someone gave it to you. - **An audience cannot be counted, only addressed.** No primitive answers "who can see this"; you know who you sent it to. ### And the second reason, which stands on its own: nothing is COMMON Even setting discovery aside, a global index is **data shared between users/wallets**, and that is not acceptable in an emulation whose whole job is to simulate the boundary of a single-user wallet (PO, 2026-07-30): > Nothing common — only **indexing mechanisms to make the virtual users work**. The distinction is the operative one, and it is sharp: | | Verdict | Why | |---|---|---| | The **shim** (pointer → doc-shim → account → its scope documents) | **acceptable** | pure plumbing: it holds no user data, only the table that makes a virtual user resolvable at all. Remove it and no wallet exists. | | A **discovery index** (announcements deposited by users, read by everyone) | **not acceptable** | it is application data pooled across wallets. Remove it and every wallet still works — you simply have to be given links, which is the model. | The test to apply to anything shared: *does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?* Only the first justifies existing outside a wallet. *Impact on this library, recorded 2026-07-30 and not yet resolved*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md). ## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch **There is no "keyring" object in NextGraph, and this section used to say there was.** It read *"the store branch **is** the owner's keyring… upstream of that, the keyring is the wallet"*, which is wrong twice: the wallet holds **one** key per user (the private store's read cap, §4quinquies level 1), not every key; and the caps of one's own documents live on a **Store branch**, per store, not in any single trousseau. An agent built a global in-memory "keyring" on that sentence. Corrected 2026-07-30 on the PO's instruction — *use the Store branch logic, not an invented keyring*. What is actually true: On every document creation, an `AddRepo { read_cap }` is committed to the store's **Store branch** — the store being itself a repo with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role, its own pub/sub topic, and here `BranchCrdt::None` — service commits, not triples). That branch lists **the store's documents, each with its read cap**, and replaying it is what reloads them (`AddRepo::verify` → `load_repo_from_read_cap`, `engine/verifier/src/commits/mod.rs:644-664`). So the answer to *"how does an owner find the cap of a document they created?"* is: **it is on the Store branch of the store that document lives in** — one such branch per store, reached from the root key the wallet does hold. **This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store's cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2), and a cap received for someone else's document goes somewhere else entirely (`AddLink` on the User branch, §4quinquies). *(VERIFIED for the `AddRepo { read_cap }` mechanism and for `BranchType::Store` / `BranchCrdt::None`; the full enumeration of branch types is in `engine/repo/src/types.rs:1536-1551`.)* ## 4quinquies. WHERE the caps actually live — three levels, and one of them does not exist yet **VERIFIED 2026-07-30** by reading `nextgraph-rs` (`git 213338f6`), answering "where does a received cap get stored?". ### Nomenclature first — `wallet` in the source is NOT what we call a wallet A **wallet is only a keyring**. What we have been calling a "virtual user" is, upstream, a **user** (a *site*): `SensitiveWalletV0.sites: HashMap` (`engine/wallet/src/types.rs:434,457`) — one wallet holds SEVERAL sites. `SiteV0` (`engine/verifier/src/site.rs:23`) is what owns the three stores (`public`, `protected`, `private`), and `UserId = PubKey` (`engine/repo/src/types.rs:453`). **Our vocabulary must follow: virtual user → user.** ### The three levels **1. The wallet (keyring) holds ONE root key per user.** `SiteV0.site_type = SiteType::Individual((priv_key, read_cap))`, read back by `get_individual_site_private_store_read_cap` (`site.rs:52`) — the read cap of the **private store**, and nothing else. Everything else is reached *from* it. Following links applied to your own data. **2. The store's own branch carries `AddRepo { read_cap }` — one per document.** `doc_create` performs **four distinct writes**; the two that matter here (`engine/verifier/src/request_processor.rs:697-710`): - `send_add_repo_to_store` → a commit `AddRepo { read_cap }` on the **Store branch** of the store (`verifier.rs:2172-2199`) — *the key*; - `INSERT DATA { ldp:contains }` on the store's **main branch** — *the listing*. *(The other two: the class quad on the **Header** branch, `request_processor.rs:719-728`; and `AddSignerCap` on the private store's **User** branch, `verifier.rs:3022-3040`.)* **The key and the list are separate, deliberately.** Replaying the Store branch is what reloads the repos with their keys: `AddRepo::verify` calls `load_repo_from_read_cap` then `add_doc` (`engine/verifier/src/commits/mod.rs:644-664`). Our `shim:contains` emulates `ldp:contains` and `shim:readCap` (on a `storeBranch` subject) emulates `AddRepo` — so a created document's cap is stored beside it and read back, not recomputed. > **The Store branch holds NO triples.** Its CRDT is `BranchCrdt::None` — *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`; `store.rs:426`). It is a stream of **service commits** (`AddRepo` / `RemoveRepo`), not a graph. Any RDF we use to emulate it is our invention, and should be labelled as such rather than presented as "the same thing". **3. Local user storage persists the read cap of EVERY opened repo.** `user_storage/repo.rs` stores `READ_CAP` as a property per repo (`:109,:219,:248,:359`), and a persistent verifier reloads from it at startup (`verifier.rs:542-544`). This is a **local store (RocksDB / IndexedDB), not a NextGraph document** — the verifier's own cache, per user. ### Giving access is a **Link** — one word, three places, all already named **VERIFIED 2026-07-30.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like: | Step | Upstream | State | |---|---|---| | The message deposited in the recipient's inbox | `InboxMsgContent::Link` (`engine/net/src/types.rs:4249-4261`) | **declared, payload-less** — a variant with no fields, i.e. specified and not implemented | | Where the recipient files it on processing | `AddLink { read_cap }` on the **User branch** of the private store (`engine/repo/src/types.rs:1934-1950`) | implemented (verifier arm `commits/mod.rs:681`) | | Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | implemented | | What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062`) | implemented | So: **deposit a Link into the recipient's inbox; on connection the recipient processes the inbox and files it with `AddLink` on their User branch.** That is the whole gesture, and every piece of it has a name. Two consequences worth stating, because both are easy to get wrong: - **What travels is a cap-BEARING reference.** A bare NURI in a Link grants nothing — it names a document the recipient still cannot open. `AddLink` carries a `read_cap`, not a `RepoId`. - **`ContactDetails` is a different gesture.** It shares a *profile* (with an optional `read_cap` on it), not an arbitrary document. Do not route document sharing through it. ### A cap received from someone else: the **User branch**, via `AddLink` **CORRECTED 2026-07-30 after adversarial review — an earlier version of this section claimed there was no register at all. That was wrong, and it was the kind of wrong this document exists to prevent: concluding "it does not exist" from having looked in one place.** There IS a register, and it is a fourth commit type next to `AddRepo`: ```rust /// Adds a link into the user branch, so that a user can share with all its device a new Link they received. /// The repo's `store` field should not match with any store of the user. Only external repos are accepted here. pub struct AddLinkV0 { pub read_cap: ReadCap, /* … */ } ``` `engine/repo/src/types.rs:1934-1950`, with `RemoveLink` as its ORset counterpart (`:1952`) and a verifier arm at `engine/verifier/src/commits/mod.rs:681`. So: - it lives on the **User branch** — created only on the **private store** (`engine/repo/src/store.rs:448-452`; the public/protected stores get an `Overlay` branch instead), which also carries `AddInboxCap { repo_id, overlay, priv_key }` — *"so that a user can share with all its device"* (`engine/repo/src/types.rs:1969-1981`). So the User branch answers two questions with one mechanism: **which caps I received**, and **which inboxes I may read**; - it is explicitly for **external repos** — someone else's documents, exactly the received-cap case; - and its stated purpose is to **share the link with all of the user's devices**. It is wallet-resident and cross-device, not a local cache. **Level 3 (local user storage) is therefore a cache, not the register.** The register is level 2': `AddLink` on the User branch of the private store. What remains true, and is a separate matter — the *delivery* path is unimplemented: - `InboxMsgContent::ContactDetails` processing (`engine/verifier/src/inbox_processor.rs:778-847`) creates a contact document holding the profile, inbox, name and email — and **never reads `details.read_cap`**. Confirmed on sight: the receiver discards it. So the cap never reaches the User branch today — the register exists, the road to it does not. - `RepoLinkV0` states the intended flow (`engine/net/src/types.rs:5055-5061`): *"the link is shared and then the recipient opens it and subscribes soon afterward"*. **The key IS kept**: opening the repo persists its `read_cap` in local user storage, so the next session decrypts fine. What is not durable is the key's **validity** — a `RootCapRefresh` (§3) mints a new one, and receiving it depends on **the rotating party choosing to send it to you** (§3's DIRECTION block), not on any subscription state. > **Do not write "only a subscriber receives the new key".** That reads the `RepoLinkV0` comment as intent, which §3 already forbids. **Subscribing is a purely LOCAL act** — automatic pull of changes — and the other party records nothing about it; there is no subscriber list to send to. Who gets a rotated key is the rotating party's decision, delivered to an inbox. - `PermaCap` — still a **TODO** (`engine/repo/src/types.rs:578`) — covers exactly the gap that leaves: a link *"stored on disk and kept there unopened for a long period"*, i.e. never loaded, therefore never subscribed, therefore missing every refresh. > **So there are TWO registers, by origin**: `AddRepo` on the **Store** branch for the documents a user creates in that store, and `AddLink` on the **User** branch of the private store for caps received for someone else's documents. Local user storage caches both. Opening a repo persists its cap locally, but that is the cache filling — not the durable record. *Consequence for this library*: **both durable registers are now emulated** (2026-07-30) — `AddRepo` as a `shim:readCap` record on a distinct subject of the store document (`storeBranch`), `AddLink` as `shim:link` on another (`userBranch`) — and the in-memory `CapRegistry` is what it always was, level 3: the cache. Caps are READ back from those records, never recomputed. What stays an invention is representing branches as RDF subjects at all: upstream both branches carry `BranchCrdt::None` and hold service commits, not triples. What is faithful is that the key sits beside the document, and that the listing (`contains`, the Main branch) is separate from the keys. ## 5. What the polyfill emulates (caps.ts) — and where it still diverges **Realigned 2026-07-28 (batch P1a).** `packages/client/src/caps.ts` used to model `readers: Map>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `store-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache. | | Real NextGraph | caps.ts emulation (post-P1a) | |---|---|---| | Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI | | Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read | | Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document | | Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke | | Granularity | repo / branch / commit / object | **one cap per doc-NURI** | | Ref. without rights | **cap-less NURI** (no `:k:`) | same — `Nuri` names, `ReadCap` names and reads | **The divergence that REMAINS, and it is the load-bearing one**: the stand-in key is a constant (`OK`) rather than a secret, and several read paths consult no cap at all (`docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`). So P1a bought the **shape**, not the isolation — per-document encryption and closing that inventory are **P1b**. Nothing may be claimed "anonymous" or "private" before it. **App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap. ## 6. Implications for consumers (e.g. Festipod) - "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level. - **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".) - **Alignment DONE for the surface (P1a, 2026-07-28)**: the emulated ACL is gone, replaced by per-identity cap possession + per-document delivery to an inbox; `declareConnections`-as-a-re-declared-ACL has disappeared. What remains for the real cap operations is swapping the stand-in key value (`OK`) for the real one and closing the bypasses (P1b) — a key-material step, not a reshape. See `migration-guide.md` §1. ## Caveats / gaps - `file:line` references are dated (2026-07) — re-verify by symbol; the core moves. - ~~INFERRED: keyless broker fetch (existence without a key)~~ — **RESOLVED and REFUTED, 2026-07-27**: not constructible. See the CORRECTED block in §4bis. Kept struck through because the hypothesis is intuitive and will otherwise be re-formed. - Not traced: the full execution of `RootCapRefresh` on the verifier side (`verifier/src/commits/mod.rs:616`), wallet storage of `private_store_read_cap` (`repo/types.rs:945,976`).