diff --git a/README.md b/README.md index f634aaf..d717aef 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ away; the app code (SDK-shaped) is unchanged. testable). - Authorization is emulated capabilities: documents carry grants; the client enforces them generically (read filter + write guard). The app declares a - document's read policy and issues directed grants — the same acts it will + document, shares one document's cap to an inbox — the same acts it will perform in the target. No policy is injected. - Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet emulation, reads the deposits back (`read` / `materialize` / `watch`) in place @@ -107,7 +107,7 @@ Implemented. The polyfill mechanisms are wired against a real broker, not stubbe (avoids the `@ng-org` double-proxy `DataCloneError`). - Emulated ReadCaps — `caps.ts` (`CapRegistry`, per-document, directed grants) + read filter `read-filter.ts` (reactive-set `Proxy` view), applied by - `use-shape.ts` only when a policy is declared. + `use-shape.ts` only once a cap exists (`caps.isEnforcing()`). - Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap). - Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`). - Identity — `accounts.ts` (`IdentityStore`, injected storage). diff --git a/docs/briefs/2026-07-27-p1a-cap-surface.md b/docs/briefs/2026-07-27-p1a-cap-surface.md index d8752cc..ba8b974 100644 --- a/docs/briefs/2026-07-27-p1a-cap-surface.md +++ b/docs/briefs/2026-07-27-p1a-cap-surface.md @@ -8,6 +8,15 @@ The spec below is unchanged — read it first. Everything from here to *Why this # Implementation report (2026-07-28) +> **Superseded in places by later lots — read with [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md).** This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written: +> +> - *"ReadCaps are NOT persisted as caps anywhere. There is no key store"* — **no longer true.** Both durable registers are now emulated: `shim:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on its User branch (`AddLink`). Caps are read back, not recomputed. +> - *"Processing inboxes … Not started"* — **done** (`src/connect.ts`), at both levels, including per-document inboxes. +> - *"`Nuri` and `ReadCap` are plain strings"* — **superseded the same week**: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The *Typing* section below records the change; the earlier sentences were not rewritten. +> - The `:k:` segment throughout — **a ReadCap is `r:`** (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), reported by NextGraph's developer and verified. `:k:` belongs to objects, files and commits. +> - *"That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet"* — **wrong when written**, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quater. + + ## What landed | Spec | Where | diff --git a/docs/briefs/2026-07-30-virtual-wallet-boundary.md b/docs/briefs/2026-07-30-virtual-wallet-boundary.md index 78dad8f..39dc98b 100644 --- a/docs/briefs/2026-07-30-virtual-wallet-boundary.md +++ b/docs/briefs/2026-07-30-virtual-wallet-boundary.md @@ -1,6 +1,6 @@ # 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.** +**Status: specified 2026-07-30; all seven steps done** (four planned, three added as the work uncovered them). 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 @@ -117,7 +117,7 @@ Applies to every exported surface, including ones added later: **if it is expose *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 +### Two defects this step surfaced — the first still open, the second closed by steps 5–6 **`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. diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 3f3d2b0..71fa902 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -76,9 +76,7 @@ Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-walle document) with the native `inbox_post_link` (proposed/future). On the read side the recipient's own verifier unseals each queued sealed message and applies it inline when it processes its inbox — there is no separate curator to build; the in-lib read -emulation simply goes away (see the deferred global-index note in the top-level -README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). The -single global index replaces the cross-account fan-out. +emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)* ### 5. Retire the identity store → real per-user login Remove `accounts.ts` (the `IdentityStore` that persists the identity id in @@ -87,11 +85,10 @@ becomes the real per-user login (see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)). The flow shape ("broker redirect → app") does not change. -### 6. Drop the isolation scaffold -`isolation.ts` (application-visibility scaffold) disappears against a -different piece of infra than the caps: real per-account wallets, and the -relationship concept the consumer application owns. Distinct axis from ReadCaps — -remove independently. +### 6. ~~Drop the isolation scaffold~~ — already gone +`isolation.ts` (the old application-visibility filter) was deleted from the library; +nothing remains to remove at migration. Kept as a numbered step so the following +numbers stay stable across references. ### 7. Remove the build alias — the client becomes the real SDK The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md index b709331..5ab71cc 100644 --- a/docs/nextgraph-current-state.md +++ b/docs/nextgraph-current-state.md @@ -663,7 +663,7 @@ On a FRESH session over the SAME persistent wallet (reconnect, new page, re-logi anchored `sparql_query` against a document written in an earlier session comes back with **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`), +`packages/client/src/open-repo.ts`): the user's own documents, 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 diff --git a/docs/read-model.md b/docs/read-model.md index d7a9e74..f890390 100644 --- a/docs/read-model.md +++ b/docs/read-model.md @@ -57,7 +57,7 @@ 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`, and whose caps what I hold holds); +- my own docs (always in `self.repos`, and whose caps I hold); - 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). diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index d846fe3..9a74bc5 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -2,7 +2,7 @@ **Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex. -Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph". +Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (an ACL until P1a — the inverse of the real model; realigned 2026-07-28, see §5). This is the basis for the item "align ReadCap/WriteCap with NextGraph". > ## How to use this document — verify, never infer > @@ -48,7 +48,7 @@ So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders. - "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`). -- Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; perms `types.rs:1748-1749`). +- Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`engine/verifier/src/commits/mod.rs:616,630` — both no-op `Ok(())` stubs today; perms `types.rs:1748-1749`). - Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh). - **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`). @@ -178,7 +178,7 @@ The distinction is the operative one, and it is sharp: 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). +*Impact on this library, RESOLVED 2026-07-30 — the module was removed*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md). ## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch @@ -280,9 +280,9 @@ What remains true, and is a separate matter — the *delivery* path is unimpleme | 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 | +| Ref. without rights | **cap-less NURI** (no `r:` segment) | 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. +**The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **P1b** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private". **App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap. diff --git a/docs/simulation.md b/docs/simulation.md index d07f82f..8561c1a 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -374,7 +374,7 @@ 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. +`test/cross-user-access.test.ts`, 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 @@ -387,7 +387,7 @@ Alice owns a protected document holding a secret and a public one that carries a 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 +only difference between them is each of them 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. @@ -423,7 +423,7 @@ possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one, 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` +The old item-level application-visibility filter (the since-deleted `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 shares caps, and trusts the SDK. Its matrix functions are diff --git a/docs/vision.md b/docs/vision.md index f8367fe..0bbe576 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -18,7 +18,9 @@ Without a minimum of crypto simulation, damaging shortcuts get taken (reading th > **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.** -Concretely: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model. +Concretely, **in the target**: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model. + +> **Not yet true here, and saying so matters.** The shape is in place — possession decides, every access is confined to the connected virtual user, caps are stored and read back — but the cap value is the constant `OK` and nothing is encrypted. Per-document encryption is **P1b**, and it is one function (`nuri.ts` `mintCap`). Until it lands, nothing this library does may be described as anonymous or private. ## Shape consequences (to respect everywhere) diff --git a/packages/client/docs/sdk-reference.md b/packages/client/docs/sdk-reference.md index 6cf0808..706e999 100644 --- a/packages/client/docs/sdk-reference.md +++ b/packages/client/docs/sdk-reference.md @@ -92,6 +92,9 @@ useShape( - `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type and the properties to materialize. - `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string. + **Not to be confused with this library's `Scope`** (`types.ts`), which is the + literal union `public | protected | private` naming a store. Same word, two + meanings: the ORM's is a read target, ours is a placement. `undefined` yields an empty read. - **Returns** a `DeepSignalSet` — a **live reactive set**. Iterate it like a set; the component re-renders whenever the set changes. diff --git a/packages/client/src/caps.ts b/packages/client/src/caps.ts index e0cb1c9..5a0296a 100644 --- a/packages/client/src/caps.ts +++ b/packages/client/src/caps.ts @@ -63,7 +63,7 @@ export class CapRegistry { * whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the * URL reads the content"), and holding it means having received it. The set * exists so the library can refuse to surface a document its holder never - * published (see `discovery.submitToIndex`). + * published. *(This fed `discovery.submitToIndex`, removed 2026-07-30; the flag is kept because publishing is still what turns a document into a shareable link.)* */ private published = new Set(); /** doc NURI → principals holding its WRITE cap. Decorative until P1b. */ diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index b9b35f3..e51ab1c 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -295,7 +295,7 @@ export async function read(targetInbox: Nuri): Promise { await assertOwnInbox(targetInbox, "read"); const sid = await sessionId(); // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it - // (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch` + // (a cold reader that opens the repo before reading), NOT here — `inbox.watch` // already holds the repo open via its own `subscribeDoc`, so opening a second // bootstrap subscription from inside a watch's re-read would be redundant and can // race the watch's own initial-`State` delivery. Keeping `read` a pure anchored @@ -384,7 +384,7 @@ export const materialize = read; * `self.repos`, so a plain anchored `read` resolves an unopened repo and silently * returns 0 deposits — even for a deposit a remote session already synced to the * broker. Gating on the sync barrier makes the read see the synced deposits. This - * is the SAME cold-read heal `discovery.readIndex` applies to the index inbox. + * is the same cold-read heal any cold direct reader needs. * * NOT for the `watch` path: {@link watch} already holds the repo open via its own * `subscribeDoc`, so opening a second bootstrap subscription from inside a watch diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index bb3207c..7ec129c 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -14,7 +14,7 @@ * inbox files it, which fires the held-caps signal, which re-runs the read — the * protected document appears with nothing else happening. * - * The difference between Bob and Charlie is ONLY what what they hold holds. There is + * The difference between Bob and Charlie is ONLY each of them holds. There is * no authorization list anywhere, and nobody was named to the registry. */ import { test, expect, mock, afterAll } from "bun:test"; @@ -257,7 +257,7 @@ test("Charlie: same public document, same reference — and he reads through it" expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]); }); -test("the ONLY difference between Bob and Charlie is what what they hold holds", async () => { +test("the ONLY difference between Bob and Charlie is each of them holds", async () => { inject(); const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); const CHARLIE_INBOX = await walletInbox("charlie");