docs(concept): passer les 59 fiches de doctrine en anglais

Convention du projet pour la documentation projet. Traduction fidèle, sans
changement de fond : mêmes fiches, mêmes sections, mêmes liens. Le lint est
identique à la baseline (59 leaves, 0 nouveau lien cassé, wikilinks bit-à-bit
inchangés) et aucun `.feature` n'a été touché.

Le `summary:` du frontmatter est traduit lui aussi — c'est ce que le hook affiche
dans l'index, il porte autant que le corps.

RESTENT EN FRANÇAIS, délibérément :
- les fichiers .feature (convention explicite du projet : Etant donné/Quand/Alors)
  et le bloc Gherkin cité dans brief_2026-07-06 ;
- les libellés d'interface cités en prose (« Entrer », « ✓ Je participe »,
  « Voir tous les participants », « participant inconnu »…) : ce sont des chaînes
  réelles de l'app, pas de la prose ;
- les noms de scénarios BDD ;
- les `triggers.keywords` des _overview : jetons de matching du hook, et la
  conversation reste en français — les traduire aurait cassé la livraison.

EFFET SECONDAIRE UTILE : relire intégralement a fait remonter des contradictions
et des péremptions que personne ne voyait section par section. Notées, non
corrigées (hors périmètre de la traduction) :
- rule_document-per-entity décrit la lecture via readEntities/readUnion/
  registerDoc/bumpRead, que rule_app-uses-sdk-surface-only déclare SUPPRIMÉS au
  profit de watchShape/useShapeQuery. Une règle qui décrit des APIs retirées est
  activement trompeuse — à traiter en priorité.
- brief_2026-07-06 §P3 réaffirme « prouvé par l'e2e D.2, sans reload » juste
  après l'encadré qui déclare cette phrase fausse et sur-cadrée.
- knowledge_data-modes cite useShapeWithDefaults() là où useShapeQuery est
  documenté ailleurs.
- knowledge_stack-and-commands : build:orm pointe ./src/shapes/* alors que les
  shapes vivent sous src/shared/shapes/.
- knowledge_screens / knowledge_routing : ConnectScreen décrit comme absent du
  registre mais présent en route.
- brief_2026-05-18 : « identifié si connecté » était ambigu en français (session
  ouverte vs est une connexion) ; l'anglais a forcé à trancher — rendu par
  « if a connection », à confirmer côté produit.

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:27:24 +02:00
parent 3ec3b37a65
commit b6a6b14fad
59 changed files with 1185 additions and 1465 deletions
+20 -20
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
summary: How Festipod persists its data through the @ng-eventually/client SDK — entities stored as documents placed by scope, direct SPARQL writes + union-model reads, SHEX stack, connected/demo modes, seed
triggers:
keywords: [nextgraph, "@ng-eventually", polyfill, union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, overlay, ReadCap, WriteCap, cap-less, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
@@ -8,32 +8,32 @@ triggers:
# Data layer
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private). L'**écriture** est un SPARQL direct dans le document de l'entité ; la **lecture** est le **modèle union** (résoudre les documents par besoin → ouvrir/sync → **une** requête `sparql_query` sans ancre sur l'union → re-query sur signal), et non un abonnement ORM réactif en fan-out (qui *hang*). Voir [[rule_document-per-entity]]. Le mapping *quelle entitéquel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-end encrypted). The data SDK is **`@ng-eventually/client`**: we treat it as a finished NextGraph SDK — every entity is a **document** placed in the store of its **scope** (public / protected / private). A **write** is direct SPARQL into the entity's own document; a **read** is the **union model** (resolve the documents on demand → open/sync → **one** unanchored `sparql_query` over the union → re-query on signal), not a fan-out reactive ORM subscription (which *hangs*). See [[rule_document-per-entity]]. The mapping *which entitywhich scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes the **persistence mechanics**.
> **Frontière SDK.** Le SDK de données de Festipod est `@ng-eventually/client` — initiali/injecté **une seule fois** via `ngSession.configure(...)`. On l'écrit comme un SDK NextGraph **fini** : ne jamais documenter ici l'état courant de NextGraph (contraintes, contournements, internes broker) — cela vit dans le repo `@ng-eventually/client`. Voir [[knowledge_nextgraph-stack]].
> **SDK boundary.** Festipod's data SDK is `@ng-eventually/client` — initialized/injected **exactly once** through `ngSession.configure(...)`. We write against it as a **finished** NextGraph SDK: never document NextGraph's current state here (constraints, workarounds, broker internals) — that lives in the `@ng-eventually/client` repo. See [[knowledge_nextgraph-stack]].
## Modèle & données
## Model & data
- [[knowledge_nextgraph-stack]] — SDK `@ng-eventually/client`, shapes SHEX, ORM réactif, `build:orm`, injection via `ngSession`
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (état local seedé), choix du provider
- [[knowledge_entities]] — types `Fp*` et leurs shapes SHEX
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, **deux espaces d'id** principal↔NURI de profil, auto-seed dev, `participantCount` cache, reset au changement d'identité, no-op local)
- [[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
- [[knowledge_seed-data]] — seed data, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (currentUser, **two id spaces** principal ↔ profile NURI, dev auto-seed, `participantCount` cache, reset on identity change, local no-op)
## Règles d'écriture
## Write rules
- [[rule_document-per-entity]] — chaque entité = **son propre document** (par scope), jamais au niveau du store ; c'est ce qui rend l'isolation par-document du SDK possible
- [[rule_app-uses-sdk-surface-only]] — l'app se comporte comme si NextGraph était fini ; tout contournement vit dans le polyfill
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; this is what makes the SDK's per-document isolation possible
- [[rule_app-uses-sdk-surface-only]] — the app behaves as if NextGraph were finished; every workaround lives in the polyfill
## Ce qui sort de ce repo (deux destinations, ne pas les confondre)
## What leaves this repo (two destinations, don't confuse them)
- [[rule_capture-nextgraph-findings]] — une **connaissance** établie sur le fonctionnement réel de NextGraph → doc de référence du **polyfill**, au moment de la découverte
- [[rule_nextgraph-inbox]] — un **dysfonctionnement** de NextGraph, ou un **manque** dont on a besoin et qu'on émule en attendant → fiche dans `orm-tests/INBOX/`, qui suit l'avancement amont et dit quoi retirer du polyfill
- [[rule_capture-nextgraph-findings]] — established **knowledge** about how NextGraph actually works → the **polyfill**'s reference docs, at the moment of discovery
- [[rule_nextgraph-inbox]] — a NextGraph **malfunction**, or a **gap** we need and emulate in the meantime → a note in `orm-tests/INBOX/`, which tracks upstream progress and says what to remove from the polyfill
## Pièges (lire avant de toucher aux suppressions / aux champs d'event)
## Pitfalls (read before touching deletions / event fields)
- [[caveat_participation-deletion]] — la désinscription doit être **autoritative** et ne pas réapparaître
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… non couverts par la shape Event → perdus en connecté
- [[caveat_write-durability-across-disconnect]] — une écriture juste avant une inactivité/chute de socket peut être **perdue** (non durable broker) ; compte survit. Incident ouvert → post-mortem dans le polyfill
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
- [[caveat_write-durability-across-disconnect]] — a write made just before an idle period / socket drop can be **lost** (no broker durability); the account survives. Open incident → post-mortem in the polyfill
> Confidentialité (isolation par scope, confiance dans le SDK) : concept `app-security`. Périmètres produit par entité + découverte : concept `functional-domain`.
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
@@ -1,145 +1,145 @@
---
type: brief
summary: Design d'implémentation — rendre les lectures RÉACTIVES cross-session via doc_subscribe (par-document, sans fan-out ORM qui hang) et remplacer le participantCount muté-en-place par le flux Option-B (l'inscrit dépose dans l'inbox de l'événement, le propriétaire matérialise et incrémente son propre doc) ; plan de test 2-browsers réel sans polling
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
---
# Reactive reads + participant-count correct (Option B)
# Reactive reads + correct participant count (Option B)
Brief d'implémentation, ancré dans le code courant. Objectif : deux évolutions couplées de la couche données Festipod (mode connected / `@ng-eventually/client`).
Implementation brief, anchored in the current code. Goal: two coupled evolutions of Festipod's data layer (connected mode / `@ng-eventually/client`).
1. **Lectures réactives cross-session** — remplacer le one-shot `readUnion` + `bumpRead` (re-query manuel, local-only) par une réactivité réelle poussée par le broker, **sans jamais poller** et **sans le fan-out ORM qui hang**.
2. **Compteur de participants correct (Option B)**supprimer la violation d'isolation actuelle (l'inscrit écrit `participantCount` sur le doc de l'événement qui ne lui appartient pas) et la remplacer par le flux dépôt-inbox → matérialisation-propriétaire.
1. **Cross-session reactive reads** — replace the one-shot `readUnion` + `bumpRead` (manual re-query, local-only) with real broker-pushed reactivity, **never polling** and **without the ORM fan-out that hangs**.
2. **Correct participant count (Option B)**remove the current isolation violation (the participant writes `participantCount` on the event doc, which is not theirs) and replace it with the inbox-deposit → owner-materialization flow.
Ce brief décrit **quoi construire et dans quel ordre**. Aucune modification de code n'est faite ici.
This brief describes **what to build and in what order**. No code change is made here.
Références transverses : [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, et le contrat SDK `@ng-eventually/client` (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
Cross-cutting references: [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, and the `@ng-eventually/client` SDK contract (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
---
## 0. État courant (le point de départ, fichier:fonction)
## 0. Current state (the starting point, file:function)
### Lecture (one-shot, re-query manuel)
`src/shared/context/FestipodDataContext.tsx``useNgData()` :
- Le jeu de docs à lire **par besoin** est deux `useState` : `publicDocs` / `protectedDocs` (l.232-233). Il est alimenté par (a) l'effet de listing (l.302-332) qui appelle `listMyEntityDocs(owner, 'public'|'protected')` (borné à mon compte) + `readDiscoveredEvents()` (l'index global), et (b) `registerDoc(scope, nuri)` (l.251-255) qui ajoute un doc fraîchement créé.
- La **lecture réelle** (l.347-364) : `readEntities(allReadDocs)``readModel.readUnion(docs)` (un `sparql_query` anc par doc, en parallèle, tolérant par-doc). Elle **re-tourne** quand `allReadDocs` change **ou** quand `readTick` change.
- `readTick`/`bumpRead` (l.236-237) = **signal de re-query manuel**, bumpé après chaque mutation. **Il n'y a AUCUN signal venant du broker** : une écriture faite par une AUTRE session n'incrémente jamais `readTick` de cette session → **pas de réactivité cross-session**. C'est le trou que ce brief comble.
- `listTick`/`relist` (l.246-247) rejoue l'effet de listing après un seed.
### Reads (one-shot, manual re-query)
`src/shared/context/FestipodDataContext.tsx``useNgData()`:
- The set of docs to read **on demand** is two `useState`s: `publicDocs` / `protectedDocs` (lines 232-233). It is fed by (a) the listing effect (lines 302-332) which calls `listMyEntityDocs(owner, 'public'|'protected')` (bounded to my own account) + `readDiscoveredEvents()` (the global index), and (b) `registerDoc(scope, nuri)` (lines 251-255) which adds a freshly created doc.
- The **actual read** (lines 347-364): `readEntities(allReadDocs)``readModel.readUnion(docs)` (one `sparql_query` anchored per doc, in parallel, per-doc tolerant). It **re-runs** when `allReadDocs` changes **or** when `readTick` changes.
- `readTick`/`bumpRead` (lines 236-237) = a **manual re-query signal**, bumped after every mutation. **There is NO signal coming from the broker**: a write made by ANOTHER session never increments this session's `readTick`**no cross-session reactivity**. That is the gap this brief fills.
- `listTick`/`relist` (lines 246-247) replays the listing effect after a seed.
### Écriture du compteur (la violation à retirer)
- `joinEvent` (l.597-668) : après avoir écrit sa propre `Participation` (doc protected, l.621-631), il fait `updateEntityField(eventId, eventId, 'participantCount', int(next))` sur **le doc de l'événement** (l.635-640) — or ce doc appartient au **propriétaire de l'événement**, pas à l'inscrit. C'est un write hors-scope. Il dépose *aussi* dans l'inbox via `depositRegistration` (l.652) — ce dépôt-là est le bon canal ; c'est l'écriture directe du `participantCount` qui est à supprimer.
- `leaveEvent` (l.670-712) : symétriquement, décrémente `participantCount` sur le doc de l'événement (l.705-710) après le DELETE autoritatif de la participation.
- `caveat_participation-deletion` : le DELETE de participation doit rester **autoritatif** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` l.260-334, vérifié `remaining === 0`) — ce brief ne change pas ce contrat.
- [[knowledge_context-internals]] documente déjà que `participantCount` est un **cache muté en place**, jamais recalculé, et « pas une source de vérité ». Option B en fait une valeur **dérivée et possédée par le propriétaire**.
### Writing the counter (the violation to remove)
- `joinEvent` (lines 597-668): after writing its own `Participation` (protected doc, lines 621-631), it calls `updateEntityField(eventId, eventId, 'participantCount', int(next))` on **the event's doc** (lines 635-640) — but that doc belongs to the **event's owner**, not to the participant. That is an out-of-scope write. It *also* deposits into the inbox via `depositRegistration` (line 652) — that deposit is the right channel; it is the direct `participantCount` write that must go.
- `leaveEvent` (lines 670-712): symmetrically, decrements `participantCount` on the event's doc (lines 705-710) after the authoritative DELETE of the participation.
- `caveat_participation-deletion`: the participation DELETE must remain **authoritative** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` lines 260-334, verified `remaining === 0`) — this brief does not change that contract.
- [[knowledge_context-internals]] already documents that `participantCount` is a **cache mutated in place**, never recomputed, and "not a source of truth". Option B turns it into a value **derived and owned by the owner**.
### Affichage (déjà « compte + anonyme », à conserver)
`src/modules/event/screens/EventDetailScreen.tsx` :
- `joined = isParticipating(eventId)` (l.20).
- `participants = getEventParticipants(eventId)` (l.21) → dans le contexte, `getEventParticipants` (FestipodDataContext l.108-111) filtre les `participations` connues par `eventId` et joint les `users` **lisibles** (donc seulement mes connexions, cf. cap protected).
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (l.33).
- Le libel **« Participants ({event.participantCount}) »** (l.146) affiche le **compte dérivé**, et `knownParticipants.length < event.participantCount` rend les **placeholders « voir tous les participants »** (l.163-170) — c'est exactement le modèle « compte + anonymes » voulu. **Cet affichage ne change pas** : Option B ne fait que rendre `participantCount` correct et réactif, et les `knownParticipants` restent gouvernés par le cap de lecture protected.
### Display (already "count + anonymous", to be kept)
`src/modules/event/screens/EventDetailScreen.tsx`:
- `joined = isParticipating(eventId)` (line 20).
- `participants = getEventParticipants(eventId)` (line 21) → in the context, `getEventParticipants` (FestipodDataContext lines 108-111) filters the known `participations` by `eventId` and joins the **readable** `users` (so only my connections, per the protected cap).
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (line 33).
- The label **« Participants ({event.participantCount}) »** (line 146) displays the **derived count**, and `knownParticipants.length < event.participantCount` renders the **« voir tous les participants » placeholders** (lines 163-170) — exactly the intended "count + anonymous" model. **This display does not change**: Option B only makes `participantCount` correct and reactive, and `knownParticipants` remains governed by the protected read cap.
### Les watchers polling de la lib (à remplacer)
Confirmé par lecture de la lib (`packages/client/src/`) :
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, se déclenche uniquement sur changement de `deposits.length`.
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = **`setInterval` polling** identique.
- `useShape` (`use-shape.ts:12`) EST pous/réactif, mais **seulement sûr sur UN seul document déjà ouvert** — le fan-out `graphs:[…]` hang (§2).
- **Aucun wrapper `doc_subscribe` n'est exposé aujourd'hui** dans `docs.ts` (qui n'expose que `docCreate` / `sparqlUpdate` / `sparqlQuery`). Le primitif `ng.doc_subscribe` est atteignable *untyped* via le proxy `ng` (`ng-proxy.ts:54-56` passthrough), mais il n'y a **pas de couche typée****la lib doit en ajouter une** (§A).
### The lib's polling watchers (to be replaced)
Confirmed by reading the lib (`packages/client/src/`):
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, firing only when `deposits.length` changes.
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = the same **`setInterval` polling**.
- `useShape` (`use-shape.ts:12`) IS push-based/reactive, but **only safe on ONE already-open document** — the `graphs:[…]` fan-out hangs (§2).
- **No `doc_subscribe` wrapper is exposed today** in `docs.ts` (which only exposes `docCreate` / `sparqlUpdate` / `sparqlQuery`). The `ng.doc_subscribe` primitive is reachable *untyped* through the `ng` proxy (`ng-proxy.ts:54-56` passthrough), but there is **no typed layer****the lib must add one** (§A).
---
## 1. Les primitives plateforme (nextgraph-rs, vérifié)
## 1. The platform primitives (nextgraph-rs, verified)
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) est **par-document** : un seul NURI de repo, un callback. Il monte une souscription sur **une branche** du doc (`verifier.rs:352` `create_branch_subscription`), pousse d'abord un `TabInfo` + `State` initial (`verifier.rs:470-477`), puis un flux de `Patch` à chaque commit.
- Le push : à chaque transaction vérifiée sur une branche B, le vérifieur appelle `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) sur le `Sender` enregistré dans `branch_subscriptions[B]` (`verifier.rs:115`). **Unité de souscription = une branche d'un doc.**
- Le **fan-out ORM** vit ailleurs : `orm_start_graph(scope.graphs[], …)` (un seul appel sur un tableau). Là, un **seul** repo non-synchronisé dans le tableau fait que `open_for_target → resolve_target` retourne `RepoNotFound` (`request_processor.rs:147-171`, et surtout la boucle `initialize.rs:125-128` où le `?` **avorte toute la souscription**). Le `readyPromise` ne se résout jamais → **hang ~75s** (`nextgraph-current-state.md` § *The ORM fan-out hang*, cité dans `read-model.md:93-98` et l'en-tête de `read-model.ts:24-31`). **Corollaire : `doc_subscribe` par-doc n'a PAS ce défaut** — il ne subit pas de fan-out, donc un doc absent ne casse que sa propre souscription.
- **Write membership-bound, pas d'append** (confirmé, `repo.rs:584` `verify_permission` : auteur non-membre → `PermissionDenied` ; `commit.rs` : une transaction exige `WriteAsync`/`WriteSync`, obtenus uniquement par grant du propriétaire ; **aucune variante `Append` dans `PermissionV0`**). ⇒ **Option A est impossible** : un inscrit ne peut pas écrire/incrémenter un compteur sur le doc public d'un autre. D'où Option B via l'inbox.
- **Inbox = primitif plateforme réel** (`server_broker.rs:826` `inbox_post` : aucun contrôle de membership sur l'émetteur ; message scellé à la clé de l'inbox, lisible seulement par les *readers* enregistrés). C'est exactement le canal « n'importe qui dépose, seul le propriétaire dépile ». Aujourd'hui la lib l'émule sur le wallet partagé (`inbox.ts` post/read RDF), le natif étant différé.
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document**: a single repo NURI, a single callback. It mounts a subscription on **one branch** of the doc (`verifier.rs:352` `create_branch_subscription`), first pushes a `TabInfo` + initial `State` (`verifier.rs:470-477`), then a stream of `Patch`es on every commit.
- The push: on every verified transaction on a branch B, the verifier calls `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) on the `Sender` registered in `branch_subscriptions[B]` (`verifier.rs:115`). **Unit of subscription = one branch of one doc.**
- The **ORM fan-out** lives elsewhere: `orm_start_graph(scope.graphs[], …)` (a single call over an array). There, a **single** unsynchronized repo in the array makes `open_for_target → resolve_target` return `RepoNotFound` (`request_processor.rs:147-171`, and above all the `initialize.rs:125-128` loop where the `?` **aborts the whole subscription**). The `readyPromise` then never resolves → **~75s hang** (`nextgraph-current-state.md` § *The ORM fan-out hang*, quoted in `read-model.md:93-98` and the header of `read-model.ts:24-31`). **Corollary: per-doc `doc_subscribe` does NOT have this flaw** — it is not subject to fan-out, so a missing doc only breaks its own subscription.
- **Writes are membership-bound, with no append** (confirmed, `repo.rs:584` `verify_permission`: a non-member author`PermissionDenied`; `commit.rs`: a transaction requires `WriteAsync`/`WriteSync`, obtainable only via a grant from the owner; **there is no `Append` variant in `PermissionV0`**). ⇒ **Option A is impossible**: a participant cannot write to / increment a counter on someone else's public doc. Hence Option B through the inbox.
- **The inbox is a real platform primitive** (`server_broker.rs:826` `inbox_post`: no membership check on the sender; the message is sealed to the inbox's key, readable only by the registered *readers*). That is exactly the "anyone deposits, only the owner drains" channel. Today the lib emulates it over the shared wallet (`inbox.ts` post/read RDF), the native one being deferred.
---
## A. Lectures réactives — le design
## A. Reactive reads — the design
### Principe : `doc_subscribe` par-doc comme **signal de changement**, `readUnion` reste le lecteur
On **ne** rend **pas** `readUnion` réactif et on **n'introduit pas** de fan-out ORM. On garde le pattern documenté (`read-model.md:100-110`) :
### Principle: per-doc `doc_subscribe` as a **change signal**, `readUnion` remains the reader
We do **not** make `readUnion` reactive and we do **not** introduce an ORM fan-out. We keep the documented pattern (`read-model.md:100-110`):
> une souscription réactive légère (`doc_subscribe`, ou l'ORM sur un seul store déjà ouvert — jamais un fan-out par-entité) sur les docs synchronisés ; sur son signal de changement, re-jouer le jeu borné de `sparql_query` par-doc (`readUnion`).
> a lightweight reactive subscription (`doc_subscribe`, or the ORM on a single already-open store — never a per-entity fan-out) on the synchronized docs; on its change signal, replay the bounded set of per-doc `sparql_query` calls (`readUnion`).
Concrètement :
Concretely:
1. **La lib expose un wrapper typé `doc_subscribe`.** Il n'existe pas aujourd'hui. Ajouter dans `packages/client/src/docs.ts` (ou un nouveau `subscribe.ts`) une fonction, p.ex. :
1. **The lib exposes a typed `doc_subscribe` wrapper.** It does not exist today. Add to `packages/client/src/docs.ts` (or a new `subscribe.ts`) a function, e.g.:
```ts
// renvoie un unsubscribe ; onChange appelé au State initial puis à chaque Patch
// returns an unsubscribe; onChange called on the initial State then on every Patch
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
```
qui wrappe `ng.doc_subscribe(nuri, sessionId, cb)` et normalise l'AppResponse (initial + patches) + la fermeture du flux. C'est **par-document** (un NURI), donc immunisé au hang du fan-out.
- Exposer aussi un helper pour souscrire **un ensemble** de docs en montant **une souscription par doc** (map `nuri → unsubscribe`), avec **isolation par-doc des erreurs** : un `RepoNotFound` / doc non-synchronisé ne fait échouer QUE sa propre souscription (retry/skip), jamais les autres. C'est le point-clé qui évite de reproduire le fan-out. Le contrat SDK (`sdk-reference.md`) devra documenter ce wrapper.
which wraps `ng.doc_subscribe(nuri, sessionId, cb)` and normalizes the AppResponse (initial + patches) plus stream teardown. It is **per-document** (a single NURI), hence immune to the fan-out hang.
- Also expose a helper to subscribe to **a set** of docs by mounting **one subscription per doc** (a `nuri → unsubscribe` map), with **per-doc error isolation**: a `RepoNotFound` / unsynchronized doc only fails ITS OWN subscription (retry/skip), never the others. That is the key point that avoids reproducing the fan-out. The SDK contract (`sdk-reference.md`) will need to document this wrapper.
2. **Le contexte data (FestipodDataContext) monte une souscription par-doc sur le jeu qu'il lit déjà.** Le jeu `allReadDocs` (union `publicDocs` `protectedDocs`) est déjà borné et par-besoin. Nouvel effet dans `useNgData()` :
2. **The data context (FestipodDataContext) mounts a per-doc subscription over the set it already reads.** The `allReadDocs` set (union of `publicDocs` `protectedDocs`) is already bounded and on-demand. A new effect in `useNgData()`:
```
useEffect(() => {
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
return () => unsubs.forEach(u => u());
}, [allReadDocs]);
```
sur **tout** patch d'un des docs abonnés (écrit par CETTE session OU une autre), `bumpRead()` re-déclenche le `readUnion` existant (l.347-364). **`readTick`/`bumpRead` restent** — ils cessent d'être « manuel après ma mutation » pour devenir « poussé par le broker ». La forme du contexte (valeurs `events`/`users`/`participations` en `useState`) **ne change pas** ; les écrans continuent de lire via `useFestipodData()` sans modification.
on **any** patch of a subscribed doc (written by THIS session OR another one), `bumpRead()` re-triggers the existing `readUnion` (lines 347-364). **`readTick`/`bumpRead` stay** — they stop being "manual after my own mutation" and become "pushed by the broker". The shape of the context (`events`/`users`/`participations` values in `useState`) **does not change**; screens keep reading through `useFestipodData()` unmodified.
3. **Entrée de NOUVEAUX docs dans le jeu abonné, sans fan-out hang :**
- **Nouvel événement découvert** : la découverte réactive remplace `discovery.watchIndex` (setInterval) par une **souscription `doc_subscribe` sur le doc d'index global** (l'inbox d'index, un seul doc — `resolveInboxAnchor`-style). À chaque patch de l'index → re-lire `readDiscoveredEvents()` → les nouveaux `doc` NURIs entrent dans `publicDocs` (via `setPublicDocs`), ce qui **agrandit `allReadDocs`**, ce qui **remonte la souscription par-doc** (nouveau `useEffect` ci-dessus) → le nouvel événement est lu ET désormais abonné. Pas de fan-out : chaque doc est abonné **individuellement**, quand il entre.
- **Nouveau dépôt d'inbox** (nouveau participant, notification hôte) : idem, remplacer `inbox.watch` (setInterval) par une **souscription `doc_subscribe` sur le doc-inbox** concerné (un seul doc). Un patch → re-matérialiser (§B).
- **Doc que je viens de créer** : `registerDoc` continue de l'ajouter à `publicDocs`/`protectedDocs` → il entre dans `allReadDocs` → il est abonné. (`bumpRead` immédiat garde la latence perçue nulle localement.)
3. **NEW docs entering the subscribed set, without a fan-out hang:**
- **A newly discovered event**: reactive discovery replaces `discovery.watchIndex` (setInterval) with a **`doc_subscribe` subscription on the global index doc** (the index inbox, a single doc — `resolveInboxAnchor`-style). On every patch of the index → re-read `readDiscoveredEvents()` → the new `doc` NURIs enter `publicDocs` (via `setPublicDocs`), which **grows `allReadDocs`**, which **remounts the per-doc subscription** (the new `useEffect` above) → the new event is read AND from then on subscribed. No fan-out: each doc is subscribed **individually**, as it enters.
- **A new inbox deposit** (new participant, host notification): likewise, replace `inbox.watch` (setInterval) with a **`doc_subscribe` subscription on the relevant inbox doc** (a single doc). A patch → re-materialize (§B).
- **A doc I just created**: `registerDoc` keeps adding it to `publicDocs`/`protectedDocs` → it enters `allReadDocs` → it gets subscribed. (An immediate `bumpRead` keeps perceived local latency at zero.)
4. **La lib remplace ses watchers polling** : `inbox.watch` et `discovery.watchIndex` deviennent des wrappers `doc_subscribe` sur le doc-inbox / doc-index respectif (un doc chacun — pas de fan-out). Signature publique conservée (callback + unsubscribe) pour ne pas casser les appelants ; l'implémentation passe de `setInterval(read)` à `subscribeDoc(anchor, () => read().then(onX))`.
4. **The lib replaces its polling watchers**: `inbox.watch` and `discovery.watchIndex` become `doc_subscribe` wrappers on the inbox doc / index doc respectively (one doc each — no fan-out). The public signature is preserved (callback + unsubscribe) so callers do not break; the implementation moves from `setInterval(read)` to `subscribeDoc(anchor, () => read().then(onX))`.
### Ce qui NE change pas
- `readUnion` reste one-shot, par-doc, tolérant (un doc en échec → `[]`, jamais d'abort).
- Le mapping `readEntities` (`src/shared/data/readEntities.ts`) est inchangé.
- **Aucun `useShape({graphs:[…]})` par-entité n'est introduit** — le seul `useShape` restant est le `FanoutProbe` du harness de test (qui sert justement à *démontrer* le hang), pas un chemin applicatif.
### What does NOT change
- `readUnion` stays one-shot, per-doc, tolerant (a failing doc → `[]`, never an abort).
- The `readEntities` mapping (`src/shared/data/readEntities.ts`) is unchanged.
- **No per-entity `useShape({graphs:[…]})` is introduced** — the only remaining `useShape` is the test harness's `FanoutProbe` (whose very purpose is to *demonstrate* the hang), not an application path.
---
## B. Compteur de participants — Option B (dépôt → matérialisation propriétaire)
## B. Participant count — Option B (deposit → owner materialization)
### Les documents / inboxes impliqués
- **Doc de participation de l'inscrit** : protected, **possédé par l'inscrit** (déjà créé par `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Lisible en clair par les **connexions** de l'inscrit uniquement (cap protected + `declareConnections`).
- **Inbox de l'événement** : résolue par `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (aujourd'hui une anchor unique ; à migration, un doc-inbox par événement — `hostInboxNuri` réserve déjà le param `eventId`). C'est là que l'inscrit **dépose le lien de participation**.
- **Doc de l'événement** : public, **possédé par le propriétaire**. C'est **le propriétaire** qui y écrit `participantCount` — jamais l'inscrit.
- **(référence) enregistrée par le propriétaire** : une entrée reliant le compte incrémenté au dépôt (idempotence + audit) ; peut vivre dans le doc de l'événement (référence de dépôt déjà matérialisé) ou un doc protected du propriétaire.
### The documents / inboxes involved
- **The participant's participation doc**: protected, **owned by the participant** (already created by `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Readable in plaintext only by the participant's **connections** (protected cap + `declareConnections`).
- **The event's inbox**: resolved by `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (today a single anchor; after migration, one inbox doc per event — `hostInboxNuri` already reserves the `eventId` param). That is where the participant **deposits the participation link**.
- **The event's doc**: public, **owned by the owner**. It is **the owner** who writes `participantCount` there — never the participant.
- **A (reference) recorded by the owner**: an entry linking the incremented count to the deposit (idempotence + audit); it can live in the event's doc (reference to an already-materialized deposit) or in a protected doc of the owner's.
### Le flux (qui écrit quoi)
1. **Inscrit — `joinEvent`** (modifié) :
- Écrit sa propre `Participation` (protected, à lui) — **inchangé**.
- **Dépose dans l'inbox de l'événement** un payload `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` via `depositRegistration` (aujourd'hui `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` reste anonyme au transport (le SDK lie `from` à l'identité et rejette un spoof — cf. `registration.ts:106-108`) ; l'identité domaine voyage dans le payload. **Le dépôt porte le NURI du doc de participation** (`participationDoc`) pour que le propriétaire, s'il est une connexion, puisse le lire en clair.
- **SUPPRIME l'écriture de `participantCount` sur le doc de l'événement** (l.635-640 actuelles). L'inscrit n'écrit plus jamais sur le doc d'un autre.
2. **Propriétaire — matérialisation (quand connecté)** : la session du propriétaire est abonnée (`doc_subscribe`, §A.3) au doc-inbox de son événement. Sur un nouveau dépôt `new-participant` :
- dédup via `uid` (idempotence : ne pas re-compter un dépôt déjà matérialisé — vérifier la (référence) enregistrée) ;
- **incrémente `participantCount` sur SON PROPRE doc d'événement** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **c'est le propriétaire qui écrit son propre doc**, pas un privilège de lecture ni un write hors-scope ;
- enregistre la **(référence)** du dépôt matérialisé (marqueur d'idempotence).
- Cette logique remplace/prolonge l'effet de **matérialisation des notifications** existant (FestipodDataContext l.443-479, `readRegistrationNotifications`) : aujourd'hui il ne fait que surfacer des notifications ; il devient aussi le point où le compteur est incrémenté. Le déclencheur passe du polling implicite à la souscription `doc_subscribe` sur l'inbox.
3. **Autres sessions voient le compte changer** : le doc de l'événement est **public**, donc **toute** session qui l'a dans son `allReadDocs` y est abonnée (§A). L'écriture du propriétaire produit un patch → `bumpRead()` → `readUnion` re-lit → `event.participantCount` mis à jour → `EventDetailScreen` re-rend « Participants (N) » **sans reload ni action**. C'est le chemin réactif complet, cross-session.
### The flow (who writes what)
1. **Participant — `joinEvent`** (modified):
- Writes its own `Participation` (protected, theirs) — **unchanged**.
- **Deposits into the event's inbox** a `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` payload via `depositRegistration` (today `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` stays anonymous at the transport level (the SDK binds `from` to the identity and rejects a spoof — see `registration.ts:106-108`); the domain identity travels in the payload. **The deposit carries the NURI of the participation doc** (`participationDoc`) so that the owner, if they are a connection, can read it in plaintext.
- **REMOVES the `participantCount` write on the event's doc** (current lines 635-640). The participant never writes to someone else's doc again.
2. **Owner — materialization (when connected)**: the owner's session is subscribed (`doc_subscribe`, §A.3) to their event's inbox doc. On a new `new-participant` deposit:
- dedup via `uid` (idempotence: do not re-count an already materialized deposit — check the recorded (reference));
- **increments `participantCount` on THEIR OWN event doc** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **the owner writing their own doc**, not a read privilege nor an out-of-scope write;
- records the **(reference)** of the materialized deposit (idempotence marker).
- This logic replaces/extends the existing **notification materialization** effect (FestipodDataContext lines 443-479, `readRegistrationNotifications`): today it only surfaces notifications; it also becomes the point where the counter is incremented. The trigger moves from implicit polling to the `doc_subscribe` subscription on the inbox.
3. **Other sessions see the count change**: the event's doc is **public**, so **every** session that has it in its `allReadDocs` is subscribed to it (§A). The owner's write produces a patch → `bumpRead()` → `readUnion` re-reads → `event.participantCount` updated → `EventDetailScreen` re-renders « Participants (N) » **with no reload and no user action**. That is the complete reactive path, cross-session.
### Désinscription (symétrique, autoritative)
- `leaveEvent` : garde le **DELETE autoritatif** de la participation (`deleteParticipation`, vérifié `remaining === 0`) — [[caveat_participation-deletion]] intact (ne doit pas ressusciter).
- **Retire la décrémentation directe** de `participantCount` par l'inscrit (l.705-710). À la place, l'inscrit **dépose un `leave`** (`{ kind:'leave-participant', eventId, uid }`) dans l'inbox de l'événement ; le propriétaire matérialise → **décrémente son propre doc** (idempotent via `uid`, `max(0, n-1)`, et refuse de re-décrémenter un `uid` déjà traité pour ne pas « ressusciter » un compte faux).
- **Cas propriétaire hors-ligne = comportement éventuel ACCEPTÉ** : si le propriétaire n'est pas connecté, le dépôt reste dans l'inbox ; le compte n'est **pas** mis à jour tant qu'il ne se reconnecte pas et ne matérialise pas. **C'est un comportement accepté** (cohérence à terme, local-first). Les autres voient le compte se corriger quand le propriétaire revient. À énoncer tel quel dans le contrat produit.
### Withdrawal (symmetric, authoritative)
- `leaveEvent`: keeps the **authoritative DELETE** of the participation (`deleteParticipation`, verified `remaining === 0`) — [[caveat_participation-deletion]] intact (it must not come back to life).
- **Removes the direct decrement** of `participantCount` by the participant (lines 705-710). Instead, the participant **deposits a `leave`** (`{ kind:'leave-participant', eventId, uid }`) into the event's inbox; the owner materializes → **decrements their own doc** (idempotent via `uid`, `max(0, n-1)`, and refuses to re-decrement an already processed `uid` so as not to "resurrect" a wrong count).
- **Owner-offline case = eventual behaviour, ACCEPTED**: if the owner is not connected, the deposit stays in the inbox; the count is **not** updated until they reconnect and materialize. **This is accepted behaviour** (eventual consistency, local-first). Others see the count correct itself when the owner comes back. To be stated as such in the product contract.
### Identité (C)
- Un participant est montré **par son nom** uniquement si le viewer est une **connexion** du participant : le doc de participation + le profil du participant sont protected, donc lisibles en clair seulement via le cap accordé par `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Sinon le doc reste illisible → le participant n'apparaît **pas** dans `getEventParticipants` (qui joint sur les `users`/`participations` lus) → il tombe dans les **placeholders « inconnu »** de `EventDetailScreen` (l.163-170), le compte dérivé restant visible via `participantCount`.
- **Aucune lecture privilégiée de l'hôte** : le propriétaire ne lit pas les participations ; il ne fait que **compter des dépôts** et écrire son propre compteur. Il ne voit un participant nommé que s'il en est une connexion — exactement comme n'importe quel viewer. C'est conforme à `functional-domain/knowledge_data-scopes-and-discovery` (« identifié si connu, anonyme sinon ») et à `app-security/knowledge_trust-model` (pas de contrôle d'accès applicatif, l'isolation est par-document déléguée au SDK).
### Identity (C)
- A participant is shown **by name** only if the viewer is a **connection** of that participant: the participation doc + the participant's profile are protected, so they are readable in plaintext only through the cap granted by `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Otherwise the doc stays unreadable → the participant does **not** appear in `getEventParticipants` (which joins on the `users`/`participations` that were read) → they fall into the **« inconnu » placeholders** of `EventDetailScreen` (lines 163-170), the derived count remaining visible through `participantCount`.
- **No privileged read for the host**: the owner does not read participations; they only **count deposits** and write their own counter. They see a named participant only if they are a connection of theirs — exactly like any other viewer. This matches `functional-domain/knowledge_data-scopes-and-discovery` ("identified if known, anonymous otherwise") and `app-security/knowledge_trust-model` (no application-level access control; isolation is per-document and delegated to the SDK).
---
## D. Plan de test (e2e réel, sans polling)
## D. Test plan (real e2e, no polling)
### D.1 — POLYFILL bas-niveau : `doc_subscribe` réagit vraiment
But : prouver que la primitive réactive fonctionne, indépendamment de Festipod.
- Emplacement : test unité/intégration de la lib (`packages/client`) — ou un `@data` Festipod si le harness broker est requis.
- Setup : deux « vues » du **me** doc (deux souscriptions, ou une souscription + une écriture par un autre chemin). Monter `subscribeDoc(nuri, onChange)`, écrire dans le doc via `sparqlUpdate`.
- **Assertion** : `onChange` est appelé (State initial) **puis** re-appelé après l'écriture, **sans polling** (aucun `setInterval` ; l'assertion attend un event, pas un timeout). Vérifier qu'une écriture sur un **autre** doc ne déclenche PAS `onChange` (isolation par-branche). Vérifier qu'un doc non-synchronisé qui échoue **n'avorte pas** les autres souscriptions (par-doc).
### D.1 — POLYFILL low-level: `doc_subscribe` really does react
Goal: prove the reactive primitive works, independently of Festipod.
- Location: a unit/integration test of the lib (`packages/client`) — or a Festipod `@data` test if the broker harness is required.
- Setup: two "views" of the **same** doc (two subscriptions, or one subscription plus a write through another path). Mount `subscribeDoc(nuri, onChange)`, write to the doc via `sparqlUpdate`.
- **Assertion**: `onChange` is called (initial State) **and then** called again after the write, **without polling** (no `setInterval`; the assertion waits on an event, not on a timeout). Check that a write on **another** doc does NOT trigger `onChange` (per-branch isolation). Check that an unsynchronized doc which fails **does not abort** the other subscriptions (per-doc).
### D.2 — FESTIPOD app-level : 2 navigateurs réels, sans reload ni action de A
But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incrémenté **et** un « participant inconnu », **sans que A recharge ni n'agisse**.
- Étendre `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) et `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
- Nouveau scénario (esquisse Gherkin FR) :
### D.2 — FESTIPOD app-level: 2 real browsers, with no reload and no action from A
Goal: B signs up → A's `EventDetailScreen` shows `participantCount` incremented **and** an "unknown participant", **without A reloading or acting**.
- Extend `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) and `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
- New scenario (French Gherkin sketch):
```
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
Étant donné un navigateur "A" avec le wallet partagé
@@ -155,32 +155,32 @@ But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incr
Alors sans recharger, le compteur de participants affiché dans "A" pour "Apéro réactif" passe à 2
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
```
- **Assertions exactes** :
1. `participantCount` **côté A** passe de 1 à 2 — assert via `frame.waitForFunction` sur l'état réactif du contexte (`__testData.events` → l'event → `participantCount === 2`) **puis** confirmé sur le DOM rendu (le libellé « Participants (2) » de `EventDetailScreen`), **sans appel de `loadAppInBrowser`/reload** entre le join de B et l'assertion de A.
2. **Placeholder inconnu** : `knownParticipants.length < participantCount` → assert présence du bloc « Voir tous les participants » (ou un compteur d'anonymes = `participantCount knownParticipants.length ≥ 1`), le participant B n'étant PAS une connexion de A → non nommé.
3. **Négatif no-polling** : le passage 1→2 arrive via souscription (event-driven) ; le test attend l'event, il ne doit pas dépendre d'un `waitForTimeout` fixe comme *source* de la mise à jour (un timeout de garde reste toléré pour laisser la sync broker, comme dans le scénario désinscription existant l.131).
- **Helpers harness nécessaires** (dans `harness-ng.tsx`, exposés sur `window.__testData`, et répliqués dans les DEUX harness — cf. `bdd-testing/cookbook_add-scenario`) :
- un getter du `participantCount` réactif pour un event (déjà accessible via `__testData.events`).
- un accès au **rendu** `EventDetailScreen` de A **sans navigation manuelle** : soit monter l'app réelle sur la route détail (chemin @e2e), soit exposer `knownParticipants` / le compte d'anonymes. Réutiliser `createEventReal` (l.232), `appJoinEvent` (l.245), `readInboxDeposits` (l.283), `authParticipationCount` (l.302).
- un hook « le propriétaire a matérialisé » : comme A est le propriétaire ET connecté, sa souscription inbox doit incrémenter son propre doc — le test observe le résultat (count 2) sans piloter la matérialisation à la main.
- **Symétrie désinscription** : étendre le scénario existant « la désinscription ne ressuscite pas » (l.36-48) d'une assertion réactive : après le leave de B, `participantCount` côté A **repasse à 1 sans reload**, et `authParticipationCount === 0` (déjà couvert).
- **Exact assertions**:
1. `participantCount` **on A's side** goes from 1 to 2 — asserted via `frame.waitForFunction` on the context's reactive state (`__testData.events` → the event → `participantCount === 2`) **and then** confirmed on the rendered DOM (the « Participants (2) » label of `EventDetailScreen`), **with no `loadAppInBrowser`/reload call** between B's join and A's assertion.
2. **Unknown placeholder**: `knownParticipants.length < participantCount` → assert the presence of the « Voir tous les participants » block (or an anonymous count = `participantCount knownParticipants.length ≥ 1`), B not being a connection of A → not named.
3. **Negative, no-polling**: the 1→2 transition arrives through the subscription (event-driven); the test waits on the event, and must not depend on a fixed `waitForTimeout` as the *source* of the update (a guard timeout remains tolerated to let the broker sync, as in the existing withdrawal scenario, line 131).
- **Harness helpers required** (in `harness-ng.tsx`, exposed on `window.__testData`, and replicated in BOTH harnessessee `bdd-testing/cookbook_add-scenario`):
- a getter for an event's reactive `participantCount` (already reachable via `__testData.events`).
- a way to reach A's **rendered** `EventDetailScreen` **without manual navigation**: either mount the real app on the detail route (the @e2e path), or expose `knownParticipants` / the anonymous count. Reuse `createEventReal` (line 232), `appJoinEvent` (line 245), `readInboxDeposits` (line 283), `authParticipationCount` (line 302).
- a "the owner has materialized" hook: since A is the owner AND connected, their inbox subscription must increment their own doc — the test observes the outcome (count 2) without driving materialization by hand.
- **Withdrawal symmetry**: extend the existing scenario « la désinscription ne ressuscite pas » (lines 36-48) with a reactive assertion: after B's leave, `participantCount` on A's side **goes back to 1 without a reload**, and `authParticipationCount === 0` (already covered).
---
## E. Risques / questions ouvertes
## E. Risks / open questions
1. **Le hang du fan-out** (le risque n°1). Le design l'évite **par construction** : souscription **par-document** (`doc_subscribe`), jamais `orm_start_graph(graphs:[…])`. À garder comme invariant : tout nouveau doc entre via une souscription **individuelle** avec isolation d'erreur par-doc — un doc non-synchronisé ne doit jamais pouvoir avorter les autres souscriptions ni bloquer le `readUnion` (qui reste tolérant par-doc). Risque résiduel : le **volume** de souscriptions par-doc (une par doc lu) — à valider sur le broker réel ; sinon, plafonner/prioriser les docs abonnés (event courant + son inbox + mes docs) plutôt que l'union entière.
1. **The fan-out hang** (risk #1). The design avoids it **by construction**: **per-document** subscription (`doc_subscribe`), never `orm_start_graph(graphs:[…])`. To be kept as an invariant: every new doc enters through an **individual** subscription with per-doc error isolation — an unsynchronized doc must never be able to abort the other subscriptions nor block `readUnion` (which stays per-doc tolerant). Residual risk: the **volume** of per-doc subscriptions (one per doc read) — to be validated against the real broker; failing that, cap/prioritize the subscribed docs (current event + its inbox + my own docs) rather than the whole union.
2. **Compte propriétaire hors-ligne = éventuel — DÉCIDÉ (2026-07-06).** Tant que le propriétaire n'est pas connecté, aucun dépôt n'est matériali → `participantCount` reste périmé pour les autres (la participation elle-même est persistée côté broker — rien n'est perdu, seul l'agrégat attend la reconnexion de l'hôte). Accepté pour la V1. **Plus tard, un SERVICE prendra le relai** quand le propriétaire est déconnecté (le paquet différé `@ng-eventually/service` — le « curateur » évoqué dans les docs inbox de la lib) : un acteur toujours disponible matérialisera l'inbox à la place de l'hôte. Pas de fallback « N+ en attente » en V1.
2. **Owner-offline count = eventual — DECIDED (2026-07-06).** As long as the owner is not connected, no deposit is materialized → `participantCount` stays stale for everyone else (the participation itself is persisted broker-side — nothing is lost, only the aggregate waits for the host to reconnect). Accepted for V1. **Later, a SERVICE will take over** when the owner is disconnected (the deferred `@ng-eventually/service` package — the "curator" mentioned in the lib's inbox docs): an always-available actor will materialize the inbox in the host's stead. No « N+ en attente » fallback in V1.
3. **`doc_subscribe` par-doc — FAIT (lib `c0498a6`).** La lib expose désormais `subscribeDoc`/`subscribeDocs` (isolation d'erreur par-doc, pas de fan-out ORM), `inbox.watch`/`discovery.watchIndex` sont passés en `doc_subscribe` (plus de polling), et le contrat est dans `sdk-reference.md`. Validé broker réel (le callback traverse le RPC iframe et fire sur changement). Reste : brancher la souscription dans le chemin de lecture app (P3).
3. **Per-doc `doc_subscribe` — DONE (lib `c0498a6`).** The lib now exposes `subscribeDoc`/`subscribeDocs` (per-doc error isolation, no ORM fan-out), `inbox.watch`/`discovery.watchIndex` have moved to `doc_subscribe` (no more polling), and the contract is in `sdk-reference.md`. Validated against the real broker (the callback crosses the iframe RPC and fires on change). Remaining: wire the subscription into the app's read path (P3).
> **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3).
> **The SDK's reactive hooks** (clarification): NextGraph's React adapter exposes `useShape` (reactive RDF shapes) and `useDiscrete` (discrete CRDT docs) — there is no `useQuery`. The lib re-exposes `useShape`. For a UNION read over N docs (Festipod's case), `useShape`/the ORM in fan-out *hangs*; the app's reactive path therefore goes through `subscribeDocs` (per-doc) + a re-`readUnion`, possibly wrapped into a reactive read hook on the lib side (to be decided in P3).
Autres points à trancher :
Other points to settle:
> ⚠️ **RECADRÉ + CORRIGÉ (2026-07-13).** L'affirmation ci-dessous « Prouvé par l'e2e D.2 … sans reload » était **FAUSSE** (le « vert » venait d'un wallet bloaté). Mais surtout le **cadrage « réactif / sans reload / push cross-session » était un SUR-CADRAGE** : la spec réelle est **« le propriétaire traite son inbox de façon fiable à sa PROCHAINE CONNEXION »** (pas de notification live temps-réel entre deux utilisateurs connectés). Bug corrigé sous ce cadrage : le materializer lisait l'inbox **avant sa sync** (→ 0 mémoïsé). Fix = lecture inbox **gated sur barrière** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + déclenchement à la connexion + source unique `event.participantCount`. Scénario `event/e2e-multibrowser.feature` **reframé « à la prochaine connexion » et dé-`@wip`, VERT sur profil frais** (une reconnexion/re-matérialisation de A est le mécanisme accepté). Détail : [[knowledge_context-internals]] §participantCount. Le plan de phasage ci-dessous doit être relu à cette lumière (le « sans reload » n'est plus l'exigence).
> ⚠️ **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).
- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1P3 avant P4P6.
- **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte.
- **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration.
- **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.
- **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.
@@ -1,104 +1,104 @@
---
type: brief
summary: Modèle cible des inscriptions — Participation LISIBLE par tous (réf. événement + booléen `active` + did cap-less vers le profil du participant), déposée dans l'inbox de l'événement ; le créateur traite l'inbox, déduplique sur l'overlay sans savoir qui, range la référence dans un Set de l'événement et PURGE les annulées ; compteur = Set.size sans filtrage (borne haute assumée) ; seules les connexions détiennent le cap du profil et reconnaissent la personne. Supersede l'Option-B (compteur muté + userId en clair).
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + cap-less did to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups on the overlay without knowing who, files the reference into a Set on the event and PURGES the cancelled ones; count = Set.size with no filtering (accepted upper bound); only connections hold the profile cap and recognize the person. Supersedes Option-B (mutated counter + plaintext userId).
---
# Brief (2026-07-20, révisé 2026-07-27) — inscriptions par Set
# Brief (2026-07-20, revised 2026-07-27) — Set-based sign-ups
## Le modèle
## The model
Posé et affiné par le PO les 2026-07-27. Tout est **clés et URLs**pas de rôle, pas d'appartenance, pas de liste d'autorisation.
Laid down and refined by the PO on 2026-07-27. Everything is **keys and URLs**no roles, no membership, no allow-list.
1. Le participant crée un objet **Participation**, **lisible par tous**, contenant : la **référence à l'événement**, un **booléen `active`**, et un **did cap-less vers son profil** *protected*. **Rien d'autre**pas de description pour l'instant.
2. Il dépose le **did de la Participation** dans l'**inbox de l'événement**.
3. Le **créateur** traite son inbox **automatiquement**, dès qu'il est en ligne.
4. Il **déduplique** (voir plus bas) — **sans savoir qui est le participant** : il détient le did du profil, pas son cap.
5. Il range une **référence** à la Participation dans un **Set** porté par le document de l'événement.
6. N'importe qui lit **`Set.size`** → le nombre de participants.
7. Une personne **connectée** au participant détient le cap de son profil, le lit, et **reconnaît** la personne.
1. The participant creates a **Participation** object, **readable by everyone**, holding: the **reference to the event**, an **`active` boolean**, and a **cap-less did to their *protected* profile**. **Nothing else**no description for now.
2. They deposit the **Participation's did** into the **event's inbox**.
3. The **creator** processes their inbox **automatically**, as soon as they are online.
4. They **dedup** (see below) — **without knowing who the participant is**: they hold the profile's did, not its cap.
5. They file a **reference** to the Participation into a **Set** carried by the event's document.
6. Anyone reads **`Set.size`** → the number of participants.
7. Someone **connected** to the participant holds their profile's cap, reads it, and **recognizes** the person.
**Désinscription** : le participant passe `active` à faux **sur son propre objet**. Le créateur le constate en lisant, et **purge**il retire la référence du Set.
**Withdrawal**: the participant sets `active` to false **on their own object**. The creator sees it when reading, and **purges**removing the reference from the Set.
Trois propriétés en découlent : **présence anonyme par défaut** (le créateur lui-même ne voit pas qui) ; **personne ne modifie l'inscription d'autrui** (seul le participant détient la clé d'écriture de son objet) ; **aucun `userId` en clair** ne circule.
Three properties follow: **anonymous attendance by default** (even the creator cannot see who); **nobody modifies anyone else's sign-up** (only the participant holds the write key to their own object); **no plaintext `userId`** ever travels.
### Le principe qui tient tout : la vérité est dans l'objet, les messages sont des indices
### The principle that holds it all together: the truth is in the object, messages are only hints
C'est l'objet **contrôlé par le participant** qui fait foi. Tout message — dépôt d'inbox, notification de purge — n'est qu'un **indice** qui déclenche une vérification, jamais une autorité.
The object **controlled by the participant** is what counts. Any message — an inbox deposit, a purge notification — is only a **hint** that triggers a check, never an authority.
Conséquence : la **forgerie devient structurellement inoffensive**. Un faux « purge X » conduit le créateur à lire X, constater qu'elle est encore active, et ne rien faire. C'est pourquoi les dépôts d'inbox **n'ont pas besoin d'être signés**ce qui tombe bien, puisque NextGraph ne l'offre pas (voir tableau).
Consequence: **forgery becomes structurally harmless**. A fake « purge X » leads the creator to read X, find it still active, and do nothing. That is why inbox deposits **need not be signed**which is just as well, since NextGraph does not offer that (see table).
### Pourquoi un booléen plutôt qu'une suppression
### Why a flag rather than a deletion
Une **suppression** n'est **pas détectable** sans la clé de lecture (VÉRIFIÉ : append-only, tombstone chiffré). Un objet **lisible** avec un **drapeau** transforme le problème : l'annulation n'est plus à *détecter*, elle est à *lire*. Le blocage disparaît au lieu d'être contourné par un message forgeable.
A **deletion** is **not detectable** without the read key (VERIFIED: append-only, encrypted tombstone). A **readable** object carrying a **flag** transforms the problem: the cancellation no longer has to be *detected*, it is simply *read*. The blocker disappears instead of being worked around with a forgeable message.
### Pourquoi le pointeur d'identité vise le profil existant
### Why the identity pointer targets the existing profile
Pas besoin d'un second document par participation : le **profil protected** du participant joue ce rôle, et ses connexions en détiennent **déjà** le cap — c'est la définition d'« être connecté ». Un tiers voit un did opaque.
No need for a second document per participation: the participant's **protected profile** already plays that role, and their connections **already** hold its cap — that is the very definition of being connected. A third party sees an opaque did.
L'avantage sur un champ chiffré dans la Participation : **ajouter une connexion ne réécrit rien**. On lui scelle le cap du profil, une fois, durablement. Un champ chiffré exigerait de re-sceller à N destinataires et de réécrire la Participation à chaque nouvelle connexion. *(Accessoirement, un champ chiffré n'est pas un primitif NextGraph : la granularité de chiffrement est le document, en tout-ou-rien.)*
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile's cap is sealed to them once, durably. An encrypted field would require re-sealing to N recipients and rewriting the Participation on every new connection. *(Incidentally, an encrypted field is not a NextGraph primitive: the encryption granularity is the document, all-or-nothing.)*
## Sur quoi ça repose — faits établis dans NextGraph
## What this rests on — facts established in NextGraph
Vérifiés par lecture de `nextgraph-rs`. Détail et pointeurs côté polyfill (`docs/readcap-and-nuri-model.md`) — cf. [[rule_capture-nextgraph-findings]].
Verified by reading `nextgraph-rs`. Details and pointers live on the polyfill side (`docs/readcap-and-nuri-model.md`) — see [[rule_capture-nextgraph-findings]].
| Fait | Statut | Rôle ici |
| Fact | Status | Role here |
|---|---|---|
| L'**overlay** (`:v:`) est **store-scopé**, jamais document-scopé | VÉRIFIÉ | **La clé de dédup** |
| Un NURI cap-less **nomme sans donner à lire** | VÉRIFIÉ | Le did du profil pointe sans divulguer |
| Un cap se **scelle durablement** à un destinataire (pas d'ACL re-déclarée) | VÉRIFIÉ | Le cap du profil, scellé une fois aux connexions |
| Sans la clé, les blocs restent du **ciphertext** | VÉRIFIÉ | Le créateur ne peut vraiment pas lire le profil |
| Une **suppression** n'est **PAS** détectable sans la clé | VÉRIFIÉ | **Pourquoi c'est un drapeau, pas une suppression** |
| Un dépôt d'inbox n'est **PAS authentifié** (sealed box anonyme) | VÉRIFIÉ | **Pourquoi les messages doivent rester des indices** |
| La vérification de signature d'auteur **n'est pas implémentée** au runtime, et exigerait de déchiffrer | VÉRIFIÉ | Écarte l'alternative « dépôt d'inbox signé » |
| The **overlay** (`:v:`) is **store-scoped**, never document-scoped | VERIFIED | **The dedup key** |
| A cap-less NURI **names without granting read access** | VERIFIED | The profile's did points without disclosing |
| A cap is **sealed durably** to a recipient (no ACL re-declared) | VERIFIED | The profile's cap, sealed once to the connections |
| Without the key, blocks remain **ciphertext** | VERIFIED | The creator genuinely cannot read the profile |
| A **deletion** is **NOT** detectable without the key | VERIFIED | **Why this is a flag, not a deletion** |
| An inbox deposit is **NOT authenticated** (anonymous sealed box) | VERIFIED | **Why messages must stay hints** |
| Author signature verification **is not implemented** at runtime, and would require decrypting | VERIFIED | Rules out the « signed inbox deposit » alternative |
## La dédup : sur quoi exactement
## The dedup: on exactly what
**Validé par le PO (2026-07-27).**
**Validated by the PO (2026-07-27).**
Le segment `:v:` d'un NURI ne vient **pas du document** mais de **son store**. Or une personne a un seul store par scope. Donc **toutes ses Participations portent le même `:v:`**, quel que soit le nombre d'objets qu'elle crée. Le créateur déduplique là-dessus : deux références de même `:v:` dans le Set d'un même événement = la même personne. **Sans jamais savoir qui.**
A NURI's `:v:` segment comes **not from the document** but from **its store**. And a person has a single store per scope. So **all their Participations carry the same `:v:`**, however many objects they create. That is what the creator dedups on: two references with the same `:v:` in the Set of a single event = the same person. **Without ever knowing who.**
C'est le critère **robuste** — plus que le did du profil, qu'un participant pourrait multiplier en créant plusieurs documents de profil dans son store.
This is the **robust** criterion — more so than the profile's did, which a participant could multiply by creating several profile documents in their store.
Conséquence de conception : le Set est **indexé par `:v:`** — au plus une référence par `:v:`. `Set.size` = nombre de `:v:` distincts = nombre de personnes distinctes.
Design consequence: the Set is **keyed by `:v:`** — at most one reference per `:v:`. `Set.size` = the number of distinct `:v:` = the number of distinct people.
### La contrepartie — réserve durable, à ne pas perdre
### The trade-off — a standing reservation, not to be lost
> **Elle vit dans `app-security/`[[caveat_stable-overlay-pseudonym]]**, pas ici. Ce brief a vocation à être dissous à sa graduation ; la réserve doit lui survivre.
> **It lives in `app-security/`[[caveat_stable-overlay-pseudonym]]**, not here. This brief is meant to be dissolved when it graduates; the reservation must outlive it.
En bref : ce `:v:` est un **pseudonyme stable et permanent** de la personne, présent dans toute référence cap-less vers ses documents. Il ne dit pas *qui*, mais un **seul** recoupement dé-anonymise **rétroactivement** tout son historique — et **aucune porte de sortie n'existe** (aucune rotation possible, VÉRIFIÉ). C'est **le même bit d'information** qui permet de dédupliquer sans lire et de tracer d'un événement à l'autre : les deux ne se séparent pas. Rendre la Participation publique **augmente la surface de collecte** de ce pseudonyme.
In short: this `:v:` is a **stable, permanent pseudonym** for the person, present in every cap-less reference to their documents. It does not say *who*, but a **single** cross-reference de-anonymizes their whole history **retroactively** — and **no way out exists** (no rotation is possible, VERIFIED). It is **the same bit of information** that makes it possible to dedup without reading and to trace from one event to the next: the two cannot be separated. Making the Participation public **increases the collection surface** for this pseudonym.
## Arbitrages assumés (PO, 2026-07-27)
## Trade-offs deliberately accepted (PO, 2026-07-27)
- **Pas de filtrage à la lecture.** Le compteur est `Set.size`, **sans** vérifier les `active`. On accepte le **risque d'obsolescence** : une participation annulée compte encore tant que le créateur n'a pas purgé. `Set.size` est donc une **borne haute**, exacte après purge. *Motif : garder la lecture simple et en O(1).*
- **La purge incombe au créateur.** Pas de service curateur, pas de rattrapage par les lecteurs.
- **Pas de description** dans la Participation pour l'instant. *(À rouvrir quand le besoin viendra : ce qu'on y mettrait deviendrait public.)*
- **Créateur hors-ligne** : le Set ne bouge pas tant qu'il n'a pas traité son inbox. Accepté.
- **No filtering on read.** The count is `Set.size`, **without** checking the `active` flags. We accept the **staleness risk**: a cancelled participation still counts until the creator has purged. `Set.size` is therefore an **upper bound**, exact once purged. *Rationale: keep reads simple and O(1).*
- **Purging is the creator's job.** No curator service, no catch-up by readers.
- **No description** in the Participation for now. *(To be reopened when the need arises: whatever we put there would become public.)*
- **Creator offline**: the Set does not move until they have processed their inbox. Accepted.
## Ce qui change vs l'implémentation actuelle (Option-B)
## What changes vs the current implementation (Option-B)
L'existant ([[brief_2026-07-06_reactive-reads-and-attendance]]) dérive un `participantCount` **muté en place** depuis des marqueurs d'inbox portant le **`userId` en clair**.
What exists today ([[brief_2026-07-06_reactive-reads-and-attendance]]) derives a `participantCount` **mutated in place** from inbox markers carrying the **plaintext `userId`**.
- **Retirer le `userId`** des dépôts d'inbox → ne reste que le **did de la Participation**.
- **Compter des références distinctes** (par `:v:`), plus des `userId`.
- **`event.participantCount` muté disparaît** au profit de `Set.size`.
- La **résolution d'identité** passe par la **lecture du profil** (donc par son cap), plus par le marqueur.
- **La désinscription cesse d'être une suppression** → un `active` à faux + purge par le créateur. Cf. [[caveat_participation-deletion]], dont l'exigence (« autoritative, ne doit pas réapparaître ») reste valable mais change de mécanisme.
- **Drop the `userId`** from inbox deposits → only the **Participation's did** remains.
- **Count distinct references** (by `:v:`), no longer `userId`s.
- **The mutated `event.participantCount` goes away**, replaced by `Set.size`.
- **Identity resolution** now goes through **reading the profile** (hence through its cap), no longer through the marker.
- **Withdrawal stops being a deletion** → `active` set to false + a purge by the creator. See [[caveat_participation-deletion]], whose requirement (« authoritative, must not come back ») still holds but changes mechanism.
Reste valable tel quel : la **lecture réactive**, le **ré-armement à la reconnexion**, le **fix d'espaces d'id** déjà livré.
Still valid as-is: **reactive reads**, **re-arming on reconnection**, and the **id-space fix** already shipped.
## Points ouverts
## Open points
- **Scope de Participation** — elle devient **publique** alors que la doctrine produit actuelle la place en *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). Ce leaf décrit **ce qui est implémenté** : ne pas le modifier tant que ce brief n'a pas gradué, mais **le mettre à jour à ce moment-là**.
- **Reconnaissance par les connexions** (étape 7) — comment le cap du profil est scellé, et ce qu'il advient d'une connexion rompue (la révocation est un re-key grossier et nontroactif). Explicitement remis à un 2e temps.
- **Lecture publique non récursive** — c'est le principe qui fait tenir tout le modèle, et il mérite d'être énoncé seul : *un élément du store **public** est public — qui a l'URL lit le contenu.* Mais **pas récursivement** : un contenu public peut **référencer** du contenu privé, et **c'est exactement notre cas**. Le créateur lit donc la Participation (publique) et **ne peut pas** suivre la référence vers le profil (protected). C'est ce qui donne à la fois la lecture par le créateur et l'anonymat vis-à-vis de lui — sans mécanisme supplémentaire.
- **Participation scope** — it becomes **public**, whereas current product doctrine places it in *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). That leaf describes **what is implemented**: do not change it until this brief has graduated, but **do update it at that point**.
- **Recognition by connections** (step 7) — how the profile's cap gets sealed, and what happens to a broken connection (revocation is a coarse, non-retroactive re-key). Explicitly deferred to a second stage.
- **Public reads are not recursive** — this is the principle the whole model rests on, and it deserves to be stated on its own: *an item in the **public** store is public — whoever has the URL reads the content.* But **not recursively**: public content may **reference** private content, and **that is exactly our case**. So the creator reads the Participation (public) and **cannot** follow the reference to the profile (protected). That is what yields both readability by the creator and anonymity towards them — with no additional mechanism.
## Dépendances
## Dependencies
- **Bloquant** : l'**émulation caps du polyfill**. `caps.ts` modélise aujourd'hui une **ACL** (set de principals par document) là où le réel est **possession de clé**, et le contenu reste lisible en clair (`sparqlQuery`, `inbox.read` contournent le filtre). Tant que ce n'est pas corrigé, coder l'anonymat côté Festipod produirait du code qui **prétend** isoler sans isoler. Brief polyfill `2026-07-20-caps-emulation-alignment`, lot P1.
- **Parké** : la **terminologie identité** (wallet / user / profil) — cf. `.project/to-discuss.md`.
- **Blocking**: the **polyfill's caps emulation**. Today `caps.ts` models an **ACL** (a set of principals per document) where the reality is **key possession**, and the content stays readable in plaintext (`sparqlQuery` and `inbox.read` bypass the filter). Until that is fixed, coding anonymity on the Festipod side would produce code that **claims** to isolate without isolating. Polyfill brief `2026-07-20-caps-emulation-alignment`, batch P1.
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
## Statut : modèle tranché, mise en œuvre gatée
## Status: model settled, implementation gated
Le modèle est **arrêté** (PO, 2026-07-27) et ses fondations sont **vérifiées**. Ce qui reste gaté, c'est la **mise en œuvre** : elle attend le lot P1 du polyfill. **Ne pas retirer l'Option-B** d'ici là.
The model is **settled** (PO, 2026-07-27) and its foundations are **verified**. What remains gated is the **implementation**: it is waiting on the polyfill's P1 batch. **Do not remove Option-B** in the meantime.
Liens : [[brief_2026-07-06_reactive-reads-and-attendance]] (superseded), [[caveat_participation-deletion]], [[rule_capture-nextgraph-findings]], [[rule_document-per-entity]], app-security ([[caveat_stable-overlay-pseudonym]], [[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]), polyfill `readcap-and-nuri-model.md` + `docs/vision.md`.
Links: [[brief_2026-07-06_reactive-reads-and-attendance]] (superseded), [[caveat_participation-deletion]], [[rule_capture-nextgraph-findings]], [[rule_document-per-entity]], app-security ([[caveat_stable-overlay-pseudonym]], [[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]), polyfill `readcap-and-nuri-model.md` + `docs/vision.md`.
@@ -1,17 +1,17 @@
---
type: caveat
summary: Le type FpEventData et le seed portent startDate/endDate/startTime/endTime/themes, mais le SHEX Event ne les définit pas — ces champs sont silencieusement perdus en mode connected (NextGraph)
summary: The FpEventData type and the seed carry startDate/endDate/startTime/endTime/themes, but the Event SHEX does not define them — these fields are silently lost in connected mode (NextGraph)
last_checked: 2026-06-15
---
# Caveat : champs d'événement non persistés en mode connected
# Caveat: event fields not persisted in connected mode
Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`) portent des champs **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — mais la **shape SHEX `Event`** (`src/shared/shapes/shex/festipodShapes.shex`) ne les définit **pas**. La shape ne couvre que : `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (à vérifier dans le `.shex`).
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape only covers: `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (to be checked in the `.shex`).
## Conséquence
## Consequence
En **mode connected** (SDK), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
In **connected mode** (SDK), the mapping (`mapEvent` in `FestipodDataContext.tsx`) only reads/writes the shape's fields. Fields outside the shape are **silently lost**: filled with defaults, or empty. Yet screens **do display them** (e.g. `startTime`/`endTime` in `EventDetailScreen`) — so in demo mode (the local seed) they show up, but when connected they vanish. The discrepancy is only observable in actual use.
## Pour corriger (si on veut les persister)
## To fix it (if we want them persisted)
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
Add the fields to `festipodShapes.shex`, then `bun run build:orm`, and extend `mapEvent`. Until that is done, **do not rely on the date/time/theme fields in connected mode**.
@@ -1,15 +1,15 @@
---
type: caveat
summary: La désinscription à un point de rencontre doit être AUTORITATIVE — une fois la Participation supprimée, elle ne doit plus réapparaître ; vérifier après un vrai rafraîchissement que l'inscription a bien disparu côté données
summary: Withdrawing from a meeting point must be AUTHORITATIVE — once the Participation is deleted, it must not come back; after a real refresh, check that the sign-up is really gone on the data side
last_checked: 2026-07-03
---
# Caveat : la désinscription doit être autoritative
# Caveat: withdrawal must be authoritative
Contrat métier : quand un utilisateur **se désinscrit** d'un point de rencontre (`leaveEvent` dans `src/shared/context/FestipodDataContext.tsx`), la `Participation` doit être **supprimée durablement**. Elle ne doit **pas ressusciter** après une resynchronisation.
Business contract: when a user **withdraws** from a meeting point (`leaveEvent` in `src/shared/context/FestipodDataContext.tsx`), the `Participation` must be **durably deleted**. It must **not come back to life** after a resynchronization.
## Le piège
## The pitfall
Refléter la suppression uniquement dans l'état réactif de l'UI ne suffit pas : l'inscription peut réapparaître si la suppression n'est pas **persistée** côté données. La désinscription doit donc être **autoritative** au niveau du document, pas seulement au niveau de l'affichage.
Reflecting the deletion only in the UI's reactive state is not enough: the sign-up can reappear if the deletion is not **persisted** on the data side. Withdrawal must therefore be **authoritative at the document level**, not merely at the display level.
**À vérifier après toute évolution de `leaveEvent`** : s'inscrire puis se désinscrire, faire un **vrai rafraîchissement**, et confirmer que la participation a bien disparu (le bouton ne doit pas rester « ✓ Je participe »). Couvert par le scénario `@e2e` « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) et un `@data` « désinscription persistante » (`inscription-inbox.feature`).
**To check after any change to `leaveEvent`**: sign up, then withdraw, do a **real refresh**, and confirm that the participation is indeed gone (the button must not stay on « ✓ Je participe »). Covered by the `@e2e` scenario « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) and a `@data` one, « désinscription persistante » (`inscription-inbox.feature`).
@@ -1,17 +1,17 @@
---
type: caveat
summary: Une entité écrite juste avant une inactivité/chute de socket peut être perdue silencieusement (jamais durable côté broker) ; le compte survit (pas de fork). Observé Firefox. Le SDK ne confirme pas la durabilité et ne se reconnecte pas seul.
summary: An entity written just before an idle period / socket drop can be silently lost (never made durable broker-side); the account survives (no fork). Observed on Firefox. The SDK neither confirms durability nor reconnects on its own.
last_checked: 2026-07-14
---
# Piège : une écriture juste avant une déconnexion n'est pas garantie durable
# Pitfall: a write made just before a disconnect is not guaranteed durable
**Symptôme produit.** L'utilisateur crée une entité (un événement), ça semble réussir, puis une **période d'inactivité** survient ; au rechargement / à la reconnexion, l'entité a **disparu**. Le scope se relit **vide**. L'**identité/compte survit** — ce n'est PAS un fork, c'est une écriture non durable.
**Product symptom.** The user creates an entity (an event), it appears to succeed, then a **period of inactivity** follows; on reload / reconnection, the entity has **disappeared**. The scope reads back **empty**. The **identity/account survives**this is NOT a fork, it is a write that was never made durable.
**Mécanisme (sumé, non tranché).** Le socket broker peut mourir spontanément pendant l'idle (`SOCKET IS CLOSED … SerializationError`). L'écriture était dans l'outbox local ; au retour, le replay échoue (`Err(TopicNotFound)`) et l'entité est abandone. **Observé Firefox uniquement** à ce jour. Un test @data à froid (2026-07-14) a par ailleurs montré qu'une session **fraîche** (aucun état local, même compte A) ne récupère **pas** le scope propre de A depuis le broker : le test de reconnexion @data qui « passait » relisait en fait l'IndexedDB **locale**. Reste à trancher : **perte à l'écriture** vs **échec de réhydratation à froid** (mécanismes distincts) — voir le post-mortem dans le polyfill.
**Mechanism (summary, not settled).** The broker socket can die spontaneously while idle (`SOCKET IS CLOSED … SerializationError`). The write was in the local outbox; on return, the replay fails (`Err(TopicNotFound)`) and the entity is abandoned. **Observed on Firefox only** so far. A cold @data test (2026-07-14) also showed that a **fresh** session (no local state, same account A) does **not** recover A's own scope from the broker: the @data reconnection test that "passed" was in fact re-reading the **local** IndexedDB. Still to be settled: **loss at write time** vs **failure to rehydrate from cold** (two distinct mechanisms) — see the post-mortem in the polyfill.
**Pourquoi l'app ne le voit pas.** `NgStatus` est dérivé **une seule fois** de la session initiale → aveugle aux chutes en cours de session. Le canal `disconnections_subscribe` du SDK se déclenche sur la panne mais **n'est pas consommé** (ni polyfill ni app). Aucune API ne confirme qu'une écriture a atteint le broker.
**Why the app does not see it.** `NgStatus` is derived from the initial session **exactly once** → blind to drops that happen mid-session. The SDK's `disconnections_subscribe` channel does fire on the failure but **is not consumed** (neither by the polyfill nor by the app). No API confirms that a write reached the broker.
**Ne pas documenter ici les internes NextGraph.** Frontière SDK (voir [[knowledge_nextgraph-stack]]) : cause racine, chaîne causale (socket, reconnexion en TODO) et pistes de correction vivent dans le repo `@ng-eventually/client``docs/incidents/2026-07-14-write-loss-on-disconnect.md`. Cette fiche ne garde que l'**impact consommateur** + le pointeur.
**Do not document NextGraph internals here.** SDK boundary (see [[knowledge_nextgraph-stack]]): the root cause, the causal chain (socket, reconnection still TODO) and the fix leads live in the `@ng-eventually/client` repo`docs/incidents/2026-07-14-write-loss-on-disconnect.md`. This note keeps only the **consumer-side impact** + the pointer.
**Statut : ouvert, non traité (2026-07-14).** À revisiter quand le core/SDK adresse la reconnexion ou expose une confirmation de durabilité — ce caveat tombera alors. Voir aussi le débat lecture-à-froid vs perte réelle dans [[brief_2026-07-06_reactive-reads-and-attendance]] (le `BARRIER timed-out` de @data est une signature distincte, non confirmée comme ce bug).
**Status: open, not addressed (2026-07-14).** To revisit when the core/SDK addresses reconnection or exposes a durability confirmation — this caveat will then fall away. See also the cold-read vs real-loss debate in [[brief_2026-07-06_reactive-reads-and-attendance]] (@data's `BARRIER timed-out` is a distinct signature, not confirmed to be this bug).
@@ -1,114 +1,98 @@
---
type: knowledge
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, DEUX espaces d'id joints par l'identifiant normalisé (resolveParticipantUser / USER_PRINCIPAL_PREFIX), auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), reset de session au changement d'identité (overlay + caps), instrumentation useShapeQuery (spinner+timing) + logs identité-first, mutations no-op en mode local malgré le toast
summary: Internal pitfalls of FestipodDataContext — currentUserId = a stable principal derived from the identifier, TWO id spaces joined through the normalized identifier (resolveParticipantUser / USER_PRINCIPAL_PREFIX), OPT-IN auto-seed (FESTIPOD_AUTO_SEED, OFF by default), Option-B derived participantCount (reliable at the owner's connection through a barrier-gated inbox read; single source = event.participantCount), session reset on identity change (overlay + caps), useShapeQuery instrumentation (spinner + timing) + identity-first logs, mutations that are no-ops in local mode despite the toast
last_checked: 2026-07-27
---
# Internals & pièges de `FestipodDataContext`
# Internals & pitfalls of `FestipodDataContext`
Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à connaître avant de toucher au contexte de données.
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
## Résolution du `currentUser` (mode NG)
## Resolving `currentUser` (NG mode)
En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas** `CURRENT_USER_ID` ('user-1', mode local) ni l'IRI du profil lu. Quand un identifiant est connecté, c'est un id **stable dérivé de l'identifiant** : `urn:festipod:user:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]`un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu.
In connected mode, the currentUser's **principal** (`currentUserId`) is **not** `CURRENT_USER_ID` ('user-1', local mode), nor the IRI of the profile that was read. When an identifier is logged in, it is an id **derived from that identifier and stable**: `urn:festipod:user:<normalized-identifier>`, available immediately (without depending on reading the protected profile) and invariant for the session — the same key used by `setCurrentUser`, the owner cap and the shim account (see [[rule_document-per-entity]], identity corollary). Remaining pitfalls:
- The `currentUser` object (the displayed profile), by contrast, is resolved by `users.find(u => normalizeIdentifier(u.username) === identifiant)` with a **fallback** to `@mariedupont` then `users[0]`a silent fallback if the identifier matches no profile (the identifier is a space id, not necessarily the `username` of a seeded profile).
- With no identifier logged in (dev/demo), `currentUserId` falls back to the IRI of the profile that was read (or `''` if the wallet is empty → a `Participation` with `user: ''`, which is invalid): only create a participation once the principal is resolved.
## DEUX espaces d'id se rencontrent — joindre une participation à son profil
## TWO id spaces meet — joining a participation to its profile
**Invariant.** Une `Participation` stocke son user comme **principal** (`urn:festipod:user:<identifiant-normalisé>`, = `currentUserId`), alors qu'un `UserProfile` a pour `id` le **NURI de son document** (`did:ng:…`). En mode connecté, **ces deux valeurs ne sont jamais égales**. Une jointure brute `participation.userId === profile.id` ne matche donc **jamais** — symptôme livré puis corrigé (2026-07-27) : chaque participant s'affichait « participant inconnu ». Toute jointure participation→profil passe par **`resolveParticipantUser`** (`FestipodDataContext`), jamais par une comparaison directe.
**Invariant.** A `Participation` stores its user as a **principal** (`urn:festipod:user:<normalized-identifier>`, = `currentUserId`), whereas a `UserProfile` has as its `id` the **NURI of its document** (`did:ng:…`). In connected mode, **these two values are never equal**. So a raw `participation.userId === profile.id` join **never** matches — a symptom that shipped and was then fixed (2026-07-27): every participant displayed as « participant inconnu ». Every participation→profile join goes through **`resolveParticipantUser`** (`FestipodDataContext`), never through a direct comparison.
Le **pont** entre les deux espaces est l'**identifiant normalisé** : `principal préfixe` == `normalizeIdentifier(profile.username)` (la même égalité que la résolution de `currentUser`). D'où l'ordre d'essai de `resolveParticipantUser` : (1) **match direct** `u.id === userId`l'espace du seed demo, où les deux côtés valent le même id nu (`user-1`) et où le username seedé `@mariedupont` ne normaliserait *pas* vers cet id, donc le direct doit passer en premier ; (2) à défaut, **match sur l'identifiant normalisé** après retrait du préfixe.
The **bridge** between the two spaces is the **normalized identifier**: `principal prefix` == `normalizeIdentifier(profile.username)` (the same equality that resolves `currentUser`). Hence the order in which `resolveParticipantUser` tries: (1) a **direct match** `u.id === userId`the demo seed's space, where both sides hold the same bare id (`user-1`) and where the seeded username `@mariedupont` would *not* normalize to that id, so the direct match must come first; (2) failing that, a **match on the normalized identifier** after stripping the prefix.
**`USER_PRINCIPAL_PREFIX` est la source unique du préfixe**, partagée par l'**écriture** (dérivation de `currentUserId`) et la **lecture** (`resolveParticipantUser`). Si tu changes la forme du principal, change-la **** : sinon écriture et lecture divergent en silence et la jointure retombe sur « inconnu » sans lever d'erreur.
**`USER_PRINCIPAL_PREFIX` is the single source of the prefix**, shared by the **write** side (deriving `currentUserId`) and the **read** side (`resolveParticipantUser`). If you change the shape of the principal, change it **there**: otherwise write and read drift apart silently and the join falls back to « inconnu » without raising an error.
Un **troisième** espace d'id existe et ne participe **pas** à cette jointure : l'`uid` de dépôt d'inbox (`mint…`) — il identifie un **dépôt** pour le compteur, jamais un utilisateur.
A **third** id space exists and takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
> **Horizon.** Ce paragraphe décrit l'**implémenté** (Option-B). Le modèle cible retire le `userId` en clair et fait passer la résolution d'identité par la **lecture du profil** — cf. [[brief_2026-07-20_attendance-set-model]], dont la mise en œuvre est gatée. Le fix des espaces d'id y est explicitement noté comme **restant valable** : ne pas le défaire en anticipant la cible.
> **Horizon.** This paragraph describes **what is implemented** (Option-B). The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
### Quel espace attend chaque query (contrat de `buildQueries`)
### Which space each query expects (the `buildQueries` contract)
| Query | Ce qu'elle attend / rend |
| Query | What it expects / returns |
|---|---|
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | **attendent le principal** (elles filtrent sur `participation.userId` / `friendship.userId`) — leur défaut est `currentUserId`, correct |
| `getEventParticipants(eventId)` | **rend des profils** (`FpUserData``id` = NURI), la jointure étant faite en interne |
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | **expect the principal** (they filter on `participation.userId` / `friendship.userId`) — their default is `currentUserId`, which is correct |
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData``id` = NURI), the join being done internally |
**Impact côté écran** : se filtrer soi-même hors d'une liste de participants se compare à **`currentUser?.id`** (NURI de profil, même espace que les éléments rendus), **pas** à `currentUserId` (principal) — sinon on ne se retire pas et on se voit soi-même apparaître comme un participant de plus. Inversement, passer un **id de profil** à `getUserEvents`/`isParticipating` rend une liste **vide** en mode connecté. Voir `app-architecture`, [[caveat_identity-ids-in-screens]].
**Screen-side impact**: filtering yourself out of a participant list compares against **`currentUser?.id`** (the profile NURI, the same space as the rendered items), **not** against `currentUserId` (the principal) — otherwise you do not remove yourself and you see yourself appear as one more participant. Conversely, passing a **profile id** to `getUserEvents`/`isParticipating` returns an **empty** list in connected mode. See `app-architecture`, [[caveat_identity-ids-in-screens]].
## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
**Depuis 2026-07-10** : `useNgData` lit via `useShapeQuery(shape, scope)` (binding
`useSyncExternalStore` sur `watchShape` du polyfill) — TROIS lectures useQuery-shaped
(events/public, users/protected, participations/protected) + adaptateurs Fp
(`shapeAdapters.ts`). Supprimés : `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`,
le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture),
`relist`. `ready` = combinaison des `isSuccess`. Cf. [[rule_app-uses-sdk-surface-only]].
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over the polyfill's `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
**Visibilité immédiate des mutations = overlay OPTIMISTE** (pas de `registerDoc`) :
`createEvent`/`joinEvent`/`leaveEvent` alimentent `pendingAddEvents`/
`pendingAddParticipations`/`pendingRemoveIds` ; l'état exposé = merge(réactif, adds)
moins removes, dédupé par id (id = NURI du doc). Réconciliation auto au push
(un add qui apparaît dans le réactif / un remove qui en disparaît est retiré) —
jamais de poll ([[rule_no-broker-polling]]). Vidé au changement d'identité.
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]). Cleared on identity change.
## Auto-seed de dev
## Dev auto-seed
**Depuis 2026-07-13, l'auto-seed est OPT-IN et OFF par défaut** : il ne se déclenche que si la variable d'env `FESTIPOD_AUTO_SEED` est définie (`=1`), plus sur `NODE_ENV`. Variable absente**aucun seed automatique**, même en dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts` ; livrée en dev via la route runtime `/festipod-config.json` + `define` compile-time dans `build.ts`, me mécanisme que le shared-wallet — cf. `tech-stack/knowledge_build-pipeline`). Le seed **explicite** (`loadTestData()`, tests @data) est inchangé. Motivation : le seed auto répété bloatait le wallet (lenteurs de lecture, cf. [[caveat_wallet-bloat-hang]]).
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
Quand il est activé, l'auto-seed se déclenche si events ET users sont vides**gardé sur `isSuccess`** (la readiness de `watchShape`),
PLUS sur un `setTimeout` de 3s : on ne décide « wallet vide » qu'une fois la sync
**confirmée** (`isSuccess`), sinon la lecture pas-encore-finie était prise pour un
wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants :
- **Un seul seed à la fois** : `loadTestData()` pose `hasTriedAutoSeed`, l'auto-seed le
re-teste → un chargement explicite supprime l'auto-seed en attente (sinon deux
`bootstrapWallet` concurrents écrivent en double).
- Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`) : les
entités protégées seedées passent le cap de lecture par-document du propriétaire.
- **Pas de retry** : si le seed échoue, écran vide + `console.error`.
When it is enabled, the auto-seed fires if events AND users are both empty**gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
- The seed is **owned by the current identity** (`bootstrapWallet(…, owner)`): the seeded protected entities go through the owner's per-document read cap.
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
## `participantCount` — dérivé et possédé par le propriétaire (Option B)
## `participantCount` — derived and owned by the owner (Option B)
> ✅ **CORRIGÉ (2026-07-13).** L'exigence est **« fiable à la PROCHAINE CONNEXION du propriétaire »** (le créateur traite son inbox à sa connexion), PAS une notification live cross-utilisateur temps-réel. Le bug était : le owner-materializer matérialisait **trop tôt** (avant que le dépôt de l'inscrit soit synchronisé) → lisait `active=0` → écrivait 0 → **mémoïsait ce 0** → ne retraitait plus. Fix : (1) **lecture inbox gated sur barrière** — `inbox.readSynced` (= `ensureRepoOpen(doc)` attend le premier `State`, PUIS `read`, comme `discovery.readIndex`) au lieu de `inbox.read`, donc un dépôt déjà synchronisé EST vu à la connexion ; (2) le materializer se déclenche **directement à la connexion** (`[ready, ownedKey]`), plus seulement sur un push ; (3) `materializedCountRef` ne verrouille plus un 0 prématuré (son seul rôle = anti-boucle : n'écrire que si la valeur dérivée change) ; (4) **source unique du NOMBRE = `event.participantCount`** (le littéral `participantCount: 1` de `CreateEventScreen` est retiré → démarre à 0 ; l'affichage ne calcule plus de nombre local). Gardé VERT (profil frais) par `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (-`@wip`). Pas de polling ([[rule_no-broker-polling]]).
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) a **barrier-gated inbox read** — `inbox.readSynced` (= `ensureRepoOpen(doc)` waits for the first `State`, THEN `read`, like `discovery.readIndex`) instead of `inbox.read`, so an already-synced deposit IS seen on connection; (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
**Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire :
- `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`).
- La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur.
- **Le compteur est DÉRIVÉ, pas incrémenté** : `materializeAttendance` (registration.ts) lit l'inbox et calcule l'**ensemble** des inscriptions actives distinctes (dépôts `new-participant` dédupés par `uid`, MOINS ceux annulés par un `leave-participant`par `regUid` exact ou fallback `(eventId, userId)`). `participantCount = |ensemble actif|`**pas de base « hôte »** : le créateur ne participe pas automatiquement (pas de notion d'hôte, cf. concept `functional-domain`), donc le compteur démarre à **0** à la création et n'avance que sur des inscriptions réelles. `createEvent` **n'écrit plus** de participation à la création (elle écrivait une participation hôte + posait le compteur à 1) ; le créateur voit « J'y serai » et peut rejoindre/quitter son propre événement comme tout le monde. Comme c'est une **fonction pure de l'inbox**, un rejeu de sync broker converge — jamais de double-comptage ni de décrément fantôme (idempotence). L'écriture est gardée (n'écrit que si la valeur change), anti-boucle. Couvert par le scénario `@data` « Le créateur ne participe pas automatiquement à son événement » (us-13) : compteur 0 + `isParticipating(E)===false` à la création, puis join→true / leave→false.
- **Propriétaire hors-ligne = éventuel** : seule la session du propriétaire matérialise ; déconnecté, le compteur n'avance pas pour les autres (les participations/dépôts restent persistésrien n'est perdu ; un futur service matérialisera à sa place).
- Le compteur reste néanmoins un **agrégat**, pas la liste des participants nommés : `getEventParticipants` (identité nommée) reste gouverné par le cap de lecture protected ([[caveat_participation-deletion]] pour la suppression autoritative, inchangée). Cf. le brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
**Since Option B (2026-07-07)**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc (that would be an isolation violation — the participant writing someone else's doc; NextGraph writes are membership-bound, with no append). The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
- The event **owner's** session does the materializing: it is subscribed (`inbox.watch`, `doc_subscribe`, no polling) to the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant`by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|`**no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a broker sync replay convergesnever double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations/deposits stay persistednothing is lost; a future service will materialize in their stead).
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by the protected read cap ([[caveat_participation-deletion]] for the authoritative deletion, unchanged). See the brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
### Invariant id-form : apparier sur la forme CANONIQUE de l'event-id
### Id-form invariant: match on the CANONICAL form of the event id
Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<overlay>]`). Le matérialiseur du propriétaire apparie les **dépôts** de l'inbox aux événements possédés **par l'event-id** : `ownedEventIds` (ce que le matérialiseur itère), la **clé de dépôt** (`payload.eventId`, ce sous quoi l'inscrit dépose) et la **cible d'écriture** du compteur doivent désigner le même événement.
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
**Constat mesuré (2026-07-07)** : sur l'arbre courant ces trois voies portent le **me** NURI (suffixe `:v:<overlay>` inclus) — create-time, `listMyEntityDocs` et le `@id` relu coïncident, parce que `readUnion` **épingle le subject au NURI d'entrée** (lib `read-model.ts`, `63ecfee`). L'appariement marche donc déjà, **y compris** pour un événement possédé atteint via `listMyEntityDocs` (validé par le scénario @data « …fait converger le compteur dérivé »). La canonicalisation ci-dessous est **défensive**, pas la correction d'un bug actif. (Le non-match qu'une investigation avait cru voir était l'artefact **seedé-mais-pas-possédé** : sur un wallet persistant, le seed appartenait à une identité `test-*` d'un run antérieur → la session courante l'atteint par découverte, pas par `ownedEventIds` — comportement correct.)
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
**Règle** : apparier l'event-id sur sa **forme canonique** — l'id de repo de base, en retirant tout suffixe `:v:<overlay>` (`canonicalEventId`, `src/shared/data/registration.ts`). Cette forme canonique est utilisée pour l'**appariement** dans `materializeAttendance` / `readRegistrationNotifications`, et pour **dédupliquer** `ownedEventIds` (`ownedKey`, FestipodDataContext) afin qu'un même événement atteint par deux voies ne soit pas matérialisé deux fois. **Attention** : seul l'**appariement** utilise la forme stripée ; le compteur est toujours **écrit** sur le vrai NURI possédé (un doc vivant, ouvrable) — un id stripé ne doit jamais servir de cible d'écriture / d'ancre. C'est un invariant **côté app** (pas un détail NextGraph) : quelle que soit la façon dont la lib fait varier l'overlay, l'app apparie sur la base commune.
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
## Changement d'identité = session fraîche (isolation)
## Identity change = a fresh session (isolation)
> **Historique du symptôme** (le paragraphe qui suit décrit le montage d'alors — le jeu de lecture bespoke `publicDocs`/`protectedDocs`/`readTick` **n'existe plus** depuis le passage à `watchShape`). Il est conservé parce qu'il explique *pourquoi* la règle du reset existe ; le **mécanisme courant** est décrit plus bas.
> **History of the symptom** (the paragraph that follows describes the setup of the time — the bespoke read set `publicDocs`/`protectedDocs`/`readTick` **no longer exists** since the move to `watchShape`). It is kept because it explains *why* the reset rule exists; the **current mechanism** is described further down.
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumulait** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement`AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
The on-demand read set (`publicDocs`/`protectedDocs`) **accumulated** the current identity's scope docs (so as not to lose a just-created doc before the re-listing). But the shared-wallet stopgap keeps **a single React tree** across a fake logout + re-login under a **different identifier** (no page reload`AccountContext.login` merely rewrites the identifier in localStorage, and `AuthGate` remounts nothing). Without a reset, **the previous identity's PROTECTED docs (its participations) survive in the new identity's read set and leak** through the union read: the cap gate cannot filter them out when the (in-memory) cap registry does not govern that doc in *this* session (a doc persisted from an earlier run, or a fresh load where the caps are empty). Symptom observed: a user B saw A's participation (and A's event appeared on B's **home screen**, since home = `getUserEvents(currentUserId)`, see concept `app-architecture`).
**Règle** : traiter **tout changement d'identifiant** comme une **session fraîche**. Un `useEffect([identifier])`, **ref-gardé** (il ne tire pas au premier mount, seulement sur un vrai changement de valeur), remet à zéro **tout l'état de session porté par l'app**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
**Rule**: treat **any identifier change** as a **fresh session**. A `useEffect([identifier])`, **ref-guarded** (it does not fire on first mount, only on a genuine value change), resets **all session state carried by the app**. Isolation remains per-document/emulated (concept `app-security`, [[knowledge_trust-model]]); this reset only removes the carry-over of state between identities.
**Mécanisme courant** (depuis la lecture par `watchShape`) : la **lecture** n'a plus rien à réinitialiser`watchShape` re-résout son scope sur le nouveau `getCurrentUser()` au prochain push. Ce que l'effet vide est l'état **app-side** : `ownedEventIds` (le jeu du matérialiseur du propriétaire), la map `joinUids` (uid de dépôt de la session courante), l'**overlay optimiste** (`pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`sinon les mutations de l'ancienne identité saignent dans les lectures de la nouvelle), puis `resetCaps()` + `resetRegistryCache()`.
**Current mechanism** (since reads go through `watchShape`): the **read** side has nothing left to reset`watchShape` re-resolves its scope against the new `getCurrentUser()` on the next push. What the effect clears is the **app-side** state: `ownedEventIds` (the owner materializer's set), the `joinUids` map (the current session's deposit uids), the **optimistic overlay** (`pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`otherwise the old identity's mutations bleed into the new one's reads), then `resetCaps()` + `resetRegistryCache()`.
> **Impact — l'invariant à ne pas casser** : **tout nouvel état de session** ajouté au provider (cache, `useRef`, overlay, jeu de docs) doit être ajouté à cet effet. Un état oublié **fuit d'une identité à l'autre** sans erreur — c'est exactement la classe de bug que la garde de régression ci-dessous couvre.
> **Impact — the invariant not to break**: **any new session state** added to the provider (a cache, a `useRef`, the overlay, a doc set) must be added to that effect. Forgotten state **leaks from one identity to the next** with no error — exactly the class of bug the regression guard below covers.
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset tiré, le doc de A quittait le jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps (à l'époque via `setProtectedDocs([])` ; aujourd'hui c'est `watchShape` qui re-résout le scope, et le reset ne porte plus que l'état app-side listé plus haut). **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit Je participe » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
**Mechanism confirmed empirically (2026-07-07)**: the leak reproduces ONLY when TWO conditions coincide — (a) the read set still carries A's PROTECTED doc across the switch (no reset), AND (b) the in-memory cap registry does not govern that doc (`resetCaps()` already fired / caps empty for a doc persisted from a session earlier than the reload). Then A's participation makes it through B's union read (the per-document filter has no cap to check). With the reset fired, A's doc left B's read set BEFORE the cap-less read could expose it → no more leak whatever the state of the caps (at the time via `setProtectedDocs([])`; today it is `watchShape` that re-resolves the scope, and the reset now carries only the app-side state listed above). **Regression guarded** by the `@data` scenario « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature): A creates E and signs up to it, B (a fresh page on the same wallet, with a distinct identifier) has NEITHER E on their home screen (`getUserEvents(B)`), NOR `isParticipating(E,B)`, AND reads NO participation carrying A's principal. The historical symptom « B voit "Je participe" » mostly occurred when B **reused an identifier already used by A** (the same normalized principal) on a **bloated** wallet (docs persisted from an earlier run, empty caps).
## Instrumentation `useShapeQuery` — spinner global + timing
## `useShapeQuery` instrumentation — global spinner + timing
`useShapeQuery` (binding `useSyncExternalStore` sur `watchShape`) instrumente **chaque cycle de requête** : au début d'un cycle il s'enregistre dans un store module-level `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, Set d'ids — idempotent, sûr sous StrictMode), et à la 1re transition `isPending → isSuccess|isError` (le « premier résultat », équivalent readPromise) il se résout ET logge le délai : `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (le délai des événements Event/public est donc visible nommément). Le `cycleId` est mémoïsé sur `[shapeKey, scope]`un switch d'identité/scope recrée l'observable ET un nouveau cycle (re-`beginQuery`), et le cleanup résout au démontage (jamais bloqué). Le hook `usePendingQueries()` expose le nombre de requêtes en attente ; `HomeScreen` affiche un `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` dans `index.css`) à côté du titre « Festipod » tant que le compte > 0 → il ne s'arrête que quand **toutes** les requêtes en cours ont reçu leur premier résultat. Toute future `useShapeQuery` y contribue automatiquement. La mesure vit côté app (délai perçu React), **pas** dans le polyfill.
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]`an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
## Convention de log — préfixe identité-first, et compteur avant→après
## Logging convention identity-first prefix, and counter before→after
Tout log DATA du provider passe par **`logPrefix`** : `[<currentUserId>][app][data]` quand le principal est résolu, `[app][data]` sinon (état transitoire de connexion). Raison : avec le wallet partagé, **deux identités partagent la même console** (deux onglets / un multi-navigateur) — une ligne non préfixée ne dit pas *de qui* elle parle et devient inexploitable pour diagnostiquer une fuite ou un compteur bloqué. **Ajouter un log DATA = réutiliser `logPrefix`**, pas un `console.log` nu.
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: with the shared wallet, **two identities share the same console** (two tabs / a multi-browser run) — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
Deux points de mesure sont posés **par paire** et servent ensemble : le matérialiseur du propriétaire logge `participantCount` **avant → après** son écriture, et la lecture d'affichage logge la valeur **telle qu'exposée au rendu**. Les comparer tranche un compteur bloqué entre un problème **DONNÉE** (jamais incrémenté) et un problème **AFFICHAGE** (incrémenté mais pas relu avant la session suivante). Ne pas retirer l'un des deux sans l'autre — isolément ils ne diagnostiquent rien.
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
## Mutations no-op en mode local
## Mutations are no-ops in local mode
En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut.
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
@@ -1,28 +1,28 @@
---
type: knowledge
summary: Deux modes (connected = SDK @ng-eventually/client, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut de connexion, tous les écrans passent par useFestipodData()
summary: Two modes (connected = the @ng-eventually/client SDK, disconnected/demo = seeded local state); FestipodDataContext picks the provider based on connection status, and every screen goes through useFestipodData()
---
# Modes de données & contextes
# Data modes & contexts
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
The app has **two modes**, both consumed through the `useFestipodData()` hook:
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
2. **Disconnected / Demo**état React local seedé depuis `seedData.ts` (voir [[knowledge_seed-data]])
1. **Connected** ORM shapes from the `@ng-eventually/client` SDK (P2P, encrypted, local-first)
2. **Disconnected / Demo**local React state seeded from `seedData.ts` (see [[knowledge_seed-data]])
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
- Cycle de connexion : `disconnected``connecting``connected` | `error`.
- Fournit la session (l'utilisateur courant et son accès aux stores par scope).
- Connection cycle: `disconnected``connecting``connected` | `error`.
- Provides the session (the current user and their access to the per-scope stores).
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
- Enveloppe les shapes via `useShapeWithDefaults()`.
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider selon le statut de connexion** :
- `disconnected``LocalDataProvider` avec seed (démo)
- `connecting``LocalDataProvider` **vide** (évite de flasher le seed avant le chargement du wallet)
- `connected``NgDataProvider` (données réelles du wallet)
- `error``LocalDataProvider` avec seed (fallback gracieux)
- Wraps the shapes through `useShapeWithDefaults()`.
- Exposes `useFestipodData()` (consumed by every screen) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider chosen by connection status**:
- `disconnected``LocalDataProvider` with the seed (demo)
- `connecting` an **empty** `LocalDataProvider` (avoids flashing the seed before the wallet loads)
- `connected``NgDataProvider` (real wallet data)
- `error``LocalDataProvider` with the seed (graceful fallback)
> Les mutations sont **réellement persistées** en mode connected (`joinEvent` écrit une Participation et notifie l'hôte du PdR, `leaveEvent` supprime de façon autoritative — cf. [[caveat_participation-deletion]]). En mode local/demo elles sont des no-ops (cf. [[knowledge_context-internals]]).
> Mutations are **genuinely persisted** in connected mode (`joinEvent` writes a Participation and notifies the meeting point's host, `leaveEvent` deletes authoritativelysee [[caveat_participation-deletion]]). In local/demo mode they are no-ops (see [[knowledge_context-internals]]).
@@ -1,24 +1,24 @@
---
type: knowledge
summary: Types de données Fp* — Event, UserProfile, Participation, MeetingPoint et Notification sont persistés NextGraph (shapes SHEX + ORM) ; seul Friendship reste local-only (app-TS)
summary: The Fp* data types — Event, UserProfile, Participation, MeetingPoint and Notification are persisted in NextGraph (SHEX shapes + ORM); only Friendship stays local-only (app-TS)
last_checked: 2026-07-03
---
# Entités de données
# Data entities
`src/shared/data/types.ts` :
`src/shared/data/types.ts`:
| Type | Persistance | Champs clés |
| Type | Persistence | Key fields |
|---|---|---|
| `FpEventData` | SDK (shape Event) | id, title, date, location, distance, themes |
| `FpUserData` | SDK (shape UserProfile) | id, name, username, bio, city, counts |
| `FpParticipationData` | SDK (shape Participation) | eventId + userId + confirmed |
| `FpMeetingPointData` | SDK (shape MeetingPoint) | eventId, location, time, host |
| `FpNotificationData` | SDK (shape Notification) | kind, target, source |
| `FpEventData` | SDK (Event shape) | id, title, date, location, distance, themes |
| `FpUserData` | SDK (UserProfile shape) | id, name, username, bio, city, counts |
| `FpParticipationData` | SDK (Participation shape) | eventId + userId + confirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | eventId, location, time, host |
| `FpNotificationData` | SDK (Notification shape) | kind, target, source |
| `FpFriendshipData` | **local-only** | userId + friendId |
`MeetingPoint` et `Notification` ont de vraies **shapes SHEX** (`src/shared/shapes/shex/festipodShapes.shex`) avec bindings ORM générés (`festipodShapes.shapeTypes.ts` : `FpMeetingPointShapeType`, `FpNotificationShapeType`) et **sont persistés**. `Notification` est notamment créée lors de l'inscription à un point de rencontre (`joinEvent`).
`MeetingPoint` and `Notification` do have real **SHEX shapes** (`src/shared/shapes/shex/festipodShapes.shex`) with generated ORM bindings (`festipodShapes.shapeTypes.ts`: `FpMeetingPointShapeType`, `FpNotificationShapeType`) and **are persisted**. A `Notification` is created in particular when signing up to a meeting point (`joinEvent`).
`Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_nextgraph-stack]]).
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
> Piège : même pour `FpEvent` (persisté), plusieurs champs du type app ne sont **pas** dans la shape et sont perdus en connectévoir [[caveat_event-fields-not-persisted]].
> Pitfall: even for `FpEvent` (which is persisted), several fields of the app type are **not** in the shape and are lost when connectedsee [[caveat_event-fields-not-persisted]].
@@ -1,34 +1,34 @@
---
type: knowledge
summary: Le SDK de données est @ng-eventually/client (traité comme un SDK NextGraph fini) — injecté une seule fois via ngSession.configure ; ORM réactif useShape sur shapes SHEX festipodShapes, bindings régénérés via build:orm ; ne jamais documenter l'état courant de NextGraph ici
summary: The data SDK is @ng-eventually/client (treated as a finished NextGraph SDK) — injected exactly once through ngSession.configure; reactive useShape ORM over the festipodShapes SHEX shapes, bindings regenerated with build:orm; never document NextGraph's current state here
---
# Stack de données (SDK `@ng-eventually/client`)
# Data stack (the `@ng-eventually/client` SDK)
Festipod persiste via **`@ng-eventually/client`** — le SDK NextGraph que l'app consomme. On le traite comme un **SDK fini et mature** : documents par entité placés par scope, capabilities, inboxes, ORM réactif.
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.
```
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
@ng-eventually/client # THE app's data SDK (reactive useShape ORM, docs, scopes, inbox)
```
## Frontière SDK (règle d'or)
## SDK boundary (the golden rule)
- L'app **ne dépend que de `@ng-eventually/client`** pour la donnée.
- Le SDK est **initiali/injecté une seule fois** via `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — point d'injection unique. Le reste de l'app (data-plane, lifecycle, login, types) passe par la lib.
- **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`. Ici on décrit seulement **comment Festipod utilise ce SDK**.
- The app **depends on `@ng-eventually/client` only** for data.
- The SDK is **initialized/injected exactly once** through `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — a single injection point. Everything else in the app (data plane, lifecycle, login, types) goes through the lib.
- **Never document NextGraph's current state in this repo** (constraints of the underlying SDK, workarounds, broker/verifier internals): that lives in the `@ng-eventually/client` repo. Here we describe only **how Festipod uses that SDK**.
## ORM & shapes SHEX
## ORM & SHEX shapes
L'ORM réactif (`useShape`) s'appuie sur des **shapes SHEX** : `src/shared/shapes/shex/festipodShapes.shex` définit :
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
- **Event** — titre, description, dates, lieu, thèmes, participants
- **UserProfile** — nom, username, bio, ville, visibilité
- **Participation** — lie event + user, statut de confirmation
- **MeetingPoint** — point de rencontre (lieu, horaire, hôte)
- **Notification** — notification (créée notamment à l'inscription à un PdR)
- **Event** — title, description, dates, location, themes, participants
- **UserProfile** — name, username, bio, city, visibility
- **Participation** — links an event and a user, confirmation status
- **MeetingPoint** — a meeting point (location, time, host)
- **Notification** — a notification (created in particular when signing up to a meeting point)
Bindings ORM générés dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
The ORM bindings are generated in `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Regenerate** with `bun run build:orm` after any `.shex` change.
> **Lecture recommandée = le hook réactif du SDK.** La façon canonique de lire, c'est `useShape` : on s'abonne à une shape sur un scope, on obtient la valeur courante, et le composant se re-rend à chaque changement (local **ou** distant synchroni) — abonnement/push, jamais de polling ; les lectures one-shot sont l'exception. La référence complète du SDK (contrat de lecture/réactivité + où l'émulation courante diverge encore) vit côté lib : `packages/client/docs/sdk-reference.md` dans `@ng-eventually/client`. Ne pas recopier les internes NextGraph ici.
> **Recommended way to read = the SDK's reactive hook.** The canonical way to read is `useShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change (local **or** remote once synchronized) — subscription/push, never polling; one-shot reads are the exception. The SDK's full reference (read/reactivity contract + where the current emulation still diverges) lives on the lib side: `packages/client/docs/sdk-reference.md` in `@ng-eventually/client`. Do not copy NextGraph internals here.
> `Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_entities]]).
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
@@ -1,17 +1,17 @@
---
type: knowledge
summary: seedData.ts fournit des fixtures déterministes (10 users, events, participations) avec CURRENT_USER_ID = 'user-1' (Marie Dupont) ; utilisé en mode démo et par les tests @ui
summary: seedData.ts provides deterministic fixtures (10 users, events, participations) with CURRENT_USER_ID = 'user-1' (Marie Dupont); used in demo mode and by the @ui tests
---
# Seed data
`src/shared/data/seedData.ts` fournit des fixtures **déterministes** :
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
- 10 users — **Marie Dupont = utilisateur courant**, `user-1`
- Plusieurs events (dates, lieux, thèmes)
- 10 users — **Marie Dupont = the current user**, `user-1`
- Several events (dates, locations, themes)
- Participations, meeting points, friendships
- `CURRENT_USER_ID = 'user-1'`
Ces fixtures servent (a) le **mode démo** (`LocalDataProvider`, cf. [[knowledge_data-modes]]) et (b) les tests **`@ui`** qui rendent les écrans avec ces données prévisibles (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, etc. — voir concept `bdd-testing`).
These fixtures serve (a) **demo mode** (`LocalDataProvider`, see [[knowledge_data-modes]]) and (b) the **`@ui`** tests, which render the screens against this predictable data (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, etc. — see concept `bdd-testing`).
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »).
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
@@ -1,53 +1,25 @@
---
type: rule
summary: L'app se comporte EXACTEMENT comme si NextGraph était fini et sans défaut — elle ne consomme QUE des surfaces SDK-shaped (`useShape`, `docs`, `inbox`…) et ne raisonne JAMAIS sur un problème courant de NextGraph (hang du fan-out ORM, cold-open, etc.). La raison d'être du polyfill est le WALLET VIRTUEL ; tout contournement interne (read-model union, subscribeDoc, open-repo…) vit DANS le polyfill, invisible à l'app.
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.
---
# L'app n'utilise que la surface SDK — jamais les internes du polyfill
# The app uses the SDK surface only — never the polyfill's internals
## La règle
## The rule
L'app Festipod traite `@ng-eventually/client` comme un **SDK NextGraph fini et sans
défaut**. Concrètement :
The Festipod app treats `@ng-eventually/client` as a **finished, flawless NextGraph SDK**. Concretely:
1. **Lecture réactive = `useShape`** (la surface SDK-shaped, fournie par le polyfill,
**scopée au wallet virtuel**). L'app ne lit PAS via des internes du polyfill
(`readModel.readUnion`, `subscribeDoc`, un read-model maison…), et ne monte PAS sa
propre réactivité (re-run sur signal).
2. **L'app ne raisonne JAMAIS sur l'état courant de NextGraph** : pas de code ni de
commentaire du type « on fait X parce que le fan-out ORM hang / parce que la lecture
à froid rend 0 ». Ces problèmes n'existent pas du point de vue de l'app.
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.
## La raison d'être du polyfill = le WALLET VIRTUEL
## The polyfill exists for the VIRTUAL WALLET
Le polyfill existe pour émuler le **wallet virtuel** (plusieurs identités sur un seul
wallet physique), que NextGraph ne fournit pas encore nativement. **Ce n'est PAS**
« parce que le fan-out ORM hang » — ça, c'est un simple **détail d'implémentation
interne** de la façon dont le polyfill livre un `useShape` fonctionnel. Tous les
contournements (read-model union à la place du fan-out ORM, `open-repo`, readiness
miroir de `readyPromise`, émulation de caps…) sont **internes au polyfill** et
n'apparaissent jamais dans l'app.
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.
## État (déviation résolue)
## Status (deviation resolved)
**Résolu** : `FestipodDataContext` lit désormais via `useShapeQuery` (binding
`useSyncExternalStore` sur `watchShape` du polyfill) + adaptateurs Fp
(`src/shared/data/shapeAdapters.ts`). Sont **supprimés** : `readEntities.ts`, la
réactivité bespoke (`subscribeDocs`+`bumpRead`+`readTick`), le listing manuel
(`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture), et les commentaires
raisonnant sur le hang ORM. L'auto-seed est gardé sur `isSuccess` (plus de
chronomètre 3 s). L'app ne consomme plus que la surface SDK.
**Resolved**: `FestipodDataContext` now reads through `useShapeQuery` (a `useSyncExternalStore` binding over the polyfill's `watchShape`) + Fp adapters (`src/shared/data/shapeAdapters.ts`). **Removed**: `readEntities.ts`, the bespoke reactivity (`subscribeDocs`+`bumpRead`+`readTick`), the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and the comments reasoning about the ORM hang. The auto-seed is gated on `isSuccess` (no more 3s timer). The app consumes nothing but the SDK surface.
**Cible (rappel du design)** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont
la **forme suit TanStack `useQuery`**`{ data, isPending/isLoading, isSuccess, isError,
… }`**en anticipation de la mise à jour PRÉVUE de `useShape` par NextGraph** (qui va
adopter ce fonctionnement). Ce n'est donc pas une invention : c'est une API future de
NextGraph, émulée d'avance, qui s'aligne quand NextGraph la livre. Elle **distingue
nativement** `isPending` (sync en cours) de `isSuccess` + `data` vide (synchronisé,
réellement vide) — exactement le besoin. En interne, le hook encapsule readUnion sur
`subscribeDoc` + le scoping identité (invisible à l'app). L'app **supprime** sa
machinerie bespoke (`readEntities`/`subscribeDocs`/`bumpRead`) et lit via ce hook.
**Target (design reminder)**: the polyfill exposes a `useShape` that is **reactive and scoped to the virtual wallet**, whose **shape follows TanStack `useQuery`**`{ data, isPending/isLoading, isSuccess, isError, … }`**in anticipation of the PLANNED update of `useShape` by NextGraph** (which is going to adopt that behaviour). So this is not an invention: it is a future NextGraph API, emulated ahead of time, that will align once NextGraph ships it. It **natively distinguishes** `isPending` (sync in progress) from `isSuccess` + empty `data` (synchronized, genuinely empty) — exactly what is needed. Internally, the hook encapsulates readUnion over `subscribeDoc` plus the identity scoping (invisible to the app). The app **removes** its bespoke machinery (`readEntities`/`subscribeDocs`/`bumpRead`) and reads through that hook.
Le bug d'auto-seed (chronomètre 3 s) est un **symptôme** : avec `isSuccess`, l'auto-seed
décide « vide » seulement une fois la sync confirmée, au lieu de deviner un délai. Voir
[[rule_no-broker-polling]] et [[knowledge_nextgraph-stack]].
The auto-seed bug (the 3s timer) is a **symptom**: with `isSuccess`, the auto-seed decides "empty" only once the sync is confirmed, instead of guessing a delay. See [[rule_no-broker-polling]] and [[knowledge_nextgraph-stack]].
@@ -1,33 +1,33 @@
---
type: rule
summary: Toute connaissance importante établie sur le fonctionnement RÉEL de NextGraph (mécanisme du cœur/broker/verifier, sémantique d'un primitif, propriété de forme) → la consigner AU MOMENT de la découverte dans la doc de référence du polyfill `../../nextgraph/ng-eventually-js/docs/`, jamais dans le repo Festipod ; distinguer VÉRIFIÉ d'INFÉRÉ, et ne jamais déduire la forme CIBLE de l'état COURANT du source
summary: Any important knowledge established about how NextGraph ACTUALLY works (a core/broker/verifier mechanism, a primitive's semantics, a shape property) → record it AT THE MOMENT of discovery in the polyfill's reference docs `../../nextgraph/ng-eventually-js/docs/`, never in the Festipod repo; distinguish VERIFIED from INFERRED, and never deduce the TARGET shape from the source's CURRENT state
---
# Règle : consigner toute connaissance NextGraph au moment où on l'établit
# Rule: record NextGraph knowledge the moment you establish it
Quand une enquête établit un **fait important sur le fonctionnement réel de NextGraph** — le mécanisme d'un primitif, la sémantique d'une structure, une propriété de forme (« l'overlay est *store*-scopé, jamais document-scopé »), une garde d'accès, ce qu'une opération exige ou n'exige pas — **écris-le tout de suite** dans la documentation de référence du polyfill :
When an investigation establishes an **important fact about how NextGraph actually works** — a primitive's mechanism, a structure's semantics, a shape property ("the overlay is *store*-scoped, never document-scoped"), an access guard, what an operation does or does not require — **write it down straight away** in the polyfill's reference documentation:
`../../nextgraph/ng-eventually-js/docs/` (depuis la racine de ce repo) — typiquement la fiche de référence du sujet (modèle de caps/NURI, état courant, référence SDK).
`../../nextgraph/ng-eventually-js/docs/` (from this repo's root) — typically the reference note for the subject (caps/NURI model, current state, SDK reference).
**Jamais dans le repo Festipod.** `AGENTS.md` l'interdit explicitement : la doctrine Festipod décrit *comment Festipod utilise le SDK*, pas l'état de NextGraph. Cf. [[rule_app-uses-sdk-surface-only]].
**Never in the Festipod repo.** `AGENTS.md` forbids it explicitly: Festipod doctrine describes *how Festipod uses the SDK*, not the state of NextGraph. See [[rule_app-uses-sdk-surface-only]].
## Au moment de la découvertepas à la fin
## At the moment of discoverynot at the end
Le « je consignerai en fin de session » ne marche pas : le contexte est compacté avant, et le fait est perdu. Ces connaissances coûtent **très cher** à établir (plusieurs enquêtes d'agents dans le source Rust, souvent contradictoires avant convergence) et sont **invérifiables de mémoire**une seconde session repaiera le prix fort pour la même réponse, ou pire, se contentera d'une intuition fausse.
"I will write it up at the end of the session" does not work: the context is compacted before that, and the fact is lost. This knowledge is **very expensive** to establish (several agent investigations through the Rust source, often contradicting each other before they converge) and **impossible to verify from memory**a second session will pay full price again for the same answer, or worse, will settle for a wrong intuition.
## Le piège central : état courant ≠ forme cible
## The central pitfall: current state ≠ target shape
**Ne jamais lire l'état courant de `nextgraph-rs` pour en DÉDUIRE la forme cible.** Le source contient de l'**échafaudage inachevé** qui ressemble à du modèle : on peut y lire des types d'appartenance et de permissions qui sont **inertes au runtime** (jamais appelés hors tests unitaires, structures construites vides). En déduire un primitif « membership » et le façonner dans le polyfill, c'est y graver une forme qui n'existera pas — exactement le mode d'échec que le polyfill existe pour empêcher.
**Never read `nextgraph-rs`'s current state to DEDUCE the target shape from it.** The source contains **unfinished scaffolding** that looks like model: you can find membership and permission types in it that are **inert at runtime** (never called outside unit tests, structures built empty). Deducing a "membership" primitive from that and shaping it into the polyfill means carving in a shape that will never exist — exactly the failure mode the polyfill exists to prevent.
Le source sert à **vérifier un mécanisme existant**, jamais à **inférer une intention**. L'intention se demande au concepteur de NextGraph.
The source is there to **verify an existing mechanism**, never to **infer an intention**. Intentions are to be asked of NextGraph's designer.
## Forme de la note
## Shape of the note
- **Distinguer VÉRIFIÉ** (chemin lu de bout en bout, ou mieux : observé à l'exécution) d'**INFÉRÉ** (déduit, non tracé). Un fait porteur non marqué se transforme silencieusement en certitude.
- **Pointer des symboles**, pas des numéros de ligne (volatils) — et dater la note.
- Écrire aussi la **conséquence** du fait, pas seulement le fait : c'est elle qu'on relira.
- Un fait qui **contredit** une note existante → corriger la note, ne pas empiler.
- **Distinguish VERIFIED** (a path read end to end, or better: observed at runtime) from **INFERRED** (deduced, not traced). A load-bearing fact left unmarked silently turns into a certainty.
- **Point at symbols**, not line numbers (which are volatile) — and date the note.
- Write down the fact's **consequence** too, not just the fact: that is what will be re-read.
- A fact that **contradicts** an existing note → fix the note, do not pile on.
## Règle sœur
## Sibling rule
Celle-ci vise la **connaissance**ce qui *est* ; [[rule_nextgraph-inbox]] vise ce qu'il faut **remonter ou attendre** — les dysfonctionnements et les manques (→ `../../nextgraph/orm-tests/INBOX/`). Une même enquête produit souvent les deux : ranger chaque moitié à sa place. Cf. [[knowledge_nextgraph-stack]].
This one covers **knowledge**what *is*; [[rule_nextgraph-inbox]] covers what must be **reported upstream or waited for** — the malfunctions and the gaps (→ `../../nextgraph/orm-tests/INBOX/`). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].
@@ -1,112 +1,46 @@
---
type: rule
summary: Festipod persiste CHAQUE entité comme SON PROPRE document (via le SDK), placé dans son scope (public/protected/private) — jamais plusieurs entités écrites dans un document de niveau store. Le document est l'unité de partage et de droits : l'isolation du SDK est PAR-DOCUMENT, donc un document par entité est ce qui la rend possible.
summary: Festipod persists EVERY entity as ITS OWN document (through the SDK), placed in its scope (public/protected/private) — never several entities written into a store-level document. The document is the unit of sharing and of rights: the SDK's isolation is PER-DOCUMENT, so one document per entity is what makes it possible.
---
# Règle : un document par entité (jamais au niveau du store)
# Rule: one document per entity (never at store level)
Quand Festipod crée une entité (événement, point de rencontre, profil, participation,
notification), il l'écrit comme **son propre document**, via l'appel « créer un document » du
SDK de données ([[knowledge_nextgraph-stack]]), en indiquant son **scope**
(`public` / `protected` / `private`). L'entité est ensuite lue et écrite dans **ce** document.
When Festipod creates an entity (event, meeting point, profile, participation, notification), it writes it as **its own document**, through the data SDK's "create a document" call ([[knowledge_nextgraph-stack]]), stating its **scope** (`public` / `protected` / `private`). The entity is then read from and written to **that** document.
**Ne jamais** écrire plusieurs entités dans un document partagé « de niveau store » (p. ex.
tout mettre dans un seul document racine). C'est un anti-pattern qui casse l'isolation.
**Never** write several entities into a shared "store-level" document (e.g. putting everything into a single root document). That is an anti-pattern that breaks isolation.
## Pourquoi
## Why
Le **document est l'unité de partage et de droits** du SDK : l'isolation (qui peut lire quoi)
est appliquée **par document**. `private` → le propriétaire ; `protected` → le propriétaire +
ses connexions ; `public` → tout le monde. Cette discrimination n'est possible **que si chaque
entité a son propre document** : mettre plusieurs entités (voire plusieurs propriétaires) dans
un même document rend le partage tout-ou-rien et défait l'isolation par périmètre.
The **document is the SDK's unit of sharing and of rights**: isolation (who can read what) is enforced **per document**. `private` → the owner; `protected` → the owner + their connections; `public` → everyone. That discrimination is possible **only if each entity has its own document**: putting several entities (or worse, several owners) into a single document makes sharing all-or-nothing and defeats scope-based isolation.
L'isolation elle-même est **entièrement assurée par le SDK** ([[knowledge_trust-model]] du
concept `app-security`) — l'app ne porte aucune logique d'accès ; elle déclare seulement son
identité (au login) et ses connexions (acte de partage), puis fait confiance à ce que le SDK
renvoie. La granularité « un document par entité » est la contrepartie côté écriture de cette
confiance.
Isolation itself is **entirely handled by the SDK** ([[knowledge_trust-model]] in the `app-security` concept) — the app carries no access logic; it only declares its identity (at login) and its connections (an act of sharing), then trusts whatever the SDK returns. The "one document per entity" granularity is the write-side counterpart of that trust.
## Comment l'appliquer
## How to apply it
- À la création : demander au SDK **un document pour l'entité, dans son scope**
(`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre
périmètre ni un document de niveau store.
- En lecture : passer par le SDK via le **modèle de lecture union** (voir plus bas) — l'app
résout un jeu de documents *par besoin* (index de découverte pour les événements publics ;
ses propres documents de scope pour ses entités) et le SDK ouvre/synchronise puis lit
l'union en **une seule** requête ; pas de résolution de NURI ni de choix union/ancré côté app.
- Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected,
settings → private) est un fait produit (concept `functional-domain`,
[[knowledge_data-scopes-and-discovery]]).
- 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.
- 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]]).
## Lecture : modèle union (open/sync + une requête ancrée-libre + re-query)
## Reads: the union model (open/sync + one unanchored query + re-query)
La **lecture** ne passe **PAS** par un abonnement ORM réactif en fan-out sur un jeu de documents
par-entité (`useShape({ graphs: […] })`) : contre le vrai broker un document fraîchement créé /
non-synchronisé dans ce fan-out fait avorter tout l'abonnement (`RepoNotFound`) → l'abonnement
n'émet jamais son initial → **hang ~75 s**. À la place, la lecture est le **modèle union** du SDK
([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`) :
**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`):
1. **résoudre par besoin** le jeu de NURIs à lire — événements publics via l'**index de découverte**
(la seule énumération cross-comptes sanctionnée) ; « mes entités » (profil, participations) via
**mes propres** documents de scope (`listMyEntityDocs(username, scope)`, borné à mon compte —
jamais de fan-out sur tous les comptes) ;
2. le SDK **ouvre/synchronise** ces documents puis exécute **UNE** requête `sparql_query`
**sans ancre** sur l'union locale (`GRAPH ?g { … }`) et rend les triplets groupés par sujet
(`src/shared/data/readEntities.ts``readModel.readUnion`) ;
3. il n'y a **pas** de requête union réactive → la **réactivité = re-query** sur un signal de
changement (un document créé/enregistré déclenche `bumpRead`).
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`).
Côté app, `FestipodDataContext` collecte les NURIs par besoin puis appelle `readEntities` ;
un document fraîchement créé est aussi enregistré localement (`registerDoc`) pour apparaître
immédiatement, avant que la re-liste ne le rattrape.
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.
## Écriture directe (piège d'aller-retour)
## Direct writes (the round-trip pitfall)
L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à un
ensemble réactif. Raison : un ensemble réactif n'est *inscriptible* que si le document cible est
**déjà** dans son scope d'abonnement ; or enregistrer le document fraîchement créé est un état
React qui ne prend effet qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe
synchrone (boucle de seed, première création). Contre le vrai broker, un `add` sur un scope vide
lève « Set is readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas).
**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).
Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ;
**lecture = union + re-query** (ci-dessus).
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = union + re-query** (above).
**Convention de graphe (écrire dans le graphe par défaut ancré).** L'écriture passe le NURI du
document comme **ancre** de `docs.sparqlUpdate` et écrit le corps SPARQL **sans** clause
`GRAPH <…>` explicite ; la lecture union interroge le même graphe par défaut ancré
(`readEntities`/`readUnion`). C'est la forme **canonique et toujours sûre** — à conserver pour
`writeEntity`, `updateEntityField` et `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 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`.
> **Correction (2026-07-06).** Un commentaire antérieur (et une version de ce paragraphe)
> affirmaient qu'un corps `GRAPH <nuriDuDoc>` explicite écrit dans un graphe *nommé distinct* que
> la lecture ancrée ne verrait pas → l'entité « disparaîtrait ». **C'est faux sur le broker
> courant** (`@ng-org/web 0.1.2-alpha.13`) : le harness e2e réel de la lib
> (`packages/client/e2e/`) vérifie qu'un `INSERT DATA { GRAPH <plainNuri> {…} }` **ancré** au doc
> round-trippe (relu aussi bien en graphe par défaut qu'en `GRAPH <plainNuri>`). Le symptôme « 0
> entité » qu'on avait attribué à ce « piège » venait en réalité du **hang de wallet gonflé** (cf.
> `bdd-testing/caveat_wallet-bloat-hang`), pas d'un mismatch de graphe. La règle « sans wrapper
> `GRAPH` » reste donc un choix de **simplicité/sûreté**, pas une nécessité de round-trip. (Le
> *pourquoi* côté SDK vit dans `@ng-eventually/client`, pas ici.)
> **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.)
Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : muter une valeur
en mémoire ne tient pas — la re-query union relit la valeur **persistée** depuis le broker
(retour à l'ancienne valeur) → persister via SPARQL (`updateEntityField` : DELETE puis
INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer /
float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire
manquant ou mal typé fait que la lecture **jette l'entité** (elle ne fait jamais
l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document),
ce qui donne un `@id` en `did:ng:…`.
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:…`.
Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais
l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant
est **stable et dérivé du username** (`urn:festipod:user:<username-normalisé>`), disponible
**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut
retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de
session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification
sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner
dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces
mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine.
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.
@@ -1,42 +1,42 @@
---
type: rule
summary: L'inbox NextGraph partagée `../../nextgraph/orm-tests/INBOX/` reçoit DEUX familles de fiches — les dysfonctionnements (un primitif se comporte mal) ET les manques (un primitif dont on a besoin, pas encore implémenté, qu'on émule dans le polyfill en attendant). Elle sert de suivi de l'avancement de NextGraph : quand un manque est comblé en amont, sa fiche dit quoi RETIRER du polyfill.
summary: The shared NextGraph inbox `../../nextgraph/orm-tests/INBOX/` takes TWO families of notes — malfunctions (a primitive misbehaves) AND gaps (a primitive we need, not yet implemented, which we emulate in the polyfill in the meantime). It doubles as a tracker of NextGraph's progress: when a gap is filled upstream, its note says what to REMOVE from the polyfill.
---
# Règle : l'inbox NextGraph reçoit les dysfonctionnements ET les manques
# Rule: the NextGraph inbox takes malfunctions AND gaps
L'inbox NextGraph partagée est `../../nextgraph/orm-tests/INBOX/` (depuis la racine de ce repo) — dans le repo frère `nextgraph/orm-tests`, qui héberge les tests d'intégration ORM contre un vrai broker (`tests/standalone/` pour les repros).
The shared NextGraph inbox is `../../nextgraph/orm-tests/INBOX/` (from this repo's root) — in the sibling repo `nextgraph/orm-tests`, which hosts the ORM integration tests against a real broker (`tests/standalone/` for repros).
Elle n'est **pas** qu'un bug-tracker. Elle a **deux entrées** et **une boucle de sortie**.
It is **not** just a bug tracker. It has **two inputs** and **one feedback loop**.
## Entrée 1 — les dysfonctionnements
## Input 1 — malfunctions
Un primitif NextGraph existe mais **se comporte mal** : socket qui meurt (`SerializationError`), pas de reconnexion automatique, `doc_subscribe` qui ne délivre pas ou tarde, cold-open de repo lent, écriture non durable côté broker, panique atteignable.
A NextGraph primitive exists but **misbehaves**: a socket that dies (`SerializationError`), no automatic reconnection, a `doc_subscribe` that does not deliver or delivers late, a slow repo cold-open, a write that is not durable broker-side, a reachable panic.
## Entrée 2 — les manques dont on a besoin
## Input 2 — the gaps we need
Un primitif **n'est pas encore implémenté** (ou n'est qu'un échafaudage inerte) alors que notre modèle en dépend. Le déposer aussi, avec les trois informations qui font sa valeur :
A primitive **is not implemented yet** (or is only inert scaffolding) while our model depends on it. File it too, with the three pieces of information that make it valuable:
- **ce dont on a besoin** et pourquoile modèle qui en dépend ;
- **ce que le polyfill fait en attendant** — l'émulation qui bouche le trou ;
- **ce qu'il faudra retirer** du polyfill le jour où ça atterrit en amont.
- **what we need** and whythe model that depends on it;
- **what the polyfill does in the meantime** — the emulation that fills the hole;
- **what will have to be removed** from the polyfill the day it lands upstream.
C'est ce troisième point qui transforme la fiche en **ticket de nettoyage**. Sans lui, l'émulation survit à sa raison d'être et le polyfill se met à diverger de la cible — exactement ce qu'il existe pour éviter.
It is that third point that turns the note into a **cleanup ticket**. Without it, the emulation outlives its reason for existing and the polyfill starts drifting away from the target — exactly what it exists to prevent.
## Ce qui ne qualifie PAS
## What does NOT qualify
Un bug de l'**app** (effet React mal câblé, gating d'un effet) ou un **câblage du polyfill** (mauvais NURI, souscription non ré-armée). Ceux-là se corrigent **chez nous**. La distinction est cruciale : d'abord prouver que c'est le primitif qui faute — idéalement par un test — pas notre intégration. Cf. [[rule_app-uses-sdk-surface-only]].
An **app** bug (a badly wired React effect, an effect's gating) or a **polyfill wiring** issue (wrong NURI, subscription not re-armed). Those are fixed **on our side**. The distinction is crucial: first prove that the primitive is at fault — ideally with a test — not our integration. See [[rule_app-uses-sdk-surface-only]].
## La boucle : l'inbox suit l'avancement de NextGraph
## The loop: the inbox tracks NextGraph's progress
Les fiches ne partent pas seulement vers l'amont, elles se **relisent** : ensemble, elles disent où en est NextGraph par rapport à ce dont Festipod a besoin. Quand une fiche se résout en amont, la mise à jour du polyfill suit — souvent en **retirant** de l'émulation devenue inutile, pas en ajoutant du code.
The notes do not only travel upstream, they are also **re-read**: taken together, they say where NextGraph stands relative to what Festipod needs. When a note is resolved upstream, the polyfill update follows — often by **removing** emulation that has become useless, not by adding code.
## Format de la fiche
## Note format
Nom : `YYYY-MM-DD-<slug>.md`. Contenu : nature (**dysfonctionnement** ou **manque**), symptôme ou besoin, **preuve verbatim** (logs, mesures, pointeurs source marqués « à re-vérifier »), repro quand c'est un dysfonctionnement (idéalement un standalone dans `orm-tests/tests/standalone/`), attendu vs observé, et — pour un manquele **contournement polyfill** et **ce qu'il faudra retirer**. Sévérité + statut.
Name: `YYYY-MM-DD-<slug>.md`. Contents: nature (**malfunction** or **gap**), symptom or need, **verbatim evidence** (logs, measurements, source pointers marked "to re-verify"), a repro when it is a malfunction (ideally a standalone in `orm-tests/tests/standalone/`), expected vs observed, and — for a gapthe **polyfill workaround** and **what will have to be removed**. Severity + status.
L'inbox reçoit le **rapport actionnable pour les mainteneurs NextGraph** ; un post-mortem plus long peut vivre côté polyfill.
The inbox receives the **report that is actionable for the NextGraph maintainers**; a longer post-mortem can live on the polyfill side.
## Règle sœur
## Sibling rule
Celle-ci vise ce qu'il faut **remonter ou attendre** ; [[rule_capture-nextgraph-findings]] vise la **connaissance** établie sur le fonctionnement réel (→ doc de référence du polyfill). Une même enquête produit souvent les deux : ranger chaque moitié à sa place. Cf. [[knowledge_nextgraph-stack]].
This one covers what must be **reported upstream or waited for**; [[rule_capture-nextgraph-findings]] covers established **knowledge** about how things actually work (→ the polyfill's reference docs). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].