ae9c32e271
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
206 lines
12 KiB
Markdown
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 accounts. Each is reached by
|
|
what is already reachable to me:
|
|
|
|
- my own docs (always in `self.repos`, and whose caps what I hold holds);
|
|
- docs whose cap an owner has delivered to my inbox (`shareCap` — 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 (`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.)
|
|
|
|
> **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 — `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.
|