Files
ng-eventually/docs/readcap-and-nuri-model.md
Sylvain Duchesne 2726f4a26f docs: nommer par la fonction, et n'annoncer qu'un point d'entrée
Deux corrections indépendantes dans la doc vivante, les briefs et décisions
datés restant tels qu'écrits.

P1a et P1b ne disaient rien à personne. Six mois plus tard il aurait fallu lire
le code pour savoir de quoi on parle, et le coût de la recherche se repaie à
chaque lecture. Ils deviennent cap-surface — la forme des capacités, livrée le
2026-07-28 — et cap-enforcement — ce qui reste : le chiffrement par document et
les gardes d'écriture aujourd'hui décoratives. 28 occurrences.

Et api-contract.md se contredisait à quatre lignes d'intervalle : il annonçait
deux points d'entrée en tête, et en bas qu'il n'y en a qu'un depuis la fusion du
2026-08-07. Vérifié dans package.json avant d'écrire — exports mappe exactement
{".": "./src/index.ts"} et src/polyfill.ts n'existe pas.

Ce qui identifie un symbole polyfill-era ne change pas : le bloc marqué dans
src/index.ts et le test de vocabulaire, plus aucun chemin d'import.
2026-08-11 19:10:34 +02:00

48 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 emulated-verifier/caps.ts emulation (an ACL until the cap-surface work — the inverse of the real model; realigned 2026-07-28, see §5). 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, and how the polyfill fakes something belongs in 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.
  • The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads". An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed.
  • NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows. Where upstream has a word for something, that is the word, full stop. publisher is a case in point: it appears 126 times in the engine (as_publisher, publisher_advert) for a pub/sub role on a topic, and it must be used, unchanged, whenever that is what is meant.
  • What is banned is OUR loose "publish a document", which has no upstream referent and covers three different acts: placing a document in a public store, making it findable, and (in this emulation only) handing out a key. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls publisher.
  • "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.

0. The security model, and why an agent keeps losing it

This is the one thing to hold on to. Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it.

The rule

A document is readable by whoever holds the reference AND the key. Not by whoever holds the reference.

There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (verify_permissionPermissionDenied in Commit::verify), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism.

Why this is easy to lose

Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them does not fail, it silently grants. There is no error to notice, no test that goes red on its own.

What the rule buys — composable confidentiality

Because a reference grants nothing, a reference is not recursive: a widely-circulated document may point at a restricted one. The reference says it exists; the key says who reads it.

That is what lets confidentiality be composed inside one document:

  • a public summary pointing at restricted chapters,
  • a public event pointing at its participant list,
  • a profile pointing at data reserved for a circle.

The author circulates one document and still decides, per referenced document, who gets the key. Remove non-recursiveness and this whole construction collapses: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares.

test/cross-user-access.test.ts is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference bare — which is exactly what an application must do.

The failure mode to watch for

Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. linkTo (added and removed 2026-08-06) was exactly this.


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<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_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 (engine/verifier/src/commits/mod.rs:616,630 — both no-op Ok(()) stubs today; 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 ReadCapBlockRef::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_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<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, requires BlockIds 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 primitive a non-member can reach (ExtObjectGet) requires the ObjectIds, which one only holds once one can already read. (Corrected 2026-08-10 — this line used to say "the ObjectIds and their keys", and to call ExtObjectGet the only such primitive. Both are wrong at the source: ExtObjectGetV0 { overlay, ids: Vec<ObjectId>, include_files } has no key field at all (engine/net/src/types.rs:4492-4501), and ExtRequestContentV0 has three variants — WalletGetExport, ExtObjectGet, ExtTopicSyncReq (:4520-4526), the last of which falls into unimplemented!() (:4533). The conclusion is unchanged and rests on addressing, not on keys: blocks come back encrypted, and naming them needs ObjectIds a non-holder does not have — the formulation docs/nextgraph-current-state.md § "The Ext protocol serves blocks with no control" already carried.)

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.

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, RESOLVED 2026-07-30 — the module was removed: 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::verifyload_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 commit AddRepo { 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 branchthe 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.

VERIFIED 2026-07-30, state column corrected 2026-08-10. The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like. What none of them is, is implemented — the table said so of three rows, and re-reading the source refuted it:

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) declared, stubbed — the verifier arm is a no-op Ok(()) (engine/verifier/src/commits/mod.rs:681-693), and nothing in the workspace constructs one
Withdrawing it RemoveLink, ORset (engine/repo/src/types.rs:1952) declared, stubbed — the same no-op Ok(()) arm (commits/mod.rs:695-707)
What circulates RepoLinkV0 { read_cap, … } (engine/net/src/types.rs:5062-5078) declared only — zero constructors and zero consumers in the workspace (only the RepoLink wrapper and its two accessors, :5082-5097)

What makes "stub" the right word rather than a quibble: the neighbouring arm in the same file does real work. CommitVerifier for AddRepo calls load_repo_from_read_cap then add_doc (commits/mod.rs:644-664); AddLink and RemoveLink, twenty lines below, return Ok(()) with #[allow(unused_variables)] on every parameter. Same trait, same file, opposite states — so "there is a verifier arm" cannot be read as "the register works".

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 — which is not the same as having a behaviour. Per this document's own rule, none of that says what the target will do; it says the gesture is fully specified and none of it runs.

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.

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 — a no-op Ok(()) one — at engine/verifier/src/commits/mod.rs:681-693. 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 — the durable, cross-device record — is level 2': AddLink on the User branch of the private store.

But "the register exists, only the delivery is missing" is FALSE, and this section said it until 2026-08-10. Both ends are declared and stubbed, as the table above now records: nothing constructs an AddLink commit anywhere in the workspace, and the arm that would apply one returns Ok(()). So the corrected statement is: AddLink on the User branch is where a received cap belongs in the model — a placement the source states unambiguously and this library aligns on — and no part of the gesture runs today, neither the road nor the destination. Per the design principle that gap says nothing about the target; it forbids only calling the register "implemented".

The delivery half, in detail:

  • 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 no cap reaches the User branch today.

  • 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. (AddRepo runs; AddLink is declared and stubbed — see the table above. The split by origin is the model's, whatever each half's state.) 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.

4sexies. What a NURI TRANSPORTS — and the TWO acts that cover every use of it

VERIFIED 2026-08-06 by reading nextgraph-rs, after a design discussion kept stalling on an assumed split between "a NURI" and "a link". There is no such split, and getting that wrong is what made this library ship a linkTo that was wrong three ways at once.

NuriV0 (engine/net/src/app_protocol.rs:181-194) carries exactly what a link carries:

NuriV0 { identity, target, entire_store, objects, signature,
         branch, overlay, access: Vec<NgAccessV0>, topic, locator: Option<Locator> }

NgLinkV0 = Repo | PublicRepo | Branch | Object (engine/net/src/types.rs:5206-5211) is the structured form of the same information; the NURI is its URI form. So "should we hand out a NURI or a link?" is not a question — they are the same thing, and the real question is what one puts in the access and locator slots.

Caveat, and it matters for anything printed on paper: the type has all the slots, the string grammar does not expose them all. A self-contained share URL exists for objects, commits and files (…:v:{overlay}(:[cj]:{id}:k:{key})+:l:{locator}); the repo case is not exercised. See document-links.md.

access is a LIST of access forms, not a key flag

NgAccessV0 = ReadCap(ReadCap) | Token(Digest) | ExtRequest(Vec<u8>)
           | Key(BlockKey) | Inbox(PrivKey) | Topic(PrivKey)

(app_protocol.rs:54-62.) An empty access is therefore a legitimate, meaningful state: the reference NAMES its target and grants nothing. That is not a degenerate link — see below, it is the common case.

There are TWO acts, and they cover everything

An earlier version of this section listed four "needs" (name, let read, let find, join). That was wrong: it confused what an act DOES with what its result depends on. A sender does exactly two things, and every use case is one of them:

Act What is transmitted What the recipient can do
Transmit the reference target (+ overlay/locator), access EMPTY Name the document. And read it, if it sits in a public store — same value transmitted, different outcome, decided by where the document is and not by what was sent.
Transmit the reference AND the key the above + ReadCap in access Read it. Irreversible: nothing is checked later, and there is no revoking a key already handed out (only rotation, which also cuts off legitimate recipients — PermaShare/PermaCap, engine/repo/src/types.rs:1761, is the reserved answer, unimplemented).

"Let someone find a public document" is therefore not a third act: it is the first one, applied to a document that happens to be in a public store. And "join" is not an act at all — the locator is about the reference being COMPLETE enough to be resolved by a stranger (no locator → no broker to ask → nothing opens, key or no key). It belongs to the recipient's side.

The consequence for anything this library exposes: a call either hands over the key or it does not, and that must be the caller's explicit intent — never a side effect of asking for a reference.

Three confusions worth naming, because each one was made here

"No key" does not mean "public". A key-less reference to a PROTECTED document withholds access deliberately — the recipient can name it, not read it. A PublicRepoLinkV0 (types.rs:5105-5127) carries no read_cap for an unrelated reason: the content is served publicly, so there is nothing to hand over. Same shape, opposite situations.

Naming is not reading, and it is the DEFAULT act. This is what test/cross-user-access.test.ts proves: Alice has a document in a public store that REFERENCES her protected one; Bob follows the public link, finds the reference, and can name the protected document while reading nothing of it. Publication is not recursive. Withholding the key is the ordinary, reversible gesture; including it is the exceptional, irreversible one.

Nothing is checked at access time. Reading IS possession — the engine verifies a permission on WRITE only (verify_permissionPermissionDenied in Commit::verify), never on read. So a reference cannot be "neutral about rights, resolved later": what you put in it IS the grant. There is no revoking a link you have circulated, only rotating the key (and RepoLinkV0's own comment says shared caps go stale on refresh — PermaShare / PermaCap, engine/repo/src/types.rs:1761, is the reserved answer to that, unimplemented).

Where this library stands, act by act

  • Transmit the reference — covered, with no dedicated call: every reference the surface returns is bare (createEntityDoc, docCreate, listMyEntityDocs, UnionSubject.subject/.graph). An application cites what it already holds. Faithful.
  • Transmit the reference and the keyinbox.share(doc, toUser). Names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting, with nothing to call. Faithful in shape. One recorded divergence: the deposit always goes to the recipient's PROTECTED inbox, where upstream the choice follows the profile the person was reached by (engine/verifier/src/inbox_processor.rs:787).

And the property that makes the first act worth anything — a public store SERVES its documents' caps. PublicRepoLinkV0 (engine/net/src/types.rs:5098-5124) carries repo, public_store and peers and no read_cap, and its own comment says why: "The latest ReadCap of the branch (or main branch) will be downloaded from the outerOverlay, if the peer brokers listed below allow it. […] This link is durable, because the public site are served differently by brokers." The key is not something a sender hands over; it is something the network gives to whoever asks, because the broker pinned the outer overlay (expose_outer, engine/broker/src/server_storage/core/overlay.rs:103-133).

That is emulated, since 2026-08-06, in emulated-verifier/public-store.ts — and emulated without touching the guard. Possession remains the one criterion: a public document is readable not because mayReach makes an exception, but because its cap is obtainable — the library asks, files what it gets, and from there the ordinary path applies. Every read door asks first (readUnion, docs.sparqlQuery, ensureRepoOpen, documentInboxAddress).

Where the emulation shows its seams, stated rather than hidden:

  • Upstream nothing is WRITTEN to make a repo public — the store is public and the broker serves it. Here one broker serves every virtual user identically, so the cap is recorded on the document's Header branch and read back through the machinery's unguarded door. Fetching, not enumerating: a reader asks the document it already names.
  • A reader therefore learns a document is public by asking THAT document. One it has never heard of stays invisible, where upstream a broker would serve it just the same. That limits discovery, not access.
  • The cap a public store serves is a READ grant, and this emulation says so: caps.learnFromPublicStore files it apart, and docs.sparqlUpdate refuses a write on it (assertMayWrite). Without that, a bare reference would buy a write, which upstream it never does — writing needs the write cap, and no store hands that out.
  • useShape cannot ask (its signature is the real ORM's, with no await to spend), so a public document reached through it alone, read nowhere first, is filtered out. Recorded in emulated-verifier/read-filter.ts.
  • No locator anywhere, and the emulation's topology is why it does not show. Upstream a reference must be complete enough for a stranger to resolve — without a locator there is no broker to ask, and nothing opens, key or no key (NuriV0.locator, engine/net/src/app_protocol.rs:181-194). Here every virtual user is on the same broker, so the question never arises and no reference this library produces carries one. An application must not conclude that a bare reference travels anywhere: it travels between users of one deployment. The day two deployments have to exchange one, the locator is what will be missing, and nothing in the emulation will have prepared it.

One thing a consumer must not conclude from the emulation: that a public document has anything per-reader. Upstream there is no grant, no revoke and no audience on it — there is nothing to build a UI around.

5. What the polyfill emulates (caps.ts) — and where it still diverges

Realigned 2026-07-28 (the cap-surface batch). packages/polyfill/src/emulated-verifier/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 shared-wallet/account-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-cap-surface)
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 inbox.share(doc, toUser) → 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 r: segment) same — Nuri names, ReadCap names and reads
Public store the broker serves the outer overlay; the ReadCap is downloaded from it public-store.ts — the cap is exposed on the document and fetched through the machinery's door, then held like any other. Filed apart (learnFromPublicStore) so it grants reading and not writing

The divergence that REMAINS: the stand-in cap value is the constant OK rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (emulated-verifier/reach.ts, 2026-07-30) — docs.sparqlQuery/sparqlUpdate and subscribeDoc are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for cap-enforcement is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private".

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 (inbox.share 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 (cap-surface, 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 (cap-enforcement) — 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).