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
+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.