Files
ng-eventually/docs/simulation.md
T
Sylvain Duchesne bea9f51d91 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>
2026-07-03 23:23:23 +02:00

15 KiB
Raw Blame History

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

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

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 § 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.tsmakeReadFilteredView 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 § Inbox). Rather than fork the broker (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). 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). 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.