docs: own the current-NextGraph-state knowledge + boundary (lib side)

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:23:23 +02:00
parent d804a436d7
commit bea9f51d91
9 changed files with 1021 additions and 4 deletions
+54 -4
View File
@@ -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 The name: *eventually* NextGraph will ship these features; until then this layer
fills the gap (and nods at eventual consistency / events). 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 ## Packages
| Package | Role | At migration | | Package | Role | At migration |
@@ -39,12 +64,37 @@ app code (SDK-shaped) is unchanged.
enforces them generically (read filter + write guard). The app *attaches* enforces them generically (read filter + write guard). The app *attaches*
grants via cap operations — same as it will in the target. No policy is grants via cap operations — same as it will in the target. No policy is
injected. injected.
- **Inbox**: the client `inbox.post()` deposits; materialization (the curator) - **Inbox**: the client `inbox` namespace deposits (`post`) and, in the
is deferred with the global-index mechanism — see the note above. 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 - **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. the consuming app can test its features against a mocked, clean API.
## Status ## Status
Scaffold. Mechanisms are stubbed with `TODO(broker)` / `TODO(polyfill)` where **Implemented.** The polyfill mechanisms are wired against a real broker, not
real NextGraph wiring is required. 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.
+81
View File
@@ -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).
@@ -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.
@@ -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).
@@ -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 <graph> { <subject> ?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.
+93
View File
@@ -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.
+83
View File
@@ -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.
+279
View File
@@ -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:<RepoID>`.
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<RepoId>`: 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<ReadCap>` 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:<inbox_id>`. 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=<url>`. 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).
+255
View File
@@ -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 (`<PREFIX:${…}>`,
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.