Expose a clean scope-based surface so consumers work by scope (public/protected/ private) and never see a physical store id — the library resolves placement and performs the shared-wallet simulation internally. RegistrySession gains optional protected/public store ids, supplied at the single injection point (configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
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
undefinedand not a real store? Becausedoc_createcannot target a non-private native store today:StoreRepois not JS-constructible (verified — see the parkedgetNativeStorenote inmigration-guide.md). The private store is reachable because it opens withoutRepoNotFound.
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.docCreateprimitive. Thescope(public|protected|private) is a logical attribute tracked here, not a physical store. - The
sharedWalletShimis the mappingaccount → 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 asuseShape(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)).
SDK-shaped scope resolvers — the consumer holds NO store-id
The consumer must never construct a did:ng:${store_id} NURI itself: physical
placement is the lib's job (the whole point of the SDK boundary). Two resolvers
turn a logical scope into an opaque graph NURI without exposing any
store-id:
resolveScopeGraph(scope)— the graph where the current session writes entities ofscope, and whose repouseShapesubscribes to read them back. Use the returned value as BOTH the read scope (useShape(shape, nuri)) and the@graphwrite target. Placement lives HERE (Axis A):private→ the private native store;public+protected→ the protected native store, becausedoc_create/ORM cannot target a non-private/protected native store today (SDK blocker,migration-guide.md). At migration each scope resolves to the user's REAL per-scope store — the change is in this function, the consumer is unchanged.resolveInboxAnchor()— the anchor where emulated inbox deposits land (today the shared wallet's private store — a real repo NURI, required because the broker rejects aurn:anchor). At migration it becomes the host's native inbox NURI.
Both resolve the native store ids from the injected session
(RegistrySession.protectedStoreId / publicStoreId, alongside the existing
privateStoreId anchor). The consumer hands the whole session to the lib at the
ONE injection point (configureStoreRegistry({ getSession })) — that is wiring,
not placement logic; everything else in the consumer speaks only in scopes. If
the session omits protectedStoreId, the non-private scopes fall back to the
private store rather than emit a broken NURI.
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:ias 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 withRepoNotFound.
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.tsprimitives):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 innextgraph-rsa 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—makeReadFilteredViewwraps the reactive set in aProxy: iteration /size/forEachare filtered bycaps.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).filterReadableis the pure variant.useShape(use-shape.ts) applies the view only ifcaps.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:
postseals 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) viadocs.sparqlUpdate. Each deposit is a unique RDF subject → concurrent deposits don't collide.fromis optional: passnullfor 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 storesfrom = nullas absence of a triple, it does not provide the target's crypto anonymity (from = Nonesealed), which only a native inbox would.read/materialize(alias) play the emulated CURATOR: they read the deposits back viadocs.sparqlQuery, JSON-parse each payload, sort byts.watch(targetInbox, onDeposits, { intervalMs })is the emulated watcher: it pollsreadand 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
localStorageso 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 (nosession_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(awindow.localStorage, a test fake, ornullfor SSR). The ReactContext/Providerstays 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 fromng(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.