From 6a3501e7009c3955b75fe918a90158436b17d783 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 5 Jul 2026 20:49:01 +0200 Subject: [PATCH] feat(client): union-read ReadCap gate + listMyEntityDocs + doc corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read-model.ts `readUnion` now applies the emulated ReadCap gate (drops a subject when its doc is governsRead && !canRead for the current identity), so per-scope isolation holds by construction AND by filter. - store-registry: `listMyEntityDocs(username, scope)` (current account only) vs the all-accounts `listEntityDocs` fallback (documented as the enumeration to avoid on the read path). - docs: nextgraph-current-state / read-model — corrected to the SOURCE-VERIFIED reality that the JS SDK exposes NO open/sync-by-cap primitive (load_repo_from_read_cap is pub(crate)); in the mono-wallet all repos are already local (same session), so the anchorless union spans them with no open step. simulation.md: listEntityDocs+useShape({graphs}) is a fallback, not the read path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/nextgraph-current-state.md | 30 ++++++++++---- docs/read-model.md | 14 +++++-- docs/simulation.md | 11 +++++- packages/client/src/read-model.ts | 36 +++++++++++++---- packages/client/src/store-registry.ts | 57 +++++++++++++++++++-------- 5 files changed, 112 insertions(+), 36 deletions(-) diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md index 4790263..ffb7139 100644 --- a/docs/nextgraph-current-state.md +++ b/docs/nextgraph-current-state.md @@ -176,13 +176,29 @@ and the web variant at ~`553-610`) reads the target from the `nuri` arg: a strin A repo's triples enter `graph_dataset` (hence the union) only after the repo is opened/synced into `self.repos` **and** its commits applied via `update_graph`. -Paths that open a repo: `doc_create` (own docs), bootstrap / -`load_repo_from_read_cap`, or being followed from a store's `ldp:contains` / a -shared cap / an inbox. Opening requires possessing the repo's **NURI + ReadCap** — -there is **no store-level read inheritance** (see § Capability / ReadCap -granularity). *(INFERRED: that "following a graph reference makes a previously -unknown repo known/openable" is the one step not read verbatim in source; the -open-requires-cap and no-inheritance facts around it ARE verified.)* + +**VERIFIED (T03.k) — there is NO JS primitive to sync an *unknown* repo.** Every +JS entry point that could "open" a repo — `sparql_query` anchored, +`doc_subscribe` (`Fetch::Subscribe`), `orm_start_graph` — resolves its target via +`resolve_target`/`resolve_target_for_sparql`, which does +`self.repos.get(repo_id).ok_or(RepoNotFound)` (`request_processor.rs:155/163/264/269`). +None of them PULLS a repo that is absent from `self.repos`; they only touch a repo +already there. The primitive that actually loads a repo from its ReadCap, +`Verifier::load_repo_from_read_cap` (`verifier.rs:2237`), is **`pub(crate)` — +unexposed to JS**; it is only reached internally (bootstrap, inbox processing). So +from JS today a repo becomes queryable ONLY by being `doc_create`d in this session +(own docs) or synced by an internal path — never on demand by NURI+ReadCap. + +**Consequence for this lib's mono-wallet polyfill:** every account's documents are +`doc_create`d in the ONE shared wallet within the SAME session, so they are ALL +already in `self.repos` and queryable by the anchorless union **without any per-doc +open step**. `read-model.ts`'s per-doc anchored `ASK` is therefore a *no-op* on a +present repo and an instant `RepoNotFound` skip on an absent one — it never SYNCS +(it cannot). The union query alone spans all same-session repos. At the real +multi-store migration this gap closes: opening a real per-user store repo by cap is +a native broker sync (the `OpenRepo` TODO at `verifier.rs:1423`), and the open step +becomes a real sync. Opening still requires the repo's **NURI + ReadCap** — there is +**no store-level read inheritance** (see § Capability / ReadCap granularity). ### The union is READ-ONLY — writes must target one document diff --git a/docs/read-model.md b/docs/read-model.md index f4667bf..e587293 100644 --- a/docs/read-model.md +++ b/docs/read-model.md @@ -14,7 +14,14 @@ The governing constraints (all verified in `nextgraph-rs`, cited there): - `sparql_query` with **no anchor** → the **LOCAL UNION** of all opened graphs; with an anchor → **one** repo. Union is **read-only**. - A repo is queryable **only after it is opened/synced** (needs its NURI + ReadCap; - no store-level read inheritance). + 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 / unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`). @@ -128,8 +135,9 @@ 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` -purely for its side effect of opening the repo, not to restrict a read.) +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.) ## Implementation — `read-model.ts` diff --git a/docs/simulation.md b/docs/simulation.md index 94b0958..afbe4f2 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -69,8 +69,15 @@ public/protected/private stores — on top of one shared wallet. is its own document/repo with a future inbox) and appends its NURI to the account's **scope index document** — the index doc plays the role of the future **store-container** (it lists the entity-document NURIs "in" that scope). - `listEntityDocs(scope)` unions the contained NURIs across all accounts — the - read fan-out. Use the returned NURIs as `useShape(shape, { graphs })`. + `listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a + **fallback / test-only** path, NOT the read path: enumerating every account and + handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly- + unsynced docs and HANGS (the ORM fan-out, ~75s — see + [`read-model.md`](./read-model.md)). The real READ path is + `readModel.readUnion(docs)` (open/sync by-need + ONE anchorless union + `sparql_query`); the app resolves the by-need doc set from the discovery index + (public events) and `listMyEntityDocs(username, scope)` (my own account, bounded — + no cross-account fan-out). - **GENERIC by construction.** The registry knows only the three native scopes, **zero** application entity kind. The consumer maps its entities to a scope and injects the session + username normalization via `configureStoreRegistry({ diff --git a/packages/client/src/read-model.ts b/packages/client/src/read-model.ts index c2931b0..8d32333 100644 --- a/packages/client/src/read-model.ts +++ b/packages/client/src/read-model.ts @@ -34,7 +34,7 @@ */ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; -import { getStoreRegistryDeps } from "./polyfill"; +import { getStoreRegistryDeps, getCaps, getCurrentUser } from "./polyfill"; import { assertNuri } from "./sparql"; import type { Nuri } from "./types"; @@ -72,17 +72,26 @@ async function sessionId(): Promise { } /** - * Open/sync ONE doc into the session store by running a cheap anchored probe - * query against it (`resolve_target_for_sparql(anchor)` opens that repo). Per-doc - * and tolerant: a doc that can't be opened (not yet synced, no cap) throws HERE, - * in isolation, and is skipped — it never aborts the listing of the others (the - * fan-out ORM's failure mode). Returns whether the open succeeded. + * Touch ONE doc with a cheap anchored `ASK`, tolerant per-doc. + * + * VERIFIED (T03.k, see docs/nextgraph-current-state.md § *A repo is only queryable + * once OPENED/synced*): the current JS SDK exposes **no primitive that SYNCS an + * unknown repo**. An anchored `sparql_query` resolves via `resolve_target_for_sparql` + * → `self.repos.get(id).ok_or(RepoNotFound)` — it never PULLS an absent repo (and + * neither do `doc_subscribe` nor `orm_start_graph`; the real loader + * `load_repo_from_read_cap` is `pub(crate)`, unexposed). So this `ASK` cannot sync: + * on a repo already in `self.repos` (every doc `doc_create`d this session — which, + * in the mono-wallet polyfill, is ALL of them) it is a no-op that confirms presence; + * on a genuinely-absent repo it throws `RepoNotFound` HERE, in isolation, and the + * doc is skipped — never aborting the listing of the others (the ORM fan-out's + * failure mode). Returns whether the touch succeeded. + * + * At the real multi-store migration this becomes a real sync: opening a per-user + * store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO). */ async function openDoc(sid: string, doc: Nuri): Promise { try { const nuri = assertNuri(doc); - // ASK is the cheapest way to touch (hence open) the repo; the result is - // irrelevant — the side effect (repo opened into the union) is the point. await sparqlQuery(sid, "ASK { ?s ?p ?o }", undefined, nuri); return true; } catch (error) { @@ -126,6 +135,16 @@ export async function readUnion(docs: Nuri[]): Promise { return []; } + // Cap gate (defence-in-depth). The union spans every OPENED graph, so a subject + // whose DOCUMENT is under a read policy the current user may not satisfy must be + // dropped — isolation holds BY CONSTRUCTION (the app only resolves docs it is + // entitled to) AND BY FILTER here. Generic: the lib owns the cap registry; a doc + // under no policy (`!governsRead`) flows through unchanged. In this polyfill each + // subject IRI IS its own document NURI, so the cap key is the subject. + const caps = getCaps(); + const user = getCurrentUser(); + const denied = (doc: string): boolean => caps.governsRead(doc) && !caps.canRead(doc, user); + const bySubject = new Map(); for (const row of rows) { const s = row.s?.value; @@ -133,6 +152,7 @@ export async function readUnion(docs: Nuri[]): Promise { const o = row.o?.value; const g = row.g?.value ?? ""; if (!s || !p || o === undefined) continue; + if (denied(s)) continue; let entry = bySubject.get(s); if (!entry) { entry = { subject: s, graph: g, props: {} }; diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 9155a69..f43a2b3 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -369,31 +369,56 @@ export async function createEntityDoc(username: string, scope: Scope): Promise { + const s = await session(); + const out: Nuri[] = []; + try { + const res = await sparqlQuery( + s.sessionId, + `SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, + undefined, + indexDoc, + ); + for (const row of readBindings(res)) { + const v = bindingValue(row, "e"); + if (v) out.push(v); + } + } catch (error) { + console.error("[storeRegistry] readScopeIndex read failed:", error); + } + return out; +} + /** * Every entity document NURI of `scope`, across all accounts — the read * fan-out for per-entity scopes. Reads each account's scope index document and * unions the contained NURIs. Use as `useShape(shape, { graphs })`. + * + * NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need + * model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced + * docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope + * docs) for "my entities", and the discovery index for "all public events". + * Retained for callers that legitimately need every account (tests). */ export async function listEntityDocs(scope: Scope): Promise { const accounts = await allAccounts(); - const s = await session(); const out: Nuri[] = []; for (const a of accounts) { - const indexDoc = indexDocOf(a, scope); - try { - const res = await sparqlQuery( - s.sessionId, - `SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, - undefined, - indexDoc, - ); - for (const row of readBindings(res)) { - const v = bindingValue(row, "e"); - if (v) out.push(v); - } - } catch (error) { - console.error("[storeRegistry] listEntityDocs read failed:", error); - } + out.push(...(await readScopeIndex(indexDocOf(a, scope)))); } return out; } + +/** + * The entity-document NURIs of `scope` belonging to ONE account (`username`) — + * the read-by-need path for "my own entities" (my profile, my participations). + * Bounded to a SINGLE account: it resolves only that account's scope index doc + * (via `ensureAccount`) and reads the contained NURIs — NO cross-account + * enumeration, so it never touches another account's unsynced docs. This is the + * helper FestipodDataContext uses instead of the all-accounts `listEntityDocs`. + */ +export async function listMyEntityDocs(username: string, scope: Scope): Promise { + const record = await ensureAccount(username); + return readScopeIndex(indexDocOf(record, scope)); +}