From 7459d49e83db0025afc056c185cbe2ee11634ba5 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 28 Jul 2026 16:49:27 +0200 Subject: [PATCH] =?UTF-8?q?docs+fix:=20recadrer=20le=20polyfill=20comme=20?= =?UTF-8?q?compensateur=20d'=C3=A9cart,=20corriger=20la=20doctrine=20p?= =?UTF-8?q?=C3=A9rim=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../app-architecture/knowledge_routing.md | 2 +- .../app-architecture/knowledge_screens.md | 2 +- .project/concepts/data-layer/_overview.md | 1 + ...026-07-06_reactive-reads-and-attendance.md | 4 +- .../data-layer/knowledge_data-modes.md | 2 +- .../data-layer/knowledge_nextgraph-stack.md | 2 + .../data-layer/knowledge_sdk-surface.md | 95 +++++++++++++++++++ .../rule_app-uses-sdk-surface-only.md | 16 +++- .../data-layer/rule_document-per-entity.md | 22 +++-- .../knowledge_stack-and-commands.md | 6 +- AGENTS.md | 8 +- package.json | 2 +- 12 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 .project/concepts/data-layer/knowledge_sdk-surface.md diff --git a/.project/concepts/app-architecture/knowledge_routing.md b/.project/concepts/app-architecture/knowledge_routing.md index 4a6c299..49cabf7 100644 --- a/.project/concepts/app-architecture/knowledge_routing.md +++ b/.project/concepts/app-architecture/knowledge_routing.md @@ -24,7 +24,7 @@ summary: Path-based routing via the History API (hand-rolled router in src/app/r | `/profile/edit` | UpdateProfileScreen | | `/profile/friends` | FriendsListScreen | | `/profile/share` | ShareProfileScreen | -| `/profile/connect` | (connect) | +| `/profile/connect` | ConnectScreen (`src/modules/user/screens/ConnectScreen.tsx`) — routed and mounted in `App.tsx`, but **absent from the screen registry**, see [[knowledge_screens]] | | `/users/:id` | UserProfileScreen | | `/settings` | SettingsScreen | diff --git a/.project/concepts/app-architecture/knowledge_screens.md b/.project/concepts/app-architecture/knowledge_screens.md index 09fd08a..b5dc437 100644 --- a/.project/concepts/app-architecture/knowledge_screens.md +++ b/.project/concepts/app-architecture/knowledge_screens.md @@ -38,5 +38,5 @@ Screens per module (IDs = registry keys): ## Pitfall: incomplete registry -The registry must list **every** screen. Observed case: `ConnectScreen` (`src/modules/user/screens/`, routed at `/profile/connect`, mounted in `App.tsx`) is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]). +The registry must list **every** screen. Observed case, **still true (verified 2026-07-28)**: `ConnectScreen` (`src/modules/user/screens/ConnectScreen.tsx`) exists, is routed at `/profile/connect` (`router.tsx`) and is mounted in `App.tsx` (`case 'connect'`), yet it is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]). diff --git a/.project/concepts/data-layer/_overview.md b/.project/concepts/data-layer/_overview.md index bb4ab6d..17fec5c 100644 --- a/.project/concepts/data-layer/_overview.md +++ b/.project/concepts/data-layer/_overview.md @@ -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 diff --git a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md index c0d405d..3565c61 100644 --- a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md +++ b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md @@ -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. P1–P3 can ship before P4–P6. +- **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. P1–P3 can ship before P4–P6. - **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. diff --git a/.project/concepts/data-layer/knowledge_data-modes.md b/.project/concepts/data-layer/knowledge_data-modes.md index 5a1ca47..b57e5f4 100644 --- a/.project/concepts/data-layer/knowledge_data-modes.md +++ b/.project/concepts/data-layer/knowledge_data-modes.md @@ -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) diff --git a/.project/concepts/data-layer/knowledge_nextgraph-stack.md b/.project/concepts/data-layer/knowledge_nextgraph-stack.md index 3fa4f30..367dee1 100644 --- a/.project/concepts/data-layer/knowledge_nextgraph-stack.md +++ b/.project/concepts/data-layer/knowledge_nextgraph-stack.md @@ -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) ``` diff --git a/.project/concepts/data-layer/knowledge_sdk-surface.md b/.project/concepts/data-layer/knowledge_sdk-surface.md new file mode 100644 index 0000000..3cbdf47 --- /dev/null +++ b/.project/concepts/data-layer/knowledge_sdk-surface.md @@ -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`** — 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]]). diff --git a/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md b/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md index a382f33..48b6b01 100644 --- a/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md +++ b/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md @@ -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) diff --git a/.project/concepts/data-layer/rule_document-per-entity.md b/.project/concepts/data-layer/rule_document-per-entity.md index 3d0cfae..e786784 100644 --- a/.project/concepts/data-layer/rule_document-per-entity.md +++ b/.project/concepts/data-layer/rule_document-per-entity.md @@ -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 ` 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 {…} }` **anchored** to the doc round-trips (read back both from the default graph and from `GRAPH `). 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:`), 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. diff --git a/.project/concepts/tech-stack/knowledge_stack-and-commands.md b/.project/concepts/tech-stack/knowledge_stack-and-commands.md index def0e3c..c7e6218 100644 --- a/.project/concepts/tech-stack/knowledge_stack-and-commands.md +++ b/.project/concepts/tech-stack/knowledge_stack-and-commands.md @@ -1,6 +1,6 @@ --- type: knowledge -summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, build:orm pointing at a stale path, build:ng for the local fork, link:polyfill for the reactive local link) +summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, build:ng for the local fork, link:polyfill for the reactive local link) --- # Stack & commands @@ -31,7 +31,7 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React, | `cucumber:report` | `bun scripts/parse-test-results.ts` — `cucumber-report.json` → HTML | | `features:parse` | `bun scripts/parse-features.ts` → `features.ts` | | `steps:extract` | `bun scripts/extract-step-definitions.ts` | -| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` | +| `build:orm` | `rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm` | | `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) | | `link:polyfill` | `bun scripts/link-polyfill.ts` — **reactive** local link to the `@ng-eventually/client` polyfill (strategy S2: copy-overlay + watcher), preserving the single `@ng-org` instance. Details in [[knowledge_deployment]]. | | `storybook` / `build-storybook` | Storybook dev (6006) / static build | @@ -40,4 +40,4 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React, - **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts. - **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`. -- **`build:orm` targets `./src/shapes/shex` and `./src/shapes/orm`**, whereas the real shapes live under **`src/shared/shapes/`** — the script's path is most likely **stale** (either fix it or run it with the right paths; check before regenerating the ORM). +- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs. Beware of a side effect: the generator has moved on since the committed bindings were produced, so a run reformats them and drops the `: Schema` annotation. That regeneration is a **tool-version bump, not a content fix** — treat it as its own validated change, do not let it ride along. diff --git a/AGENTS.md b/AGENTS.md index 5642c46..b398cee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,13 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu ## Frontière SDK NextGraph -Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`. **Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *comment Festipod utilise ce SDK* + le domaine + l'architecture + le contrat BDD. +Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`. + +**`@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 (plusieurs identités sur un wallet physique) en est la plus grosse pièce, pas la totalité. + +**L'app ignore ENTIÈREMENT l'état d'implémentation de NextGraph.** Elle est codée contre le SDK cible, dont le contrat est écrit **dans ce repo** (concept `data-layer`, fiche `knowledge_sdk-surface`). Aucun code ni commentaire du type « on fait X parce que NextGraph fait Y aujourd'hui ». 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 »**. + +**Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *le contrat SDK cible* + *comment Festipod l'utilise* + le domaine + l'architecture + le contrat BDD. ## Doctrine du projet — concepts (livrée automatiquement) diff --git a/package.json b/package.json index c9a55bb..5316a82 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "cucumber:report": "bun scripts/parse-test-results.ts", "features:parse": "bun scripts/parse-features.ts", "steps:extract": "bun scripts/extract-step-definitions.ts", - "build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm", + "build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm", "validate": "bun scripts/validate.ts", "build:ng": "bash scripts/build-ng-packages.sh", "link:polyfill": "bun scripts/link-polyfill.ts",