Files
ng-eventually/docs/read-model.md
T
Sylvain Duchesne 6a3501e700 feat(client): union-read ReadCap gate + listMyEntityDocs + doc corrections
- 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>
2026-07-05 20:49:01 +02:00

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_query with 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 reposparql_query/doc_subscribe/ orm_start_graph all resolve via self.repos.get().ok_or(RepoNotFound) and only touch a repo already present; the real loader load_repo_from_read_cap is pub(crate), unexposed. In THIS mono-wallet polyfill that is fine: every account's docs are doc_created in the SAME session, so they are all already in self.repos and 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 (RepoNotFound aborts orm_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:

  1. 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.
  2. Run a single sparql_query with NO anchor over the LOCAL UNION, using a GRAPH ?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:

  1. doc_create two docs A and B (own docs → both opened into the session store).

  2. sparql_update a distinct triple into each (target A's @graph, then B's).

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