From bea9f51d918ac7f78c76fe0cb81d3f7c47f1e8ee Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 23:23:23 +0200 Subject: [PATCH] docs: own the current-NextGraph-state knowledge + boundary (lib side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This library presents a mature-NextGraph SDK face to consumers while compensating for the current SDK's gaps via a shared-wallet simulation. It therefore OWNS all current-state + simulation knowledge — moved here out of the Festipod app repo, which must treat this library as a finished SDK. New docs/: - nextgraph-current-state.md — what the current SDK/broker do and don't expose (5 store types, document=repo, per-document ReadCap, inbox not exposed, iframe RPC proxy, mono-user/no-global-data, wallet import constraint). Keeps the nextgraph-rs source pointers. - simulation.md — how the lib emulates the mature behaviour on one shared wallet (shim, store!=document two axes, docCreate→private store, RepoNotFound scope rule, @ng-org double-proxy DataCloneError, emulated ReadCap/inbox/curator). - decisions/ — the current-SDK ADRs (private-store-nuri-scope, sparql-delete, shared-wallet-login, discovery mechanism). - fork-inbox-fallback.md — the Rust-patch/self-host route not taken. - migration-guide.md — the checklist for when real NextGraph matures. README: boundary framing from the lib's side + docs/ index; replaced the stale "scaffold/stubbed" status with the actually-implemented mechanisms per source. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 58 +++- docs/decisions/discovery-model.md | 81 +++++ docs/decisions/private-store-nuri-scope.md | 51 ++++ docs/decisions/shared-wallet-login-flow.md | 64 ++++ .../sparql-delete-for-orm-objects.md | 61 ++++ docs/fork-inbox-fallback.md | 93 ++++++ docs/migration-guide.md | 83 ++++++ docs/nextgraph-current-state.md | 279 ++++++++++++++++++ docs/simulation.md | 255 ++++++++++++++++ 9 files changed, 1021 insertions(+), 4 deletions(-) create mode 100644 docs/decisions/discovery-model.md create mode 100644 docs/decisions/private-store-nuri-scope.md create mode 100644 docs/decisions/shared-wallet-login-flow.md create mode 100644 docs/decisions/sparql-delete-for-orm-objects.md create mode 100644 docs/fork-inbox-fallback.md create mode 100644 docs/migration-guide.md create mode 100644 docs/nextgraph-current-state.md create mode 100644 docs/simulation.md diff --git a/README.md b/README.md index 351c02f..1dd7cc9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,31 @@ it contains **no application domain** — the consumer injects its shapes and th The name: *eventually* NextGraph will ship these features; until then this layer fills the gap (and nods at eventual consistency / events). +## The boundary — mature face out, compensation in + +The asymmetry is the whole point. **Consumers write SDK-shaped code as if +NextGraph were finished**: per-entity documents in public/protected/private +stores, capabilities, inboxes. This library **owns all the current-state +NextGraph knowledge and the simulation** that fabricates that mature face — a +**shared-wallet** emulation — so the application never sees it. When NextGraph +matures, **only this library changes**; the consumer's code does not. + +Docs (this library's own engineering doctrine, under [`docs/`](./docs/)): + +- [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the + authoritative reference on what the CURRENT SDK/broker do and do NOT expose + (the ground truth every polyfill compensates for). +- [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature + behaviour on ONE shared wallet (shim, per-document ReadCaps, emulated + inbox+curator, write guard, faux login, the two axes, the double-proxy + constraint). +- [`docs/decisions/`](./docs/decisions/) — historical current-SDK ADRs + (private-store scope, SPARQL delete, shared-wallet login, discovery mechanism). +- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch / + self-host inbox path NOT taken (kept as fallback). +- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when + real NextGraph matures. + ## Packages | Package | Role | At migration | @@ -39,12 +64,37 @@ app code (SDK-shaped) is unchanged. enforces them generically (read filter + write guard). The app *attaches* grants via cap operations — same as it will in the target. No policy is injected. -- **Inbox**: the client `inbox.post()` deposits; materialization (the curator) - is deferred with the global-index mechanism — see the note above. +- **Inbox**: the client `inbox` namespace deposits (`post`) and, in the + shared-wallet emulation, also plays the curator inline (`read` / `materialize` + / `watch`). At migration the read side moves to a separate curator package, + deferred with the global-index mechanism — see the note above. - **Tests** of the polyfill (against a real broker) live **in this repo**, so the consuming app can test its features against a mocked, clean API. ## Status -Scaffold. Mechanisms are stubbed with `TODO(broker)` / `TODO(polyfill)` where -real NextGraph wiring is required. +**Implemented.** The polyfill mechanisms are wired against a real broker, not +stubbed: + +- **Shared-wallet shim** — `store-registry.ts` (`(account, scope) → document + NURI`, `createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via + the RDF shim anchored in the private store). +- **Document / SPARQL primitive** — `docs.ts`, calling the real injected `ng` + directly (avoids the `@ng-org` double-proxy `DataCloneError`). +- **Emulated ReadCaps** — `caps.ts` (`CapRegistry`, per-document) + read filter + `read-filter.ts` (reactive-set `Proxy` view), applied by `use-shape.ts` only + when a policy is declared. +- **Write guard** — `ng-proxy.ts` (`sparql_update` override, emulated write cap). +- **Inbox** — `inbox.ts` (`post` / `read` / `materialize` / `watch`, emulated + curator inline). +- **Isolation** — `isolation.ts` (pure social-visibility matrix, distinct axis + from ReadCaps). +- **Accounts** — `accounts.ts` (faux username login, injected storage). +- **SPARQL hardening** — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`). + +Remaining `TODO` markers are narrow: the shared-wallet credential passthrough in +the `login`/`session_start` proxy branch, and the anticipated cap/inbox SDK +signatures to reconcile if the official API differs. See +[`docs/simulation.md`](./docs/simulation.md) for what each piece does and +[`docs/migration-guide.md`](./docs/migration-guide.md) for what is removed at +migration. diff --git a/docs/decisions/discovery-model.md b/docs/decisions/discovery-model.md new file mode 100644 index 0000000..980d552 --- /dev/null +++ b/docs/decisions/discovery-model.md @@ -0,0 +1,81 @@ +# ADR — Discovery mechanism (inbox-fed index, curator, fan-out) + +**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided. +Ported here for the **discovery MECHANISM** it defines — the piece this lib +realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The +product intent (what a consumer *should* surface) is the consumer's concern, not +this lib's; only the mechanism is recorded here. + +## Access ≠ discovery + +- **Access**: may I read this document if I hold it? A public entity is + world-readable with its NURI. +- **Discovery**: how do I learn it exists, in order to read it? ← this ADR. + +## The mechanism + +1. **A single global index**, **fed via ITS inbox**. The creator does **not** edit + the index directly: it **deposits a reference into the index's inbox**. The + index is an **owned document** (public read), **materialized from its inbox** (a + watcher ingests deposits → adds entries). +2. **Primary discovery = that global index.** +3. **Relational = secondary axis**, overlaid: a connection's participations, + markers on the global list. Rests on existing per-item data (protected scope) — + no new primitive. + +## The 3-stage frame + +`discovery → synchronization → query` + +1. **Discovery**: the index gives the NURIs of the entity documents. +2. **Synchronization**: subscribe to those documents → they **replicate locally** + (verifier: `self.repos` + oxigraph dataset). +3. **Query**: query what is **now local** (sort, limit, reactivity). **SPARQL/ORM + run on the local set only** (`resolve_target_for_sparql` searches `self.repos`) + — you cannot query what is not loaded. + +**Corollary:** a reactive query does not replace the index — it runs at stage 3 on +the local union that stages 1-2 built. You don't sync what you didn't discover. + +## Why one reused mechanism + +- **No Group store.** The index is **not** open-write: it is an **owned document** + (public read) **+ native inbox** (a primitive present on every document). Nobody + writes the index but its owner (by materializing inbox deposits). So the model + stays "3 stores + Dialog + inboxes, no Group store." +- **One mechanism, reused.** The **inbox + materialization watcher** serve BOTH + submitting an entity to the index AND registering to a meeting-point — same + `inbox.post` API, same handling. This is exactly `inbox.ts` in this lib (`post` + / `read` / `materialize` / `watch`). +- **Natural dedup / moderation point:** materialization (inbox → index) is where + duplicates are detected / moderated before insertion. + +## Index owner — target model undecided + +The "dedicated service with its own wallet sharing a freely-readable index" was +**incorrect**: NextGraph apps and services are **mono-user with no global data** +(see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps & +services). The only path glimpsed for a global document is a **singleton app** +bound to the developer-user — **not implemented, uncertain**, to explore later. +This is why a global-index curator is a **deferred separate package** in this lib +(see the top-level README). + +## Polyfill reality (fan-out) vs target (global index) + +What ships in the shared-wallet polyfill today is the **cross-account fan-out over +every account's public documents** (`store-registry.ts` `listEntityDocs('public')` +/ `resolveReadGraphs`) — one account sees another's public entity **without a +connection**. This ADR classified per-account fan-out as a **drift** to be +replaced by the single global index; the target (inbox-fed global index) remains +valid but the fan-out is the mechanism the shared-wallet staging actually runs on +until the global-index owner is decided. Recorded here as mechanism history — the +resolution belongs to [`../migration-guide.md`](../migration-guide.md). + +## Alternatives rejected (mechanism) + +- **Open-write index** (creator writes the index directly): required a + collaborative document (Group store, SDK-blocked) and exposed the index to + corruption. Replaced by inbox deposit + owner materialization. +- **Purely relational discovery** (`social_query`): rejected as *primary* (a + global list is wanted); kept as a secondary axis. +- **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3). diff --git a/docs/decisions/private-store-nuri-scope.md b/docs/decisions/private-store-nuri-scope.md new file mode 100644 index 0000000..1152e7e --- /dev/null +++ b/docs/decisions/private-store-nuri-scope.md @@ -0,0 +1,51 @@ +# ADR — Use a store NURI as the `useShape` scope AND `@graph` + +**Date:** 2026-03-17 · **Status:** Accepted (partially superseded — see below). +Historical decision, ported into this lib because the *insight* still governs how +the shim opens repos. Original context: the consuming app. + +> **Partially superseded (2026-07-03).** The private-store-only scope was replaced +> for shareable domain entities: they are now scoped AND written to the +> **protected** store (`did:ng:${protected_store_id}`), verified to open without +> `RepoNotFound`. **The central insight of this ADR still holds** and now applies +> to **both** stores: you must open the repo via the store's NURI +> (`orm_start_graph`) or you get `RepoNotFound`. + +## Context + +Loading test data updated the in-memory ORM signals (immediate UI) but produced +`RepoNotFound` on `doc_create` and `orm_frontend_update`. Data vanished on reload +because the SPARQL writes never reached the broker: the verifier's `self.repos` +HashMap did not contain the store's repo → `resolve_target()` failed. + +## Options considered + +### A — `did:ng:i` scope + `doc_create` for `@graph` +`did:ng:i` is documented as a subscription scope; `doc_create` returns a real +NURI. **Against:** `did:ng:i` goes through `NuriTargetV0::UserSite`, which does +NOT open individual repos; `doc_create` calls `resolve_target(PrivateStore)`, +which requires the repo already in `self.repos` → fails; needs complex retry/timing. + +### B — the store NURI as scope AND `@graph` (chosen) +Exact copy of the working `expense-tracker-rdf` example: `orm_start_graph` with +the store's NURI opens the repo in `self.repos`; subsequent `orm_frontend_update` +finds it. Simple, no retry. **Against:** slightly less flexible than `did:ng:i` +(scoped to one store); requires passing the session down to the ORM hook. + +### C — `did:ng:i` scope + reuse an existing entity's `@graph` +Works for users who already have data. **Against:** fails for empty wallets (no +entity to reuse) → falls back to `doc_create` and the same `RepoNotFound`. + +## Decision + +**Option B**: use the store NURI as both the `useShape` scope AND the write +`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens a +store repo via `orm_start_graph` before writing, and why **`did:ng:i` must never +be used as a scope** (it breaks writes with `RepoNotFound`). See the +`orm_start_graph` scope rule in [`../simulation.md`](../simulation.md). + +## Consequences + +- **Positive:** immediate writes after connect (no retry); persistence across + reload; aligned with the official examples. +- **Risk:** if NextGraph changes the store's open behaviour, this breaks. diff --git a/docs/decisions/shared-wallet-login-flow.md b/docs/decisions/shared-wallet-login-flow.md new file mode 100644 index 0000000..fa58827 --- /dev/null +++ b/docs/decisions/shared-wallet-login-flow.md @@ -0,0 +1,64 @@ +# ADR — Shared-wallet login/logout flow + +**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this +lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph. + +## Starting constraint + +NextGraph login **is not programmable**: it is a **web redirect** to the broker +page (`nextgraph.net`). The shared wallet cannot be opened silently — at least one +broker-redirect pass is required per device. The question is therefore not "how to +avoid the redirect" but "how to order and present it" so the UX stays coherent. + +## Decision — technical gate first, application "Connexion" second + +Two distinct auth layers, presented in this order: + +1. **Real layer (technical, NOT perceived as login).** The broker redirect appears + **immediately, before any app render**. Because it precedes the app, the user + reads it as a **technical access barrier to the test environment** (a beta + wall), **not** an application login. Same shared credentials for everyone + (given in the invitation, "access code" style). Once per device, then + persistent. **Never labelled "login."** +2. **Application layer (perceived as THE login).** A **"Connexion"** screen = + **username only** (→ `localStorage`, the current principal). This is the login + *in the user's perception*. **No password** → declarative connection (anyone + takes any username — coherent with zero-security / friends). **"Déconnexion"** + clears **only** the username and returns to "Connexion"; it **calls no NG + function**. + +The **real logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays +**hidden** (settings/debug), because it forces a new redirect. + +## Why (vs the rejected option) + +**Rejected** — faux login first, then a warning page "enter this username/password", +then a *Continue* button triggering the redirect. Rejected: strange workflow, +dissonant double-login, a warning page that **looks like a scam**, and the +redirect **resurfacing mid-use** on every session expiry. + +**Chosen** because: the mental model stays coherent (the technical barrier not +being perceived as login, the app-level Connexion/Déconnexion pair is complete and +self-consistent); graceful degradation (a re-gate after a browser restart reads as +"reconnecting to the environment", not a bug); and **similarity to the target +infra** — the "broker redirect → app" shape is exactly the real multi-wallet flow. +At migration you **remove the username "Connexion" screen** and the **technical +barrier becomes the real per-user login** — the flow shape does not change. + +## Verified technical facts (`nextgraph-rs`, 2026-06-15) + +- **Session persistence: YES.** Wallet remembered iframe-side (`localStorage` + long-term + `sessionStorage` for the active session); on reload `init()` + recovers the session **without** re-triggering the redirect while the broker + session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A full + browser restart (losing `sessionStorage`) can re-trigger the gate. +- **Real logout exposed: YES.** `ng.session_stop()`, `ng.user_disconnect()`, + `ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`); they stop the session / + clear the wallet and **force a new redirect** afterwards — hence: do NOT call + them in the app-level "Déconnexion," and hide the real logout. + +## How this lib realizes it + +`accounts.ts` `AccountStore.login()`/`logout()` only read/write the username in an +injected `AccountStorage`; they **never** call NG. See the faux login in +[`../simulation.md`](../simulation.md). diff --git a/docs/decisions/sparql-delete-for-orm-objects.md b/docs/decisions/sparql-delete-for-orm-objects.md new file mode 100644 index 0000000..cbfe152 --- /dev/null +++ b/docs/decisions/sparql-delete-for-orm-objects.md @@ -0,0 +1,61 @@ +# ADR — Use SPARQL DELETE (not ORM `ngSet.delete()`) to remove objects + +**Date:** 2026-03-17 · **Status:** Accepted → Superseded (2026-06-15). Historical +decision, ported for the current-SDK behaviour it records. Original context: the +consuming app. + +> **Superseded (2026-06-15).** The `ngSet.delete()` non-persistence bug that +> motivated this decision was largely fixed upstream in `@ng-org/orm`; deletion +> code went back to `ngSet.delete()`. Kept as arbitration memory — the CRDT +> conflict rule ("don't combine the two") **still holds**. + +## Context + +Removing an object from the NextGraph store: `DeepSignalSet.delete()` updates the +local reactive state (immediate UI) but **does not persist** to the broker — after +refresh the object reappears. + +## Options considered + +### A — ORM `ngSet.delete(item)` +Official API, instant local reactive update. **Against:** did not persist in +practice (`delete()` returned `true`, local set updated, object back after +refresh); `graph_orm_update` seemed to mishandle "remove" patches for top-level +set objects (likely engine bug); failed silently. + +### B — `ng.sparql_update()` with SPARQL DELETE +`DELETE WHERE { GRAPH { ?p ?o } }` removes all the RDF triples. +**For:** persists (survives refresh); the broker confirms via a `GraphOrmUpdate` +`op: "remove"` that reactively removes the item from the ORM set; direct control. +**Against:** not instant (~50ms SPARQL round-trip + broker callback); must NOT be +combined with `ngSet.delete()`. + +### C — both together +**Does not work:** the ORM `.delete()` patch and the SPARQL DELETE conflict at the +CRDT level → neither UI nor persistence. + +## Decision + +**Option B — SPARQL DELETE alone.** The broker returns a `GraphOrmUpdate` +`op: "remove"` that reactively removes the item from the ORM set (UI updates, just +not synchronously). **Do NOT** call `ngSet.delete()` alongside. + +```ts +await ng.sparql_update( + session_id, + `DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`, + partGraph, +); +``` + +This is the authoritative-delete pattern this lib's emulation relies on for inbox +deposits and shim graphs (an interpolated NURI/subject must pass through +`assertNuri`/`escapeLiteral` first — see SPARQL hardening in +[`../simulation.md`](../simulation.md)). + +## Consequences + +- **Positive:** deletion persists; single source of truth (broker → ORM → UI). +- **Negative:** slight UI delay (~50ms); diverges from the ORM README examples. +- **Risk:** if `ng.sparql_update` changes, this breaks; revisit as `ngSet.delete()` + matures upstream. diff --git a/docs/fork-inbox-fallback.md b/docs/fork-inbox-fallback.md new file mode 100644 index 0000000..cdd770c --- /dev/null +++ b/docs/fork-inbox-fallback.md @@ -0,0 +1,93 @@ +# Fallback — forking NextGraph to expose the inbox (path NOT taken) + +**Status:** NOT taken — short-circuited by this lib's **emulated inbox** +(`inbox.ts`, see [`simulation.md`](./simulation.md)). Kept as the fallback plan if +a **native** broker inbox ever becomes necessary — chiefly for the **crypto +anonymity** the emulation does not provide (native `from = None` sealed deposit). + +Current NextGraph does not expose the inbox to the JS SDK: the verifier has no +`InboxPost` arm and no wasm helper seals a deposit (see +[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Two ways to +get a real inbox: **emulate it** (what this lib does) or **fork the engine** (this +document). The emulation won; this is the archived alternative. + +## Strategic posture + +The fork would be **explicitly temporary, not for upstream**. Hypothesis: +NextGraph will eventually expose its **own** JS-SDK inbox solution, possibly +different. When it lands, drop the fork and adapt. No PR is targeted. This posture +is the reason the emulation was preferred: it avoids maintaining a fork + hosting +a full stack for a feature the upstream will likely ship differently. + +## Layer 1 — the Rust patch: 4 files (vanilla broker) + +1. **`engine/net/src/types.rs`** — `InboxMsgContent::Link` is a **unit** variant + (stub); give it a payload (or a `Notification` variant) carrying the target + NURI + link to the deposited reference. Add an `InboxPost::new_link(...)` + builder modeled on `new_contact_details`. `from = None` → anonymity. +2. **`engine/verifier/src/request_processor.rs`** — add the missing command arm + (there is no `InboxPost` arm). Ideally a high-level command (`NotifyInbox`) that + builds the post on the Rust side (keeps crypto sealing in Rust). Model on + `SocialQueryStart`. +3. **`sdk/js/lib-wasm/src/lib.rs`** — expose `pub async fn inbox_post_link( + session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, modeled on + `social_query_start`. +4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — a receive arm + that **materializes** the message into a document in the owner's store (model on + the `ContactDetails` handler). The app then reads via ORM/SPARQL — no new + inbox-read API. + +**Identity resolution** (known/anonymous): free via app-side SPARQL (JOIN the +sender inbox NURI against `social:contact` docs). **Discovering the owner's +inbox**: embed the owner's `public_store` inbox NURI in the entity document or +public profile (the QR profile-share flow already carries it). + +## Layer 2 — deployment (from the fork) + +The patched verifier runs **in the iframe ng-app** (see integration model in +[`nextgraph-current-state.md`](./nextgraph-current-state.md)) → **build and +self-host `ngd` + the ng-app** from the fork, then rebuild Festipod's `@ng-org/web` +with `NG_REDIR_SERVER`/`NG_DEV*` pointing at that ng-app. **No rewrite of the +third-party integration** (stays iframe). The broker's inbox routing is already +native, but since the patched ng-app is self-hosted, the **whole stack ships from +the fork** (one source tree). + +### Coolify hosting — 3 web pieces + +1. **`ngd`** — stateful WebSocket daemon: container with a **persistent volume** + for `--base-path` (RocksDB + keys + PeerId, never wiped), `--domain` mode behind + Coolify's Traefik. Build: official Dockerfiles are broken → write a **custom + multi-stage Rust Dockerfile** (RocksDB needs llvm/clang). First boot is + **interactive** (admin-wallet invitation link) → script via `ngcli` or do it + once by hand then persist in the volume. +2. **ng-app** (iframe frontend, patched wasm) — **static build** + (`pnpm webfilebuild`). Served statically. +3. **Routing**: one domain serves the ng-app static AND proxies the WebSocket to + `ngd`. + +### Layer 1 (JS libs) — patched client npm packages + +Maintain patched client packages, not just the wasm. The generic forwarding +*technically* reaches a wasm method without touching the JS, but that's an untyped +**hack** — quick test only. To modify for real: +- **`@ng-org/web`** — modified anyway (broker URL) → add `inbox_post_link` to the + typed API surface + `.d.ts`. +- **Streamed methods** (if inbox *reads* ever stream) — need an entry on both + sides. For write-only (request/response) — unneeded. +- **`@ng-org/orm`** — only if inbox writes join the ORM flow. Otherwise unneeded. + +## Layer 3 — consumer integration + +Exposing the method is not enough; the consumer must model the entity + its inbox +NURI, write the registration, deposit into the host inbox, and read/resolve +notifications. Several of these are **already done** in the shared-wallet emulation +(registration wired on the emulated `inbox.post`), which is precisely why this fork +was not needed. + +## Why this fallback still matters + +The emulated inbox stores `from = null` as *absence of a triple*; it does not seal +deposits, so it does not provide the target's **crypto** anonymity. If a consumer +needs true anonymous-but-verifiable deposits to a non-connected host, only a native +inbox (`from = None` sealed) delivers it — and this fork is the route. Until then, +the emulation is sufficient. diff --git a/docs/migration-guide.md b/docs/migration-guide.md new file mode 100644 index 0000000..533a012 --- /dev/null +++ b/docs/migration-guide.md @@ -0,0 +1,83 @@ +# Migration guide — when real NextGraph matures + +The whole point of this library: the consumer already writes SDK-shaped code, so +when NextGraph ships cross-wallet reads, capabilities and inboxes, **only this lib +changes**. The consumer's application code does **not** change. This is the +checklist. + +## Guiding invariant + +Every emulated piece has a 1:1 image in the real infra. Migration = swap the +emulation for the real primitive, remove the scaffold. If a piece of the emulation +has no clear target image, that is a drift signal (see +[`simulation.md`](./simulation.md)). + +## Checklist + +### 1. Emulated ReadCaps → real capabilities +Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the +broker/verifier enforces them, and `useShape` already returns only authorized +documents. The read filter (`read-filter.ts`) and the write guard (`ng-proxy.ts` +`sparql_update` override) are then **dead code** — remove them. The access unit is +already the document (`@graph`), matching the native per-repo cap model, so this is +a data step, not a reshape. + +### 2. Place documents in real native stores +Today `docCreate(..., undefined)` writes every document into the shared wallet's +**private** store, and the `public|protected|private` scope is a **logical label** +in the shim (see the two-axes section in [`simulation.md`](./simulation.md)). + +- **`doc_create` cannot target a non-private native store today** — verified: + `StoreRepo` is **not JS-constructible** from the SDK, so there is no way to pass + a public/protected store as the create destination (`docCreate`'s trailing + `store` arg is left `undefined` → private store). The private store works only + because it opens without `RepoNotFound`. +- When the SDK lets you construct/target a native store, the migration adds a + `getNativeStore(scope)`-style resolver returning the real store to pass as the + `docCreate` destination, so the logical scope label becomes a real store + placement. (No such helper exists yet — it is blocked on the SDK gap above.) +- At that point `store-registry.ts` maps `(account, scope)` to the user's **real + store NURI** instead of a document in the shared wallet; the per-scope index + document (the store-container emulation) is replaced by the store itself. The + consumer-facing surface (`createEntityDoc`, `listEntityDocs`, resolvers) is + designed to survive that swap unchanged. + +### 3. Drop the resolver / shim +The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store) +has **no target equivalent** — the target has no central directory. Remove it: +`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads +replace the fan-out; per-user wallets replace the shared one. + +### 4. Real inbox → drop the emulated curator +Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet +document) with the native `inbox_post_link`, and move `read`/`materialize`/`watch` +to a **separate curator package** (the deferred global-index curator — see the +top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). +The in-client read side goes away. The single global index replaces the +cross-account fan-out. + +### 5. Faux login → real per-user login +Remove `accounts.ts` (the username `localStorage` faux login) and the app-level +"Connexion" screen. The technical broker gate **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 social-visibility filter) disappears against a +different piece of infra than the caps: real per-account wallets + a real +per-account social graph. Distinct axis from ReadCaps — remove independently. + +### 7. Remove the build alias — the client becomes the real SDK +The consumer imports `@ng-org/web` / `@ng-org/orm` resolved to this lib via a +**build alias** during the polyfill period. Removing the alias makes those imports +resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so +**no consumer code changes**. The one non-SDK call — `configure(...)` / +`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears. + +## What does NOT change + +**The consumer's application code.** Shapes, screens, the *acts* of granting +access, entity→scope mapping, the connection graph — all injected, all untouched. +Migration is entirely inside this library plus removing the alias + the bootstrap +call. That asymmetry — a mature SDK face outward, all compensation inward — is the +library's reason to exist. diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md new file mode 100644 index 0000000..f1a75d2 --- /dev/null +++ b/docs/nextgraph-current-state.md @@ -0,0 +1,279 @@ +# Current-state NextGraph — what the SDK/broker do and do NOT expose + +**Owner:** this library. `@ng-eventually/client` exists because the *current* +NextGraph JS SDK is immature. This file is the authoritative reference on what +today's SDK/broker actually give us — the ground truth every polyfill in this +lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate +the mature behaviour on top of these limits, and +[`migration-guide.md`](./migration-guide.md) for what changes when they lift. + +Verified against `nextgraph-rs` (local clone at `../nextgraph-rs`, sibling of +this repo) and the installed npm packages. Store/permission facts cross-checked +with the official docs ([Documents & Stores](https://docs.nextgraph.org/en/documents/), +[Getting started](https://docs.nextgraph.org/en/getting-started/)). + +## Source pointers (`nextgraph-rs`) + +Where the ground truth lives, so future re-verification is cheap: + +- `sdk/js/lib-wasm/src/lib.rs` — the wasm API actually exposed to JS. +- `engine/net/src/app_protocol.rs` — `AppRequestCommandV0` enum, `NuriV0` formats. +- `engine/verifier/src/request_processor.rs` — the effective `app_request` + dispatch (the truth on what is actually *processed*). +- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`). +- `engine/verifier/src/inbox_processor.rs` — inbox message handling. +- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO (cross-wallet read). +- `engine/repo/src/types.rs` — `RootBranchV0.store: StoreOverlay` (repo → its store). + +## The 5 store types + +Every wallet has the **3 default stores** out of the box (session fields +`private_store_id`, `protected_store_id`, `public_store_id`). Group and Dialog +are created on demand. + +| Store | Read | Write | Creation | +|---|---|---|---| +| **Private** | Owner only | Owner only | Default | +| **Protected** | Owner + link+permission holders | Owner + permissioned collaborators | Default | +| **Public** | Everyone, no capability | Owner only | Default | +| **Group** | Group members | Group members (collaborative) | On demand | +| **Dialog** | The two users only | The two users only | On demand | + +Doc citations (verbatim): Private — *"only you have access to … not possible to +share"*; Protected — *"share … but they will need a special link and permission"*; +Public — *"equivalent to your website … without the need for special permissions"*; +Group — *"each Group is a separate Store … documents inherit the permissions of +the store"*; Dialog — *"hold all the data you exchange with another user (and only +with that other user) … You cannot add more users"*. + +## Document = repo (there is no `Document` type) + +*"A Repo is the equivalent of an E2EE group for one and only one Document."* +**1 document = 1 repo** (commits + permissions). Identifier: `did:ng:o:`. + +There is **no `Document` type** in `nextgraph-rs` (verified 2026-06-29): a +"document" is simply *any repo*. A **store is a special repo** (`is_store=true`, +with `Store`/`Overlay`/`User` branches) — so *a store is a document, but a +document is not necessarily a store*. + +**Containment (store → repos) is by REFERENCE, not by a list.** A store does not +hold a `Vec`: it references its repos through an **RDF graph** in its +Overlay/User branch. Conversely each repo names its parent store via +`RootBranchV0.store: StoreOverlay` — **a repo belongs to exactly one store**. + +## Capability / ReadCap granularity — the load-bearing fact for this lib + +`ReadCap = ObjectRef`. Granularity is at the **repo AND branch** level (each +branch has its own `read_cap`), down to the **block** (`ObjectKey`/ChaCha20 key). +Write is managed at the **document (repo)** level. + +**No automatic read inheritance.** Holding a **store's** ReadCap does **not** +grant the repos it contains — **you need each repo's own ReadCap**. The optional +`inherit_perms_users_and_quorum_from_store: Option` shares only +users/quorum (write/permissions), **not** read-cap possession. (Repos of a +`private_store` inherit implicitly.) + +> **Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):** +> the read access UNIT is the **repo = each item's `@graph`** — a per-DOCUMENT +> filter, never per-store and never per-item. This is exactly what +> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level +> inheritance, purely per-document caps. In a mono-store layout (all items in one +> repo) the filter is therefore all-or-nothing on that document — which *is* the +> native behaviour, and why fine-grained isolation requires one document per +> entity. + +### Store ↔ document confusion (recurring) + +The isolation axis is the **document (repo/`@graph`)**, never the **store**: a +store *contains* several documents and does not share their read caps. See the +two-axes warning in [`simulation.md`](./simulation.md): "multi-store" in this +lib's emulation means **multiple DOCUMENTS in one shared store**, not multiple +stores. + +## Capability sharing / NURI + +Sharing transmits a **NURI** embedding the crypto capability (read and/or write). +No central ACL: holding the NURI *is* the right. *"adding permissions can be done +offline"*; *"removing permissions … requires a SyncSignature"* (synchronous). + +## Inbox + +**Every document has a native inbox.** A non-editor can **deposit a link (DID +cap)** into it without being invited as an editor; the owner **moderates**. NURI: +`did:ng:d:`. Content: the `InboxMsgContent` enum (`ContactDetails`, +`DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`, +`RemoteQuery`, `SocialQuery`…). Messages are **sealed** (`crypto_box::seal`) to +the inbox pubkey → only the owner decrypts. The `from` field is **optional** → an +**anonymous** sender is possible. This is the "identified if known, anonymous +otherwise" behaviour native to the protocol. + +### The inbox is NOT usable from the JS SDK + +- `app_request(request)` is exposed, and `AppRequestCommandV0::InboxPost` + + `AppRequest::inbox_post()` exist. **BUT** the verifier's `request_processor` + has **no `InboxPost` arm** (arms actually handled: `OrmStart(Discrete)`, + `Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`, + `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost` + triggers nothing. +- Building an `InboxPost` requires crypto sealing on the Rust side; **no wasm + helper** exposes it. +- Inbox deposit is only triggered **internally** by `QrCodeProfileImport` + (`post_to_inbox(new_contact_details)`) and `social_query_start` (contact + propagation via inbox). + +**Consequence:** there is no clean way to "drop a Link" into an arbitrary +document's inbox from the JS SDK today. This lib emulates the inbox instead of +patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox + +curator) and [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch +path NOT taken). A related exposed primitive: `social_query_start` (a federated +query via inbox up to `degree` hops) exists but is limited to **contacts** — it +does not cover an anonymous notification to a non-connected host. + +## JS SDK limits (`@ng-org/web`) + +`@ng-org/web` (verified `0.1.2-alpha.13` = `upstream/main` at 2026-05-21, the +installed version) **does NOT expose**: Group/Dialog store creation; capability +sharing (a NURI with rights); permission manipulation; inbox deposit/read. + +Available JS methods: `doc_create`, `doc_subscribe`, `sparql_query`, +`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`, +`discrete_orm_update`, `file_get`, `app_request_stream`. The docs announce *"An +API will be provided for permission manipulation"* (no date). + +## Integration & deployment model + +NextGraph is consumed via an **iframe proxy** (`@ng-org/web`): the third-party +app contains no engine, it delegates to a hosted ng-app (default `nextgraph.net`) +that runs the engine in an iframe. + +### The JS packages + +- **`@ng-org/web`** — **published**. Lightweight postMessage proxy (no wasm + embedded). **The** third-party integration path; `@ng-org/orm` and every + example depend on it. This lib wraps it. +- **`@ng-org/api-web`** — **private** (unpublished). Full in-browser engine + (loads `@ng-org/lib-wasm` in a Web Worker). Consumed only by `app/nextgraph` + (the ng-app frontend) — **not** a third-party integration target. +- **`@ng-org/lib-wasm`** — the compiled wasm engine (contains the verifier). + Source `sdk/js/lib-wasm/`. +- **`nextgraph`** (npm) — the NodeJS API (`pkg-node` build). +- **`@ng-org/orm`** — reactive ORM (`useShape`…), built on `@ng-org/web`. + +### Where the verifier runs + +In the standard web model, the verifier runs **in the iframe**: `app/nextgraph` +loads `api-web` → `lib-wasm` in a Web Worker, browser-side. The broker (`ngd`) +only does **transport and storage**. + +**Consequence:** changing verifier logic (`request_processor`, +`inbox_processor`) means rebuilding the **ng-app**, not the broker. + +### iframe model & build-time retargeting + +`@ng-org/web` redirects to the hosted ng-app, which reloads the third-party app +in an iframe after auth, then relays over `postMessage`. **Retargetable at build +time** (`sdk/js/web/src/index.ts`, `import.meta.env`): + +| Variable | Target | +|---|---| +| `NG_REDIR_SERVER` | default `nextgraph.net` | +| `NG_DEV3` | `127.0.0.1:3033` | +| `NG_DEV` | `localhost:14402`/`14404` | +| `NG_DEV_LOCAL_BROKER` | `localhost:1421` | + +**No runtime override** — `init()` takes no broker URL. To point at a +self-hosted ng-app: **rebuild `@ng-org/web`** (pure TS, no wasm → trivial build). + +### Proxy ↔ iframe ↔ worker plumbing (generic) + +The call path is **entirely generic** (no allowlist): `@ng-org/web` is a JS +`Proxy` relaying *any* method name over `postMessage`; `app/nextgraph` dispatches +via `Reflect.apply(ng[method], …)`. So a new wasm function in simple +request/response form is *reachable* without touching the JS — but that's an +untyped **hack** (quick test, not a plan). The **streamed** case needs an entry +on both sides (`E` in `@ng-org/web` + `streamed_api` in api-web; current streamed +methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`, +`app_request_stream`). + +> This is exactly why `docs.ts` in this lib calls the **real injected `ng`** +> directly and never layers our own `Proxy` on top of `@ng-org/web`'s +> iframe-RPC proxy — see the `DataCloneError` double-proxy constraint in +> [`simulation.md`](./simulation.md). + +### The broker (ngd) + +- Already supports the inbox natively (`inbox_post`, `inbox_register`, + `inbox_pop_for_user` in `engine/net/src/server_broker.rs`) — a standard `ngd` + would route the inbox, **no broker patch needed**. The gap is in the + verifier/SDK layer, not the broker. +- **WebSocket** daemon (`async-tungstenite`), **stateful**: RocksDB under + `--base-path`, persisted PeerId (critical volume). +- CLI: `--local PORT`, `--domain DOMAIN:PORT,LOCAL_PORT` (behind a TLS-terminated + reverse proxy — Traefik/Coolify). +- **Serves no static assets**: the ng-app frontend is a separate static deploy + (`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation + link). Official Dockerfiles are **broken**. + +## Apps & services: mono-user, no global data + +NextGraph's app/service execution model — important because it **invalidates** +the idea of "a service with its own wallet sharing global data". + +- **Apps AND services are mono-user.** They see only **what the user makes + available** to them. There is **no global data** natively, and no central + service holding shared data. +- **Local settings document.** Every app — even a singleton — and every service + has a **settings document** the user configures it through. +- **Multi-instance apps.** A **non-singleton** app can be **instantiated several + times** (e.g. a text editor, once per open file). +- **Singleton apps.** Also **mono-user**, but **bound to a particular user (the + developer)**. A singleton app **can hold a global document**, administered by + that user. + +**Consequence for a "global document" (e.g. a discovery index):** the only path +glimpsed is a **singleton app** whose global document is administered by the +developer-user — **but this is not implemented and not guaranteed** (simpler +paths may exist; to explore later). The **incorrect** model to avoid: "a +dedicated service with its own wallet sharing a freely-readable index" — that +does not exist in NextGraph (a service is mono-user, no global data). This is why +a global-index curator package is **deferred** in this lib (see the top-level +README). + +## Third-party wallet auto-import constraint + +Verified empirically (2026-06-17): with the **hosted** broker (`nextgraph.net`), +a third-party web app **cannot** provision/import a wallet programmatically. A +wallet must **pre-exist** in the browser before the auth redirect can succeed. + +Mechanism (from `@ng-org/web`'s `ngweb.js` dist): + +- **`init()` top-level REDIRECTS**: when `window.self === window.top` it does + `window.location.href = https://nextgraph.net/redir/#/?o=`. The app's code + stops running. +- **Every `ng.*` method is relayed** by `parent.postMessage` to `nextgraph.net`, + and the handler **throws `"you must call init() first"` until a session is + established** (internal `d !== false` guard). This includes + `wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`. +- The third-party app runs **inside the iframe only AFTER** the broker has opened + a wallet and established the session. There is **no window** where our code runs + *before* the broker's wallet gate → **nothing to hook an auto-import onto**. + +Of the wallet-import methods offered on `nextgraph.eu`, only the **wallet FILE** +(`.ngw`) is a static, reusable export; TextCode/QR are temporary device↔device +transfers (5 min, both devices online, single use) — unusable to embed. The only +real way to eliminate the cross-origin round-trip is to self-host/fork the ng-app +(see [`fork-inbox-fallback.md`](./fork-inbox-fallback.md)). + +## Login is not programmable + +NextGraph login is a **web redirect** to the broker page (`nextgraph.net`). There +is no way to open a wallet silently — at least one broker-redirect pass per device +is required. Session persistence: the wallet is remembered iframe-side +(`localStorage` long-term + `sessionStorage` for the active session); on reload, +`init()` recovers the session **without re-triggering the redirect** while the +broker session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A +full browser restart (losing `sessionStorage`) can re-trigger the gate. A real +logout IS exposed (`ng.session_stop()`, `ng.user_disconnect()`, +`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but **forces a new +redirect** afterwards. This lib's faux login sidesteps all of it — see the faux +login in [`simulation.md`](./simulation.md). diff --git a/docs/simulation.md b/docs/simulation.md new file mode 100644 index 0000000..f6ef624 --- /dev/null +++ b/docs/simulation.md @@ -0,0 +1,255 @@ +# How this library emulates mature NextGraph on ONE shared wallet + +The consumer writes against `@ng-eventually/client` **as if** NextGraph already +shipped per-entity documents in public/protected/private stores, capabilities and +inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)). +This file is the lib's own engineering doctrine on how it fabricates that mature +face on top of **one single shared wallet / broker**. Everything here is +polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)). + +## The premise: one shared wallet, everything readable + +Current NextGraph has **no cross-wallet read** (`OpenRepo` is a TODO at +`engine/verifier/src/verifier.rs:1423`; a foreign NURI raises `RepoNotFound`; a +session only holds its own 3 stores in `self.repos`). So "each user their own +wallet" is blocked at the root — no data ever crosses the boundary between two +wallets. + +The lib's answer: **everyone opens the same wallet**. NextGraph sees a single +identity → **everything is physically readable**. "Multi-user" becomes an +application fiction the lib maintains. On top of that one wallet the lib rebuilds, +by emulation, the per-user stores + capabilities + inbox the consumer codes +against. + +## Two axes, never conflate them (store ≠ document) + +The single most load-bearing distinction. Two **orthogonal** axes the +terminology historically fused: + +- **Axis A — which native STORE?** A wallet has 3: `private_store_id`, + `protected_store_id`, `public_store_id`. Historic origin of "mono-store / + multi-store" (use 1 store vs the 3). +- **Axis B — how many DOCUMENTS in a store?** A store contains documents; the + **document (= repo = `@graph`) is the sharing + rights boundary**. The ReadCap + — hence **isolation** — is **PER-DOCUMENT**. + +**`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` → the shared +wallet's PRIVATE store.** The trailing `store` arg left `undefined` targets the +private store (this is what `store-registry.ts`'s `createDoc()` does). So every +document the shim creates physically lives in ONE store (private), and the +`public|protected|private` scope is a **LOGICAL LABEL** tracked in RDF by the +shim — **not** a NextGraph store. Therefore what a consumer's "multi-store" flag +switches on is really **multi-DOCUMENT with logical scope labels**, never +multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the +logical label the registry attaches. + +> Why `undefined` and not a real store? Because `doc_create` **cannot target a +> non-private native store** today: `StoreRepo` is not JS-constructible (verified +> — see the parked `getNativeStore` note in +> [`migration-guide.md`](./migration-guide.md)). The private store is reachable +> because it opens without `RepoNotFound`. + +## The shared-wallet shim (`store-registry.ts`) + +Emulates the target infrastructure — where each user owns their own +public/protected/private stores — on top of one shared wallet. + +- **One document per (account × scope)** inside the shared wallet, created via the + `docs.docCreate` primitive. The `scope` (`public|protected|private`) is a + logical attribute tracked here, not a physical store. +- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document + NURIs`, persisted as RDF in the shared wallet's **private store** (the anchor, + always known from the session: `RegistrySession.privateStoreId`). That makes + login **cross-device**: another device opening the same wallet reads the same + shim and finds the same accounts. It is the account→document **trust root** — + which is why every untrusted value that reaches its SPARQL is escaped (see + SPARQL hardening below). +- **Per-entity documents + per-scope index.** `createEntityDoc(username, scope)` + makes a dedicated document for ONE entity (mirrors the target, where each entity + is its own document/repo with a future inbox) and appends its NURI to the + account's **scope index document** — the index doc plays the role of the future + **store-container** (it lists the entity-document NURIs "in" that scope). + `listEntityDocs(scope)` unions the contained NURIs across all accounts — the + read fan-out. Use the returned NURIs as `useShape(shape, { graphs })`. +- **GENERIC by construction.** The registry knows only the three native scopes, + **zero** application entity kind. The consumer maps its entities to a scope and + injects the session + username normalization via `configureStoreRegistry({ + getSession, normalizeUser })` (`polyfill.ts`). + +The `store≠document` two axes materialize here directly: the registry moves along +axis B (more documents = more isolation), never axis A (it always writes into the +one private store via `docCreate(..., undefined)`). + +## `RepoNotFound` and the `orm_start_graph` scope rule + +A hard constraint inherited from the SDK: to read **and** write entities through +the ORM, the store's repo must be **explicitly opened** in the verifier's +`self.repos` HashMap. `orm_start_graph` with a store's NURI opens that repo; +without it, `orm_frontend_update` fails with `RepoNotFound`. + +- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or, + in the consumer, a per-user store once that migration happens). +- **`@graph`** (write target): the same store NURI. +- **Never use `did:ng:i` as a scope.** It subscribes to the user's whole site via + a special code path (`NuriTargetV0::UserSite`) that **does not open individual + repos** → breaks every write with `RepoNotFound`. + +Both the private and the protected native stores were verified to open the same +way for ORM+SPARQL (round-trip probe, no `RepoNotFound`). The original arbitration +is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-store-nuri-scope.md). + +## The `@ng-org` double-proxy `DataCloneError` constraint + +**Validated hard constraint, not a style choice.** `docs.ts` calls the **real +injected `ng`** (`getConfig().ng`) DIRECTLY — never the public `ng` proxy +(`makeNg` in `ng-proxy.ts`). + +`@ng-org/web`'s `ng` is already an **iframe-RPC proxy** (postMessage marshaling, +see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration). +Wrapping it in the lib's own JS `Proxy` (double proxy) breaks `doc_create`'s +postMessage marshaling → `DataCloneError: function ... could not be cloned`. +Reaching the real `ng` held in the config avoids the double-proxy. This was +verified: routing the shim's `doc_create`/SPARQL through the public proxy turned +4 multistore scenarios red; it was reverted. The integration boundary is: + +- **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap + filter), `init`/`initNg`, `login`. +- **Through the real injected `ng`** (`docs.ts` primitives): `doc_create` + all + shim/inbox SPARQL. + +`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from +`./ng-proxy`. + +## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`) + +In the target the broker only delivers documents the wallet holds a **ReadCap** +for, so `useShape` already returns an authorized subset. Here (single shared +wallet, everything readable) the lib reproduces that with a read-filtered VIEW: + +- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer + can. The access UNIT is the **document = repo NURI** (an item's `@graph`), + **never the item** — because in `nextgraph-rs` a store is just a container repo + and holding its cap does NOT grant the repos it references (no store-level read + inheritance; verified). So the registry is **purely per-document**: + `grantRead` / `grantWrite` / `makePublic` / `open(doc, scope, owner)` / + `canRead` / `canWrite` / `governsRead` / `hasReadPolicy`. The consumer performs + the *acts* of granting (create-public, grant-to-a-connection…) exactly as it + will in the target; the lib injects no policy. +- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a + `Proxy`: iteration / `size` / `forEach` are filtered by + `caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`, + `getById`…) forwards to the target, preserving writes and reactivity. An item + with no `@graph`, or in a document under no cap policy, is KEPT (the filter only + restricts documents that *declare* a cap — no regression on ungoverned data). + `filterReadable` is the pure variant. +- **`useShape` (`use-shape.ts`)** applies the view **only if + `caps.hasReadPolicy()`** — otherwise it passes the real set through unchanged + (no regression when the consumer declares no caps). + +In a mono-store layout (every item in one repo) this is all-or-nothing on that +document — exactly the native behaviour, and why fine-grained isolation requires +one document per entity (axis B). + +### Emulated ReadCap ≠ application isolation — they COEXIST + +`isolation.ts` is a **separate, deliberately non-merged** axis: + +| | ReadCap (`caps.ts` + `read-filter.ts`) | isolation (`isolation.ts`) | +|---|---|---| +| Unit | the DOCUMENT (`@graph` = repo) | the ITEM / record | +| Question | does the principal HOLD this doc's read cap? | given WHO is connected to WHOM, may this principal see it? | +| Models | NextGraph's native capability delivery (broker-enforced) | an application social-visibility policy, above the doc layer | +| Grants | explicit, per-document (`grantRead` / `makePublic`) | implicit, from the connection graph + item scope | + +`isolation.ts` honors a visibility matrix (public = everyone; protected = owner + +direct connections; private = owner only) with **pure** functions — no NextGraph, +no React, zero domain. The consumer injects the connection graph (`Connections`) +and the `ownerOf`/`scopeOf` accessors. The connection-derived `protected` +visibility has no equivalent in the per-document cap model, so the two are not +redundant. Each is a removable scaffold that disappears against a different piece +of real infra (caps → native ReadCaps; isolation → real per-account social graph ++ per-account wallets). + +## Emulated inbox + curator (`inbox.ts`) + +Current NextGraph does not expose the inbox to the JS SDK (verifier has no +`InboxPost` arm; no wasm sealing helper — see +[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than +fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib +**emulates** the inbox on the shared wallet: + +- **Target vs polyfill.** Target: `post` seals a reference into the owner's native + inbox (`ng.inbox_post_link(...)`) and a **separate curator** materializes + deposits into the owned document. Here, everything is readable, so both sides are + emulated in-lib. +- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF + into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each + deposit is a unique RDF subject → concurrent deposits don't collide. `from` is + optional: pass `null` for an ANONYMOUS deposit; omit it to default to the + current polyfill user (`getCurrentUser`). This reproduces the protocol's + "identified if known, anonymous otherwise" — though the emulation stores + `from = null` as *absence of a triple*, it does not provide the target's + **crypto** anonymity (`from = None` sealed), which only a native inbox would. +- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the + deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`. +- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it + polls `read` and fires when the deposit count changes (the polyfill has no + reactive inbox subscription). Fires once immediately; returns an unsubscribe. + +GENERIC: the module knows no domain — the consumer supplies the inbox document +NURI and interprets `payload`. At migration `post` becomes the native +`inbox_post_link` and the read side moves to a **separate curator package** (see +the deferred global-index note in the top-level README and +[`decisions/discovery-model.md`](./decisions/discovery-model.md)). The inbox + +watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point +registration AND submission to a discovery index — same `post` API, same watcher. + +## Emulated write guard (`ng-proxy.ts`) + +The public `ng` proxy overrides `sparql_update` to enforce an emulated **write +cap**: a write is refused unless the current user holds the target document's +WRITE cap. Passthrough (no regression) unless a WRITE policy exists AND that +specific document (the `anchor` arg) is governed by it — ungoverned docs (the +mono-store default, no cap declared) flow through unchanged. Mirrors the target +broker/verifier, which refuses a write without the document's write cap. + +## Faux login (`accounts.ts`) + +The real NextGraph login (redirect to the broker, opening the single SHARED +wallet) is perceived as a **technical access barrier**, not a login (see login +flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)). +THIS layer is the **perceived** login: + +- The user picks a **username** (no password — declarative), persisted in + `localStorage` so the "session" survives reloads and lands on the same account + when the shared wallet re-opens. +- `login()` / `logout()` are **FAUX**: they only read/write the username in + storage. They must **NEVER** call NextGraph (no `session_stop` / `wallet_close`) + — the shared wallet stays open underneath. The real logout lives elsewhere + (hidden in the consumer's settings/debug), because it forces a new redirect. +- **Framework-agnostic**: no React, no DOM beyond an optional injected + `AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The + React `Context`/`Provider` stays in the consumer. `normalizeUsername` + (case-insensitive, optional leading `@` stripped, trimmed) is the pure + normalizer, reusable as the shim key normalizer. + +## SPARQL injection hardening (`sparql.ts`) + +Every module that builds SPARQL by interpolation (inbox, store-registry) routes +untrusted values through `sparql.ts` first, because a `"` closes a literal and a +`>` closes an IRI, letting an injected value wreck the shim graph (the account → +document trust root): + +- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash, + double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary + text — JSON payloads, display names). +- **`escapeIri`** — for UNTRUSTED values embedded into an IRI (``, + e.g. a username minted into an account-subject IRI): percent-encodes every + IRI-hostile character so any username (spaces, unicode, punctuation) stays + usable while breakout is impossible. +- **`assertNuri`** — for trusted-SHAPED NURIs coming back from `ng` + (`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting + a malformed/injected query. + +These are re-exported from `@ng-eventually/client` so the consumer reuses the same +escaping when it builds SPARQL.