Fix documentation defects found by an adversarial review
Fifteen findings, all verified before acting. The ones that mattered: - Corrections added without updating what they corrected. §5's table still said a cap-less NURI is one "without :k:", two hundred lines after §4 established the discriminant is `r:`. Same shape of defect in the P1a report, which kept the sentence "it is the owner's keyring, upstream the keyring is the wallet" — the exact sentence §4quater declares wrong, and the one that produced a global in-memory keyring. - A wrong source citation: RootCapRefresh/BranchCapRefresh live in verifier/src/commits/mod.rs, not repo/src/commit.rs, and are no-op stubs. - Documentation describing deleted code: isolation.ts, discovery.readIndex, the global index, and an acceptance test that was dropped with discovery. - The P1a implementation report had aged into being wrong in four places (caps not persisted, inbox processing not started, plain string types, the :k: segment). It is dated, so it now carries a header saying what later lots overtook, rather than being rewritten. - vision.md stated "a document's data is stored encrypted" in the present tense. That is the target; here the cap value is the constant OK and nothing is encrypted. Said plainly now. - Prose left mangled by an earlier mechanical find-and-replace, in four places I had claimed were repaired. Also: reach.ts and connect.ts had no home in the permanent docs — the boundary and the connection sequence are now described in simulation.md, not only in a brief.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ useShape<T extends BaseType>(
|
||||
- `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<T>` — a **live reactive set**. Iterate it like a set;
|
||||
the component re-renders whenever the set changes.
|
||||
|
||||
@@ -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<Nuri>();
|
||||
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
|
||||
|
||||
@@ -295,7 +295,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user