- read-model.ts `readUnion` now applies the emulated ReadCap gate (drops a
subject when its doc is governsRead && !canRead for the current identity), so
per-scope isolation holds by construction AND by filter.
- store-registry: `listMyEntityDocs(username, scope)` (current account only) vs
the all-accounts `listEntityDocs` fallback (documented as the enumeration to
avoid on the read path).
- docs: nextgraph-current-state / read-model — corrected to the SOURCE-VERIFIED
reality that the JS SDK exposes NO open/sync-by-cap primitive
(load_repo_from_read_cap is pub(crate)); in the mono-wallet all repos are
already local (same session), so the anchorless union spans them with no open
step. simulation.md: listEntityDocs+useShape({graphs}) is a fallback, not the
read path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.4 KiB
The READ MODEL the polyfill implements
How the polyfill turns "give me my lists" into concrete NextGraph reads on the
shared wallet. This is a design decision, grounded entirely in the query
capability documented in
nextgraph-current-state.md § The query
capability. The consumer (Festipod) never sees any of this: it asks
@ng-eventually/client for its lists by need and trusts the answer — the whole
read mechanism lives here, in the polyfill.
The governing constraints (all verified in nextgraph-rs, cited there):
- One local oxigraph store per session; every opened repo is a named graph.
sparql_querywith no anchor → the LOCAL UNION of all opened graphs; with an anchor → one repo. Union is read-only.- A repo is queryable only after it is opened/synced (needs its NURI + ReadCap;
no store-level read inheritance). VERIFIED (T03.k): the current JS SDK exposes
no primitive that syncs an unknown repo —
sparql_query/doc_subscribe/orm_start_graphall resolve viaself.repos.get().ok_or(RepoNotFound)and only touch a repo already present; the real loaderload_repo_from_read_capispub(crate), unexposed. In THIS mono-wallet polyfill that is fine: every account's docs aredoc_created in the SAME session, so they are all already inself.reposand the anchorless union spans them with no per-doc open needed. The open step becomes a real broker sync only at the multi-store migration. - No reactive union query, and the reactive ORM hangs if handed a per-entity
/ unsynced graph fan-out (
RepoNotFoundabortsorm_start_graph).
Two read regimes — enumerate vs follow
There is no cross-wallet read in current NextGraph, so nothing is globally enumerable "for free". The polyfill splits every list into one of two regimes:
Events (all public) = the GLOBAL INDEX — the ONE enumeration hack
Public events are the only thing enumerated across accounts, via the emulated
discovery index (discovery.readIndex, see
simulation.md § Emulated discovery index). This is the ONE
"hack", and it is justified precisely because P2P has no cross-wallet read: without
a shared index a client could never learn that another account's public event-doc
exists. readIndex yields the event-doc NURIs to open/sync; those repos
then enter the local union and become union-queryable.
Everything else = FOLLOW a graph, never enumerate across accounts
My participations / my profile, a connection's shared protected data, my notifications — none of these is enumerated across accounts. Each is reached by what is already reachable to me:
- my own docs (always in
self.repos); - docs reachable via a connection's shared cap (a bilateral connection surfaces
the peer's protected NURIs — see the bilateral connection registry in
simulation.md); - my inbox (deposits addressed to me).
The rule of thumb: Access ≠ discovery. You only union-query over graphs you were already entitled to open.
Listing = open/sync + ONE union query (never the ORM fan-out)
To produce a list:
- Open/sync the relevant repos (the index-yielded event NURIs, my own docs, a connection's shared NURIs). This is what puts them in the local union.
- Run a single
sparql_querywith NO anchor over the LOCAL UNION, using aGRAPH ?g { ... }body so each result row is attributed to its source graph.
Do NOT drive listing through the reactive ORM's per-document fan-out
(orm_start_graph over many graphs): a freshly-created or not-yet-synced graph in
the fan-out makes RepoNotFound abort the whole subscription → the readyPromise
never resolves → the ~75s hang (root cause verified in
nextgraph-current-state.md § The ORM fan-out
hang).
Reactivity = re-query on a change signal (no reactive union)
There is no reactive union query. So reactivity is assembled:
- keep a lightweight reactive subscription —
doc_subscribe, or the ORM on an already-opened single store (never a per-entity fan-out) — on the synced docs; - on its change signal, re-run the one-shot union
sparql_query.
Keep the reactive ORM strictly to already-opened single stores; it is a change signal source here, not the list source.
The boundary with the consumer
Festipod asks the SDK for its lists by need (listMyMeetingPoints(),
listEvents(), …) and trusts the returned set. It never constructs a NURI, never
picks the union-vs-anchor mode, never touches the ORM. Open/sync + union-query +
re-query-on-signal all live in the polyfill.
Minimal broker probe (confirms the union behaviour)
The one experiment that pins down union vs anchor, to run against a real broker:
-
doc_createtwo docs A and B (own docs → both opened into the session store). -
sparql_updatea distinct triple into each (target A's@graph, then B's). -
No anchor — expect BOTH graphs:
sparql_query( sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }", undefined /* base */, undefined /* anchor → UserSite → LOCAL UNION */ ) // → rows from BOTH A's and B's graphs -
Anchor = A — expect only A:
sparql_query(sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }", undefined, A /* string NURI → one repo */) // → rows from A's graph only
If (3) returns both and (4) returns only A, the union read model above holds as
implemented in resolve_target_for_sparql /
set_default_graph_as_union.
Verified against the real broker (T03.k)
Step (3) — the load-bearing one — is CONFIRMED: an anchorless
SELECT … WHERE { GRAPH ?g { ?s ?p ?o } } returns triples from BOTH docs A and B
(the local union of the opened graphs). That is the entire premise the listing
path relies on.
Step (4) has a nuance worth recording: with an explicit GRAPH ?g { … } body,
passing anchor = A did not restrict the result to A (B still appeared). The
reason: the anchor sets the query's default graph, but a GRAPH ?g pattern
iterates over the named graphs regardless of the default graph — so an
explicit GRAPH ?g body spans every opened graph independently of the anchor.
The anchor's "one repo" restriction is observable only for a body that reads the
default graph (no GRAPH wrapper). The read model never needs the anchored
form for listing — it uses the anchorless GRAPH ?g union — so this does not
affect it. (The per-doc "open" step in read-model.ts uses an anchored ASK
only to CONFIRM presence — it cannot sync an unknown repo, see the VERIFIED note
above; a repo absent from self.repos throws RepoNotFound and is skipped.)
Implementation — read-model.ts
readModel.readUnion(docs) implements this: (1) open/sync each doc via a per-doc
anchored ASK (tolerant — a doc that can't open is skipped, never aborting the
listing like the ORM fan-out would); (2) run ONE anchorless
SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o . VALUES ?s { … } } } over the
local union, constrained to the requested subjects (each entity's subject IRI IS
its own document NURI). Returns the triples grouped per subject; the consumer maps
them to its types (e.g. Festipod's readEntities). Reactivity = the consumer
re-calls readUnion on its change signal (no reactive union query exists).