docs+fix: recadrer le polyfill comme compensateur d'écart, corriger la doctrine périmée

RECADRAGE — la doctrine était trop étroite. rule_app-uses-sdk-surface-only
disait « le polyfill existe pour le WALLET VIRTUEL » : juste sur le fond, mais à
la lettre l'émulation des caps qu'on vient de livrer n'entrait pas dans son
mandat. Nouvelle formulation, portée aussi dans AGENTS.md :

  @ng-eventually/client est un POLYFILL, et ce mot dit toute sa mission :
  compenser l'écart entre le SDK tel qu'il devrait être et ce que NextGraph
  fournit aujourd'hui. Le wallet virtuel en est la plus grosse pièce, pas la
  totalité.

Avec la conséquence opérationnelle : quand quelque chose ne marche pas, la
question n'est jamais « comment contourner dans l'app » mais « qu'est-ce que le
polyfill doit compenser ». Un contournement côté app est une violation même
quand il fonctionne — il grave un état temporaire de NextGraph dans du code qui
doit lui survivre. Et l'ignorance de l'état d'implémentation est durcie :
ENTIÈREMENT, pas « sauf quand ça mord ».

NOUVEAU — data-layer/knowledge_sdk-surface : le contrat SDK cible, écrit dans CE
repo pour qu'un agent n'ait jamais à ouvrir le repo du polyfill. Couvre lectures
réactives, écritures, placement par scope, inbox, discovery, capabilities
(capFor/shareCap/publishRepoLink, livrées avec P1a), identité, sûreté SPARQL —
et les surfaces exportées mais interdites à l'app.

DOCTRINE PÉRIMÉE corrigée, après vérification dans le code :
- rule_document-per-entity décrivait la lecture via readEntities/readUnion/
  registerDoc/bumpRead : ZÉRO site d'appel, readEntities.ts supprimé. Réécrite
  sur watchShape/useShapeQuery. Le fond (un document par entité) est intact.
- brief_2026-07-06 §P3 réaffirmait une phrase que son propre encadré déclare
  fausse : rétractée explicitement.
- knowledge_data-modes citait useShapeWithDefaults(), qui n'existe nulle part.
- ConnectScreen : les fiches avaient raison mais étaient vagues — l'écran existe,
  est routé et monté, et est bien absent du registre. Précisé.

FIX CODE — build:orm était CASSÉ : il pointait ./src/shapes/, qui n'existe pas
(les shapes vivent sous src/shared/shapes/), et sortait en erreur. Donc la
commande que la doctrine prescrit après tout changement de .shex ne marchait
pas. Corrigé et vérifié : exit 0. La fiche avait raison, c'est le code qui était
faux — le point 4 approuvé, simplement situé dans l'autre fichier.

Régénération NON embarquée : lancer build:orm reformate les bindings et retire
l'annotation `: Schema`. C'est une montée de version d'outil, pas une correction
de contenu — elle mérite son propre changement validé, pas un passage clandestin.
Noté dans la fiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
Sylvain Duchesne
2026-07-28 16:49:27 +02:00
parent b6a6b14fad
commit 7459d49e83
12 changed files with 139 additions and 23 deletions
@@ -14,6 +14,7 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
## Model & data
- [[knowledge_sdk-surface]] — **the data contract**: the `@ng-eventually/client` surface the app codes against (reads, writes, documents, inbox, discovery, capabilities, identity) and what may / may not be assumed of each
- [[knowledge_nextgraph-stack]] — the `@ng-eventually/client` SDK, SHEX shapes, reactive ORM, `build:orm`, injection through `ngSession`
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (seeded local state), how the provider is chosen
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes
@@ -1,6 +1,6 @@
---
type: brief
summary: Implementation design — make reads REACTIVE across sessions via doc_subscribe (per-document, without the ORM fan-out that hangs) and replace the mutated-in-place participantCount with the Option-B flow (the participant deposits into the event's inbox, the owner materializes and increments their own doc); real 2-browser test plan with no polling
summary: Implementation design (historical) — make reads REACTIVE via doc_subscribe (per-document, without the ORM fan-out that hangs) and replace the mutated-in-place participantCount with the Option-B flow (the participant deposits into the event's inbox, the owner materializes and increments their own doc). READ IN THIS LIGHT — the app-side doc_subscribe wiring was later SUPERSEDED by the SDK's watchShape/useShapeQuery surface, and the « reactive with no reload » framing was RETRACTED for « the owner processes their inbox at their next connection »; Option B (P4-P5) is still pending
---
# Reactive reads + correct participant count (Option B)
@@ -181,6 +181,6 @@ Other points to settle:
> ⚠️ **REFRAMED + CORRECTED (2026-07-13).** The claim below, "Proven by the D.2 e2e … with no reload", was **FALSE** (the "green" came from a bloated wallet). But more importantly the framing "reactive / no reload / cross-session push" was an **OVER-FRAMING**: the real spec is **"the owner reliably processes their inbox at their NEXT CONNECTION"** (not a live real-time notification between two connected users). The bug fixed under that framing: the materializer read the inbox **before it had synced** (→ a memoized 0). Fix = inbox read **gated on a barrier** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + triggering on connection + a single source of truth, `event.participantCount`. The `event/e2e-multibrowser.feature` scenario was **reframed as "at the next connection" and un-`@wip`'d, GREEN on a fresh profile** (a reconnection/re-materialization by A is the accepted mechanism). Details: [[knowledge_context-internals]] §participantCount. The phasing plan below must be re-read in that light ("no reload" is no longer the requirement).
- **Phasing order:** ~~(P1) lib: `subscribeDoc` + multi-doc variant + D.1 tests~~ **DONE (`c0498a6`)**; ~~(P2) lib: replace `inbox.watch`/`discovery.watchIndex` with `doc_subscribe`~~ **DONE (`c0498a6`)**; ~~(P3) app: wire the per-doc subscription into `useNgData` (pushed bumpRead) + reactive discovery~~ **DONE (branch `ng-eventually`, uncommitted)** — `useNgData` mounts a `subscribeDocs(allReadDocs, …)` effect keyed on a sorted join of the NURIs (`readDocKey`, loop guard: a patch → `bumpRead` → re-`readUnion` does not change the set → no re-subscription; the identity reset `prevOwnerRef` empties the set → `readDocKey=''` → cleanup unsubscribe, then re-listing → re-subscription over the rebuilt set) + a reactive discovery effect `watchDiscoveredEvents()` (an app-side wrapper over `discovery.watchIndex`, already `doc_subscribe`) → `relist()`. `readUnion` remains the tolerant one-shot reader. **Proven by the D.2 e2e** (`e2e-multibrowser.feature`, scenario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps green in isolation): B signs up → A sees `participantCount === 2` + an « inconnu » participant **with no reload and no action**, via `doc_subscribe` on the event's public doc (the join in P3 still writes that counter, see §B.5 — which is what validates P3 before P4). ; (P4) app: Option B join (remove the participant's counter write, owner materialization); (P5) app: symmetric Option B leave; ~~(P6) e2e D.2~~ **DONE with P3** (the reactive scenario above; the reactive withdrawal symmetry remains to be added with P5). P1→P3 deliver reactivity; P4→P6 the correct counter. P1P3 can ship before P4P6.
- **Phasing order:** ~~(P1) lib: `subscribeDoc` + multi-doc variant + D.1 tests~~ **DONE (`c0498a6`)**; ~~(P2) lib: replace `inbox.watch`/`discovery.watchIndex` with `doc_subscribe`~~ **DONE (`c0498a6`)**; ~~(P3) app: wire the per-doc subscription into `useNgData` (pushed bumpRead) + reactive discovery~~ **DONE, then SUPERSEDED** — P3 first wired an app-side `subscribeDocs(allReadDocs, …)` effect + a reactive discovery effect on top of the one-shot `readUnion`. That app-side wiring **no longer exists**: the read path has since moved entirely behind the SDK surface (`watchShape` bound by `useShapeQuery`), with no doc set, no `bumpRead` and no per-doc subscription left in the app (verified 2026-07-28 — see [[rule_app-uses-sdk-surface-only]] and [[rule_document-per-entity]] §Reads). **Validation — the earlier « proven by the D.2 e2e, with no reload » claim is RETRACTED**: per the REFRAMED + CORRECTED box above, that green came from a bloated wallet, and « live cross-session push with no reload » was never the spec. What `e2e-multibrowser.feature` covers is the reframed contract — **the owner reliably processes their inbox at their NEXT CONNECTION** (un-`@wip`'d, green on a fresh profile). So P3 is delivered as *the app reads through a reactive SDK surface*, **not** as *a proven reload-free live push*. ; (P4) app: Option B join (remove the participant's counter write, owner materialization); (P5) app: symmetric Option B leave; ~~(P6) e2e D.2~~ **DONE with P3** (the reactive scenario above; the reactive withdrawal symmetry remains to be added with P5). P1→P3 deliver reactivity; P4→P6 the correct counter. P1P3 can ship before P4P6.
- **Materialization idempotence**: the per-deposit `uid` (`RegistrationPayload.uid`, `registration.ts:56`) is the pivot; the (reference) recorded by the owner must be consulted before any increment/decrement so as never to double-count (sync replay) nor "resurrect" a count.
- **Native inbox migration**: today the inbox is emulated over the shared wallet (`inbox.ts` post/read RDF). On migration to the native broker inbox (`inbox_post`/`inbox_pop_for_user`, sealed), the Option B flow **remains valid** (non-member deposits allowed, reads reserved to the *readers* = the owner), but the `subscribeDoc` wrapper on the inbox will have to target the native deposit-notification mechanism. To be checked at migration time.
@@ -17,7 +17,7 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
- Wraps the shapes through `useShapeWithDefaults()`.
- Reads the shapes reactively through **`useShapeQuery()`** (`src/shared/data/useShapeQuery.ts`), the app's React binding over the SDK's `watchShape(shape, scope)` observable — three scoped reads: events (`public`), profiles and participations (`protected`), mapped to the `Fp*` domain types by `shapeAdapters.ts` (see [[rule_document-per-entity]] §Reads). There is no `useShapeWithDefaults` — that name is obsolete (verified 2026-07-28).
- Exposes `useFestipodData()` (consumed by every screen) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider chosen by connection status**:
- `disconnected``LocalDataProvider` with the seed (demo)
@@ -7,6 +7,8 @@ summary: The data SDK is @ng-eventually/client (treated as a finished NextGraph
Festipod persists through **`@ng-eventually/client`** — the NextGraph SDK the app consumes. We treat it as a **finished, mature SDK**: documents per entity placed by scope, capabilities, inboxes, a reactive ORM.
> **It is a polyfill, and that word carries its whole job**: closing the gap between the SDK **as it should be** and what NextGraph provides **today**. The app codes against the target and **ignores the current state entirely**; the polyfill absorbs the difference. The contract itself — which surfaces exist and what may be assumed of them — is written down in this repo: [[knowledge_sdk-surface]]. See [[rule_app-uses-sdk-surface-only]].
```
@ng-eventually/client # THE app's data SDK (reactive useShape ORM, docs, scopes, inbox)
```
@@ -0,0 +1,95 @@
---
type: knowledge
summary: The `@ng-eventually/client` contract Festipod is written against, as a FINISHED NextGraph SDK — reactive reads (`watchShape`, `useShape`), document writes (`docs`), per-scope placement (`storeRegistry`), `inbox`, `discovery`, capabilities (`capFor` / `inbox.shareCap` / `publishRepoLink`), identity — with what the app MAY and MAY NOT assume of each, so no agent ever needs to open the SDK's own repo.
---
# The SDK surface Festipod codes against
This is Festipod's **data contract**: what `@ng-eventually/client` offers, and what the app is entitled to rely on. It describes the SDK **as it should be** — a finished NextGraph SDK — because that is what the app is written against ([[rule_app-uses-sdk-surface-only]]). It says **nothing** about NextGraph's or the package's implementation state, on purpose: the app ignores that entirely, and any gap is the package's to absorb, never the app's.
Everything below is exported from the SDK entry `@ng-eventually/client`, **except** the few items explicitly marked `/polyfill` — the bootstrap subpath `@ng-eventually/client/polyfill`, the one part that disappears at migration. Injection happens exactly once, in `ngSession` ([[knowledge_nextgraph-stack]]).
## Reactive reads — the canonical path
**`watchShape(shapeType, scope) -> ShapeObservable`** — the read Festipod uses. It observes one SHEX shape over one **logical scope** (`'public' | 'protected' | 'private'`) and yields a `useQuery`-shaped snapshot: `{ data, isPending, isSuccess, isError, error }`. Bind it with `useSyncExternalStore` (`src/shared/data/useShapeQuery.ts`).
May assume:
- `data` is **always an array**, never `undefined`; its items are `UnionSubject` (`{ subject, graph, props }`) — raw per-subject property bags, mapped to `Fp*` types by `src/shared/data/shapeAdapters.ts` ([[knowledge_entities]]).
- `isPending` and `isSuccess` are **mutually exclusive**, and a synchronized-but-empty scope is `isSuccess` with `data: []` — the distinction the surface exists for. Never guess emptiness with a timer.
- The snapshot reference is **stable** until the value actually changes (safe for `useSyncExternalStore`).
- Reactivity is **push**: the snapshot updates on any change in scope, local or remote, and on any change to what the current identity may read. Never polling.
- The observable is **inert until first `subscribe()`** (or `refetch()`); the last unsubscribe tears everything down. `refetch()` forces a re-resolve and is idempotent w.r.t. subscriptions.
- `isError` fires **only** on a real thrown exception, never on a slow or absent peer.
**`useShape(shapeType, scope) -> DeepSignalSet<T>`** — the ORM hook, for **one already-known document NURI** as scope. Returns a live reactive set that re-renders on every change. Festipod uses it in the `@data` harness; screens go through `watchShape`.
May not assume: any ordering of `data`; that a value seen once stays; that a document the identity holds no capability for will ever appear (it silently does not).
## Writes — one document at a time
**`docs.docCreate(sessionId, crdt, cls, dest, store?)`** creates one document and returns its NURI. **`docs.sparqlUpdate(sessionId, query, anchor)`** writes into it: a SPARQL `INSERT`/`DELETE` scoped to the **anchor document's** graph. **`docs.sparqlQuery(sessionId, query, base?, anchor?)`** is the one-shot, non-reactive read.
May assume:
- One document = one repo = one entity ([[rule_document-per-entity]]); a write is a change on that document, and every observer of it is pushed.
- A write **targets exactly one document**. There is no "write to the union", and no primitive by which a non-owner appends to someone else's document — surfacing data to another identity goes through the **inbox**, or through each identity owning its own document.
May not assume: that `sparqlQuery` is reactive (it is a snapshot — to stay live, use `watchShape`); that an unanchored update means anything.
## Placement by scope — `storeRegistry`
**`storeRegistry.createEntityDoc(id, scope)`** — create the entity's own document in the right scope, and record it as the identity's. **`storeRegistry.listEntityDocs(scope)`** / **`listMyEntityDocs(id, scope)`** enumerate documents in a scope, all or mine. **`resolveWriteGraph(id, scope)`**, **`resolveScopeGraph(scope)`**, **`resolveReadGraphs(scope)`**, **`resolveInboxAnchor()`** resolve the NURIs a call needs. **`ensureAccount(id)`**, **`resolveAccount(id)`**, **`allAccounts()`** yield `AccountRecord`s (`{ id, docPublic, docProtected, docPrivate }`).
Festipod's own glue (`src/shared/utils/storeRegistry.ts`) adds only the **domain mapping** entity kind → scope; placement itself belongs to the SDK.
May assume: the SDK owns NURI construction and placement. May not assume: that the app may build a NURI by hand, or read/write a scope's container document directly.
## Inbox — delivery to an identity
**`inbox.post(targetInbox, { payload, from?, ts? })`** deposits into a document's inbox. `from` omitted defaults to the current identity; **`from: null` is an explicit anonymous deposit**, and naming another identity is rejected as a spoof. **`inbox.read(targetInbox)`** returns every `Deposit` (`{ from, payload, ts }`) sorted by ascending `ts`. **`inbox.watch(targetInbox, onDeposits)`** fires once on the initial state and again on every change; it returns an unsubscribe. **`inbox.readSynced`** is the read that waits for the document to be current. `inbox.materialize` is an alias of `read`.
May assume:
- **Any identity — even anonymous — can deposit** into an inbox it knows. That is the only way data reaches an identity that cannot write your documents. See [[rule_nextgraph-inbox]].
- `watch` is **push, never polling**; its `intervalMs` option exists for signature compatibility and is ignored.
- `payload` is **opaque to the SDK** — Festipod defines its own kinds (`src/shared/data/registration.ts`).
May not assume: exactly-once delivery semantics, or that a deposit is removed once read.
## Discovery — the global index
**`discovery.submitToIndex(ref, opts?)`** makes a reference discoverable; `SubmitOptions.from` follows the same identified/anonymous rule as `inbox.post`, and `SubmitOptions.doc` names the document being announced. **`discovery.readIndex()`** returns `IndexEntry[]` (`{ ref, from, ts }`, deduplicated). **`discovery.watchIndex(onEntries)`** is the push-based observer.
May assume: the index admits a document only if it was **published** as a repo link — announcing something past the reach you chose for it is refused. May not assume: that `ref` means anything to the SDK (it is app-defined), or that being indexed grants any read.
## Capabilities — reading is key possession
The model has **no authorization list**. You hold a document's `ReadCap` (a NURI carrying a `:k:` segment) and you read it, or you do not. A bare `Nuri` **names** a document without granting anything.
- **`capFor(nuri): ReadCap | undefined`** (`/polyfill`, also `getCaps().capFor`) — do I hold this document's key? Nothing derives a key from a bare reference; it is either in your keyring because you created the document, or it was delivered to you.
- **`inbox.shareCap(cap, toInbox)`** — the act of sharing: **one document, to one recipient inbox**. Several recipients means several calls. Recipients are addressed as **inboxes**, never as principals.
- Receiving a capability needs **no dedicated call**: it arrives as an inbox deposit, is applied inline by `inbox.read`/`watch`, and the resulting keyring change **re-triggers the reads that were empty for want of it** — a `watchShape` view fills in on its own.
- **`getCaps().publishRepoLink(nuri)`** (`/polyfill`) — publish a document as a shareable link; that link, not the bare NURI, is what goes into anything discoverable. **`getCaps().open(nuri, scope)`** records a document as mine in a scope (publishing it when `public`).
- **Public is readable by whoever has the link, and NOT recursive**: a public document may *reference* a private one without disclosing it. Festipod relies on exactly that.
- Key rotation **redelivers** through the same inbox channel; access is deferred to the next connection, never lost. The app implements nothing to "keep" an access.
May not assume: that a store-level key grants its documents (it does not — isolation is per document); that `Nuri`/`ReadCap` are compile-time-branded (they are plain strings, checked at runtime); that revocation is retroactive.
## Identity and lifecycle
**`accounts.IdentityStore`** / **`accounts.browserIdentityStore(key?)`** persist the current identity id over an injected `AccountStorage`; it is an opaque id, with no notion of password or login step. `/polyfill` adds **`setCurrentUser(id)`**, **`getCurrentUser()`**, **`resetCaps()`**, **`configure(...)`** and **`configureStoreRegistry(...)`** — the bootstrap. **`init` / `initNg`** are the lifecycle entry points, and **`ng`** is the raw SDK object, both re-exported from the SDK entry.
May assume: switching identity **switches** keyrings, it does not wipe them — a delivered capability is durable across sessions.
## SPARQL safety
**`escapeLiteral(value)`**, **`escapeIri(value)`**, **`assertNuri(nuri)`** — the app reuses the SDK's own escaping whenever it builds SPARQL by interpolation. Any untrusted value crossing into a query goes through one of them; never hand-roll quoting.
## Types re-exported for the app
`Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `UnionSubject`, `ShapeQuery`, `ShapeObservable`, `IndexEntry`, `SubmitOptions`, `Deposit`, `PostOptions`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `DocChange`, `Unsubscribe` — plus `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet` and `NG`, so the app never imports from `@ng-org/*` directly.
## Exported, but not for the app
`readModel.readUnion`, `subscribeDoc` / `subscribeDocs` / `docChangeType`, and `docs.sparqlQuery` used as a listing primitive are **lower-level** surfaces. Festipod reads through `watchShape` and does **not** assemble its own reactivity on top of them ([[rule_app-uses-sdk-surface-only]]). In demo mode none of this is reached at all ([[knowledge_data-modes]]).
@@ -1,6 +1,6 @@
---
type: rule
summary: The app behaves EXACTLY as if NextGraph were finished and flawless — it consumes ONLY SDK-shaped surfaces (`useShape`, `docs`, `inbox`…) and NEVER reasons about a current NextGraph problem (ORM fan-out hang, cold-open, etc.). The polyfill exists for the VIRTUAL WALLET; every internal workaround (union read-model, subscribeDoc, open-repo…) lives INSIDE the polyfill, invisible to the app.
summary: The app IGNORES NextGraph's implementation state entirely and is coded against the SDK as it SHOULD BE — the contract written down in this repo ([[knowledge_sdk-surface]]). @ng-eventually/client is a POLYFILL whose mission is to COMPENSATE THE GAP between that target SDK and what NextGraph provides today (the virtual wallet being the largest piece, not the whole mission). When something breaks, the question is never "how do we work around it in the app" but "what must the polyfill compensate".
---
# The app uses the SDK surface only — never the polyfill's internals
@@ -12,9 +12,19 @@ The Festipod app treats `@ng-eventually/client` as a **finished, flawless NextGr
1. **Reactive reads = `useShape`** (the SDK-shaped surface provided by the polyfill, **scoped to the virtual wallet**). The app does NOT read through the polyfill's internals (`readModel.readUnion`, `subscribeDoc`, a home-made read model…), and does NOT mount its own reactivity (a re-run on a signal).
2. **The app NEVER reasons about NextGraph's current state**: no code and no comment of the kind "we do X because the ORM fan-out hangs / because a cold read returns 0". From the app's point of view, those problems do not exist.
## The polyfill exists for the VIRTUAL WALLET
## The contract is the SDK as it SHOULD BE — written down here, in this repo
The polyfill exists to emulate the **virtual wallet** (several identities on a single physical wallet), which NextGraph does not yet provide natively. It is **NOT** "because the ORM fan-out hangs" — that is merely an **internal implementation detail** of how the polyfill delivers a working `useShape`. All the workarounds (a union read-model instead of the ORM fan-out, `open-repo`, readiness mirroring `readyPromise`, caps emulation…) are **internal to the polyfill** and never surface in the app.
The app is coded against the SDK **as it should be**, and that contract lives in Festipod's own doctrine: [[knowledge_sdk-surface]]. That is what an agent reads to know what it may rely on. It never needs to open the polyfill's repo, and it never needs to know what NextGraph does or does not implement today.
**Ignore NextGraph's implementation state — entirely.** Not "mostly", not "except when it bites". The app's code and comments must contain **nothing** of the form "we do X because NextGraph does Y today". From the app's point of view, that state does not exist.
## The polyfill's mission: COMPENSATE THE GAP
`@ng-eventually/client` is a **polyfill**, and its mission is exactly that of any polyfill: **close the gap between the target SDK and what the underlying platform currently provides**.
The **virtual wallet** (several identities on one physical wallet) is the largest piece of that gap, and historically the reason the polyfill was created — but it is **one piece, not the whole mission**. Emulating capabilities, the union read-model, `open-repo`, readiness mirroring, reconnection: all of it is gap-compensation, all of it is legitimately the polyfill's job, and **none of it surfaces in the app**.
**The operative consequence.** When something does not work, the question is never *"how do we work around NextGraph in the app?"* — it is *"what does the polyfill have to compensate?"*. An app-side workaround is a doctrine violation even when it works, because it hard-codes a temporary state of NextGraph into code that must outlive it.
## Status (deviation resolved)
@@ -18,29 +18,31 @@ Isolation itself is **entirely handled by the SDK** ([[knowledge_trust-model]] i
## How to apply it
- At creation time: ask the SDK for **a document for the entity, in its scope** (`createEntityDoc(scope)`); write the entity into it. Do not reuse a document from another scope, nor a store-level document.
- For reads: go through the SDK using the **union read model** (see below) — the app resolves a set of documents *on demand* (the discovery index for public events; its own scope documents for its own entities) and the SDK opens/synchronizes them, then reads the union in **one single** query; no NURI resolution and no union-vs-anchored choice on the app side.
- For reads: go through the SDK's **reactive shape surface** (see below) — the app names a SHEX shape and a **logical scope**, and the SDK resolves that scope to the documents to read (the discovery index for public events; its own scope documents for its own entities), opens/synchronizes them and pushes changes. No NURI resolution, no document listing and no query written on the app side.
- The *entity → scope* mapping (event/meeting point → public, network profile/participation → protected, settings → private) is a product fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]).
## Reads: the union model (open/sync + one unanchored query + re-query)
## Reads: the SDK's reactive shape surface (`watchShape` / `useShapeQuery`)
**Reads** do **NOT** go through a fan-out reactive ORM subscription over a set of per-entity documents (`useShape({ graphs: […] })`): against the real broker, a freshly created / unsynchronized document inside that fan-out aborts the entire subscription (`RepoNotFound`) → the subscription never emits its initial value → a **~75s hang**. Instead, reads use the SDK's **union model** ([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`):
**Reads go through the SDK surface only** ([[rule_app-uses-sdk-surface-only]]). The app names a shape and a scope, and gets a live, `useQuery`-shaped result back:
1. **resolve on demand** the set of NURIs to read — public events through the **discovery index** (the only sanctioned cross-account enumeration); "my entities" (profile, participations) through **my own** scope documents (`listMyEntityDocs(username, scope)`, bounded to my account — never a fan-out over every account);
2. the SDK **opens/synchronizes** those documents, then runs **ONE** `sparql_query` **without an anchor** over the local union (`GRAPH ?g { … }`) and returns the triples grouped by subject (`src/shared/data/readEntities.ts``readModel.readUnion`);
3. there is **no** reactive union query → **reactivity = a re-query** on a change signal (a created/registered document triggers `bumpRead`).
1. `watchShape(shapeType, scope)` (SDK) returns an **observable**`{ data, isPending, isSuccess, isError }` — which resolves the scope against the current identity's wallet (its own scope documents, plus the discovery index for `public`), waits for the sync barrier, and **pushes** on every change. `data` is always an array; a synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`, which is what distinguishes "still syncing" from "genuinely empty".
2. `useShapeQuery(shapeType, scope)` (`src/shared/data/useShapeQuery.ts`) is the app's **only** React binding over it (`useSyncExternalStore`), memoized per (shape, scope) so the underlying document subscriptions are not churned on every render.
3. `FestipodDataContext` mounts exactly three of them — events (`public`), profiles (`protected`), participations (`protected`) — and maps the SDK's `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
On the app side, `FestipodDataContext` collects the NURIs on demand then calls `readEntities`; a freshly created document is also registered locally (`registerDoc`) so that it shows up immediately, before the re-listing catches up with it.
**The app resolves, lists, registers and re-queries nothing.** There is no app-side document set, no manual re-read signal and no per-document subscription: reactivity is the SDK's own push. The single app-side layer laid over the read is a **pure optimistic overlay** (`pendingAdd*` / `pendingRemoveIds` in `FestipodDataContext`), auto-reconciled the moment the reactive set catches up — it hides the broker's push latency, it is not a read path.
> **Removed (verified 2026-07-28).** An earlier version of this rule described reads as a bespoke union model: an on-demand document set (`publicDocs`/`protectedDocs` fed by `listMyEntityDocs` + `registerDoc`), a one-shot `readEntities` → `readModel.readUnion`, and a manual re-query signal (`bumpRead`/`readTick`). **None of those read symbols exist in `src/` any more** — `src/shared/data/readEntities.ts` is gone, and the surviving mentions are historical code comments. Do not code against them ([[rule_app-uses-sdk-surface-only]]).
## Direct writes (the round-trip pitfall)
**Writing** an entity happens **directly into its own document** (through the SDK's SPARQL call — `src/shared/data/entityWrites.ts`, `writeEntity`), **not** by adding to a reactive set. Reason: a reactive set is only *writable* if the target document is **already** within its subscription scope; but registering the freshly created document is React state that only takes effect on the **next** render → you cannot create-then-add in a single synchronous pass (seed loop, first creation). Against the real broker, an `add` on an empty scope raises "Set is readonly because scope is empty" (the fake-ng unit tests do not catch it).
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = union + re-query** (above).
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = the SDK's reactive shape surface** (above).
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the union read queries that same anchored default graph (`readEntities`/`readUnion`). This is the **canonical, always-safe** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`.
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the SDK's shape read queries that same anchored default graph. This is the **canonical, always-safe** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`.
> **Correction (2026-07-06).** An earlier comment (and an earlier version of this paragraph) claimed that an explicit `GRAPH <docNuri>` body writes into a *distinct named graph* that an anchored read would not see → the entity would "disappear". **That is false on the current broker** (`@ng-org/web 0.1.2-alpha.13`): the lib's real e2e harness (`packages/client/e2e/`) verifies that an `INSERT DATA { GRAPH <plainNuri> {…} }` **anchored** to the doc round-trips (read back both from the default graph and from `GRAPH <plainNuri>`). The "0 entities" symptom we had attributed to that "pitfall" in fact came from the **bloated-wallet hang** (see `bdd-testing/caveat_wallet-bloat-hang`), not from a graph mismatch. So the "no `GRAPH` wrapper" rule remains a choice of **simplicity/safety**, not a round-trip necessity. (The *why* on the SDK side lives in `@ng-eventually/client`, not here.)
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the union re-query re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty principal (the entity would be discarded on read). The current user's principal is **stable and derived from the username** (`urn:festipod:user:<normalized-username>`), available **immediately** after login (no dependency on reading the protected profile, which may lag) and **invariant** (it does not flip from a fallback to the profile IRI mid-session, which would desynchronize a participation written under one value from a check made under the other). It is the same principal that the SDK identity (`setCurrentUser`) and the owner cap derive from the username; bilateral connections (`declareConnections`) are declared with those same username keys (not profile IRIs) so that "protected = my connections" discriminates.