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.
|
||||
Reference in New Issue
Block a user