Files
ng-eventually/docs/read-model.md
T
Sylvain Duchesne 63ecfeeff8 docs+refactor(client): fidelity pass — id identity, drop connections, no faux-login, accurate NextGraph framing
Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:

- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
  normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
  login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
  (NextGraph has no bilateral-connection primitive: grantee is unpersisted
  scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
  only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
  Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
  unseals and applies each queued sealed message inline (process_inbox);
  inbox_post_link is a proposed/future API. Stop attributing the emulated
  curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
  targeted read of an unheld repo -> RepoNotFound; cap introspection
  (canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
  queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
  section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
  emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
  sentinel namespace; de-domained generic-layer comments; softened tone.

Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:02:16 +02:00

10 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 application 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 rule in one line: read each by-need doc with its own anchored sparql_query; never run an anchorless union-scan over all graphs. An anchorless union spans every named graph in the session store — O(wallet size) — which is why the read path is per-doc anchored on a shared wallet that accumulates across runs. The per-doc anchored read is O(1) per doc, independent of wallet size, so a non-empty wallet does not matter.

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 (O(wallet), not used on the read path); with a string anchor → restricted to one repo (that repo becomes the query's default graph). Union is read-only.
  • The anchor's one-repo restriction applies only to a default-graph body (no GRAPH wrapper); an explicit GRAPH ?g { … } body iterates the named graphs regardless of the anchor (see § probe step 4). The read path therefore uses an anchored SELECT ?s ?p ?o WHERE { ?s ?p ?o } (default-graph body) per doc.
  • 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_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 per-doc anchored read resolves each one directly 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, protected data an owner has granted me, 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 an owner has granted me via a directed per-document read grant (grantRead(doc, granteeId) — see the per-document ReadCap in simulation.md);
  • my inbox (deposits addressed to me).

The rule of thumb: access is not discovery. You only union-query over graphs you were already entitled to open.

Accessing a document without read rights yields an empty result: a reactive / union read never decrypts a repo you hold no cap for, so it simply returns nothing (this matches NextGraph's union read). A targeted read of a repo you do not hold diverges in one way — it raises RepoNotFound rather than returning empty — and the read path tolerates that per-doc (a doc that throws is skipped). The cap-introspection used here (canRead / governsRead) is emulation-only; there is no NextGraph API behind it, so it has no migration target.

Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)

To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its own anchored sparql_query (SELECT ?s ?p ?o WHERE { ?s ?p ?o }, anchor = that doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that one repo's graph, so each read is O(1) in the doc's own size and independent of how many other graphs the (possibly bloated / shared) session store holds.

Do not run an anchorless union-scan (SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }, no anchor) over the local union: it iterates every named graph in the session store — O(wallet size) — so on a shared wallet that accumulates across runs its cost grows with the whole wallet. The read-set is already bounded and known; read exactly those docs, anchored, and never scan the wallet.

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, so the readyPromise never resolves and the subscription hangs (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 bounded set of per-doc anchored sparql_querys (readModel.readUnion) — never an anchorless union-scan.

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 application

The consumer application asks the SDK for its lists by need and trusts the returned set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in the consumer application, not the lib; the lib exposes the generic by-need read. 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, default-graph body (the form the read path actually uses) — expect only A:

    sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
                 undefined, A /* string NURI → one repo becomes the default graph */)
    // → rows from A's graph only
    

If (3) returns both and (4) returns only A, the read model above holds as implemented in resolve_target_for_sparql / set_default_graph_as_union: the anchor turns A's repo into the query's default graph, and a default-graph body reads exactly that graph.

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, and it is exactly why the read path uses a default-graph body, not an explicit GRAPH ?g one: with an explicit GRAPH ?g { … } body, passing anchor = A would not restrict the result to A (B still appears). 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). That is precisely why the per-doc read in read-model.ts uses the anchored default-graph body SELECT ?s ?p ?o WHERE { ?s ?p ?o }: the anchor makes that one repo the default graph, so the read is bounded to it — O(1) per doc, independent of wallet size — and never iterates the other named graphs. (A repo absent from self.repos throws RepoNotFound and is skipped per-doc, see the VERIFIED note above — the read cannot sync an unknown repo.)

Implementation — read-model.ts

readModel.readUnion(docs) implements this: for each requested doc NURI (the bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is skipped, never aborting the batch like the ORM fan-out would) — one anchored SELECT ?s ?p ?o WHERE { ?s ?p ?o } with anchor = docNuri. The anchor restricts the query to that doc's graph (default graph), so it returns only that doc's triples, O(1) per doc, independent of wallet size. There is no anchorless union-scan. Each entity's subject IRI is its own document NURI, so the subject is the anchor doc NURI; the result is grouped per subject (keeping the UnionSubject[] shape: subject, graph, props). A ReadCap gate drops any doc the current user may not read (defence-in-depth). The consumer application maps the result to its types (e.g. its own readEntities). Reactivity = the consumer application re-calls readUnion on its change signal (no reactive union query exists).

The name readUnion / UnionSubject is historical (it once ran a union query). The read is now per-doc anchored, bounded to the read-set — the "union" is only the logical concatenation of the per-doc results, never an anchorless graph scan.