Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
33 KiB
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-rsitself, with afile: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-rsand 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, and how the polyfill fakes something belongs insimulation.md. What matters is that it is written down somewhere durable and findable, with afile: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.
branchis not git's.walletis 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.
AddLinkhad been sitting next toAddRepoin 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 byid, and one decrypts locally withkey.
- Granularity: per commit/object the
ObjectRefis 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<ReadCap>("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_capfield exists, but the construction of the message isunimplemented!()(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; permstypes.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_Otypes.rs:52)did:ng:o:{repo_id}:v:{overlay_id}(:263,RE_REPOtypes.rs:55)did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}(RE_BRANCHtypes.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) |
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_CAPtypes.rs:48)did:ng:o:{repo}:c:{commit}:k:{key}(RE_COMMITtypes.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<Store>); 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, requiresBlockIds 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
BlockIdto probe. And the outer is never registered anyway (expose_outerhard-coded tofalse, 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:
- Place the data in your public store — that makes it readable by whoever reaches it, not visible;
- 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) 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.
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<String, SiteV0> (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 commitAddRepo { read_cap }on the Store branch of the store (verifier.rs:2172-2199) — the key;INSERT DATA { <store> ldp:contains <doc> }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.
AddLinkcarries aread_cap, not aRepoId. ContactDetailsis a different gesture. It shares a profile (with an optionalread_capon 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:
/// 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 anOverlaybranch instead), which also carriesAddInboxCap { 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::ContactDetailsprocessing (engine/verifier/src/inbox_processor.rs:778-847) creates a contact document holding the profile, inbox, name and email — and never readsdetails.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. -
RepoLinkV0states 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 itsread_capin local user storage, so the next session decrypts fine. What is not durable is the key's validity — aRootCapRefresh(§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
RepoLinkV0comment 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:
AddRepoon the Store branch for the documents a user creates in that store, andAddLinkon 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<Nuri, Set<PrincipalId>> + 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<Nuri, ReadCap>) — 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. Seemigration-guide.md§1.
Caveats / gaps
file:linereferences 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
RootCapRefreshon the verifier side (verifier/src/commits/mod.rs:616), wallet storage ofprivate_store_read_cap(repo/types.rs:945,976).