0832338201
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN `read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay externe (`expose_outer`). La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la référence suffit » — le seul acte que le modèle rend gratuit — et son unique contournement était de distribuer la clé, ce qui détruit la confidentialité composable. `emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La possession reste l'unique critère : un document public est lisible non par exception mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le chemin ordinaire s'applique. Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE : `learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont n'accorde. Autres conséquences : - `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer). Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux clés différentes le jour où la constante devient un secret. - `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? » et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun appelant hors des tests. - Les tests cross-user ne font plus traverser de cap par une variable JS : Bob n'obtient que la référence nue, comme une vraie application. Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer` est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à dépenser, et l'absence de `locator`. 179 tests unitaires, e2e 42/42 contre le broker en ligne.
404 lines
46 KiB
Markdown
404 lines
46 KiB
Markdown
# 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 P1a — 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`](./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.
|
|
> - **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_permission` → `PermissionDenied` 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 **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<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 `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, 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`](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<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 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.
|
|
|
|
## 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.
|
|
|
|
### A NURI and a Link are one thing in two shapes
|
|
|
|
`NuriV0` (`engine/net/src/app_protocol.rs:181-194`) carries exactly what a link carries:
|
|
|
|
```rust
|
|
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`](./document-links.md).
|
|
|
|
### `access` is a LIST of access forms, not a key flag
|
|
|
|
```rust
|
|
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_permission` → `PermissionDenied` 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 key** — `inbox.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 (batch P1a).** `packages/client/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-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 | `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 **P1b** 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 (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`).
|