Files
ng-eventually/docs/read-model.md
T
Sylvain Duchesne 0832338201 feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch
will be downloaded from the outerOverlay, if the peer brokers listed below allow
it […] the public site are served differently by brokers"*
(engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
2026-08-06 19:55:32 +02:00

206 lines
12 KiB
Markdown

# 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`](./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_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`).
## One read regime — follow, never enumerate
There is **no cross-wallet read** in current NextGraph, and there is no discovery
either: **you cannot discover, you can only follow links**
([`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Nothing is
globally enumerable, and nothing is meant to be.
> An earlier version of this document described a second regime — "all public
> events, enumerated through a global index" — presented as the one justified
> "hack". It was removed on 2026-07-30 along with `discovery.ts`: a global index
> emulates a capability the target will never have, and it pools data across
> wallets. A public document is reached because someone circulated its link, never
> because it was listed.
### Everything = 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 virtualUsers. Each is reached by
what is already reachable to me:
- my own docs (always in `self.repos`, and whose caps I hold);
- docs whose cap an owner has delivered to my inbox (`inbox.share` — see the
per-document ReadCap in [`simulation.md`](./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 held-caps lookup used
here (`capFor`) is emulation-only in its *implementation*; its shape is the target's
(possession), so what disappears at migration is the lookup, not the model. Note
there is deliberately no "may identity X read doc D?" call: the real model cannot
answer that either.
## 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 (my own docs, and the
NURIs whose cap someone delivered to 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`](./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_query`s (`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 `surface/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.)
> **Re-confirmed by the standing e2e harness (`packages/client/e2e/`, broker
> `@ng-org/web 0.1.2-alpha.13`).** The `docRoundTrip` check measures all three shapes
> anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicit
> `INSERT DATA { GRAPH <D> {…} }` — a **constant** plain NURI — **also** round-trips
> (readable both as the anchored default graph and via `GRAPH <D>`), i.e. when anchored
> it resolves to the **same repo graph** — there is **no phantom graph**; (c) an
> **anchorless** `SELECT … WHERE { GRAPH ?g {…} }` — a **variable** `?g` scan — spans
> **every** named graph in the session store (it saw both D and a second doc, and 32
> graphs total on the shared wallet). Note the distinction: it is the **variable**
> `GRAPH ?g` *scan* that is unbounded (the O(wallet) union — the reason the read path
> is per-doc anchored, preserved above), **not** a **constant** `GRAPH <D>` write,
> which lands in D's repo. Re-run that harness to re-verify against a newer broker.
## Implementation — `surface/read-model.ts`
`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.