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.
12 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_querywith 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
GRAPHwrapper); an explicitGRAPH ?g { … }body iterates the named graphs regardless of the anchor (see § probe step 4). The read path therefore uses an anchoredSELECT ?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_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 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 (
RepoNotFoundabortsorm_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 §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 insimulation.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 § 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 (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:
-
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, 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). ThedocRoundTripcheck measures all three shapes anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicitINSERT DATA { GRAPH <D> {…} }— a constant plain NURI — also round-trips (readable both as the anchored default graph and viaGRAPH <D>), i.e. when anchored it resolves to the same repo graph — there is no phantom graph; (c) an anchorlessSELECT … WHERE { GRAPH ?g {…} }— a variable?gscan — 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 variableGRAPH ?gscan that is unbounded (the O(wallet) union — the reason the read path is per-doc anchored, preserved above), not a constantGRAPH <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/UnionSubjectis 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.