Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
This commit is contained in:
@@ -35,7 +35,7 @@ This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the pl
|
||||
|
||||
**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
|
||||
|
||||
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:k:`) vs cap-bearing (id + key/token). Absent today.
|
||||
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:r:`) vs cap-bearing (id + key/token). Absent today.
|
||||
2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
|
||||
3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
|
||||
4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
|
||||
@@ -75,16 +75,16 @@ Erroneous content kept below as a record:
|
||||
## Open questions
|
||||
|
||||
- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
|
||||
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:k:`).
|
||||
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:r:`).
|
||||
- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
|
||||
- API migration: `declareConnections`/`grantRead` → `seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
|
||||
|
||||
## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)
|
||||
|
||||
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:k:`), **without ever reading the content**:
|
||||
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:r:`), **without ever reading the content**:
|
||||
- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
|
||||
- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
|
||||
- **Q3 — Confidentiality**: does the key (`:k:`) remain **required** in order to decrypt (keyless never gives the content)?
|
||||
- **Q3 — Confidentiality**: does the key remain **required** in order to decrypt (keyless never gives the content)?
|
||||
|
||||
**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
|
||||
|
||||
@@ -109,11 +109,11 @@ Erroneous content kept below as a record:
|
||||
- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
|
||||
- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
|
||||
|
||||
## P1a — the surface
|
||||
## P1a — the surface — **DONE (2026-07-28)**
|
||||
|
||||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).**
|
||||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
|
||||
|
||||
This batch is **specified and ready to implement**; it has its own note so that one can code from it without wading through the retracted material of this document.
|
||||
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
|
||||
|
||||
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
|
||||
|
||||
@@ -121,8 +121,8 @@ This brief remains the **overall effort**: P0 verdicts, P1b scope, P2–P4 batch
|
||||
|
||||
## Phase sketch
|
||||
|
||||
- **P1a** — **the surface**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). **The only batch that blocks Festipod.** Specified in its own note: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md). *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||||
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed.
|
||||
- ~~**P1a** — **the surface**~~ **DONE 2026-07-28**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). It was the only batch blocking Festipod, and it no longer does. See [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md) for what landed where. *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||||
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. **Requalified 2026-07-30**: the bypass inventory below is really a **virtual user boundary** problem, and it is now specified on its own in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md). That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
|
||||
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
|
||||
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
|
||||
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
|
||||
|
||||
@@ -1,6 +1,195 @@
|
||||
# Brief — P1a: the capability surface
|
||||
|
||||
**Status: specified, ready to implement.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
|
||||
**Status: IMPLEMENTED 2026-07-28, awaiting review.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
|
||||
|
||||
The spec below is unchanged — read it first. Everything from here to *Why this lot exists* is the implementation report: what landed, the exact surface a consumer codes against, the decisions taken, and what is **not** verified.
|
||||
|
||||
---
|
||||
|
||||
# Implementation report (2026-07-28)
|
||||
|
||||
## What landed
|
||||
|
||||
| Spec | Where |
|
||||
|---|---|
|
||||
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/types.ts`, `src/nuri.ts` (internal parse/mint/derive) |
|
||||
| Keyring, one per identity — `capFor` | `src/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
|
||||
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/store-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
|
||||
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
|
||||
| `publishRepoLink` | `src/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
|
||||
| Possession gate on reads | `src/read-model.ts` (`readUnion`), `src/read-filter.ts`, `src/use-shape.ts` |
|
||||
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange` → `src/watch-shape.ts` |
|
||||
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
|
||||
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
|
||||
|
||||
### The cross-user scenario, as the PO specified it (`test/cross-user-access.test.ts`)
|
||||
|
||||
Alice owns a **protected** document holding a secret, and a **public** document that carries a **reference** to it — a bare NURI. Then:
|
||||
|
||||
- **Bob** holds the public document's link. He reads it, finds the reference, and can NAME Alice's protected document while reading nothing of it. Publication is **not recursive**.
|
||||
- **Charlie** holds the same link, plus the protected document's cap — delivered by Alice to his inbox. Same document, same reference, same path: he reads through it.
|
||||
- **The only difference between them is what their keyring holds.** Nobody was named to the registry; Alice addressed an inbox.
|
||||
- **Dynamic**: Bob is refused, Alice delivers the cap to *his* inbox, his client processes it — and the read that was empty yields the content. Filing the cap fires `CapRegistry.onChange`, so a reader wired to that signal (which is what `watchShape` does internally) re-reads on its own.
|
||||
|
||||
One property this makes explicit and that is worth confirming: **the bare NURI of a PUBLIC document is not enough either** — its repo link is. See *Publication travels as a link* below.
|
||||
|
||||
## The exact surface a consumer codes against
|
||||
|
||||
From `@ng-eventually/client/polyfill`:
|
||||
|
||||
```ts
|
||||
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
|
||||
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
|
||||
getCaps(): CapRegistry
|
||||
resetCaps(): void // tests / fresh wallet ONLY — never on identity change
|
||||
setCurrentUser(id: PrincipalId | null): void // selects WHICH keyring is consulted
|
||||
```
|
||||
|
||||
On `CapRegistry` (reached via `getCaps()`):
|
||||
|
||||
```ts
|
||||
open(nuri: Nuri, scope: Scope): ReadCap // "this document is mine, in this scope"
|
||||
mint(nuri: Nuri): ReadCap // …its protected/private half
|
||||
publishRepoLink(nuri: Nuri): ReadCap // …its public half — returns the SHAREABLE LINK
|
||||
learn(cap: ReadCap): void // file a cap I was given (throws on a bare reference)
|
||||
capFor(nuri: Nuri): ReadCap | undefined
|
||||
isPublished(nuri: Nuri): boolean
|
||||
isEnforcing(): boolean // false until the first cap exists
|
||||
onChange(listener: () => void): () => void // keyring mutations
|
||||
grantWrite / canWrite / governsWrite / hasWritePolicy // unchanged, decorative, P1b
|
||||
clear(): void
|
||||
```
|
||||
|
||||
Plus the narrowing guards, from the SDK-identical entry:
|
||||
|
||||
```ts
|
||||
isNuri(s: string): s is Nuri // an untrusted string → a Nuri
|
||||
hasReadCap(s: string): s is ReadCap // …→ a ReadCap; the ONLY such narrowing
|
||||
```
|
||||
|
||||
Types: `Nuri`, `ReadCap`, `Scope`, `PrincipalId` are all exported from the SDK-identical entry (`export * from "./types"`). `Scope` is a literal union, so `open(doc, "protected")` is compiler-checked. `capFor` returns `ReadCap | undefined`, so under `strict` the consumer is forced to handle "I hold nothing".
|
||||
|
||||
Every call accepting a `Nuri` also accepts the cap-bearing form and normalizes it (`targetOf`), so passing a cap where a NURI is expected is never a silent mismatch — and it type-checks, because `ReadCap` is assignable to `Nuri`.
|
||||
|
||||
## Typing — template literal types, not `string`, not branded types
|
||||
|
||||
**Amended on the PO's instruction (2026-07-30), after the first pass shipped both as `type X = string`.** The types are now:
|
||||
|
||||
```ts
|
||||
type Nuri = `did:ng:${string}`
|
||||
type ReadCap = `did:ng:${string}:r:${string}`
|
||||
```
|
||||
|
||||
Still **strings** — assignable to `string`, JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the real SDK arrives and takes `nuri: String`. §1's two documented objections to branded types do not apply: there is nothing to un-type at migration, and the cost at the ORM/SPARQL boundaries was **measured at zero** (see below). What the template buys is the single asymmetry that matters: a `ReadCap` is freely usable wherever a `Nuri` is expected (a cap IS a NURI with the key inside — upstream's one `NuriV0`), while a bare `Nuri` where a `ReadCap` is required is a **compile error**.
|
||||
|
||||
This is the one place the implementation departs from the letter of §7 (*"the discrimination lives in what you can obtain, not in what the compiler permits"*). It was an explicit PO decision: the consumer app benefits from the distinction, and possession is still what actually decides — the compiler only stops the app from writing a call the model has no meaning for.
|
||||
|
||||
**Cost, measured on the whole repo**: typing both aliases produced 16 errors, all of them at genuine boundaries, and every one resolved by narrowing rather than casting:
|
||||
|
||||
| Boundary | Resolution |
|
||||
|---|---|
|
||||
| Broker (`docs.docCreate`, whose `ng` is `any`) | Validates with `isNuri` and throws — the declared `Promise<Nuri>` was an unchecked promise every typed NURI downstream rested on |
|
||||
| SPARQL (`store-registry` `canonicalDoc`, `readScopeIndex`) | Narrow with `isNuri`; a stored value that is not a reference is now discarded instead of flowing through as a "document NURI" |
|
||||
| ORM (`read-filter` `docOf`, an untyped `@graph`) | Narrow with `isNuri` |
|
||||
| Inbox payload | Free — it already tested `hasReadCap`, which is now a **type guard** (`s is ReadCap`) |
|
||||
| `assertNuri` | Made generic (`<T extends string>(nuri: T): T`) so the caller's type flows through instead of widening to `string` |
|
||||
| `nuri.ts` `targetOf` | **The one cast in the library**, in the primitive that defines the contract, so no caller needs one |
|
||||
| Playwright bridge (e2e) | An `asNuri` helper that throws — arguments cross the bridge as plain strings |
|
||||
|
||||
`isNuri` and `hasReadCap` are **exported from the SDK-identical entry**, so a consumer narrows its own strings (storage, URL, JSON, a form) the same way instead of casting.
|
||||
|
||||
**The runtime guards stay, and are not redundant**: a JavaScript consumer never meets the compiler, and a cap read back from storage and *cast* rather than narrowed reaches the library just the same. `CapRegistry.file` — the single door into any keyring — refuses a reference with no `:r:`, and `inbox.shareCap` does likewise. That guard was added during implementation after the trap was demonstrated: `learn(someBareNuri)` filed the bare reference under its own name, `capFor` returned it, and the document read — "naming is not reading" silently becoming "naming is reading".
|
||||
|
||||
*(An earlier version of this section flagged a gap around what a consumer put into a discovery `ref`. Moot since 2026-07-30: `discovery.ts` was removed — there is no discovery. Circulating a link is now an explicit act, `shareCap(link, inbox)`, whose argument is typed `ReadCap` and checked at runtime.)*
|
||||
|
||||
## How this articulates with the virtual users
|
||||
|
||||
This is the part worth reviewing closely, because P1a puts a NextGraph concept (the keyring = the wallet) on top of an emulation that already fakes wallets.
|
||||
|
||||
**Upstream, the keyring IS the wallet.** Here there is ONE physical user that everybody opens, and an "identity" is a *virtual* wallet: a shim account in `store-registry`, mapped to three scope-index documents. So the registry holds **one keyring per virtual user** — `Map<accountKey, Map<Nuri, ReadCap>>` — and `setCurrentUser(id)` selects which one is consulted. Switching identity switches keyrings structurally; there is nothing to reset and nothing is wiped.
|
||||
|
||||
**Where a virtual user's caps come from, and what makes them survive a reload:**
|
||||
|
||||
- *Its own documents* — the scope-index document of the (account × scope) plays the role of the store branch that carries `AddRepo { read_cap }` upstream. `createEntityDoc` files the cap on creation; `listMyEntityDocs` refiles them on any later session. Nothing is persisted as a key store: the emulated key is derived from the NURI, so listing the documents is enough to hold them again. **This is why a fresh page reads its own documents with nothing re-declared.**
|
||||
- *Documents shared with it* — the cap lives in the recipient's **inbox document**, which is persistent in the shared wallet. It re-enters the keyring when the consumer processes that inbox.
|
||||
|
||||
**Today, caps received are refiled only when the inbox is read** — which means the consumer's startup sequence has to do it. **The PO has ruled that this is wrong** (see *Follow-up* below): inbox processing belongs to the polyfill, on connection, not to the app.
|
||||
|
||||
**A defect found while writing this up, and fixed.** The keyring was keyed on the **raw** `currentUser`, while the shim keys accounts through the consumer-injected `normalizeId`. So `setCurrentUser("@Alice")` and `setCurrentUser("alice")` — ONE shim account, one set of documents — produced **two keyrings**, and the second one was empty: the identity stopped reading its own documents. The keyring now keys the same way the shim does, so one virtual user has exactly one keyring however its id is spelled. Locked by `test/isolation-active.test.ts` *one keyring per virtual WALLET, not per spelling of its id*.
|
||||
|
||||
**Still per-process, and that is correct**: the keyring is in memory, so two tabs have two keyrings. Each rebuilds itself the same way (scope index + inbox), which is exactly how a real wallet behaves on two devices.
|
||||
|
||||
## Where NURIs and ReadCaps are actually stored
|
||||
|
||||
Worth stating plainly, because "the keyring" is in memory and that sounds fragile until you see what backs it.
|
||||
|
||||
**NURIs are persisted, in RDF, in the shared wallet** — they always were:
|
||||
|
||||
| What | Where it lives | Written by |
|
||||
|---|---|---|
|
||||
| account → its 3 scope-index documents | the **doc-shim**, itself named by a write-once pointer triple in the private store-root | `store-registry.writeRecord` |
|
||||
| scope index → the NURIs of that scope's entity documents | the per-(account × scope) **index document**, as `shim:contains` literals | `store-registry.createEntityDoc` |
|
||||
| a document's own content, including any reference to another document | that **document's** graph | the consumer's write path |
|
||||
| an inbox deposit (payload, ts, from) | the **inbox document's** graph | `inbox.post` |
|
||||
|
||||
**ReadCaps are NOT persisted as caps anywhere.** There is no key store, no trousseau document, nothing on disk that says "this identity holds these keys". The keyring is a plain in-memory `Map<accountKey, Map<Nuri, ReadCap>>` inside `CapRegistry`, rebuilt from scratch on every page load out of two persisted sources:
|
||||
|
||||
- **my own documents** → `listMyEntityDocs` reads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constant `OK`. So *knowing which documents are mine is knowing their caps*. Upstream the key really is stored, on the Store branch (`AddRepo { read_cap }`); emulating that storage rather than re-minting is a separate lot.
|
||||
- **documents shared with me** → the cap sits **inside an inbox deposit's JSON payload**, which IS persisted (it is an ordinary triple in the inbox document's graph). Processing the inbox re-files it. So a shared cap survives a reload because the *delivery* is durable, not because we stored a key — which is exactly the upstream shape, where the seal sits in the inbox until the verifier applies it.
|
||||
|
||||
Consequence to keep in view: **a cap is only as durable as its delivery**. That is why the PO's follow-up below (the polyfill processing inboxes on connection) matters more than it looks — until it lands, the durability of a shared cap depends on the app remembering to read its inbox.
|
||||
|
||||
Second consequence, on the emulated key being derived: anyone can compute any document's cap from its NURI. That is the P1a/P1b line, stated once more — possession is a **shape** here, not a protection.
|
||||
|
||||
## Publication travels as a link — a choice to confirm
|
||||
|
||||
§5 says a public item is read by "whoever has the URL", and §8 says a harvested **bare** reference must yield nothing. Both hold only if what circulates for a public document is its **repo link** (`publishRepoLink` → `did:ng:o:…:r:…`), not its bare NURI. That is what was implemented, and the cross-user test pins it: Bob holding only the public document's bare NURI reads nothing; holding its link, he reads it.
|
||||
|
||||
The alternative — making a published document readable from its bare NURI — was rejected because the "published" fact would then live **only in the local registry**: another tab, another process, another user would have no way to know a document was published, and the emulation would stop being portable. Carrying the fact **in the data** (the link) is what makes it work across processes, and it matches `RepoLinkV0` upstream.
|
||||
|
||||
This is the point where the emulation is furthest from the eventual target, where the public store may not encrypt at all and a bare NURI would suffice. Per §5 that is fine — *"if the public store does not behave as this principle describes, this library adapts, not the consumer"* — but it is a deliberate divergence and the PO should confirm it.
|
||||
|
||||
## A debt this lot created — the unguarded inbox — **CLOSED 2026-07-30**
|
||||
|
||||
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
|
||||
|
||||
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
|
||||
|
||||
## Follow-up decided by the PO — to plan, NOT in this lot
|
||||
|
||||
**Processing inboxes is the polyfill's job, not the app's** (PO, 2026-07-30). As soon as the app connects a user/wallet, the polyfill must process that identity's inboxes itself — the consumer should never have to remember to do it for its shared documents to become readable.
|
||||
|
||||
And it is inboxes, plural, at **two levels**:
|
||||
|
||||
- the **user/wallet** inbox — where ReadCaps arrive;
|
||||
- the inbox of **every document the user owns** — a document is addressable and has its own inbox upstream, so deposits land per document too.
|
||||
|
||||
What exists today and is reusable: `inbox.readSynced` (the cold, barrier-gated read meant exactly for "process the inbox at (re)connection"), the inline cap absorption in `inbox.read`, and `CapRegistry.onChange` to re-trigger the reads a late cap unblocks. What is missing is the **orchestration**: a connection hook that enumerates the identity's inboxes (wallet-level + one per owned document, via the scope indexes) and processes them, idempotently and without polling.
|
||||
|
||||
Not started. It changes the consumer contract in the right direction (one less obligation), so it should land before the consumer re-architecture settles.
|
||||
|
||||
## Decisions taken while implementing, none contradicting the spec
|
||||
|
||||
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
|
||||
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
|
||||
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes `inbox_post_link` — a real SDK method — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
|
||||
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
|
||||
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
|
||||
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
|
||||
|
||||
## Verification status
|
||||
|
||||
- **Unit suite green — 138 tests**, typecheck clean on `src`, `test` and the e2e harness.
|
||||
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)` → `shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
|
||||
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
|
||||
- **The e2e was updated but NOT run** — it needs a real broker. `capsReadFilter` was rewritten around possession and a new `capsShareCap` step exercises the full share→inbox→absorb path against the real broker; both await a run.
|
||||
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
|
||||
|
||||
## Documentation state
|
||||
|
||||
The permanent documentation was updated in the same pass (root `README.md`, `packages/client/README.md`, `docs/simulation.md`, `docs/migration-guide.md` §1 + the assumed `declareConnections` break, `docs/read-model.md`, `docs/readcap-and-nuri-model.md` §5, `docs/nextgraph-current-state.md`, `packages/client/docs/sdk-reference.md`). **If the review changes the surface, those are the files to re-align** — they describe the code as it stands now, not a validated state.
|
||||
|
||||
---
|
||||
|
||||
Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`).
|
||||
|
||||
@@ -31,11 +220,11 @@ Every invented name is **vocabulary debt**: the reader has to carry a translatio
|
||||
|
||||
### 1. Types — one new name
|
||||
|
||||
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:k:` segment**.
|
||||
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:r:` segment** *(the spec said `:k:`; corrected 2026-07-30 on a report from NextGraph's developer — `:k:` belongs to objects/files/commits, a ReadCap is `r:{base64url(serde_bare(ObjectRef))}`, `repo/types.rs:518`)*.
|
||||
|
||||
```ts
|
||||
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
|
||||
type ReadCap = string // …:k:{key} — names AND reads
|
||||
type ReadCap = string // …:r:{cap} — names AND reads
|
||||
```
|
||||
|
||||
`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Brief — align on NextGraph's own model: users, stores, branches
|
||||
|
||||
> ## REFUTED by adversarial review, 2026-07-30 — do not implement as written
|
||||
>
|
||||
> Seven defects, four of them factual errors about NextGraph. The three that break the plan outright:
|
||||
>
|
||||
> 1. **D4 rests on a false premise.** There IS a register for received caps: `AddLink { read_cap }` on the **User branch** of the private store — *"so that a user can share with all its device a new Link they received… Only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`, verifier arm `commits/mod.rs:681`). It is wallet-resident and **cross-device** — the exact opposite of D4's per-browser localStorage. Corrected in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies.
|
||||
> 2. **D2's rejection of the named graph is factually wrong.** A `GRAPH <…:v:…:b:…>` quad IS resolved to that branch and committed on **its own** topic (`engine/verifier/src/commits/transaction.rs:386-434`); the verifier does exactly this in `update_header`. An unknown branch id errors rather than silently landing on Main. And a branch **is** a valid SPARQL-update target: `TargetBranchV0::is_valid_for_sparql_update` returns true for `BranchId` (`engine/net/src/app_protocol.rs:77-82`) — the fact table's claim to the contrary was wrong twice over.
|
||||
> 3. **The Store branch holds no triples at all.** `BranchCrdt::None`, *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`). It is a stream of service commits (`AddRepo`/`RemoveRepo`), not a graph — so all three D2 candidates were RDF inventions dressed as fidelity.
|
||||
>
|
||||
> And four more, all confirmed:
|
||||
>
|
||||
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
|
||||
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
|
||||
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
|
||||
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
|
||||
>
|
||||
> Also flagged: "store" already means the *native* store in this codebase (`RegistrySession.privateStoreId`), so D1's `privateStore`/`storeDoc` collide head-on; "the keyring notion disappears" contradicts `readcap-and-nuri-model.md` §4quater, which calls the Store branch the owner's keyring; upstream `ldp#contains` takes an **IRI** object while the polyfill writes a **literal**, so D1 is not "nothing behavioural".
|
||||
>
|
||||
> Rewrite required. The verified facts are being folded back into `readcap-and-nuri-model.md` first; the plan is re-derived from there, not from this text.
|
||||
|
||||
**Status: REFUTED 2026-07-30 — superseded, kept as the record of what was wrong. Original header follows.**
|
||||
|
||||
**Status: plan, not started. 2026-07-30.** Companion to [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) (whose steps 3–4 are still pending) and to the caps chantier. This lot is about saying — and storing — what NextGraph says and stores, instead of a parallel vocabulary of our own.
|
||||
|
||||
## Why this lot exists
|
||||
|
||||
Two PO corrections, 2026-07-30:
|
||||
|
||||
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||
|
||||
> I don't like the name "scope container". Let's keep NextGraph's names. We adapt for the polyfill when we need to, but there too we must stay as close as possible.
|
||||
|
||||
And the vocabulary correction underneath both:
|
||||
|
||||
> In the NextGraph code "wallet" is called "user", because a wallet is only a keyring. Virtual user → **virtual user**; physical user → **physical user**.
|
||||
|
||||
The library currently invents three things NextGraph does not have — a *keyring*, a *scope index*, a *virtual user* — and recomputes a key NextGraph stores. Each invention is a translation table a reader must carry, and each is a place where the consumer learns a model that will not exist.
|
||||
|
||||
## Verified facts this plan rests on
|
||||
|
||||
All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies. The load-bearing ones:
|
||||
|
||||
| Fact | Source |
|
||||
|---|---|
|
||||
| A wallet holds SEVERAL users: `SensitiveWalletV0.sites: HashMap<String, SiteV0>` | `engine/wallet/src/types.rs:434,457` |
|
||||
| A user (site) owns the three stores; `UserId = PubKey` | `engine/verifier/src/site.rs:23`; `engine/repo/src/types.rs:453` |
|
||||
| The wallet holds ONE root key per user — the private store's read cap | `site.rs:52` (`get_individual_site_private_store_read_cap`) |
|
||||
| `doc_create` writes TWICE: `AddRepo { read_cap }` on the **Store branch**, and `ldp:contains` on the **Main branch** | `engine/verifier/src/request_processor.rs:697-710`; `verifier.rs:2172-2199` |
|
||||
| Replaying the Store branch is what reloads the repos with their keys | `engine/verifier/src/commits/mod.rs:644-664` |
|
||||
| A branch is a NAMED GRAPH `did:ng:o:{repo}:v:{overlay}:b:{branch}`, with its own pub/sub topic and CRDT | `engine/net/src/app_protocol.rs:267-273`; `engine/repo/src/types.rs:1478-1501` |
|
||||
| Anchoring on a document targets its **Main** branch | `request_processor.rs:161-168` |
|
||||
| SPARQL cannot be anchored on a branch — every other target is `unimplemented!()` | `request_processor.rs:283` |
|
||||
| A `Store` branch exists only on a real store, created on a path `doc_create` does not take | `engine/repo/src/store.rs:425-440` |
|
||||
| A received cap has NO register: `ContactDetails.read_cap` is never read by the receiver | `engine/verifier/src/inbox_processor.rs:778-847` |
|
||||
| Durability of a received cap comes from OPENING the repo, which persists its `read_cap` in local user storage | `engine/verifier/src/user_storage/repo.rs:109,219,248,359`; `verifier.rs:542-544` |
|
||||
|
||||
**Not verified, and therefore not relied on anywhere below**: whether a `GRAPH <…:b:…>` write from the JS SDK round-trips through a real broker. It is assumed BROKEN and avoided.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Vocabulary follows NextGraph
|
||||
|
||||
| Ours today | Becomes | Why |
|
||||
|---|---|---|
|
||||
| virtual user | **virtual user** | a wallet is a keyring; what owns three stores is a user (site) |
|
||||
| physical user | **physical user** | NextGraph sees exactly one user; our identities are virtual ones |
|
||||
| keyring (`CapRegistry`) | *(the notion disappears — see D3)* | there is no keyring object in NextGraph, and the wallet does NOT hold every key |
|
||||
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
|
||||
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
|
||||
|
||||
`docPublic` / `docProtected` / `docPrivate` on `AccountRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
|
||||
|
||||
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
|
||||
|
||||
The store document gains, beside its `ldp:contains` list, the read cap of each document it lists — the emulation of `AddRepo { read_cap }` on the Store branch.
|
||||
|
||||
Three candidate shapes were considered:
|
||||
|
||||
- **A distinct named graph** (`GRAPH <…:b:store>`) — **rejected**. No branch would exist behind it; the content would be committed on the anchor's Main branch while claiming to live elsewhere. It misrepresents the structure, and its sync behaviour against a real broker is unverified (see above).
|
||||
- **A separate document per branch** — structurally closest (a document is what the JS SDK gives us that most resembles a branch: own topic, own sync, independently addressable), but it doubles the documents per store and adds an indirection to resolve them, to imitate a second pub/sub flow the polyfill will not use.
|
||||
- **A distinct subject in the same document** — **chosen**. It buys what actually matters: the key stored *next to* the document, separate from the list, read rather than recomputed. It does not buy a second event flow, which the polyfill cannot reproduce anyway.
|
||||
|
||||
*The honest cost of this choice*: our two "branches" share one commit stream and one topic, where NextGraph has two. Anything that comes to depend on them being separately subscribable will have to move to the separate-document shape.
|
||||
|
||||
### D3 — Deriving becomes minting, and `CapRegistry` stops being a keyring
|
||||
|
||||
Today `fileOwnCaps` **re-derives** each cap from its NURI, which only works because the emulated key is a function of the NURI. After D2 the cap is **read from the store document**. Derivation survives only inside `nuri.ts` as how a stand-in value is *minted at creation* — the single function P1b replaces with a real key.
|
||||
|
||||
The in-memory `CapRegistry` then stops being "the keyring" and becomes what it actually mirrors: **the verifier's local user storage** (fact table, last row) — the per-user cache of every opened repo and its read cap.
|
||||
|
||||
### D4 — Received caps: persist as local user storage, not as a document
|
||||
|
||||
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
|
||||
|
||||
So the emulation is a **local, per-virtual-user store** — the same nature as `accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
|
||||
|
||||
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1–D3 to be correct.
|
||||
|
||||
## Plan
|
||||
|
||||
1. **D1 vocabulary**, mechanically and in one pass — code, tests, docs. Nothing behavioural. Doing it first stops every later diff from being written in two vocabularies.
|
||||
2. **D2 + D3**: the store document carries each listed document's cap; `fileOwnCaps` reads it instead of re-deriving; `mintCap` keeps minting at creation only. Round-trip test: create → drop all in-memory state → re-list → the cap comes back **read, not recomputed** (assert by minting a *different* stand-in value in the test and checking the stored one wins).
|
||||
3. **D4** local per-user persistence of opened caps, replacing inbox re-reading.
|
||||
4. Then resume the boundary lot's steps 3–4 (guard at the four passage points; remove the cross-account fan-out), which are written in the new vocabulary.
|
||||
|
||||
## What this breaks
|
||||
|
||||
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `AccountRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
|
||||
|
||||
## Risks I want challenged
|
||||
|
||||
- D2's "distinct subject" may be too weak a reading of "stay close to NextGraph" — the separate-document shape is defensible and I may be under-weighting it.
|
||||
- D3 assumes reading the stored cap is always possible where deriving was — i.e. that every path reaching `fileOwnCaps` has the store document at hand.
|
||||
- D4 introduces browser-local state to a library that currently keeps everything in the shared wallet; that may be a bigger departure than it looks.
|
||||
- The vocabulary change touches the boundary brief and the P1a brief, which are mid-flight.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Brief — the virtual user boundary
|
||||
|
||||
**Status: specified 2026-07-30; all four steps done.** Sits alongside `2026-07-20-caps-emulation-alignment.md` (the wider caps chantier) and `2026-07-27-p1a-cap-surface.md` (the surface, implemented). This lot is about something more fundamental than either: **what a virtual user is allowed to reach.**
|
||||
|
||||
## Why this lot exists
|
||||
|
||||
A virtual user must **simulate the boundary of the future single-user wallet**. Today it does not: it is a grouping fiction — a shim account listing three index documents — and nothing enforces it. Every access function reaches any document of any identity, given a session id and a NURI.
|
||||
|
||||
Stated by the PO on 2026-07-30, on discovering the state:
|
||||
|
||||
> A virtual user must simulate the boundary of the future mono-user wallet. So the access functions must all be restricted to the virtual user currently "connected" (`setCurrentUser`). No cross-wallet access may be permitted, otherwise we are building on a fundamentally wrong model.
|
||||
|
||||
This is the same failure mode the whole caps chantier exists to prevent, one level down. P1a fixed the *shape* of reading (possession, not an ACL). It left the *reach* unbounded — and a consumer coded against an unbounded reach is coded against a world that will never exist, exactly like one coded against an ACL.
|
||||
|
||||
## The rule
|
||||
|
||||
> **The only reads/writes not confined to a virtual user are those that make multi-wallet operation possible at all** (e.g. the index of virtual users). — PO, 2026-07-30
|
||||
|
||||
And its sharpened form, which decides the hard cases:
|
||||
|
||||
> **Nothing common — only indexing mechanisms to make the virtual users work.** — PO, 2026-07-30
|
||||
|
||||
So an exemption must be *plumbing*, never *pooled user data*. The test: **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 living outside a wallet. The shim passes (remove it and no wallet is resolvable); a shared index of user announcements does not (remove it and every wallet still works — you simply have to be given links).
|
||||
|
||||
Everything else is confined. The exemption list is short, explicitly named, and each entry has to justify itself against those two sentences — an exemption that merely *helps* is not an exemption.
|
||||
|
||||
## The premise that collapsed: there is no discovery
|
||||
|
||||
Recorded here because it removes a whole module rather than guarding it (PO, 2026-07-30 — see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis, where the principle is documented in full):
|
||||
|
||||
> **You cannot discover. You can only follow links.** NextGraph is local-first: publishing is *place the data in your public store* **and** *circulate the link* — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. Private distribution is the same act plus the ReadCap.
|
||||
|
||||
`discovery.ts` therefore fails on **both** counts: it emulates a global-list capability the target will never have, and it is pooled user data across wallets. It is not a boundary to guard, it is a module to remove — with `watchShape('public')`'s discovery fold, `INDEX_ACCOUNT`, and the `submitToIndex` guard along with it.
|
||||
|
||||
What replaces it is not a mechanism but the model itself: a link reaches you through an **inbox**, or through a document you already hold. Which makes the inbox the bootstrap of the entire reachability graph — the reason its guard (below) and its automatic processing matter more than they first appear.
|
||||
|
||||
*Consequence for P1a's acceptance test, resolved*: `test/watch-shape.test.ts` (e) proved "a harvested bare reference reads nothing, the repo link reads the document" **on the discovery fold**. The property is independent of discovery and survives — `test/cross-user-access.test.ts` already proves it on the model's own terms (Bob follows a reference found in a document he holds), so (e) was dropped rather than re-based.
|
||||
|
||||
## The good news: the boundary already exists
|
||||
|
||||
**The keyring is the boundary.** A document is legitimately reachable when `capFor(doc)` answers — either because this wallet created it (its scope index refiles the cap, the emulated `AddRepo { read_cap }`) or because someone delivered the cap to it. No new notion is needed; the guard is written. What is missing is applying it.
|
||||
|
||||
And the surface to guard is small. Everything in the library reaches NextGraph through **four functions in two modules**:
|
||||
|
||||
- `docs.docCreate`, `docs.sparqlUpdate`, `docs.sparqlQuery`
|
||||
- `subscribe`'s `ng.doc_subscribe`
|
||||
|
||||
Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exists; `ng-proxy` is the app-facing proxy.)
|
||||
|
||||
## What is confined, and what is exempt
|
||||
|
||||
**Exempt — each one passes the rule:**
|
||||
|
||||
| Exemption | Why it makes multi-wallet operation possible |
|
||||
|---|---|
|
||||
| The store-root **pointer** + the **doc-shim**, to resolve THE CURRENT account | This is the index of virtual users. Without it no virtual user is resolvable at all. **Resolution only** — enumerating every account is not covered (see below). |
|
||||
| The **reserved accounts** (the inbox anchor; `@index` is gone with `discovery.ts`) | They host infrastructure documents; they are not anybody's wallet. |
|
||||
| **Depositing** into another wallet's inbox (write-only) | Without a cross-wallet write channel there is no sharing, hence no useful multi-wallet — and it carries no pooled data: a deposit is addressed to one wallet, not shared between them. This IS the NextGraph model: an inbox deposit is anonymous and sealed, and grants the depositor nothing in return. |
|
||||
|
||||
~~Reading a discovery index~~ — **withdrawn 2026-07-30**, on both counts: it emulates a capability that does not exist, and it is pooled user data. See *The premise that collapsed* above.
|
||||
|
||||
**Confined — none of these passes the rule:**
|
||||
|
||||
| Path | Today | Becomes |
|
||||
|---|---|---|
|
||||
| `docs.sparqlQuery` / `sparqlUpdate` (**exported from the SDK entry**) | any document, any wallet | guarded on the anchor: the cap must be held |
|
||||
| `inbox.read` / `readSynced` / `watch` | **any inbox, including someone else's** | only inboxes belonging to the current wallet |
|
||||
| `subscribeDoc` | any document | only documents whose cap is held |
|
||||
| `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too |
|
||||
| `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs |
|
||||
| `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
|
||||
| `readModel.readUnion` | ✅ already guarded (P1a) | unchanged |
|
||||
|
||||
## The breach P1a opened, and which this lot must close first
|
||||
|
||||
`inbox.read` has no guard, and since P1a it **absorbs caps into the reader's keyring**. So:
|
||||
|
||||
```ts
|
||||
setCurrentUser("mallory");
|
||||
await inbox.read(bobsInbox); // mallory pockets the caps addressed to Bob
|
||||
```
|
||||
|
||||
Directed sharing is therefore defeatable by anyone who knows an inbox NURI. Strictly speaking this is not a regression — the inbox was never guarded — but before P1a it carried nothing that granted access, and now it does. **This is the first thing to fix**, and it is arguably P1a's own debt rather than this lot's.
|
||||
|
||||
Closing it needs a notion that does not exist yet: **"my inbox"**. Today an inbox is an arbitrary NURI supplied by the caller. Which is the same brick as the PO's other instruction, so they should land together:
|
||||
|
||||
> Processing inboxes is the polyfill's job, not the app's — as soon as the app connects a user/wallet, at **two levels**: the wallet inbox (where ReadCaps arrive) and the inbox of **every document the user owns**.
|
||||
|
||||
## Design notes for the implementation
|
||||
|
||||
**Scaffolding in the keyring, not in the exemption list.** The current account's three scope-index documents belong to its wallet, so they should be *in its keyring* rather than exempted. That keeps the exemption list down to what genuinely serves multi-wallet operation (the shim, the reserved accounts). Only the pointer and the doc-shim stay outside.
|
||||
|
||||
**Exemptions are named, never inferred.** A NURI is exempt because it is *the* shim document or *a* reserved account's document, resolved as such — never because it "looks like infrastructure". An inferred exemption is a hole.
|
||||
|
||||
**Write-only really means write-only.** Depositing into another wallet's inbox must not make that inbox readable, subscribable, or listable as a side effect. This is the one asymmetric permission in the model and it needs its own test.
|
||||
|
||||
**The guard belongs to a function, not to a position.** One named predicate ("may the current wallet reach this document?"), called at each of the four passage points — not four inline checks that drift apart.
|
||||
|
||||
## Hardening is the polyfill's responsibility, not a negotiation with the consumer
|
||||
|
||||
Stated by the PO on 2026-07-30, closing the question "what does the app actually use?":
|
||||
|
||||
> We do not need to know what the app uses: the polyfill must harden **everything it exposes**. That is its responsibility. **Nothing may allow its own mechanisms to be bypassed** — the virtual user in particular.
|
||||
|
||||
This settles how the lot proceeds, and it generalizes past it. A surface that lets a caller go around the wallet boundary does not merely risk misuse: it **teaches a model that will not exist**, which is the one thing this library exists to prevent. So an exposed function that can bypass a mechanism the polyfill provides is a defect *whether or not anyone calls it that way*, and "the consumer might depend on it" is not an argument for keeping it — if the consumer depends on it, the consumer depends on something the target will refuse.
|
||||
|
||||
Applies to every exported surface, including ones added later: **if it is exposed, it is guarded**.
|
||||
|
||||
## What this breaks
|
||||
|
||||
`docs.*` is exported from the SDK-identical entry and can reach any document; the cross-account fan-out enumerates every wallet; `discovery.*` disappears entirely. The consumer will have to change where it relied on any of them. That is the point, not a side effect: each one is the API starting to tell the truth about a boundary that will exist. Update `../migration-guide.md` accordingly.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. ~~**Remove `discovery.***~~ — **DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
|
||||
2. ~~**"My inbox" + the inbox read guard**~~ — **DONE 2026-07-30.** `storeRegistry.walletInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open** — `post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
|
||||
|
||||
*Not done, and deliberately*: per-DOCUMENT inboxes. Upstream every document has one; here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
|
||||
|
||||
### Two defects this step surfaced, both open
|
||||
|
||||
**`walletInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
|
||||
|
||||
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
|
||||
|
||||
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||
|
||||
So this is not a P1b concern, it is a **shape** concern — the one thing this library exists to get right — and the stand-in key is stored exactly where the real one will be. Two storage sites, mirroring upstream:
|
||||
|
||||
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
|
||||
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
|
||||
3. ~~**The guard at the four passage points**~~ — **DONE 2026-07-30.** `src/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
|
||||
|
||||
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
|
||||
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
|
||||
|
||||
The redundancy is the point, and a test pins it: a caller that forgets rule 2 is still refused by rule 1, so a bookkeeping lapse fails loudly instead of succeeding quietly.
|
||||
|
||||
**Possession decides, never the shape of the reference in hand.** A caller legitimately manipulates a bare NURI while holding its cap elsewhere — references travel bare through content and indexes, the cap sits in what the user holds. `targetOf` first, so both forms answer alike. (An earlier reading of the directive checked the string for `:r:` instead; corrected on the PO's clarification.)
|
||||
|
||||
Exemptions are **declared**, never inferred from a NURI's shape (`declareInfrastructure`, called by the store-registry for the store-root pointer and the doc-shim — the index of virtual users, the only thing that passes the "remove it and no user resolves at all" test). A user also reaches its own three stores and its own inbox, or the boundary would lock it out of itself.
|
||||
|
||||
Not done: `subscribeDoc` is not yet guarded — it interacts with `ensureRepoOpen`, which opens documents before their cap is known on some cold-start paths. Left for step 4 with the barrier tests in view.
|
||||
4. ~~**Remove the cross-account fan-out**~~ — **DONE 2026-07-30.** `listEntityDocs`, `resolveReadGraphs`, `allAccounts`, `loadShim` and the full-shim cache are gone. Nothing in the library used them any more once `discovery` was removed; only their own tests did. `subscribeDoc` is now guarded too (rule 1) — a subscription IS an access, since the push carries the document's state, so leaving it open would have been a door beside the gate.
|
||||
|
||||
### Machinery vs virtual user: two APIs, and only one is the app's
|
||||
|
||||
The PO's framing, which replaced the exemption list entirely:
|
||||
|
||||
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
|
||||
|
||||
`src/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
|
||||
|
||||
The dividing line:
|
||||
|
||||
> Does this operate on the **index of virtual users** (the shim), or on the **content of one virtual user**? The first is machinery; everything else is the user's, and is confined.
|
||||
|
||||
So the store-root pointer, the doc-shim and the account records go through the machinery; a virtual user's stores, its inbox and its documents go through the guarded `docs.*`, even though the library is what calls them on the user's behalf.
|
||||
|
||||
**This is strictly stronger than the exemption list it replaces.** `declareInfrastructure` is deleted. The machinery no longer calls the guarded primitive and gets waved through — it calls a different primitive that was never guarded. There is no list to widen, to get wrong, or to infer from a NURI's shape, and the boundary now has no `if` in it that could be talked into saying yes.
|
||||
|
||||
5. ~~**The Link, and inbox processing on connection**~~ — **DONE 2026-07-30**, after the four steps above.
|
||||
|
||||
**The Link.** Giving access is a `Link` deposited into the recipient's inbox — upstream's word at all three stages (`InboxMsgContent::Link` for the message, `AddLink { read_cap }` for the filing, `RemoveLink` for the withdrawal). `shareCap` deposits one; the deposit kind is `…:inbox:link`.
|
||||
|
||||
**Applying it durably.** `storeRegistry.addLink` / `readLinks` emulate `AddLink` on the **User branch of the private store** — a distinct subject (`shim:userBranch`) in the private store document, kept separate from the `ldp:contains` listing exactly as upstream keeps the User branch separate from Main. Idempotent, so re-processing costs nothing.
|
||||
|
||||
**The split that matters**: `inbox.read` KEEPS a Link (in the session's keyring) but does not FILE it — reading a queue must not write to a user's store. `inbox.processInbox` *applies*: it reads, then files. That is what an inbox is upstream — **a queue you consume, not a store you re-read**.
|
||||
|
||||
**On connection.** `setCurrentUser` fires `connect.connectedUser()`: restore the already-applied Links from the User branch, then drain the inbox. Restore-first means a reconnecting user reads its shared documents immediately, without waiting on the queue. Fire-and-forget, because the setter is synchronous and every consumer calls it from synchronous code — the work announces itself through `CapRegistry.onChange`, which `watchShape` already listens to. `connectedUser()` is exported for a caller that needs to await it.
|
||||
|
||||
**Two things it deliberately does NOT do.** It does not **provision**: connecting an identity that does not exist creates nothing (`resolveAccount`, not `ensureAccount`) — otherwise connecting would mint a user's stores and caps as a background side effect, arming the whole emulation at a moment nothing controls. And it does not drain **per-document** inboxes, which do not exist yet.
|
||||
|
||||
Proven by `test/cross-user-access.test.ts`: a cap shared to Bob survives **with his inbox emptied** and every in-memory cap dropped — restored from the User branch, not from the queue.
|
||||
|
||||
*Cost noted*: `setCurrentUser` now has observable asynchronous effects (it reads, and it logs). Three log-assertion tests had to await `connectedUser()` before counting lines. That is the honest price of moving the obligation off the app, and it is worth naming rather than discovering.
|
||||
|
||||
6. ~~**The Store branch**~~ — **DONE 2026-08-03.** A document's cap is now STORED when it is created — `shim:readCap` on a `storeBranch` subject of the store document, the emulated `AddRepo { read_cap }` — and READ back by `listMyEntityDocs`, never recomputed. That closes the asymmetry left by the Link work, where received caps were filed durably while created ones were re-minted.
|
||||
|
||||
Two things this pinned down, both of which would have cost more later:
|
||||
|
||||
- **The listing and the keys stay separate**, as Main and Store branches are upstream: `contains` on one subject, `readCap` on another, written as two statements because upstream they are two commits.
|
||||
- **Creation mints the cap exactly once.** It used to mint twice — once to write, once to hold — which agreed only because the stand-in value is a constant. With P1b's real key those would be two different keys, and a creator would hold one that does not open its own document. A test pins it, and another proves the cap is read rather than recomputed by corrupting the stored value and checking the corruption wins.
|
||||
|
||||
Honest about the emulation: upstream the Store branch carries **no triples at all** (`BranchCrdt::None`). Representing it as RDF is ours; what is faithful is the storage beside the document and the separation from the listing.
|
||||
|
||||
7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
|
||||
|
||||
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
|
||||
|
||||
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
|
||||
|
||||
## Relation to P1b
|
||||
|
||||
P1b (per-document encryption, closing the read paths that bypass the guard) largely **becomes** this lot, better framed. Encrypting each document while leaving the wallet boundary open would be securing the windows with the door open — and conversely, once every access is confined to the connected wallet, "the emulated key is derivable" stops being the load-bearing weakness. This lot should therefore precede P1b, or absorb it.
|
||||
@@ -1,6 +1,23 @@
|
||||
# ADR — Discovery mechanism (inbox-fed index, fan-out)
|
||||
|
||||
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
|
||||
> ## SUPERSEDED — 2026-07-30. The premise does not hold.
|
||||
>
|
||||
> **There is no discovery in NextGraph. You cannot discover; you can only follow links** (PO, 2026-07-30 — the principle is documented in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts: place the data in your public store, **and** circulate the link — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. This is a foundation of local-first, not a gap to be filled.
|
||||
>
|
||||
> A global index therefore fails on **two independent counts**:
|
||||
>
|
||||
> 1. it emulates a capability the target will never have — teaching consumers a model that does not exist, which is the one failure mode this library exists to prevent;
|
||||
> 2. it is **data common to several users/wallets**, and nothing may be common — only indexing mechanisms that make the virtual users work (the shim qualifies; a shared index of user announcements does not).
|
||||
>
|
||||
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
|
||||
>
|
||||
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
>
|
||||
> What survives, and is worth keeping from the text below: the **3-stage frame** (`discovery → synchronization → query`) is still exactly right, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. You still cannot query what you have not synchronized, and you still do not synchronize what nobody gave you. The **inbox** is what feeds stage 1 — which makes it the bootstrap of the whole reachability graph, not a side feature.
|
||||
>
|
||||
> Kept in full below as a record of what was built and why, and of the reasoning that has to be re-read through the correction above.
|
||||
|
||||
**Date:** 2026-06-16 · **Status:** SUPERSEDED 2026-07-30 (see the block above). *Originally: mechanism accepted; target owner undecided.*
|
||||
Ported here for the discovery mechanism it defines — the piece this lib
|
||||
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
|
||||
product intent (what a consumer application *should* surface) is the consumer
|
||||
|
||||
+43
-9
@@ -14,13 +14,33 @@ has no clear target image, that is a drift signal (see
|
||||
## Checklist
|
||||
|
||||
### 1. Emulated ReadCaps → real capabilities
|
||||
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
|
||||
broker/verifier enforces them, and `useShape` already returns only authorized
|
||||
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
|
||||
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
|
||||
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
|
||||
access unit is already the document (`@graph`), matching the native per-repo cap
|
||||
model, so this is a data step, not a reshape.
|
||||
The shape is already the target's (P1a): a `ReadCap` is the document's key, a
|
||||
each identity holds a set of caps, and there is no read-ACL anywhere. So
|
||||
this step swaps the *emulated* key for the real one, not the model:
|
||||
|
||||
- `caps.ts`'s per-identity record becomes the verifier's own local user storage —
|
||||
it was always the cache, not the register. The two durable registers we emulate
|
||||
(`readCap` on the store's Store branch, `link` on its User branch) become the real
|
||||
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
|
||||
already hold them.
|
||||
- `nuri.ts`'s stand-in cap value — the constant `OK` — becomes the real
|
||||
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
||||
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
||||
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||
- `shareCap(cap, toInbox)` becomes the native sealed delivery (`inbox_post_link`
|
||||
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
||||
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||
change.**
|
||||
- `publishRepoLink` becomes `RepoLinkV0`.
|
||||
- The read filter (`read-filter.ts`) and the possession gate in
|
||||
`read-model.readUnion` are then dead code — the broker only delivers documents
|
||||
whose cap the wallet holds. Remove them.
|
||||
- The write guard (`ng-proxy.ts` `sparql_update` override) is a separate axis and
|
||||
is decorative today (every internal writer bypasses the proxy); it belongs to the
|
||||
P1b batch, not here.
|
||||
|
||||
The access unit is already the document (`@graph`), matching the native per-repo cap
|
||||
model, so this is a key-material step, not a reshape.
|
||||
|
||||
### 2. Place documents in real native stores
|
||||
Today `docCreate(..., undefined)` writes every document into the shared wallet's
|
||||
@@ -39,7 +59,7 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||
- At that point `store-registry.ts` maps `(account, scope)` to the user's real
|
||||
store NURI instead of a document in the shared wallet; the per-scope index
|
||||
document (the store-container emulation) is replaced by the store itself. The
|
||||
surface facing the consumer application (`createEntityDoc`, `listEntityDocs`,
|
||||
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
|
||||
resolvers) is designed to survive that swap unchanged.
|
||||
|
||||
### 3. Drop the resolver / shim
|
||||
@@ -80,9 +100,23 @@ resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical
|
||||
no consumer code changes. The one non-SDK call — `configure(...)` /
|
||||
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
|
||||
|
||||
## The one break already taken: `declareConnections`
|
||||
|
||||
P1a broke the consumer once, deliberately and early, so that migration would not.
|
||||
The old surface was an ACL held in memory, which forced the consumer to re-declare
|
||||
every grant on every session (`declareConnections`). That call **disappears**: with
|
||||
delivered caps the grant moves to the moment a connection is *accepted* — one
|
||||
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
|
||||
the delivery lives in the recipient's inbox rather than in a map that empties at
|
||||
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
||||
|
||||
This is a consumer **re-architecture**, not an API swap, and it is the price of
|
||||
being coded against a model that will exist. Nothing else about the migration below
|
||||
touches consumer code.
|
||||
|
||||
## What does not change
|
||||
|
||||
The consumer application's code. Shapes, screens, the *acts* of granting
|
||||
The consumer application's code. Shapes, screens, the *acts* of sharing
|
||||
access, entity→scope mapping, the relationship graph — all injected, all untouched.
|
||||
Migration is entirely inside this library plus removing the alias + the bootstrap
|
||||
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
|
||||
|
||||
@@ -83,8 +83,10 @@ users/quorum (write/permissions), **not** read-cap possession. (Repos of a
|
||||
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
|
||||
> union / reactive read returns empty (the repo is never decrypted), while a
|
||||
> targeted read of an unheld repo returns `RepoNotFound`. There is no
|
||||
> cap-introspection API — the polyfill's `canRead` / `governsRead` are
|
||||
> emulation-only, with no NextGraph API behind them.
|
||||
> cap-introspection API, and there is nothing to introspect: reading is key
|
||||
> possession, so the polyfill asks the only question the model admits —
|
||||
> `capFor(doc)`, "do I hold it?". Its *implementation* is emulation-only; its shape
|
||||
> is the target's.
|
||||
|
||||
### Store ↔ document confusion (recurring)
|
||||
|
||||
@@ -662,7 +664,7 @@ anchored `sparql_query` against a document written in an earlier session comes b
|
||||
**0 rows and no error** — persisted documents read as empty. Observed on every anchored
|
||||
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
|
||||
`packages/client/src/open-repo.ts`): the discovery index (`discovery.ts` `readIndex`),
|
||||
the per-scope index (`store-registry.ts` `readScopeIndex`), the by-need doc batch
|
||||
the user's store (`store-registry.ts` `readUserStore`), the by-need doc batch
|
||||
(`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts`
|
||||
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
|
||||
barrier) → THEN the anchored read, and it is verified to return the data.
|
||||
|
||||
+22
-22
@@ -37,31 +37,29 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
|
||||
- No reactive union query, and the reactive ORM hangs if handed a per-entity
|
||||
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
|
||||
|
||||
## Two read regimes — enumerate vs follow
|
||||
## One read regime — follow, never enumerate
|
||||
|
||||
There is **no cross-wallet read** in current NextGraph, so nothing is globally
|
||||
enumerable "for free". The polyfill splits every list into one of two regimes:
|
||||
There is **no cross-wallet read** in current NextGraph, and there is no discovery
|
||||
either: **you cannot discover, you can only follow links**
|
||||
([`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Nothing is
|
||||
globally enumerable, and nothing is meant to be.
|
||||
|
||||
### Events (all public) = the global index — the one enumeration hack
|
||||
> An earlier version of this document described a second regime — "all public
|
||||
> events, enumerated through a global index" — presented as the one justified
|
||||
> "hack". It was removed on 2026-07-30 along with `discovery.ts`: a global index
|
||||
> emulates a capability the target will never have, and it pools data across
|
||||
> wallets. A public document is reached because someone circulated its link, never
|
||||
> because it was listed.
|
||||
|
||||
Public events are the only thing enumerated across accounts, via the emulated
|
||||
discovery index (`discovery.readIndex`, see
|
||||
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one
|
||||
"hack", and it is justified precisely because P2P has no cross-wallet read: without
|
||||
a shared index a client could never learn that another account's public event-doc
|
||||
exists. `readIndex` yields the event-doc NURIs to open/sync; those repos
|
||||
then enter the local union and become union-queryable.
|
||||
|
||||
### Everything else = follow a graph, never enumerate across accounts
|
||||
### Everything = follow a graph, never enumerate across accounts
|
||||
|
||||
My participations / my profile, protected data an owner has granted me, my
|
||||
notifications — none of these is enumerated across accounts. Each is reached by
|
||||
what is already reachable to me:
|
||||
|
||||
- my own docs (always in `self.repos`);
|
||||
- docs an owner has granted me via a directed per-document read grant
|
||||
(`grantRead(doc, granteeId)` — see the per-document ReadCap in
|
||||
[`simulation.md`](./simulation.md));
|
||||
- my own docs (always in `self.repos`, and whose caps what I hold holds);
|
||||
- docs whose cap an owner has delivered to my inbox (`shareCap` — see the
|
||||
per-document ReadCap in [`simulation.md`](./simulation.md));
|
||||
- my inbox (deposits addressed to me).
|
||||
|
||||
The rule of thumb: access is not discovery. You only union-query over graphs you
|
||||
@@ -71,14 +69,16 @@ Accessing a document without read rights yields an empty result: a reactive / un
|
||||
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
|
||||
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
|
||||
in one way — it raises `RepoNotFound` rather than returning empty — and the read
|
||||
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection
|
||||
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API
|
||||
behind it, so it has no migration target.
|
||||
path tolerates that per-doc (a doc that throws is skipped). The held-caps lookup used
|
||||
here (`capFor`) is emulation-only in its *implementation*; its shape is the target's
|
||||
(possession), so what disappears at migration is the lookup, not the model. Note
|
||||
there is deliberately no "may identity X read doc D?" call: the real model cannot
|
||||
answer that either.
|
||||
|
||||
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
|
||||
|
||||
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded
|
||||
event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its
|
||||
To produce a list, take the bounded, by-need set of doc NURIs (my own docs, and the
|
||||
NURIs whose cap someone delivered to me) and read each one with its
|
||||
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
|
||||
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
|
||||
one repo's graph, so each read is O(1) in the doc's own size and independent of how
|
||||
|
||||
+174
-22
@@ -4,6 +4,20 @@
|
||||
|
||||
Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
|
||||
|
||||
> ## How to use this document — verify, never infer
|
||||
>
|
||||
> **NextGraph works very differently from what general knowledge of distributed systems suggests.** Assert nothing about it that is not, at minimum, in this repository's docs — and preferably read in `nextgraph-rs` itself, with a `file:line`. Reasoning by analogy with git, with ACL systems, with pub/sub brokers, or with "how this normally works" produces confident, wrong statements. Every correction recorded below started that way.
|
||||
>
|
||||
> **Write down everything you learn about NextGraph, as you learn it** (PO, 2026-07-30) — at least everything that helps move forward or that corrects a direction. Not at the end of an investigation, not only in the brief that happened to need it: a fact read in `nextgraph-rs` and left in a conversation is a fact the next agent will re-derive, and will get wrong.
|
||||
>
|
||||
> It does not all have to land in this file. This is where the **access model** accumulates (caps, NURIs, stores, branches, who can read what); platform behaviour and SDK gaps belong in [`nextgraph-current-state.md`](./nextgraph-current-state.md), and how the polyfill fakes something belongs in [`simulation.md`](./simulation.md). What matters is that it is written down somewhere durable and findable, with a `file:line` — not which file.
|
||||
>
|
||||
> Three traps in particular, all of which have already caught an agent more than once:
|
||||
>
|
||||
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
||||
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
||||
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
||||
|
||||
---
|
||||
|
||||
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
||||
@@ -46,11 +60,11 @@ A delivered key is not "taken back". To revoke = **re-encrypt** with a new key a
|
||||
|
||||
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 `:k:` segment)
|
||||
## 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 **`:k:{key}`** segment: 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`).
|
||||
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)
|
||||
@@ -59,10 +73,28 @@ The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent
|
||||
- `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** (embeds the key):
|
||||
- `did:ng:j:{id}:k:{key}` — object/file read cap (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49)
|
||||
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:73)
|
||||
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:64)
|
||||
**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.
|
||||
|
||||
@@ -109,36 +141,156 @@ This is a **second mechanism**, alongside key possession (§1) — not a breach
|
||||
|
||||
*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.
|
||||
|
||||
## 4quater. The keyring: where the owner gets the caps for THEIR OWN documents
|
||||
## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links
|
||||
|
||||
On every document creation, an `AddRepo { read_cap }` is committed to a **store branch** — the store being itself a repo, endowed with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role). That branch lists **the store's documents, each with its read key**.
|
||||
**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:
|
||||
|
||||
So it **is** the **owner's keyring**: the mechanism by which they find the caps of their own documents. Upstream of that, the keyring is the **wallet**.
|
||||
> **You cannot discover. You can only follow links.**
|
||||
|
||||
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2). The keyring is a private index, not an act of sharing.
|
||||
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.
|
||||
|
||||
*(VERIFIED for the `AddRepo { read_cap }` mechanism; the **exact name** of the branches and the enumeration of their types have not been re-traced — to be confirmed if this point becomes load-bearing.)*
|
||||
So **publishing is two acts, never one**:
|
||||
|
||||
## 5. What the polyfill emulates (caps.ts) — and where it diverges
|
||||
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).
|
||||
|
||||
`packages/client/src/caps.ts` models `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` (`:29-30, 41-42`) — **a per-document ACL of principals, that is the exact INVERSION of the real model** (key). Divergences:
|
||||
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".
|
||||
|
||||
| | Real NextGraph | caps.ts emulation |
|
||||
**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 |
|
||||
|---|---|---|
|
||||
| Nature | possession of a **key** | **ACL** (set of principals) |
|
||||
| Grant | seal the key (crypto_box) to the inbox | add a principal to the set |
|
||||
| Durability | **durable** (key delivered once) | **ephemeral** (Map empty every session → re-declared) |
|
||||
| Revocation | coarse **re-key**, non-retroactive | removal from the set: **instantaneous and total** |
|
||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||
| Ref. without rights | **cap-less NURI** (no `:k:`) | no such notion (the ACL says who may) |
|
||||
| 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. |
|
||||
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declares "my connections read my protected entities" **every session**, is an **artifact of this ephemeral ACL** — moot in the real model (there the seals are durable; one seals per-doc at share time, not per-session).
|
||||
The test to apply to anything shared: *does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?* Only the first justifies existing outside a wallet.
|
||||
|
||||
*Impact on this library, recorded 2026-07-30 and not yet resolved*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
|
||||
## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch
|
||||
|
||||
**There is no "keyring" object in NextGraph, and this section used to say there was.** It read *"the store branch **is** the owner's keyring… upstream of that, the keyring is the wallet"*, which is wrong twice: the wallet holds **one** key per user (the private store's read cap, §4quinquies level 1), not every key; and the caps of one's own documents live on a **Store branch**, per store, not in any single trousseau. An agent built a global in-memory "keyring" on that sentence. Corrected 2026-07-30 on the PO's instruction — *use the Store branch logic, not an invented keyring*.
|
||||
|
||||
What is actually true:
|
||||
|
||||
On every document creation, an `AddRepo { read_cap }` is committed to the store's **Store branch** — the store being itself a repo with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role, its own pub/sub topic, and here `BranchCrdt::None` — service commits, not triples). That branch lists **the store's documents, each with its read cap**, and replaying it is what reloads them (`AddRepo::verify` → `load_repo_from_read_cap`, `engine/verifier/src/commits/mod.rs:644-664`).
|
||||
|
||||
So the answer to *"how does an owner find the cap of a document they created?"* is: **it is on the Store branch of the store that document lives in** — one such branch per store, reached from the root key the wallet does hold.
|
||||
|
||||
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store's cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2), and a cap received for someone else's document goes somewhere else entirely (`AddLink` on the User branch, §4quinquies).
|
||||
|
||||
*(VERIFIED for the `AddRepo { read_cap }` mechanism and for `BranchType::Store` / `BranchCrdt::None`; the full enumeration of branch types is in `engine/repo/src/types.rs:1536-1551`.)*
|
||||
|
||||
## 4quinquies. WHERE the caps actually live — three levels, and one of them does not exist yet
|
||||
|
||||
**VERIFIED 2026-07-30** by reading `nextgraph-rs` (`git 213338f6`), answering "where does a received cap get stored?".
|
||||
|
||||
### Nomenclature first — `wallet` in the source is NOT what we call a wallet
|
||||
|
||||
A **wallet is only a keyring**. What we have been calling a "virtual user" is, upstream, a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<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.
|
||||
|
||||
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
|
||||
|
||||
**Realigned 2026-07-28 (batch P1a).** `packages/client/src/caps.ts` used to model `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map<Nuri, ReadCap>`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `store-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache.
|
||||
|
||||
| | Real NextGraph | caps.ts emulation (post-P1a) |
|
||||
|---|---|---|
|
||||
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
|
||||
| Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read |
|
||||
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
|
||||
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||
| Ref. without rights | **cap-less NURI** (no `:k:`) | same — `Nuri` names, `ReadCap` names and reads |
|
||||
|
||||
**The divergence that REMAINS, and it is the load-bearing one**: the stand-in key is a constant (`OK`) rather than a secret, and several read paths consult no cap at all (`docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`). So P1a bought the **shape**, not the isolation — per-document encryption and closing that inventory are **P1b**. Nothing may be claimed "anonymous" or "private" before it.
|
||||
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||
|
||||
## 6. Implications for consumers (e.g. Festipod)
|
||||
|
||||
- "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
|
||||
- **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
|
||||
- **Alignment to do**: when the real cap operations become available, replace the emulated ACL with durable per-doc key sealing, and `declareConnections`-as-a-re-declared-ACL disappears.
|
||||
- **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
|
||||
|
||||
|
||||
+276
-166
@@ -32,34 +32,45 @@ application fiction the lib maintains. On top of that one wallet the lib rebuild
|
||||
by emulation, the per-user stores + capabilities + inbox the consumer application
|
||||
codes against.
|
||||
|
||||
## Physical wallet vs virtual wallet — never enumerate the physical one
|
||||
## Physical user vs virtual user — never enumerate the physical one
|
||||
|
||||
**Nomenclature (aligned on NextGraph, 2026-07-30).** A **wallet** upstream is only a
|
||||
**keyring**; what owns three stores is a **user** (a *site*), and one wallet holds
|
||||
several of them (`SensitiveWalletV0.sites`, `engine/wallet/src/types.rs:434,457`).
|
||||
So this document says *user*, not *wallet*, for the thing an identity is — the two
|
||||
words meant the opposite of each other here until this was corrected.
|
||||
|
||||
Because the emulation runs on ONE shared wallet, distinguish two levels:
|
||||
|
||||
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
|
||||
holds every account's documents plus the lib's own internals (the shim index,
|
||||
the inbox docs, the discovery index) as named graphs. It accumulates without
|
||||
bound across sessions/runs. Listing or scanning "all documents" of the physical
|
||||
wallet is meaningless and O(size) — it mixes every user's data with lib internals,
|
||||
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
|
||||
(it spans every synced graph). The physical wallet is a substrate,
|
||||
not something to enumerate.
|
||||
- **The physical user** — the single NextGraph user everybody's session opens. Its
|
||||
stores hold every account's documents plus the library's own internals (the
|
||||
pointer, the doc-shim, the inbox documents) as named graphs, accumulating without
|
||||
bound across sessions and runs. Listing or scanning "all documents" at this level
|
||||
is meaningless and O(size): it mixes every virtual user's data with library
|
||||
internals, and it is exactly what an anchorless `sparql_query` (`GRAPH ?g { … }`)
|
||||
does. The physical user is a substrate, not something to enumerate.
|
||||
|
||||
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
|
||||
documents the shim attributes to that account (its per-scope index in
|
||||
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
|
||||
"list my documents" is meaningful and bounded (only that account's docs).
|
||||
- **A virtual user** — the library's emulation of one identity: the documents the
|
||||
shim attributes to that account (its three store documents in
|
||||
`store-registry.ts`). This is what "the user owns", and over it "list my
|
||||
documents" is meaningful and bounded.
|
||||
|
||||
**Consequence for reads (see `read-model.md`):** to list a user's entities you
|
||||
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
|
||||
not the physical union — then read those specific documents with a per-doc anchored
|
||||
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
|
||||
physical union is never scanned. Discovery (all public events) is the one bounded
|
||||
enumeration hack and goes through the discovery index, not a physical scan.
|
||||
**Which API you use follows that line, and it is enforced** (see `physical.ts`):
|
||||
machinery operating on the *index of virtual users* — the store-root pointer, the
|
||||
doc-shim, the account records — goes through unguarded primitives that are never
|
||||
exported from the package. Everything touching a virtual user's own content goes
|
||||
through the guarded `docs.*`, even when the library is what calls it. One API is the
|
||||
app's; the other must never be.
|
||||
|
||||
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
|
||||
distinction — and the "never enumerate the physical wallet" rule — dissolves into
|
||||
native per-wallet reads.
|
||||
**Consequence for reads (see `read-model.md`):** to list an identity's entities you
|
||||
enumerate the *virtual* user — that account's store document, bounded to its own
|
||||
documents — never the physical union. A bloated shared wallet then costs nothing,
|
||||
and nothing is enumerated across users at all: you read your own documents and the
|
||||
ones whose cap you were given.
|
||||
|
||||
At migration each virtual user becomes a real user with its own wallet; the
|
||||
physical/virtual distinction, the "never enumerate the physical one" rule, and
|
||||
`physical.ts` all dissolve into native per-user reads.
|
||||
|
||||
## Two axes, never conflate them (store ≠ document)
|
||||
|
||||
@@ -120,15 +131,14 @@ public/protected/private stores — on top of one shared wallet.
|
||||
is its own document/repo with a future inbox) and appends its NURI to the
|
||||
account's scope index document — the index doc plays the role of the future
|
||||
store-container (it lists the entity-document NURIs "in" that scope).
|
||||
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
|
||||
fallback / test-only path, not the read path: enumerating every account and
|
||||
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
|
||||
unsynced docs and hangs (the ORM fan-out — see
|
||||
[`read-model.md`](./read-model.md)). The real read path is
|
||||
`listMyEntityDocs(id, scope)` reads back ONE user's documents — bounded to that
|
||||
user, and the only listing there is: the cross-account fan-out
|
||||
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
|
||||
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
|
||||
`readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc
|
||||
anchored `sparql_query`, never an anchorless union-scan of the physical
|
||||
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
|
||||
the by-need doc set from the discovery index (public events) and
|
||||
the by-need doc set from the current wallet's own scope index and
|
||||
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
|
||||
- **Generic by construction.** The registry knows only the three native scopes,
|
||||
zero application entity kind. The consumer application maps its entities to a scope
|
||||
@@ -139,16 +149,16 @@ The `store≠document` two axes materialize here directly: the registry moves al
|
||||
axis B (more documents = more isolation), never axis A (it always writes into the
|
||||
one private store via `docCreate(..., undefined)`).
|
||||
|
||||
### A virtual wallet's structure — the three emulated stores
|
||||
### A virtual user's structure — the three emulated stores
|
||||
|
||||
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
|
||||
(the technical identifier the consumer application sets when the physical wallet is
|
||||
opened; it identifies *which* virtual wallet, and is an id rather than a
|
||||
A *virtual user* = one account in the shim, keyed by its virtual-wallet id
|
||||
(the technical identifier the consumer application sets when the physical user is
|
||||
opened; it identifies *which* virtual user, and is an id rather than a
|
||||
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
|
||||
native stores":
|
||||
|
||||
```
|
||||
Virtual wallet (id)
|
||||
Virtual user (id)
|
||||
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
|
||||
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
|
||||
└── private store = docPrivate index → [ record doc NURI, … ]
|
||||
@@ -160,17 +170,17 @@ So the 3 native stores (public/protected/private) are present, but emulated: eac
|
||||
per-entity documents in that scope. It is not a physical native store.
|
||||
|
||||
Everything is physical in one place: the 3 index documents, every per-entity
|
||||
document, and the shim anchor itself all live in the shared physical wallet's
|
||||
document, and the shim anchor itself all live in the shared physical user's
|
||||
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
|
||||
logical layer the lib maintains on top.
|
||||
|
||||
```
|
||||
Physical wallet (shared, one) → private_store (physical) holds everything:
|
||||
Physical user (shared, one) → private_store (physical) holds everything:
|
||||
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
|
||||
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index
|
||||
• every account's 3 scope-index docs + all per-entity docs + inboxes
|
||||
```
|
||||
|
||||
At migration each virtual wallet's 3 index documents become the user's 3 **real**
|
||||
At migration each virtual user's 3 index documents become the user's 3 **real**
|
||||
native stores, the entity documents move into them physically, and the
|
||||
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
|
||||
|
||||
@@ -190,16 +200,16 @@ store-id:
|
||||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
||||
resolves to the user's real per-scope store — the change is in this function,
|
||||
and the consumer application is unchanged.
|
||||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
|
||||
dedicated inbox document (a reserved account's public scope document, from
|
||||
`docCreate` — a real repo NURI, stable across clients), not the shared
|
||||
wallet's private-store root. Why dedicated: the shim (the account→document trust
|
||||
root) lives in the private-store graph and is scanned on every `loadShim`;
|
||||
routing every inbox deposit into that same graph bloats it without bound
|
||||
(thousands of deposit triples across sessions), turning `loadShim` into a
|
||||
multi-second full-graph scan. A separate inbox document keeps the shim graph
|
||||
small and the deposits isolated. At migration it becomes the host's native
|
||||
inbox NURI.
|
||||
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The
|
||||
first is a virtual user's own inbox (where Links arrive), the second the inbox of
|
||||
one of its documents, created on first ask. Both are dedicated documents (real
|
||||
repo NURIs from `docCreate`), never the private-store root: routing deposits into
|
||||
the shim graph would bloat the account→document trust root without bound.
|
||||
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection —
|
||||
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`,
|
||||
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be
|
||||
common but the mechanisms that make the virtual users work.)* At migration these
|
||||
become native per-document inboxes.
|
||||
|
||||
Both resolve the native store ids from the injected session
|
||||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||||
@@ -255,77 +265,142 @@ In the target the broker only delivers documents the wallet holds a ReadCap
|
||||
for, so `useShape` already returns an authorized subset. Here (single shared
|
||||
wallet, everything readable) the lib reproduces that with a read-filtered view:
|
||||
|
||||
- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer
|
||||
can. The access unit is the document = repo NURI (an item's `@graph`),
|
||||
never the item — because in `nextgraph-rs` a store is just a container repo
|
||||
and holding its cap does not grant the repos it references (no store-level read
|
||||
inheritance; verified). So the registry is purely per-document:
|
||||
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
|
||||
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
||||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
|
||||
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
|
||||
protected docs to grant. The consumer application performs the *acts* of granting
|
||||
(create-public, grant a specific doc to a specific identity…) exactly as it
|
||||
will in the target; the lib injects no policy.
|
||||
- **`CapRegistry` (`caps.ts`)** models a ReadCap as what it is: **the document's
|
||||
key**. The access unit is the document = repo NURI (an item's `@graph`), never
|
||||
the item — because in `nextgraph-rs` a store is just a container repo and holding
|
||||
its cap does not grant the repos it references (no store-level read inheritance;
|
||||
verified). The registry records, **per identity**, the caps it holds — `Map<Nuri, ReadCap>`
|
||||
— and answers exactly one question: `capFor(nuri)`, *do I hold this document's
|
||||
cap?* There is deliberately **no** "may principal P read document D": that is an
|
||||
ACL question, and the real model cannot answer it either.
|
||||
- **`nuri.ts`** carries the cap-less / cap-bearing distinction, which upstream is
|
||||
one object (`NuriV0 { target, access }`) discriminated by the `:r:{cap}` segment.
|
||||
`Nuri` names, `ReadCap` names *and* reads. Both are plain strings — the real SDK
|
||||
takes `nuri: String` and enforces at runtime through cryptography, so a branded
|
||||
type would be a concept NextGraph does not have. The stand-in key value is the
|
||||
constant `OK` (see the module header): the only question the emulation answers is
|
||||
*do I hold this cap or not*, so the value says exactly that and pretends nothing
|
||||
more. P1b, not P1a, is the batch that turns the shape into a protection.
|
||||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||||
`Proxy`: iteration / `size` / `forEach` are filtered by
|
||||
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
|
||||
`getById`…) forwards to the target, preserving writes and reactivity. An item
|
||||
with no `@graph`, or in a document under no cap policy, is kept (the filter only
|
||||
restricts documents that *declare* a cap — no regression on ungoverned data).
|
||||
`filterReadable` is the pure variant.
|
||||
- **`useShape` (`use-shape.ts`)** applies the view only if
|
||||
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
|
||||
(no regression when the consumer application declares no caps).
|
||||
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
|
||||
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
|
||||
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
|
||||
names no document, so there is no cap to hold). `filterReadable` is the pure
|
||||
variant. Note the absence of a `user` parameter — that absence *is* the model.
|
||||
- **`useShape` (`use-shape.ts`)** applies the view only once a cap exists at all
|
||||
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
|
||||
regression for a consumer that never touches caps). Once ANY cap is issued the
|
||||
regime is possession for **every** holder, including one who holds nothing:
|
||||
that is the isolation.
|
||||
|
||||
In a mono-store layout (every item in one repo) this is all-or-nothing on that
|
||||
document — exactly the native behaviour, and why fine-grained isolation requires
|
||||
one document per entity (axis B).
|
||||
|
||||
### Making the ReadCap active — current identity + directed grants
|
||||
### Where caps come from — stored, never derived
|
||||
|
||||
The filter only discriminates once the consumer application (a) tells the SDK who is
|
||||
reading and (b) declares the access policy on the documents. Both are plain SDK
|
||||
calls; the consumer application never touches the registry internals:
|
||||
`doc_create` returns a **cap-less** NURI, so "no function ever goes from a bare
|
||||
reference to a cap" cannot be the whole rule — it would lock a document's own creator
|
||||
out of it. The real mechanism: creating a document commits `AddRepo { read_cap }` to
|
||||
the store's **Store branch**, separately from the `ldp:contains` listing on its Main
|
||||
branch. That is where an owner finds the caps of what it created; a cap RECEIVED for
|
||||
someone else's document goes elsewhere, on the **User branch** (`AddLink`). The wallet
|
||||
itself holds one key per user — the private store's read cap — from which the rest is
|
||||
reached. Hence the invariant:
|
||||
|
||||
> **You do not derive a cap from a bare reference. You look it up in what you hold —
|
||||
> or you were given it.**
|
||||
|
||||
Three ways a cap arrives, and there are no others:
|
||||
|
||||
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
|
||||
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
|
||||
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
|
||||
which is what keeps this correct when P1b makes the key real.
|
||||
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
|
||||
recompute anything: that is the whole reason for storing them, and it is what lets
|
||||
a **fresh session** read its own documents again with nothing re-declared — the
|
||||
durability the old in-memory ACL faked and lost every reload.
|
||||
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
|
||||
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
|
||||
verifier applies queued messages upstream. **Receiving needs no operation** — a
|
||||
consumer already watching its inbox gets them, and the resulting change
|
||||
re-triggers the reads that were empty for want of that cap.
|
||||
|
||||
**The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
|
||||
away everything the store contains, present and future. The unit of sharing is the
|
||||
document; the Store branch is a private index.
|
||||
|
||||
Switching identity **switches** records — it never wipes one. If it wiped,
|
||||
durability would be a lie and per-session re-declaration would come back under
|
||||
another name.
|
||||
|
||||
### Sharing, publication, and the recipient
|
||||
|
||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
||||
`useShape`'s filtered view reads it lazily, so the delivered subset always
|
||||
reflects the identity in effect at read time. Until it is set, the filter has no
|
||||
principal and (per `canRead(doc, null)`) only public documents pass — which is
|
||||
why isolation stays dormant until the consumer application makes this call.
|
||||
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
|
||||
consumer application creates it: `public` → world-readable; `protected`/`private`
|
||||
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
|
||||
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
|
||||
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
|
||||
relationship-shaped sharing act the lib exposes: a directed per-document read
|
||||
grant issued to a specific identity. Public docs stay world-readable; private
|
||||
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
|
||||
owner grants it. The consumer application passes a document NURI and a grantee id
|
||||
— no store id.
|
||||
It selects *whose* caps are consulted, lazily, so the delivered subset always
|
||||
reflects the identity in effect at read time.
|
||||
- **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
|
||||
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
||||
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
||||
upstream. Reaching several recipients means calling it once per inbox, which is
|
||||
what the real model does too (each delivery is sealed to one recipient).
|
||||
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
|
||||
**whoever receives it** can open. Put the *link* in what you make discoverable, not
|
||||
the bare NURI, or no reader can open it. Publication is **not recursive**: a public
|
||||
document may reference private ones, and the reference grants nothing on what it
|
||||
references — which is what lets a public object point at a private identity without
|
||||
disclosing it.
|
||||
|
||||
The relationship concept — who is "connected" to whom, and therefore which of
|
||||
their protected docs to grant — is owned by the consumer application, not the lib.
|
||||
A connection or friendship is not a NextGraph primitive; the only platform-mappable
|
||||
primitive is the directed per-document read grant above. So the consumer application
|
||||
decides a relationship exists and, for each protected doc it wants to share, calls
|
||||
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
|
||||
the owner's protected docs. The intended target of such a directed grant is a native
|
||||
per-document ReadCap issued to that identity — but that target is itself
|
||||
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
|
||||
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
|
||||
to another identity is not-yet-built at the platform level. There is no bilateral
|
||||
capability exchange to mirror, only (eventually) individual directed grants.
|
||||
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
|
||||
exists, but the message construction is `unimplemented!()`, its only caller passes
|
||||
"without read_cap", and the receiver discards the cap. The shape is right; the
|
||||
implementation is absent, so this lib emulates it meanwhile.
|
||||
|
||||
The result is the target's discrimination reproduced end-to-end: private →
|
||||
owner; protected → owner + whoever the owner has directly granted; public → all.
|
||||
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
|
||||
protected document, granted it after the owner issues a directed `grantRead`, and
|
||||
reads the public document throughout.
|
||||
**Key rotation needs nothing on this surface.** A rotated key is re-sent to the
|
||||
inbox of whoever keeps access, and that inbox is processed automatically at the next
|
||||
connection — so access is not lost, it is *deferred*, consistent with local-first.
|
||||
Same channel as the initial delivery, so there is **no subscription obligation** to
|
||||
expose and no special case to write. Revocation stays what it is: stop re-delivering,
|
||||
non-retroactive.
|
||||
|
||||
This discrimination is only observable because each entity is its own document
|
||||
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
|
||||
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
|
||||
The relationship concept — who is "connected" to whom, and therefore whose documents
|
||||
to share — is owned by the consumer application, not the lib. A connection or
|
||||
friendship is not a NextGraph primitive; the only platform-mappable primitive is the
|
||||
per-document cap delivery above.
|
||||
|
||||
The result is the target's discrimination reproduced end-to-end: you read the
|
||||
documents whose caps you hold, and nothing else. Proven in
|
||||
`test/isolation-active.test.ts` (a document nobody shared is unreadable; a share to
|
||||
one inbox reveals it there and only there; a bare reference reads nothing while the
|
||||
repo link opens the published document; a returning identity keeps its caps) and in
|
||||
`test/watch-shape.test.ts` (e), the acceptance test below.
|
||||
|
||||
This discrimination is only observable because each entity is its own document (the
|
||||
consumer application creates per-entity docs via `createEntityDoc`) — in a mono-store
|
||||
layout the per-document ReadCap is all-or-nothing.
|
||||
|
||||
### The acceptance test — no cryptography required
|
||||
|
||||
Alice owns a protected document holding a secret and a public one that carries a
|
||||
**reference** to it. Bob, holding the public document's link, reads it, finds the
|
||||
reference, and can NAME the protected document while reading nothing of it —
|
||||
publication is **not recursive**. Charlie, holding the same link plus the protected
|
||||
document's cap (delivered to his inbox), reads through the very same reference. The
|
||||
only difference between them is what what they hold holds; nobody was named to any
|
||||
registry. And dynamically: the cap lands in Bob's inbox, his client processes it, and
|
||||
the read that was empty yields the content — the held-caps signal re-running it.
|
||||
|
||||
That is what real NextGraph does, and it holds **without a line of encryption** —
|
||||
which is what makes the P1a (shape) / P1b (enforcement) split honest rather than
|
||||
cosmetic. Proven in `test/cross-user-access.test.ts`.
|
||||
|
||||
> **After P1a the shape is right and the isolation is still fake.** The stand-in key
|
||||
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
|
||||
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
|
||||
> any wallet can reach any document. That is the subject of
|
||||
> [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
> Nothing may be claimed "anonymous" or "private" until it lands.
|
||||
|
||||
### Write-guard coverage (honest scope)
|
||||
|
||||
@@ -342,16 +417,16 @@ natively at migration); the read side is what makes isolation observably active.
|
||||
### The per-document ReadCap is the isolation path (item-level filter retired)
|
||||
|
||||
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
|
||||
alone: the access unit is the document (`@graph` = repo), and grants are explicit
|
||||
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed
|
||||
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
|
||||
application now writes one document per entity (`createEntityDoc` + `open` per entity),
|
||||
the per-document cap discriminates at entity granularity — the target's behaviour.
|
||||
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
||||
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
|
||||
`publishRepoLink` emits an openable link). Because the consumer application writes
|
||||
one document per entity, the per-document cap discriminates at entity granularity —
|
||||
the target's behaviour.
|
||||
|
||||
The old item-level application-visibility filter (`isolation.ts`
|
||||
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
|
||||
from the consumer path: the application carries no access logic — it declares its
|
||||
identity and issues directed grants, and trusts the SDK. Its matrix functions are
|
||||
identity and shares caps, and trusts the SDK. Its matrix functions are
|
||||
dead scaffolding kept for reference and removed at migration. There is no longer a
|
||||
second, coexisting app-layer filter to reconcile — the single axis is the
|
||||
per-document cap, exactly as in the target.
|
||||
@@ -388,69 +463,104 @@ emulates the inbox on the shared wallet:
|
||||
polls `read` and fires when the deposit count changes (the polyfill has no
|
||||
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
||||
|
||||
### An inbox BELONGS to a virtual user (2026-07-30)
|
||||
|
||||
`storeRegistry.walletInbox(id)` resolves — creating on first sight — the inbox
|
||||
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
|
||||
read by its own query (so an account written before this existed still resolves).
|
||||
The asymmetry that matters:
|
||||
|
||||
- **Depositing into anyone's inbox is open.** It is the ONLY way a link crosses
|
||||
from one wallet to another, and since you cannot discover, it is the bootstrap of
|
||||
the whole reachability graph. A deposit grants the depositor nothing in return —
|
||||
upstream it is an anonymous sealed box.
|
||||
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
|
||||
`readSynced`, hence in `watch`). Since P1a routes ReadCaps through deposits, an
|
||||
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
|
||||
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
|
||||
|
||||
At migration this guard disappears into cryptography: an inbox is sealed to its
|
||||
owner's key.
|
||||
|
||||
The module knows no domain — the consumer application supplies the inbox document
|
||||
NURI and interprets `payload`. At migration `post` becomes the native
|
||||
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
|
||||
own verifier unsealing queued messages inline (see the deferred global-index note in
|
||||
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
own verifier unsealing queued messages inline.
|
||||
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
||||
purposes — e.g. a registration/deposit in one consumer app and submission to a
|
||||
discovery index — same `post` API, same watcher.
|
||||
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
||||
someone — same `post` API, same watcher.
|
||||
|
||||
## Emulated discovery index + special account (`discovery.ts`)
|
||||
## The virtual user boundary (`reach.ts` + `physical.ts`)
|
||||
|
||||
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
|
||||
same as discovery: a public entity is world-readable *with its NURI*; the discovery
|
||||
index is how a client learns that NURI exists without holding a relationship
|
||||
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
The model is: one global index = an owned document (public read), fed via
|
||||
its inbox. Nobody writes the index directly — a creator deposits a reference into
|
||||
the index's inbox, and the index is built up from those deposits. That build-up
|
||||
step is the natural dedup / moderation point.
|
||||
Every access function is confined to the user currently connected: no cross-user
|
||||
access, so the consumer is coded against a reach that will actually exist.
|
||||
**Two rules, one criterion — possession — implemented in two places**, deliberately
|
||||
redundant so a lapse in either is caught by the other:
|
||||
|
||||
- **The special account (polyfill owner).** "Who owns the global index" is
|
||||
undecided in the target (NextGraph is mono-user with no global data — a
|
||||
singleton app is the only glimpsed path). So the polyfill parks ownership on a
|
||||
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
|
||||
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
|
||||
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
|
||||
that `normalizeId` can never produce, so no user id — not even one typed as
|
||||
"index" or "@index", which normalizes to the disjoint key "index" — can collide
|
||||
with or hijack the index account (asserted in `discovery.test.ts`). It is a
|
||||
normal shim account (so its 3 scope documents are created on first sight like
|
||||
any other), but never a real user; it only hosts the index document. Its
|
||||
`public` scope document is the index document, and its inbox receives the
|
||||
deposits — a stable NURI: every client opening the same shared wallet
|
||||
resolves the same account, hence the same document, so all clients read/write one
|
||||
shared index.
|
||||
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
|
||||
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
|
||||
the inbox convention (bound to the current identity; anonymous when `null`).
|
||||
`ref` is opaque here — the consumer application serializes whatever locates the
|
||||
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
|
||||
`opts.doc` names the document being surfaced, a document under a non-public
|
||||
(protected/private) read policy is refused (`caps.governsRead(doc) &&
|
||||
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
|
||||
governed doc's NURI would leak it past its scope. Proven in
|
||||
`test/discovery.test.ts` case (d).
|
||||
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
|
||||
serialized `ref` (the moderation point: a duplicate submission surfaces
|
||||
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
|
||||
emulated watcher (polls `readIndex`).
|
||||
- **Rule 1, authorization** (`assertMayReach`, at the passage points `docs.sparqlQuery`
|
||||
/ `sparqlUpdate` / `subscribeDoc`): nothing reaches `ng` unless the connected user
|
||||
possesses that document's cap. It fires on a request that should never have been
|
||||
made, and makes it fail loudly rather than succeed quietly.
|
||||
- **Rule 2, do not even attempt** (`mustNotAttempt`, at the callers — `readUnion`
|
||||
filters before opening or reading, `ensureRepoOpen` returns): a reader holding no
|
||||
cap does not issue the operation at all. Upstream you cannot even *address* a repo
|
||||
you have no cap for, so asking is not "a read that will be refused" — it is a read
|
||||
with no meaning.
|
||||
|
||||
This replaces the cross-account fan-out (`store-registry.ts`
|
||||
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
|
||||
path: the consumer application submits public entities to the index and reads the
|
||||
index, instead of fanning out over every account's public documents. The fan-out
|
||||
survives only as an internal lib fallback — kept for the per-scope listing it also
|
||||
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
|
||||
**Possession decides, never the shape of the reference in hand.** A caller
|
||||
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
|
||||
bare through content and stores, the cap sits in what the user holds.
|
||||
|
||||
`discovery.ts` knows no application domain — the consumer application defines the
|
||||
`ref` shape and its meaning. At migration the special account disappears:
|
||||
ownership moves to the decided global-index owner, `submitToIndex` becomes the
|
||||
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
|
||||
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
|
||||
is designed to survive that swap unchanged.
|
||||
The exception is **depositing** into another user's inbox (`docs.depositInto`): a
|
||||
named primitive rather than a flag, because it is a different act — you hold no cap,
|
||||
you cannot read back, and you get nothing in return. It is the only channel by which
|
||||
a link crosses between users, hence the bootstrap of the whole reachability graph.
|
||||
|
||||
The machinery lives in `physical.ts` (see *Physical user vs virtual user* above):
|
||||
unguarded primitives, never exported from the package, used only for the index of
|
||||
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
|
||||
the machinery does not get waved through the guard, it calls something the guard
|
||||
never saw.
|
||||
|
||||
## Connecting a user (`connect.ts`)
|
||||
|
||||
Processing inboxes is the **library's** job, not the app's: a consumer must never
|
||||
have to remember to drain a queue for documents shared with it to become readable —
|
||||
forgetting would look like "the share did not work" rather than "nobody consumed the
|
||||
queue". So `setCurrentUser` fires `connectedUser()`, which does two things in order:
|
||||
|
||||
1. **Restore** — read back the caps this user already applied (`readLinks`, the
|
||||
emulated `AddLink` records on its User branch) into what it holds. Durable state,
|
||||
one read, no inbox involved.
|
||||
2. **Drain** — process every inbox it may read (`myInboxes`: its own, plus one per
|
||||
document it opened an inbox on), filing any new Link durably.
|
||||
|
||||
Restore-first is what lets a reconnecting user read its shared documents immediately
|
||||
instead of waiting on a queue round-trip.
|
||||
|
||||
**Fire-and-forget, deliberately.** The setter is synchronous and every consumer calls
|
||||
it from synchronous code; making it async would push the wait back onto the app,
|
||||
which is the obligation this removes. The work announces itself through
|
||||
`CapRegistry.onChange` — which `watchShape` already listens to — so a view that was
|
||||
empty for want of a cap re-reads when the cap lands. `connectedUser()` is exported
|
||||
for a caller that needs to await it (tests, a deterministic startup).
|
||||
|
||||
**It does not provision.** Connecting an identity that does not exist creates
|
||||
nothing (`resolveAccount`, not `ensureAccount`): otherwise connecting would mint a
|
||||
user's stores and their caps as a background side effect, arming the whole emulation
|
||||
at a moment nothing controls.
|
||||
|
||||
*Cost worth knowing*: `setCurrentUser` therefore has observable asynchronous effects
|
||||
— it reads, and it logs. Tests asserting on log output must await `connectedUser()`
|
||||
first.
|
||||
|
||||
## ~~Emulated discovery index + special account~~ — REMOVED 2026-07-30
|
||||
|
||||
**There is no discovery in NextGraph. You cannot discover; you can only follow links** (see [`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts — place the data in your public store, **and** circulate its link (into an inbox, or into a document the reader already holds) — and it is seen only by those who received the link.
|
||||
|
||||
`discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`), its tests, and `watchShape`'s public-scope fold were **removed**. The module failed on two independent counts: it emulated a capability the target will never have — teaching consumers a model that does not exist — and it was **data common to several wallets**, where nothing may be common but the indexing mechanisms that make the virtual users work.
|
||||
|
||||
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
|
||||
## Emulated write guard (`ng-proxy.ts`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user