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>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+95 -81
View File
@@ -1,40 +1,40 @@
# The READ MODEL the polyfill implements
# 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
shared wallet. This is a design decision, grounded entirely in the query
capability documented in
[`nextgraph-current-state.md`](./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
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) — and on a
> shared/bloated wallet that accumulates across runs it produced ~90s timeouts. The
> per-doc anchored read is O(1) per doc, INDEPENDENT of wallet size, so a non-empty
> wallet no longer matters.
> 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
- 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`/
- 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_create`d 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
`pub(crate)`, unexposed. In this mono-wallet polyfill that is fine: every account's
docs are `doc_create`d 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
@@ -42,71 +42,80 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
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
### 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`](./simulation.md) § *Emulated discovery index*). This is the ONE
[`simulation.md`](./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
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
### 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 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 reachable via a **connection's shared cap** (a bilateral connection surfaces
the peer's protected NURIs — see the bilateral connection registry in
- 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`](./simulation.md));
- my **inbox** (deposits addressed to me).
- my inbox (deposits addressed to me).
The rule of thumb: **Access discovery.** You only union-query over graphs you were
already entitled to open.
The rule of thumb: access is not discovery. You only union-query over graphs you
were already entitled to open.
## Listing = a bounded set of per-doc ANCHORED reads (never a union-scan, never the ORM fan-out)
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.
To produce a list, take the **bounded, by-need** set of doc NURIs (the index-yielded
event NURIs, my own docs, a connection's shared NURIs) and read **each one with its
OWN anchored `sparql_query`** (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
## 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
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 it times
out (~90s observed). The read-set is already bounded and known; read exactly those
docs, anchored, and never scan the wallet.
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
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
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`](./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:
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
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_query`s (`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
## The boundary with the consumer application
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.
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)
@@ -127,17 +136,19 @@ The one experiment that pins down union vs anchor, to run against a real broker:
// → rows from BOTH A's and B's graphs
```
4. **Anchor = A** — expect only A:
4. **Anchor = A, default-graph body** (the form the read path actually uses) —
expect only A:
```
sparql_query(sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }",
undefined, A /* string NURI → one repo */)
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 union read model above holds as
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`.
`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)
@@ -146,33 +157,36 @@ Step (3) — **the load-bearing one** — is CONFIRMED: an anchorless
(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.)
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**
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 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 maps the result to its types (e.g.
Festipod's `readEntities`). Reactivity = the consumer re-calls `readUnion` on its
change signal (no reactive union query exists).
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 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.