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
+13 -13
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: Architecture feature-based de l'app — modules par domaine, invariant d'imports, app shell à providers, routing path-based, écrans et registre
summary: Feature-based architecture of the app — modules per domain, import invariant, provider-stacked app shell, path-based routing, screens and registry
triggers:
keywords: [module, modules, screen, écran, routing, route, navigate, useNavigate, useParams, registry, registre, app shell, shared, import]
paths: ["src/app/**", "src/screens/**", "src/modules/*/screens/**", "src/shared/components/**", "src/shared/context/**"]
@@ -8,18 +8,18 @@ triggers:
# App architecture
Comment le code de l'app est **structuré** et **assemblé**. Architecture *feature-based* : le code est organisé par **domaine métier** (module), pas par couche technique.
How the app's code is **structured** and **assembled**. *Feature-based* architecture: the code is organized by **business domain** (module), not by technical layer.
**À lire en premier :** [[rule_module-imports]] — l'invariant central qui garde les modules découplés.
**Read this first:** [[rule_module-imports]] — the central invariant that keeps modules decoupled.
## Liens
## Links
- [[knowledge_module-structure]] — arborescence modules + couche `shared/`
- [[knowledge_app-shell]] — `src/app/`, pile de providers, points d'entrée
- [[knowledge_routing]] — routing path-based (History API), table de routes, hooks
- [[knowledge_screens]] — inventaire des écrans, registre, lib de composants
- [[knowledge_screen-pattern]] — anatomie canonique d'un écran (sans props, layout flex, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` (principal) vs `currentUser.id` (NURI de profil) : deux espaces d'id non interchangeables
- [[knowledge_styling-system]] — `src/index.css`, classes `app-*`, vars, pièges (Tailwind non-utilisé, `user-content` inerte)
- [[cookbook_add-screen]] — procédure pour câbler un nouvel écran (registre + router + shell)
- `tech-stack` — build, bundler Bun, commandes
- [[knowledge_module-structure]] — module tree + `shared/` layer
- [[knowledge_app-shell]] — `src/app/`, provider stack, entry points
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
- [[knowledge_screens]] — screen inventory, registry, component library
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` (principal) vs `currentUser.id` (profile NURI): two id spaces that are not interchangeable
- [[knowledge_styling-system]] — `src/index.css`, `app-*` classes, vars, pitfalls (Tailwind unused, `user-content` inert)
- [[cookbook_add-screen]] — procedure for wiring up a new screen (registry + router + shell)
- `tech-stack` — build, Bun bundler, commands
@@ -1,28 +1,28 @@
---
type: caveat
summary: Un écran manipule DEUX ids de l'utilisateur courant qui ne sont pas interchangeables — currentUserId (principal urn:festipod:user:…) pour les queries de participation/amitié, currentUser.id (NURI du profil) pour comparer aux profils rendus ; se tromper ne lève aucune erreur, ça rend une liste vide ou se compte soi-même comme un participant inconnu
summary: A screen juggles TWO ids for the current user that are not interchangeable — currentUserId (principal urn:festipod:user:…) for participation/friendship queries, currentUser.id (profile NURI) to compare against rendered profiles; getting it wrong raises no error, it just yields an empty list or counts you as an unknown participant
last_checked: 2026-07-27
---
# Piège : deux ids de l'utilisateur courant dans un écran
# Pitfall: two ids for the current user inside a screen
`useFestipodData()` expose **deux** identifiants de l'utilisateur courant. Ils vivent dans des **espaces différents** et ne sont **jamais égaux en mode connecté** :
`useFestipodData()` exposes **two** identifiers for the current user. They live in **different spaces** and are **never equal in connected mode**:
| Valeur | Espace | À quoi elle sert |
| Value | Space | What it is for |
|---|---|---|
| `currentUserId` | **principal** stable dérivé de l'identifiant de connexion (`urn:festipod:user:<clé>`) | c'est ce que les **participations** et **amitiés** stockent |
| `currentUser.id` | **NURI du document de profil** (`did:ng:…`) | c'est ce que portent les **profils** rendus |
| `currentUserId` | stable **principal** derived from the login identifier (`urn:festipod:user:<key>`) | this is what **participations** and **friendships** store |
| `currentUser.id` | **NURI of the profile document** (`did:ng:…`) | this is what rendered **profiles** carry |
En mode seed/demo les deux coïncident (`user-1`) — **le piège ne se manifeste qu'en connecté**, et jamais sous forme d'erreur : juste un résultat faux.
In seed/demo mode the two coincide (`user-1`) — **the pitfall only shows up when connected**, and never as an error: just a wrong result.
## La règle
## The rule
- Les queries qui **filtrent des participations/amitiés**`getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)`attendent le **principal**. Leur valeur par défaut (`currentUserId`) est correcte ; **ne leur passe pas** un `user.id` de profil, sinon la liste revient **vide**.
- `getEventParticipants(eventId)` rend des **profils**. Toute comparaison sur son résultat (typiquement « me retirer de la liste ») se fait donc sur **`currentUser?.id`**, jamais sur `currentUserId`.
- Queries that **filter participations/friendships**`getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)`expect the **principal**. Their default value (`currentUserId`) is correct; **do not pass them** a profile `user.id`, or the list comes back **empty**.
- `getEventParticipants(eventId)` returns **profiles**. Any comparison over its result (typically "remove myself from the list") therefore goes through **`currentUser?.id`**, never `currentUserId`.
## Ce que coûte l'erreur (observé)
## What the mistake costs (observed)
- Comparer `participant.id !== currentUserId` pour se filtrer soi-même **ne retire rien** : on apparaît dans sa propre liste, et comme la ligne n'est plus reconnue elle s'affiche en « participant inconnu ».
- Symétriquement, un écran qui affiche les événements d'**un autre utilisateur** à partir de son **id de profil** (`getUserEvents(viewedUser.id)`) rend une liste vide en connectéme cause.
- Comparing `participant.id !== currentUserId` to filter yourself out **removes nothing**: you show up in your own list, and since the row is no longer recognized it renders as « participant inconnu ».
- Symmetrically, a screen displaying **another user's** events from their **profile id** (`getUserEvents(viewedUser.id)`) yields an empty list when connectedsame cause.
La jointure participation→profil elle-même n'est **pas** l'affaire de l'écran : elle est faite dans le provider (`resolveParticipantUser`), à travers l'identifiant normalisé. Mécanique complète et invariant d'écriture/lecture : concept `data-layer`, [[knowledge_context-internals]].
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`), through the normalized identifier. Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
@@ -1,20 +1,20 @@
---
type: cookbook
summary: Procédure pour ajouter un écran — créer le composant dans le module, l'enregistrer dans src/screens/index.ts, ajouter la route dans router.tsx, le monter dans App.tsx, et un alias screenNameMap si testé en BDD
summary: Procedure for adding a screen — create the component in the module, register it in src/screens/index.ts, add the route in router.tsx, mount it in App.tsx, plus a screenNameMap alias if it is covered by BDD
---
# Cookbook : ajouter un écran
# Cookbook: adding a screen
Un écran doit être câblé à **plusieurs endroits** — en oublier un produit des bugs silencieux (cf. le cas `ConnectScreen`, [[knowledge_screens]]).
A screen has to be wired up in **several places** — forgetting one produces silent bugs (see the `ConnectScreen` case, [[knowledge_screens]]).
1. **Créer le composant** : `src/modules/{module}/screens/MyScreen.tsx`, en suivant [[knowledge_screen-pattern]] (fonction sans props, `useFestipodData`/`useNavigate`/`useParams`, layout flex, style via [[knowledge_styling-system]]). Respecter [[rule_module-imports]] (importer seulement depuis `shared/`).
1. **Create the component**: `src/modules/{module}/screens/MyScreen.tsx`, following [[knowledge_screen-pattern]] (function with no props, `useFestipodData`/`useNavigate`/`useParams`, flex layout, styling via [[knowledge_styling-system]]). Respect [[rule_module-imports]] (import only from `shared/`).
2. **Enregistrer dans le registre** : `src/screens/index.ts` — ajouter l'import + l'entrée (`id`, `name` FR, `path`, `component`). **Étape la plus oubliée** : un écran absent du registre est invisible à Storybook et aux consommateurs du registre, même s'il fonctionne en route.
2. **Add it to the registry**: `src/screens/index.ts` — add the import plus the entry (`id`, French `name`, `path`, `component`). **The most frequently forgotten step**: a screen missing from the registry is invisible to Storybook and to the registry's consumers, even though it works as a route.
3. **Ajouter la route** : `src/app/router.tsx`étendre le type `Route`, ajouter le cas dans `parsePath()` (et la conversion inverse si présente).
3. **Add the route**: `src/app/router.tsx`extend the `Route` type, add the case in `parsePath()` (and the reverse conversion if present).
4. **Monter dans le shell** : `src/app/App.tsx` — ajouter le cas dans le switch qui mappe `route.page` → composant.
4. **Mount it in the shell**: `src/app/App.tsx` — add the case in the switch that maps `route.page` → component.
5. **(Si testé en BDD)** : ajouter un alias dans `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) si le nom français du `.feature` ne se résout pas trivialement vers l'`id`. Voir concept `bdd-testing`.
5. **(If covered by BDD)**: add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) if the French name used in the `.feature` does not resolve trivially to the `id`. See concept `bdd-testing`.
> Vérifier la cohérence : l'`id` doit être identique entre le registre, le router et `screenNameMap`. Un écart silencieux = écran injoignable ou non rendu.
> Check consistency: the `id` must be identical across the registry, the router and `screenNameMap`. A silent mismatch = an unreachable or unrendered screen.
@@ -1,50 +1,50 @@
---
type: knowledge
summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (Theme > NextGraph > Account > FestipodData > Router), AuthGate garde tout écran routé derrière la barrière d'accès, et le shell bascule l'écran selon la route
summary: src/app/ is the app's real shell — App.tsx stacks the providers (Theme > NextGraph > Account > FestipodData > Router), AuthGate keeps every routed screen behind the access barrier, and the shell switches screens according to the route
last_checked: 2026-07-27
---
# App shell
`src/app/` est le **shell de l'app réelle** (mobile web app), pas un outil de prototypage.
`src/app/` is the **shell of the real app** (mobile web app), not a prototyping tool.
> Note de migration : d'anciennes notes décrivaient `src/app/` comme un « prototyping tool » en routing par hash (`#/`, `#/demo/...`). C'est **périmé** depuis la restructuration en vraie app. La vérité courante : routing path-based via History API (voir [[knowledge_routing]]).
> Migration note: older notes described `src/app/` as a "prototyping tool" using hash routing (`#/`, `#/demo/...`). That is **obsolete** since the restructuring into a real app. The current truth: path-based routing via the History API (see [[knowledge_routing]]).
## Pile de providers
## Provider stack
`App.tsx` empile les providers puis bascule l'écran selon la route courante :
`App.tsx` stacks the providers, then switches screens according to the current route:
```
ThemeProvider
└ NextGraphProvider (cycle de connexion NextGraph — concept data-layer)
└ AccountProvider (identité courante = l'identifiant — concept app-security)
└ FestipodDataProvider (données, mode connected/demo — concept data-layer)
└ RouterProvider (route courante + navigate)
└ NextGraphProvider (NextGraph connection cycle — concept data-layer)
└ AccountProvider (current identity = the identifier — concept app-security)
└ FestipodDataProvider (data, connected/demo mode — concept data-layer)
└ RouterProvider (current route + navigate)
└ div.app-container
├ AuthGate (barrière d'accès)
│ └ AppContent (switch route.page → écran)
├ AuthGate (access barrier)
│ └ AppContent (switch route.page → screen)
└ ToastContainer
```
`AppContent` lit `useRouter()` pour résoudre `route.page`écran à rendre.
`AppContent` reads `useRouter()` to resolve `route.page`the screen to render.
### Invariants d'ordre (ce qui casse si tu déplaces une couche)
### Ordering invariants (what breaks if you move a layer)
- **`AccountProvider` est AU-DESSUS de `FestipodDataProvider`.** Le provider de données appelle `useAccount()` pour dériver son principal (`currentUserId`) *et* pour réinitialiser sa session au changement d'identité. Inverser l'ordre casse toute la résolution d'identité, silencieusement.
- **`AuthGate` est À L'INTÉRIEUR du router** : il lit `useRouter()`/`useNavigate()` pour quitter la route d'accueil déconnectée une fois connecté **et** identifié. Le sortir du `RouterProvider` le casse.
- **`AuthGate` enveloppe TOUT écran routé.** Tant que le wallet n'est pas ouvert **ou** que l'identifiant n'est pas résolu, `AccessGateScreen` est rendu **à la place** d'`AppContent`. Conséquence : **aucun écran ne peut supposer qu'il est atteignable sans identité**sauf barrière désactivée (voir ci-dessous).
- **`ToastContainer` est HORS d'`AuthGate`** (mais dans `.app-container`) : il est monté quel que soit l'état de la barrière.
- **`AccountProvider` sits ABOVE `FestipodDataProvider`.** The data provider calls `useAccount()` to derive its principal (`currentUserId`) *and* to reset its session when the identity changes. Reversing the order breaks the whole identity resolution, silently.
- **`AuthGate` sits INSIDE the router**: it reads `useRouter()`/`useNavigate()` to leave the logged-out landing route once connected **and** identified. Moving it out of `RouterProvider` breaks it.
- **`AuthGate` wraps EVERY routed screen.** As long as the wallet is not open **or** the identifier is not resolved, `AccessGateScreen` is rendered **instead of** `AppContent`. Consequence: **no screen may assume it is reachable without an identity**unless the barrier is disabled (see below).
- **`ToastContainer` sits OUTSIDE `AuthGate`** (but inside `.app-container`): it is mounted regardless of the barrier's state.
### Désactivation de la barrière (deux consommateurs)
### Disabling the barrier (two consumers)
`AuthGate` est **ON par défaut** ; il ne s'efface que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`, posé soit par `build.ts` (depuis `ACCESS_GATE_DISABLED=1`, build sans barrière), soit par le harness de test via `addInitScript` pour les `@e2e` (qui exercent les écrans, pas le flux d'auth). **Impact** : le flux de barrière n'est donc **pas** couvert par les `@e2e` — ses gardes sont des tests `@ui` (concept `bdd-testing`).
`AuthGate` is **ON by default**; it steps aside only if `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`, set either by `build.ts` (from `ACCESS_GATE_DISABLED=1`, barrier-free build) or by the test harness via `addInitScript` for the `@e2e` tests (which exercise the screens, not the auth flow). **Impact**: the barrier flow is therefore **not** covered by the `@e2e` tests — its guards are `@ui` tests (concept `bdd-testing`).
## Points d'entrée
## Entry points
| Fichier | Rôle |
| File | Role |
|---|---|
| `src/index.ts` | `Bun.serve()`serveur HTTP, sert `index.html` + rapport cucumber |
| `src/index.html` | Entrée HTML, charge `src/app/frontend.tsx` |
| `src/app/frontend.tsx` | Racine React, rend `<App />` |
| `src/index.ts` | `Bun.serve()`HTTP server, serves `index.html` + the cucumber report |
| `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` |
| `src/app/frontend.tsx` | React root, renders `<App />` |
Le build et le bundler (Bun + Tailwind, alias `@/* → ./src/*`) sont documentés dans le concept `tech-stack`.
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
@@ -1,41 +1,41 @@
---
type: knowledge
summary: Arborescence feature-based — modules métier (event, user, home, auth, workshop, meeting, notification) et couche shared/ importable par tous
summary: Feature-based tree — business modules (event, user, home, auth, workshop, meeting, notification) and a shared/ layer importable by all of them
---
# Structure des modules
# Module structure
Le code est organisé par **domaine métier**, pas par couche technique.
The code is organized by **business domain**, not by technical layer.
```
src/modules/
event/ # Événements : CRUD, discovery, participants, points de rencontre
user/ # Profils, connexions (« amis »), partage
event/ # Events: CRUD, discovery, participants, meeting points
user/ # Profiles, connections ("friends"), sharing
home/ # Dashboard, settings
auth/ # Login, welcome/onboarding
workshop/ # Specs atelier (features seulement, pas d'écrans)
meeting/ # Specs point de rencontre (features seulement)
notification/ # Specs notification (features seulement)
workshop/ # Workshop specs (features only, no screens)
meeting/ # Meeting point specs (features only)
notification/ # Notification specs (features only)
```
Chaque module peut contenir :
- `screens/`composants d'écran React
- `features/` fichiers Gherkin `.feature` (specs BDD, voir concept `bdd-testing`)
- `steps/{ui,data,e2e}/` — step definitions Cucumber par couche
Each module may contain:
- `screens/`React screen components
- `features/` — Gherkin `.feature` files (BDD specs, see concept `bdd-testing`)
- `steps/{ui,data,e2e}/` Cucumber step definitions, one folder per layer
## Couche `shared/`
## The `shared/` layer
`src/shared/` contient tout le réutilisable inter-modules :
`src/shared/` holds everything reusable across modules:
| Répertoire | Contenu |
| Directory | Contents |
|---|---|
| `components/` | Lib de composants UI (voir [[knowledge_screens]]) |
| `context/` | `ThemeContext`, `NextGraphContext`, `AccountContext` (identité courante — concept `app-security`), `FestipodDataContext` (concept `data-layer`) ; leur **ordre d'empilement** est contraint, voir [[knowledge_app-shell]] |
| `data/` | User stories, `features.ts` (auto-généré), `seedData.ts`, `types.ts` |
| `components/` | UI component library (see [[knowledge_screens]]) |
| `context/` | `ThemeContext`, `NextGraphContext`, `AccountContext` (current identity — concept `app-security`), `FestipodDataContext` (concept `data-layer`); their **stacking order** is constrained, see [[knowledge_app-shell]] |
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
| `shapes/` | SHEX + bindings ORM (voir concept `data-layer`) |
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts` |
| `steps/`, `support/` | Step definitions et hooks Cucumber partagés (concept `bdd-testing`) |
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
| `lib/` | Helpers (`cn`, etc.) |
La règle dependance entre modules et `shared/` est dans [[rule_module-imports]].
The dependency rule between modules and `shared/` lives in [[rule_module-imports]].
@@ -1,15 +1,15 @@
---
type: knowledge
summary: Routing path-based via History API (router maison dans src/app/router.tsx) — table de routes, hooks useNavigate/useParams, pas de prop drilling
summary: Path-based routing via the History API (hand-rolled router in src/app/router.tsx) — route table, useNavigate/useParams hooks, no prop drilling
---
# Routing
Routing **path-based** via l'History API — router maison dans `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). Pas de routing par hash.
**Path-based** routing via the History API — hand-rolled router in `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). No hash routing.
## Table de routes
## Route table
| Path | Écran |
| Path | Screen |
|---|---|
| `/` | WelcomeScreen |
| `/home` | HomeScreen |
@@ -24,12 +24,12 @@ Routing **path-based** via l'History API — router maison dans `src/app/router.
| `/profile/edit` | UpdateProfileScreen |
| `/profile/friends` | FriendsListScreen |
| `/profile/share` | ShareProfileScreen |
| `/profile/connect` | (connexion) |
| `/profile/connect` | (connect) |
| `/users/:id` | UserProfileScreen |
| `/settings` | SettingsScreen |
> Cette table reflète `parsePath()` dans `router.tsx` — y revenir si elle évolue, c'est la source de vérité. Il n'y a **pas** de route d'authentification : la barrière d'accès n'est pas routée, elle est rendue *au-dessus* du switch de routes par `AuthGate` (voir [[knowledge_app-shell]]).
> This table mirrors `parsePath()` in `router.tsx` — go back to it whenever it changes, that is the source of truth. There is **no** authentication route: the access barrier is not routed, it is rendered *above* the route switch by `AuthGate` (see [[knowledge_app-shell]]).
## Hooks
Les écrans utilisent `useNavigate()` et `useParams()` du router — **pas de prop drilling**. Le shell intercepte la navigation pour basculer l'écran affiché (voir [[knowledge_app-shell]]).
Screens use the router's `useNavigate()` and `useParams()` **no prop drilling**. The shell intercepts navigation to switch the displayed screen (see [[knowledge_app-shell]]).
@@ -1,32 +1,32 @@
---
type: knowledge
summary: Anatomie canonique d'un écran — fonction nommée sans props, lit tout via useFestipodData/useNavigate/useParams, layout flex colonne (Header / contenu scrollable / BottomNav pour les écrans hub), feedback via showToast, libellés français en dur
summary: Canonical anatomy of a screen — named function with no props, reads everything through useFestipodData/useNavigate/useParams, flex column layout (Header / scrollable content / BottomNav on hub screens), feedback via showToast, hard-coded French labels
---
# Pattern canonique d'un écran
# Canonical screen pattern
Tous les écrans suivent la même forme. La connaître évite de réinventer ou de diverger.
Every screen follows the same shape. Knowing it avoids reinventing or diverging.
## Forme
## Shape
```tsx
export function MyScreen() { // fonction nommée, JAMAIS de props
export function MyScreen() { // named function, NEVER any props
const navigate = useNavigate();
const { eventId, userId } = useParams();
const { getEvent, currentUser, } = useFestipodData();
const [local, setLocal] = useState(); // état local d'écran (étapes, sélections)
const [local, setLocal] = useState(); // screen-local state (steps, selections)
const handleAction = () => {
// …muter via useFestipodData
// …mutate through useFestipodData
showToast('Message', 'success'); // feedback
navigate('/path');
};
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
<Header title="…" /* left/right optionnels */ />
<div style={{ flex:1, overflow:'auto' }}>{/* contenu scrollable */}</div>
<BottomNav active="…" /> {/* seulement sur les écrans hub */}
<Header title="…" /* left/right optional */ />
<div style={{ flex:1, overflow:'auto' }}>{/* scrollable content */}</div>
<BottomNav active="…" /> {/* only on hub screens */}
</div>
);
}
@@ -34,13 +34,13 @@ export function MyScreen() { // fonction nommée, JAMAIS de props
## Invariants
- **Zéro prop** : l'écran ne reçoit rien ; tout vient du contexte/hooks (`useFestipodData`, `useNavigate`, `useParams`). Deux exceptions, de nature différente :
- `WelcomeScreen` n'utilise pas `useFestipodData` (intro) — mais reste sans props. (`LoginScreen`/`ConnexionScreen` n'existent plus.)
- **`AccessGateScreen` est la seule vraie exception au zéro-prop** : ce n'est **pas un écran routé**, il est rendu par `src/app/AuthGate.tsx` qui lui passe `status`/`error`/`initialIdentifier`/`onEnter`. Il est donc **hors registre et hors table de routes**, et n'a accès ni au router ni aux données. Voir [[knowledge_screens]] et [[knowledge_app-shell]].
- **Identité : deux espaces d'id.** `currentUserId` (principal) et `currentUser.id` (NURI de profil) ne sont **pas** interchangeables selon la query — voir [[caveat_identity-ids-in-screens]] avant de comparer un id dans un écran.
- **Layout** : flex colonne pleine hauteur ; `Header` en haut, contenu en `flex:1; overflow:auto`, `BottomNav` en bas **uniquement pour les écrans hub** (Home, Events, Profile, Friends). Les écrans de flux (création, édition, détail) n'ont pas de `BottomNav`.
- **Feedback** : `showToast(message, 'success'|'info'|'error')` (mécanisme `ToastContainer` exporté par `sketchy/`).
- **Libels** : **français, en dur**aucun i18n, aucune clé de traduction dans le projet.
- Style : voir [[knowledge_styling-system]]. Navigation/registre : [[knowledge_routing]], [[knowledge_screens]].
- **Zero props**: a screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). Two exceptions, of different kinds:
- `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen` no longer exist.)
- **`AccessGateScreen` is the only genuine exception to the zero-prop rule**: it is **not a routed screen**, it is rendered by `src/app/AuthGate.tsx`, which passes it `status`/`error`/`initialIdentifier`/`onEnter`. It therefore sits **outside the registry and outside the route table**, and has access to neither the router nor the data. See [[knowledge_screens]] and [[knowledge_app-shell]].
- **Identity: two id spaces.** `currentUserId` (principal) and `currentUser.id` (profile NURI) are **not** interchangeable depending on the query — see [[caveat_identity-ids-in-screens]] before comparing an id inside a screen.
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
- **Labels**: **French, hard-coded** — no i18n, no translation keys anywhere in the project.
- Styling: see [[knowledge_styling-system]]. Navigation/registry: [[knowledge_routing]], [[knowledge_screens]].
Pour **créer** un écran (les 3+ endroits à câbler), voir [[cookbook_add-screen]].
To **create** a screen (the 3+ places to wire up), see [[cookbook_add-screen]].
@@ -1,42 +1,42 @@
---
type: knowledge
summary: Inventaire des écrans par module, registre central src/screens/index.ts, et lib de composants sous shared/components/sketchy/ — dont le NOM est conservé mais qui rend un thème moderne (pas hand-drawn)
summary: Inventory of screens per module, central registry src/screens/index.ts, and the component library under shared/components/sketchy/ — whose NAME is kept but which renders a modern theme (not hand-drawn)
---
# Écrans et composants
# Screens and components
## Lib de composants : `sketchy/` = thème moderne
## Component library: `sketchy/` = modern theme
⚠️ **Piège de nommage.** La lib de composants vit sous `src/shared/components/sketchy/` (chemin conservé, importé par ~17 écrans), **mais elle ne rend plus un style « hand-drawn »** : elle a été portée vers un thème **moderne** (DM Sans / orange, classes `app-*`). Le *chemin d'import* est bon, la *description visuelle « sketchy »* est périmée. Ne pas réintroduire d'esthétique dessinée en se fiant au nom du dossier.
⚠️ **Naming pitfall.** The component library lives under `src/shared/components/sketchy/` (path kept, imported by ~17 screens), **but it no longer renders a "hand-drawn" style**: it was ported to a **modern** theme (DM Sans / orange, `app-*` classes). The *import path* is right, the *"sketchy" visual description* is obsolete. Do not reintroduce hand-drawn aesthetics on the strength of the folder name.
Composants typiques : `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
Typical components: `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
## Registre d'écrans
## Screen registry
`src/screens/index.ts` importe tous les écrans de tous les modules et expose :
`src/screens/index.ts` imports every screen from every module and exposes:
```typescript
export const screenGroups // groupés par domaine (home, events, user, general)
export const screens // liste à plat
export const screenGroups // grouped by domain (home, events, user, general)
export const screens // flat list
export function getScreen(id): Screen | undefined
```
Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les écrans.
Used notably by Storybook (see concept `tech-stack`) to browse the screens.
## Inventaire
## Inventory
Écrans par module (IDs = clés du registre) :
Screens per module (IDs = registry keys):
- **home/** : `welcome`, `home`, `settings`
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
- **auth/** : `WelcomeScreen` (intro, routé `/`) et `AccessGateScreen`la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé) et **pilotée par props** (`status`/`error`/`initialIdentifier`/`onEnter`), seule exception au zéro-prop ([[knowledge_screen-pattern]]). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
- **home/**: `welcome`, `home`, `settings`
- **event/**: `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
- **user/**: `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
- **auth/**: `WelcomeScreen` (intro, routed at `/`) and `AccessGateScreen`the **access barrier** (NextGraph login + identifier entry), rendered by `src/app/AuthGate.tsx`, **outside the registry and outside routing** (it is not a routed screen) and **driven by props** (`status`/`error`/`initialIdentifier`/`onEnter`), the only exception to the zero-prop rule ([[knowledge_screen-pattern]]). The former `LoginScreen`, then `ConnexionScreen`, have been removed (see concept `app-security`, [[knowledge_authentication]]).
Structurellement, cet écran ne rend **pas** un layout d'écran standard mais un **choix entre trois branches d'accès** mutuellement exclusives, pilotées par `status` + la présence d'un wallet partagé. **Impact** : un nouveau cas d'accès s'ajoute comme une branche ici, **pas** comme une route. Le contenu et l'ordre des branches sont doctrine `app-security` ([[knowledge_authentication]]) — ne pas les redéfinir depuis ici.
Structurally, this screen does **not** render a standard screen layout but a **choice between three mutually exclusive access branches**, driven by `status` + the presence of a shared wallet. **Impact**: a new access case is added as a branch here, **not** as a route. The content and ordering of the branches are `app-security` doctrine ([[knowledge_authentication]]) — do not redefine them from here.
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); exceptions: `WelcomeScreen` and the `AccessGateScreen` barrier.
## Piège : registre incomplet
## Pitfall: incomplete registry
Le registre doit lister **tous** les écrans. Cas observé : `ConnectScreen` (`src/modules/user/screens/`, routé `/profile/connect`, monté dans `App.tsx`) est **absent de `src/screens/index.ts`** → invisible à Storybook et aux consommateurs du registre, bien qu'il fonctionne en route. Toujours vérifier que l'écran est enregistré (cf. [[cookbook_add-screen]]).
The registry must list **every** screen. Observed case: `ConnectScreen` (`src/modules/user/screens/`, routed at `/profile/connect`, mounted in `App.tsx`) is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]).
@@ -1,32 +1,32 @@
---
type: knowledge
summary: src/index.css est la source de vérité du style — variables --app-* (couleurs, rayons, police DM Sans) et classes app-* rendues par les composants ; les écrans combinent ces classes avec des styles inline ; Tailwind est dans le build mais les écrans n'utilisent pas d'utilitaires Tailwind ; la classe user-content est inerte
summary: src/index.css is the styling source of truth — --app-* variables (colors, radii, DM Sans font) and app-* classes rendered by the components; screens combine those classes with inline styles; Tailwind is in the build but screens use no Tailwind utilities; the user-content class is inert
last_checked: 2026-06-15
---
# Système de style
# Styling system
**Source de vérité : `src/index.css`** (thème « Modern clean — DM Sans »). C'est là que vivent les variables CSS et les classes `app-*`. Pas de fichiers CSS par module.
**Source of truth: `src/index.css`** ("Modern clean — DM Sans" theme). That is where the CSS variables and the `app-*` classes live. No per-module CSS files.
## Variables (`:root`)
- Couleurs : `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, accent orange `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), vert `--app-green #22543D` (+ `-light`, `-border`, `-text`).
- Rayons : `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
- Police : `--font-app: 'DM Sans', …`.
- Colors: `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, orange accent `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), green `--app-green #22543D` (+ `-light`, `-border`, `-text`).
- Radii: `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
- Font: `--font-app: 'DM Sans', …`.
## Classes `app-*`
## `app-*` classes
Définies dans `index.css`, rendues par les composants de `shared/components/sketchy/` : `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
Defined in `index.css`, rendered by the components in `shared/components/sketchy/`: `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
## Conventions d'écriture d'un écran
## Conventions for writing a screen
- Utiliser les **composants `sketchy/`** (qui portent les classes `app-*`) pour boutons/inputs/cartes/typo.
- Pour le **layout** (flex, gaps, paddings, couleurs ponctuelles), les écrans utilisent des **styles inline** (`style={{…}}`) — c'est le pattern normal, pas une déviation.
- Icônes : **emojis**/symboles Unicode (📅 📍 📝 🎪…), pas d'imports d'icônes en général.
- Largeur : `.app-container` borne à **`max-width: 768px`, `height: 100dvh`** (mobile-first/tablette portrait). Aucune media query — pas de responsive desktop.
- Use the **`sketchy/` components** (which carry the `app-*` classes) for buttons/inputs/cards/typography.
- For **layout** (flex, gaps, paddings, one-off colors), screens use **inline styles** (`style={{…}}`) — that is the normal pattern, not a deviation.
- Icons: **emojis**/Unicode symbols (📅 📍 📝 🎪…), generally no icon imports.
- Width: `.app-container` caps at **`max-width: 768px`, `height: 100dvh`** (mobile-first/portrait tablet). No media queries — no desktop responsiveness.
## Pièges
## Pitfalls
- **Tailwind est dans le build** (plugin `bun-plugin-tailwind`, dépendance `tailwindcss`), mais **les écrans n'utilisent pas de classes utilitaires Tailwind** — le style réel passe par `app-*` + inline. Ne pas « tailwindiser » un écran en pensant suivre la convention.
- **`user-content` est une classe INERTE** : utilisée sur de nombreux titres/noms dans les écrans, **sans aucune définition CSS**. C'est un marqueur legacy sans effet — ne pas s'appuyer dessus pour styler, ne pas croire qu'elle fait quelque chose.
- Pas de **dark mode** : le toggle « darkMode » de `SettingsScreen` n'est branché à rien.
- **Tailwind is in the build** (`bun-plugin-tailwind` plugin, `tailwindcss` dependency), but **screens use no Tailwind utility classes** — the actual styling goes through `app-*` + inline. Do not "tailwindify" a screen thinking you are following the convention.
- **`user-content` is an INERT class**: used on many titles/names across the screens, **with no CSS definition whatsoever**. It is a legacy marker with no effect — do not rely on it for styling, do not assume it does anything.
- No **dark mode**: the "darkMode" toggle in `SettingsScreen` is not wired to anything.
@@ -1,24 +1,24 @@
---
type: rule
summary: Un module n'importe QUE depuis shared/ (et le registre d'écrans) — jamais depuis un autre module ; c'est l'invariant qui garde l'architecture feature-based
summary: A module imports ONLY from shared/ (and the screen registry) — never from another module; this is the invariant that keeps the feature-based architecture real
---
# Règle : un module n'importe jamais d'un autre module
# Rule: a module never imports from another module
**Les modules importent uniquement depuis `shared/` — jamais entre eux.**
**Modules import only from `shared/` — never from each other.**
```
src/modules/event/screens/EventDetailScreen.tsx
✅ import depuis 'shared/components/...'
✅ import depuis 'shared/context/FestipodDataContext'
✅ import depuis 'src/screens' (types du registre)
❌ import depuis 'modules/user/screens/...'
✅ import from 'shared/components/...'
✅ import from 'shared/context/FestipodDataContext'
✅ import from 'src/screens' (registry types)
❌ import from 'modules/user/screens/...'
```
## Pourquoi
## Why
C'est ce qui rend l'architecture *feature-based* réelle et pas cosmétique : chaque domaine reste un bloc autonome, déplaçable/supprimable sans casser les autres. Tout besoin partagé **remonte dans `shared/`** ; toute dépendance inter-domaines passe par un contrat de `shared/` (souvent `FestipodDataContext` ou le registre d'écrans), jamais par un import direct.
This is what makes the *feature-based* architecture real rather than cosmetic: each domain stays a self-contained block, movable or removable without breaking the others. Any shared need **moves up into `shared/`**; any cross-domain dependency goes through a `shared/` contract (usually `FestipodDataContext` or the screen registry), never through a direct import.
## Vérifier
## How to check
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` ne doit rien remonter d'un module vers un *autre* module. Un import qui croise deux noms de modules différents est une violation.
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` must not surface anything pointing from one module to *another* module. An import that crosses two different module names is a violation.
+12 -12
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: curité & confidentialité de Festipodl'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
summary: Festipod's security & privacy — isolation between scopes is enforced by the data SDK, the app trusts it and carries no authorization logic in the screens; wallet-based authentication; target authorization matrix still incubating
triggers:
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, overlay, cap-less, identité, login, scope, isolation]
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
@@ -8,19 +8,19 @@ triggers:
# App security
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
Festipod's **security, privacy and authorization** model.
- **Modèle appliqué** — l'**isolation entre périmètres** (public / protected / private) est **assurée par le SDK de données** (`@ng-eventually/client`), qui n'expose à chaque utilisateur que ce à quoi il a droit. L'app **fait confiance** au SDK : aucun écran ne porte de logique d'autorisation. Voir [[knowledge_trust-model]].
- **Matrice d'autorisations cible** — le détail *qui peut faire quoi* par acteur × verbe (données personnelles = réseau, anonymat via inbox de notification) : [[brief_2026-05-18_authorization-matrix]]. **Incubation.** Graduera en `rule_`/`behavior_` à mesure que le produit se cale.
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/client`), which exposes to each user only what they are entitled to. The app **trusts** the SDK: no screen carries authorization logic. See [[knowledge_trust-model]].
- **Target authorization matrix** — the detail of *who may do what* per actor × verb (personal data = network, anonymity through the notification inbox): [[brief_2026-05-18_authorization-matrix]]. **Incubating.** It will graduate into `rule_`/`behavior_` as the product settles.
## Pièges (lire AVANT de concevoir quoi que ce soit d'« anonyme »)
## Pitfalls (read BEFORE designing anything "anonymous")
- [[caveat_stable-overlay-pseudonym]] — une référence cap-less expose un **pseudonyme permanent** de la personne ; un seul recoupement dé-anonymise **rétroactivement** tout son historique, et aucune rotation n'est connue
- [[caveat_shared-wallet-global-before-gate-import]] — le wallet partagé étant l'unique mode, un global de mot de passe posé **après** l'import de la barrière la rend inutilisable (écran d'erreur de config, aucun champ)
- [[caveat_stable-overlay-pseudonym]] — a cap-less reference exposes a **permanent pseudonym** of the person; a single cross-reference de-anonymizes their entire history **retroactively**, and no rotation is known
- [[caveat_shared-wallet-global-before-gate-import]] — since the shared wallet is the only mode, a password global set **after** the barrier has been imported makes it unusable (config error screen, no field at all)
## Liens
## Links
- [[knowledge_trust-model]] — l'app délègue l'isolation au SDK, pas de contrôle d'accès dans les écrans
- [[knowledge_authentication]] — auth par wallet, tous authentifiés, pas d'accès anonyme
- [[brief_2026-05-18_authorization-matrix]] — matrice d'autorisations cible (incubation)
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — quel scope pour quelle entité (fait produit)
- [[knowledge_trust-model]] — the app delegates isolation to the SDK, no access control in the screens
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access
- [[brief_2026-05-18_authorization-matrix]] — target authorization matrix (incubating)
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — which scope for which entity (product fact)
@@ -1,126 +1,126 @@
---
type: brief
summary: Matrice d'autorisations cible par type de donnée (PdR, inscription, événement, profil, connexion) exprimée en scopes public/protected/private + dialog ; décisions cadre acquises (tous authentifiés, PdR publics, données personnelles = réseau, notification par inbox identifiée-ou-anonyme) ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) expressed as public/protected/private + dialog scopes; settled framing decisions (everyone authenticated, public meeting points, personal data = network, notification through an identified-or-anonymous inbox); open questions on the event write model and on host identity
last_updated: 2026-05-18
---
# Matrice d'autorisations et inventaire des requêtes
# Authorization matrix and query inventory
**Status:** Incubating — modèle cible, non figé en règles.
**Status:** Incubating — target model, not frozen into rules.
## Context
Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dérive de : (1) une matrice d'autorisations par acteur × verbe ; (2) l'inventaire des requêtes par écran ; (3) les **périmètres** (scopes) qui en découlent — données partageant à la fois autorisation *et* schéma d'accès. Le placement concret entité → scope est un fait produit : concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. L'isolation est **assurée par le SDK de données** ([[knowledge_trust-model]]).
The **target** model of who-may-do-what. Festipod's privacy derives from: (1) an authorization matrix per actor × verb; (2) the inventory of queries per screen; (3) the **scopes** that follow from them — data sharing both an authorization *and* an access pattern. The concrete entity → scope placement is a product fact: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Isolation is **enforced by the data SDK** ([[knowledge_trust-model]]).
## Cadre
## Framing
### Acteurs (tous authentifiés)
### Actors (all authenticated)
`Alice` (point de vue, propriétaire de la donnée en focus) · `Bob` (second protagoniste, relations bilatérales) · `D` (déclarant d'événement) · `H` (hôte d'un PdR) · `I` (inscrit) · `C` (connexion) · `U` (utilisateur lambda sans relation).
`Alice` (viewpoint, owner of the data in focus) · `Bob` (second protagonist, bilateral relationships) · `D` (event declarer) · `H` (host of a meeting point) · `I` (registrant) · `C` (connection) · `U` (plain user with no relationship).
### Verbes
### Verbs
`créer` · `lire` (one-shot) · `s'abonner` (lecture réactive) · `modifier` · `supprimer`. Conventions : `✓` autorisé · `✗` interdit · `cond` sous condition · `—` sans objet.
`create` · `read` (one-shot) · `subscribe` (reactive read) · `update` · `delete`. Conventions: `✓` allowed · `✗` forbidden · `cond` conditional · `—` not applicable.
## Décisions cadre (acquises)
## Framing decisions (settled)
- **Tous authentifiés.** Pas d'accès anonyme.
- **Points de rencontre publics universels.** Tout utilisateur peut lire et s'abonner.
- **Création de PdR ouverte à tous.** Pas de prérequis.
- **Hôte = détenteur des droits d'écriture** sur un PdR (1 hôte, le créateur ; le fait d'être hôte est public).
- **Informations personnelles = réservées au réseau.** Visibles seulement au titulaire et à ses connexions : participations, intégralité du profil, liste de connexions, et tout état déclaratif dont la divulgation serait une fuite. Statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans le même utilisateur.
- **Connexion bilatérale.** Existe après acceptation des deux côtés. Deux objets : `DemandeDeConnexion` (unilatérale, transitoire) et `Connexion` (bilatérale, persistante).
- **Notification d'inscription via l'inbox du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le périmètre *protected* de l'inscrit, (b) dépôt d'un lien dans l'**inbox** du document PdR. L'expéditeur est **identifié si connexion de l'hôte, anonyme sinon** — propriété du modèle de données.
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
- **Everyone is authenticated.** No anonymous access.
- **Meeting points are universally public.** Any user may read and subscribe.
- **Creating a meeting point is open to all.** No prerequisite.
- **Host = holder of the write rights** on a meeting point (1 host, the creator; being the host is public).
- **Personal information = reserved to the network.** Visible only to the holder and their connections: participations, the profile in full, the connection list, and any declarative state whose disclosure would be a leak. "Public" status (meeting point, event) and "personal" status (profile, participations, connections) coexist within the same user.
- **Bilateral connection.** It exists once both sides have accepted. Two objects: `DemandeDeConnexion` (one-sided, transient) and `Connexion` (bilateral, persistent).
- **Registration notification through the meeting point's inbox.** The act of registering is composite: (a) writing an `Inscription` object into the registrant's *protected* scope, (b) dropping a link into the **inbox** of the meeting point document. The sender is **identified if they are one of the host's connections, anonymous otherwise** a property of the data model.
- **Joining a community / following: out of current scope.**
## Matrice par type de donnée
## Matrix per data type
### Point de rencontre
### Meeting point
| Verbe | Alice (= Hôte) | I (autre inscrit) | D (déclarant parent) | U (lambda) |
| Verb | Alice (= Host) | I (other registrant) | D (parent declarer) | U (plain user) |
|---|---|---|---|---|
| créer | ✓ (rend hôte) | — | ✗ | ✓ (rend hôte) |
| lire | ✓ | ✓ | ✓ | ✓ |
| s'abonner | ✓ | ✓ | ✓ | ✓ |
| modifier | ✓ | ✗ | ✗ | ✗ |
| supprimer | ✓ | ✗ | ✗ | ✗ |
| create | ✓ (becomes host) | — | ✗ | ✓ (becomes host) |
| read | ✓ | ✓ | ✓ | ✓ |
| subscribe | ✓ | ✓ | ✓ | ✓ |
| update | ✓ | ✗ | ✗ | ✗ |
| delete | ✓ | ✗ | ✗ | ✗ |
Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage UI, pas un droit, tout étant public). Le `D` n'a aucun droit particulier sur les PdR greffés sur son événement.
Notes: no `C` differentiation (connections are a UI display filter, not a right, since everything is public). `D` has no special right over the meeting points grafted onto their event.
### Inscription à un point de rencontre
### Registration to a meeting point
`Inscription` lie un utilisateur et un PdR. **Donnée personnelle** (inscrit + ses connexions). Acte composite (a)+(b) ci-dessus.
`Inscription` links a user and a meeting point. **Personal data** (registrant + their connections). Composite act (a)+(b) above.
| Verbe | Alice (inscrite) | C (connexion) | H (hôte) | I (autre inscrit) | U |
| Verb | Alice (registrant) | C (connection) | H (host) | I (other registrant) | U |
|---|---|---|---|---|---|
| créer (acte composite) | ✓ | — | ✗ | ✗ | ✓ (rend inscrite) |
| lire le contenu | ✓ | ✓ | cond : ✓ si H ∈ connexions(Alice) ; sinon lien opaque | cond : ✓ si I ∈ connexions(Alice) | ✗ |
| s'abonner | ✓ | ✓ | cond (idem) | cond (idem) | ✗ |
| lire l'inbox du PdR (entrées brutes) | — | — | ✓ | ✗ | ✗ |
| modifier | ? **à trancher** (selon champs) | ✗ | ✗ | ✗ | ✗ |
| supprimer | ✓ (se désinscrire ; retirer le lien de l'inbox si possible) | ✗ | cond : modération inbox seule (ne supprime pas l'objet) | ✗ | ✗ |
| create (composite act) | ✓ | — | ✗ | ✗ | ✓ (becomes registrant) |
| read the content | ✓ | ✓ | cond: ✓ if H ∈ connections(Alice); opaque link otherwise | cond: ✓ if I ∈ connections(Alice) | ✗ |
| subscribe | ✓ | ✓ | cond (same) | cond (same) | ✗ |
| read the meeting point's inbox (raw entries) | — | — | ✓ | ✗ | ✗ |
| update | ? **to be decided** (depends on the fields) | ✗ | ✗ | ✗ | ✗ |
| delete | ✓ (unregister; remove the link from the inbox if possible) | ✗ | cond: inbox moderation only (does not delete the object) | ✗ | ✗ |
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox**un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ?
**Host visibility: resolved** (identified if a connection, anonymous otherwise). **Open questions:** which fields of a registration are editable (a bare boolean, or + comment/status/companions?); **inbox-side deletion**can a depositor withdraw their link from a document they do not control?
### Événement
### Event
| Verbe | Alice (= D) | H (hôte d'un PdR greffé) | U |
| Verb | Alice (= D) | H (host of a grafted meeting point) | U |
|---|---|---|---|
| créer | ✓ (rend déclarant) | — | ✓ (rend déclarant) |
| lire / s'abonner | ✓ | ✓ | ✓ |
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
| supprimer | ? **à trancher** | ✗ | ✗ |
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
| read / subscribe | ✓ | ✓ | ✓ |
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
| delete | ? **to be decided** | ✗ | ✗ |
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
**Open questions:** who may **update** a declared event — the declarer alone (owner)? every user (wiki)? nobody (immutable)? Central to deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Who may **delete** it, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
### Profil utilisateur
### User profile
**Rien dans le profil n'est public.** Deux périmètres : **profil réseau** (Alice + connexions : nom, avatar, bio, ville, intérêts) ; **profil privé** (Alice seule : settings, email, préférences).
**Nothing in the profile is public.** Two scopes: the **network profile** (Alice + connections: name, avatar, bio, city, interests); the **private profile** (Alice alone: settings, email, preferences).
| Verbe | Alice | C | U |
| Verb | Alice | C | U |
|---|---|---|---|
| créer | ✓ (à l'inscription) | — | — |
| lire — réseau | ✓ | ✓ | ✗ |
| lire — privé | ✓ | ✗ | ✗ |
| s'abonner | ✓ | ✓ (réseau) | ✗ |
| modifier | ✓ | ✗ | ✗ |
| supprimer (compte) | ✓ | ✗ | ✗ |
| create | ✓ (at sign-up) | — | — |
| read — network | ✓ | ✓ | ✗ |
| read — private | ✓ | ✗ | ✗ |
| subscribe | ✓ | ✓ (network) | ✗ |
| update | ✓ | ✗ | ✗ |
| delete (account) | ✓ | ✗ | ✗ |
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par identité seule** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
**Tension to resolve:** a meeting point is readable by everyone, yet its host should not be identifiable by a plain user. Three positions: (i) **pseudonym through identity alone** (name/avatar resolved only for connections); (ii) **identity denormalized into the offer** (the host picks a "business card" per meeting point, living inside the meeting point object, profile closed); (iii) **host anonymity** (identity revealed only to connections). To be decided. Others: field-by-field composition of each scope; status of the `username` (public/network/dropped?).
### Connexion (lien d'amitié)
### Connection (friendship link)
Bilatérale. `DemandeDeConnexion` (unilatérale, en attente) → `Connexion` (bilatérale, à l'acceptation ; ouvre l'accès aux données personnelles). La liste de connexions d'Alice est **personnelle** (Alice + ses connexions).
Bilateral. `DemandeDeConnexion` (one-sided, pending) → `Connexion` (bilateral, upon acceptance; opens access to personal data). Alice's connection list is **personal** (Alice + her connections).
| Verbe | Alice (initiatrice) | Bob (autre côté) | C | U |
| Verb | Alice (initiator) | Bob (other side) | C | U |
|---|---|---|---|---|
| créer la demande | ✓ | — | — | — |
| accepter | — | ✓ | — | ✗ |
| lire la liste d'Alice | ✓ | ✓ | ✓ | ✗ |
| s'abonner | ✓ | ✓ | ✓ | ✗ |
| supprimer (rompre A↔B) | ✓ | ✓ | ✗ | ✗ |
| create the request | ✓ | — | — | — |
| accept | — | ✓ | — | ✗ |
| read Alice's list | ✓ | ✓ | ✓ | ✗ |
| subscribe | ✓ | ✓ | ✓ | ✗ |
| delete (break A↔B) | ✓ | ✓ | ✗ | ✗ |
**Questions ouvertes :** granularité côté Bob (voit-il toute la liste d'Alice ou juste A↔B ? — conséquence du principe : toute la liste) ; découvrabilité « amis d'amis » (Alice voit-elle Bob↔Carole ? — non, sauf si Carole ∈ connexions(Alice)).
**Open questions:** granularity on Bob's side (does he see Alice's whole list, or only A↔B? — the principle implies: the whole list); "friends of friends" discoverability (does Alice see Bob↔Carole? — no, unless Carole ∈ connections(Alice)).
## Périmètres dérivés
## Derived scopes
Heuristique : même périmètre si (a) me cellule d'autorisation en écriture *et* (b) accédées ensemble. Trois **scopes** émergent, plus le cas bilatéral :
Heuristic: same scope if (a) same write-authorization cell *and* (b) accessed together. Three **scopes** emerge, plus the bilateral case:
| Périmètre | Écriture | Lecture | Données |
| Scope | Write | Read | Data |
|---|---|---|---|
| **public** | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
| **protected** (réseau) | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
| **private** | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
| **dialog** (A↔B) | Alice et Bob | Alice et Bob | La `Connexion` bilatérale (+ matière à messagerie future) |
| **public** | Alice alone | Everyone | Meeting points hosted by Alice; declared events *(subject to the write model)* |
| **protected** (network) | Alice alone | Alice + connections | Network profile; participations; connection index |
| **private** | Alice alone | Alice alone | Private profile (settings, email, preferences) |
| **dialog** (A↔B) | Alice and Bob | Alice and Bob | The bilateral `Connexion` (+ groundwork for future messaging) |
La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** dédié à la paire ; l'**index « toutes les connexions d'Alice »** vit en *protected* (liste les références des connexions). L'**inbox du PdR** est un attribut du document public, pas un périmètre séparé.
The bilateral **`Connexion`** has *two* writers → a **dialog** scope dedicated to the pair; the **"all of Alice's connections" index** lives in *protected* (it lists the connection references). The **meeting point's inbox** is an attribute of the public document, not a separate scope.
## Inventaire des requêtes par écran
## Query inventory per screen
*À remplir une fois la matrice stabilisée.* Schéma prévu : `| Écran | Lectures one-shot | Abonnements | Écritures | Acteur déclencheur |`. Écrans à analyser : voir la table de routes (concept `app-architecture`).
*To be filled in once the matrix has stabilized.* Planned schema: `| Screen | One-shot reads | Subscriptions | Writes | Triggering actor |`. Screens to analyze: see the route table (concept `app-architecture`).
## See Also
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — placement entité → scope + découverte
- [[knowledge_trust-model]] — l'isolation est assurée par le SDK
- `README.md §Modèle fonctionnel` — source des acteurs
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — entity → scope placement + discovery
- [[knowledge_trust-model]] — isolation is enforced by the SDK
- `README.md §Modèle fonctionnel` — source of the actors
@@ -1,21 +1,21 @@
---
type: caveat
summary: Le mot de passe du wallet partagé est capturé à l'ÉVALUATION de src/modules/auth/sharedWallet.ts ; depuis que le wallet partagé est l'unique mode, une valeur absente à cet instant ne donne plus un formulaire dégradé mais un écran d'erreur de config SANS champ identifiant — tout point d'entrée qui rend AccessGateScreen doit poser le global AVANT le premier import du module
summary: The shared wallet password is captured at the EVALUATION of src/modules/auth/sharedWallet.ts; now that the shared wallet is the only mode, a value missing at that instant no longer yields a degraded form but a configuration error screen WITHOUT any identifier field — every entry point that renders AccessGateScreen must set the global BEFORE the module is first imported
last_checked: 2026-07-27
---
# Piège : poser le global du wallet partagé AVANT d'importer la barrière
# Pitfall: set the shared-wallet global BEFORE importing the barrier
**L'invariant.** `src/modules/auth/sharedWallet.ts` lit `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **une seule fois, à l'évaluation du module** (la constante `SHARED_WALLET_PASSWORD`, exposée par `hasSharedWallet()`). Une valeur posée *après* ce premier import n'est jamais relue.
**The invariant.** `src/modules/auth/sharedWallet.ts` reads `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **exactly once, at module evaluation** (the `SHARED_WALLET_PASSWORD` constant, surfaced by `hasSharedWallet()`). A value set *after* that first import is never re-read.
**Pourquoi c'est devenu bloquant.** Tant que « pas de wallet partagé » était un mode replié, un global manquant dégradait vers un formulaire encore utilisable — l'ordre d'évaluation était cosmétique. Depuis [[decision_2026-07-20_shared-wallet-only-mode]], `hasSharedWallet() === false` est une **erreur de configuration** : `AccessGateScreen` rend un bloc d'erreur **sans champ identifiant**. La barrière devient une impasse, pas un login dégradé.
**Why it became blocking.** As long as "no shared wallet" was a fallback mode, a missing global degraded into a still-usable form — evaluation order was cosmetic. Since [[decision_2026-07-20_shared-wallet-only-mode]], `hasSharedWallet() === false` is a **configuration error**: `AccessGateScreen` renders an error block **with no identifier field**. The barrier becomes a dead end, not a degraded login.
## Impact — si je touche X, Y casse
## Impact — if I touch X, Y breaks
- **Import statique = piège.** Un `import` statique de `AccessGateScreen` (ou de n'importe quel module qui remonte à `sharedWallet.ts`) depuis un point d'entrée qui pose lui-même le global est **hoisté au-dessus de l'affectation** → mot de passe vide → écran d'erreur, sans erreur JS pour le signaler. Le remède est l'**import dynamique** (`await import(...)`) exécuté après avoir posé le global.
- **Points d'entrée concernés aujourd'hui** : le frontend servi depuis `src/` (`src/app/frontend.tsx` récupère `/festipod-config.json`, pose le global, puis importe l'app dynamiquement — mécanique détaillée dans tech-stack → [[knowledge_build-pipeline]]) et le harness `@ui` qui rend la barrière (`src/modules/auth/steps/ui/barriere-acces.steps.ts`, me séquence pose-puis-lazy-import). Un bundle produit par `build.ts` n'est **pas** concerné : la valeur y est inline par `define`.
- **Exploitation** : un serveur sans `FESTIPOD_SHARED_WALLET_PASSWORD` ne sert **aucune** barrière fonctionnellepar conception (échec franc). À traiter comme une panne de configuration, pas comme un bug d'écran.
- **Static import = trap.** A static `import` of `AccessGateScreen` (or of any module that transitively reaches `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → error screen, with no JS error to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
- **Entry points concerned today**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports the app dynamically — mechanics detailed in tech-stack → [[knowledge_build-pipeline]]) and the `@ui` harness that renders the barrier (`src/modules/auth/steps/ui/barriere-acces.steps.ts`, same set-then-lazy-import sequence). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`.
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` serves **no** working barrier at all — by design (fail loudly). Treat it as a configuration outage, not as a screen bug.
**Vérifié (2026-07-27)** : capture à l'évaluation dans `sharedWallet.ts`, et garde `!hasSharedWallet()` en première branche de `AccessGateScreen`.
**Verified (2026-07-27)**: capture at evaluation time in `sharedWallet.ts`, and the `!hasSharedWallet()` guard as the first branch of `AccessGateScreen`.
> Réserve : l'en-tête de `sharedWallet.ts` décrit encore l'ancien repli (« the gate falls back to the plain flow ») — commentaire périmé, c'est le rendu de `AccessGateScreen` qui fait foi.
> Caveat: the header of `sharedWallet.ts` still describes the old fallback ("the gate falls back to the plain flow") — an obsolete comment; what `AccessGateScreen` actually renders is authoritative.
@@ -1,41 +1,41 @@
---
type: caveat
summary: Toute référence cap-less vers un document protected d'une personne expose le `:v:` de son store — un pseudonyme STABLE ET PERMANENT, identique partout et pour toujours. Ne dit pas qui, mais un seul recoupement dé-anonymise RÉTROACTIVEMENT toutes ses références passées et futures. C'est le même bit d'information qui permet la dédup anonyme. Aucune rotation connue.
summary: Any cap-less reference to a person's protected document exposes the `:v:` of their store — a STABLE AND PERMANENT pseudonym, identical everywhere and forever. It does not say who, but a single cross-reference RETROACTIVELY de-anonymizes all of their past and future references. It is the very same bit of information that makes anonymous dedup possible. No known rotation.
last_checked: 2026-07-27
---
# Piège : le `:v:` d'une référence cap-less est un pseudonyme permanent
# Pitfall: the `:v:` of a cap-less reference is a permanent pseudonym
**À lire avant de concevoir quoi que ce soit qui fasse circuler des références cap-less** (inscriptions, invitations, mentions, index, notifications).
**Read this before designing anything that circulates cap-less references** (registrations, invitations, mentions, indexes, notifications).
## Le fait
## The fact
Un NURI s'écrit `did:ng:o:{document}:v:{overlay}`. Le segment `:v:` ne vient **pas du document** mais de **son store**et une personne a **un seul** store *protected*. Donc :
A NURI is written `did:ng:o:{document}:v:{overlay}`. The `:v:` segment does **not** come from the document but from **its store**and a person has **exactly one** *protected* store. Therefore:
> **Toutes** les références cap-less vers **n'importe lequel** des documents protected d'une personne portent le **me** `:v:`. Partout, et pour toujours.
> **All** cap-less references to **any** of a person's protected documents carry the **same** `:v:`. Everywhere, and forever.
VÉRIFIÉ dans `nextgraph-rs` (le détail et les pointeurs vivent côté polyfill, `docs/readcap-and-nuri-model.md`) : la valeur injectée à la création d'un document est l'overlay du store contenant ; un `Repo` ne porte aucun overlay propre, et les accès blocs d'un `Store` passent tous par **son** `overlay_id`un overlay par-document est donc structurellement impossible, pas seulement absent.
VERIFIED in `nextgraph-rs` (the details and the pointers live on the polyfill side, `docs/readcap-and-nuri-model.md`): the value injected when a document is created is the overlay of the containing store; a `Repo` carries no overlay of its own, and every block access of a `Store` goes through **its** `overlay_id`a per-document overlay is therefore structurally impossible, not merely absent.
## Pourquoi c'est un piège et pas juste une limite
## Why this is a pitfall and not just a limitation
Ce `:v:` **ne dit pas qui** — c'est un `BLAKE3` non inversible du store id. La tentation est donc de le traiter comme opaque, donc inoffensif. Il ne l'est pas : c'est un **handle constant**.
This `:v:` **does not say who** — it is a non-invertible `BLAKE3` of the store id. The temptation is therefore to treat it as opaque, hence harmless. It is not: it is a **constant handle**.
- **Corrélation** — quiconque collecte des références cap-less relie entre elles toutes celles d'une même personne, sans jamais l'identifier. Présence récurrente, appartenances, rythme.
- **-anonymisation rétroactive** — c'est le vrai danger. Il suffit d'**un seul** recoupement, **une seule fois** (une personne qui se nomme, un canal qui fuit, un croisement avec une donnée externe) pour que `:v:X` soit attaché à une identité. À cet instant, **tout** l'historique lié à ce `:v:` bascule d'un coup — y compris ce qui a été publié des années plus tôt en croyant à l'anonymat.
- **Aucune porte de sortie** — VÉRIFIÉ, sur quatre axes : pas de rotation d'overlay (l'outer est un hash pur du store id, sans secret) ; le store id est généré une seule fois à la création de l'identité et jamais régénéré ; aucun chemin de migration de contenu vers un nouveau store ; et aucune forme de référence ne permet de localiser un document sans exposer l'overlay de son store. Le renouvellement de capabilities ne changerait que l'overlay *inner* — l'outer, seul présent dans les NURIs cap-less, y survivrait. **La seule sortie est d'abandonner l'identité entière**, ce qui n'emporte aucun contenu. Signalé en amont comme possible défaut de conception (`orm-tests/INBOX/2026-07-27-outer-overlay-permanent-pseudonym-no-rotation.md`, cf. [[rule_nextgraph-inbox]]).
- **Correlation** — anyone collecting cap-less references can link together all those belonging to one and the same person, without ever identifying them. Recurring presence, memberships, rhythm.
- **Retroactive de-anonymization** — this is the real danger. **One single** cross-reference, **one single time** (a person naming themselves, a channel that leaks, a match against outside data), is enough for `:v:X` to become attached to an identity. At that instant, **all** of the history tied to that `:v:` flips at once — including what was published years earlier in the belief that it was anonymous.
- **No way out** — VERIFIED, along four axes: no overlay rotation (the outer one is a pure hash of the store id, with no secret); the store id is generated once at identity creation and never regenerated; there is no migration path for content towards a new store; and no form of reference allows locating a document without exposing its store's overlay. Renewing capabilities would only change the *inner* overlay — the outer one, the only one present in cap-less NURIs, would survive it. **The only way out is to abandon the entire identity**, which carries none of the content along. Reported upstream as a possible design flaw (`orm-tests/INBOX/2026-07-27-outer-overlay-permanent-pseudonym-no-rotation.md`, see [[rule_nextgraph-inbox]]).
## Le couplage à ne pas espérer défaire
## The coupling you must not hope to break
Ce même `:v:` est ce qui permet de **dédupliquer sans lire** — deux références de même `:v:` viennent de la même personne, c'est la base du compteur de participants anonyme ([[brief_2026-07-20_attendance-set-model]] côté `data-layer`).
That very same `:v:` is what makes it possible to **deduplicate without reading** — two references sharing a `:v:` come from the same person, and that is the basis of the anonymous participant counter ([[brief_2026-07-20_attendance-set-model]] on the `data-layer` side).
**C'est le même bit d'information.** Dédup anonyme et non-traçabilité ne sont pas deux exigences à concilier : ce sont deux lectures d'une seule et même donnée. On ne peut pas obtenir l'une en supprimant l'autre. Le seul curseur réel est le **découpage en stores** — qui déplace l'arbitrage sans le faire disparaître.
**It is the same bit of information.** Anonymous dedup and untraceability are not two requirements to be reconciled: they are two readings of one and the same piece of data. You cannot obtain one by removing the other. The only real dial is **how the stores are carved up** — which shifts the trade-off without making it disappear.
Et ce n'est **pas** un artefact du polyfill : la propriété survit au vrai NextGraph.
And this is **not** an artifact of the polyfill: the property survives into real NextGraph.
## Ce qu'il faut en faire
## What to do about it
- **Ne jamais présenter à l'utilisateur** une action comme « anonyme » sans réserve si elle fait circuler une référence cap-less. Elle est **pseudonyme**, et le pseudonyme est permanent.
- **Compter** les occurrences d'un `:v:` qu'on expose : chaque contexte supplémentaire où il apparaît augmente la surface de recoupement.
- **Revérifier** ce caveat si NextGraph introduit une rotation d'overlay ou une forme de référence indirecte — il deviendrait alors caduc, ce qui serait une bonne nouvelle.
- **Never present an action to the user** as "anonymous" without a caveat if it circulates a cap-less reference. It is **pseudonymous**, and the pseudonym is permanent.
- **Count** the occurrences of a `:v:` that you expose: every additional context in which it appears widens the cross-referencing surface.
- **Recheck** this caveat if NextGraph introduces overlay rotation or an indirect form of reference — it would then become moot, which would be good news.
Liens : [[knowledge_trust-model]], [[brief_2026-05-18_authorization-matrix]], data-layer ([[brief_2026-07-20_attendance-set-model]], [[rule_capture-nextgraph-findings]], [[rule_nextgraph-inbox]]).
Links: [[knowledge_trust-model]], [[brief_2026-05-18_authorization-matrix]], data-layer ([[brief_2026-07-20_attendance-set-model]], [[rule_capture-nextgraph-findings]], [[rule_nextgraph-inbox]]).
@@ -1,45 +1,29 @@
---
type: decision
summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (AccessGateScreen), dans le même acte que l'ouverture du wallet ; l'écran de « login perçu » séparé (ConnexionScreen, « choisissez un nom d'utilisateur ») est retiré ; l'identifiant est un id technique normalisé en minuscules, pas un username Festipod
summary: The identifier of the virtual space is entered at the access barrier (AccessGateScreen), in the same act that opens the wallet; the separate "perceived login" screen (ConnexionScreen, « choisissez un nom d'utilisateur ») is removed; the identifier is a lowercase-normalized technical id, not a Festipod username
---
# Décision (2026-07-06) : identifiant saisi à la barrière d'accès
# Decision (2026-07-06): identifier entered at the access barrier
## Contexte
## Context
Le flux stopgap antérieur (décision du 2026-06-15, fiche disparue avec le concept `nextgraph-platform` — voir `git log`) enchaînait **deux
écrans** : (1) `AccessGateScreen`, la barrière d'accès (vrai login NextGraph, ouverture du
wallet partagé) ; (2) `ConnexionScreen`, un « login perçu » où l'utilisateur choisissait un
**nom d'utilisateur**. Cette identité applicative était en réalité la clé du **wallet virtuel**
(clé du compte shim / cap owner), pas un username produit — le cadrage « nom d'utilisateur »
était donc trompeur (logique `setUsername` confuse).
The earlier stopgap flow (decision of 2026-06-15, a note that disappeared along with the `nextgraph-platform` concept — see `git log`) chained **two screens**: (1) `AccessGateScreen`, the access barrier (the real NextGraph login, opening the shared wallet); (2) `ConnexionScreen`, a "perceived login" where the user picked a **username**. That application-level identity was in fact the key of the **virtual wallet** (shim account / cap owner key), not a product username — so the "username" framing was misleading (confusing `setUsername` logic).
## Décision
## Decision
L'utilisateur saisit son **identifiant** directement dans `AccessGateScreen`, **dans le même
acte** qui ouvre le wallet (« Entrer » enregistre l'identifiant puis déclenche `connect()`).
`ConnexionScreen` est **supprimé**. L'identifiant :
The user enters their **identifier** directly in `AccessGateScreen`, **in the same act** that opens the wallet (« Entrer » records the identifier, then triggers `connect()`). `ConnexionScreen` is **deleted**. The identifier:
- est un **id technique** qui nomme l'espace virtuel (un pseudo en pratique, **pas** un
username Festipod) ;
- est **normalisé** à la saisie (trim, `@` retiré, **minuscules**) et persisté avant la
redirection broker (donc il survit au round-trip) ;
- **est** l'id d'identité remis au SDK (`setCurrentUser`), et la clé des caps et du compte
shim — plus de handle à casse mixte à réconcilier.
- is a **technical id** that names the virtual space (a nickname in practice, **not** a Festipod username);
- is **normalized** on entry (trimmed, `@` stripped, **lowercased**) and persisted before the broker redirect (so it survives the round-trip);
- **is** the identity id handed to the SDK (`setCurrentUser`), and the key for the caps and the shim account — no more mixed-case handle to reconcile.
`AuthGate` affiche donc la barrière tant que le wallet n'est pas ouvert **ou** que l'identifiant
n'est pas posé, puis l'app directement — sans écran intermédiaire.
`AuthGate` therefore shows the barrier as long as the wallet is not open **or** the identifier is not set, then the app directly — with no intermediate screen.
## Alternatives écartées
## Rejected alternatives
- **Garder les deux écrans** : le second écran « nom d'utilisateur » perpétuait la confusion
entre identité-produit et identifiant-de-wallet, et ajoutait une étape sans valeur.
- **Dériver l'identifiant du wallet** (pas de saisie) : impossible ici — le wallet partagé est
unique ; l'identifiant est précisément ce qui distingue les espaces virtuels au sein de ce
wallet (émulation, cf. concept `data-layer` et le SDK `@ng-eventually/client`).
- **Keeping both screens**: the second, "username" screen perpetuated the confusion between product identity and wallet identifier, and added a step with no value.
- **Deriving the identifier from the wallet** (no entry at all): impossible here — there is a single shared wallet; the identifier is precisely what distinguishes the virtual spaces inside that wallet (emulation, see concept `data-layer` and the `@ng-eventually/client` SDK).
## Portée
## Scope
Supersede la partie « écran 2 / login perçu » du flux stopgap du 2026-06-15
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
[[knowledge_authentication]].
Supersedes the "screen 2 / perceived login" part of the 2026-06-15 stopgap flow (opening the shared wallet through the broker is unchanged). Current state of the flow: [[knowledge_authentication]].
@@ -1,32 +1,32 @@
---
type: decision
summary: Le wallet partagé est le SEUL mode de fonctionnement (le polyfill @ng-eventually/client en dépend comme backend de données) ; le repli « sans wallet partagé » est retiré — mauvaise config → écran d'erreur franc, plus de formulaire nu. Réaffirme que l'identifiant de la barrière = id du wallet/espace, distinct du username du profil.
summary: The shared wallet is the ONLY operating mode (the @ng-eventually/client polyfill relies on it as its data backend); the "no shared wallet" fallback is removed — misconfiguration → a blunt error screen, no more bare form. Reaffirms that the barrier's identifier = the wallet/space id, distinct from the profile username.
---
# Décision (2026-07-20) — le wallet partagé est l'unique mode ; identifiant ≠ username du profil
# Decision (2026-07-20) — the shared wallet is the only mode; identifier ≠ profile username
## Contexte
## Context
gression observée : à l'ouverture, l'app tombait sur un **formulaire nu demandant un identifiant**, sans l'assistance de chargement du portefeuille. Cause : `FESTIPOD_SHARED_WALLET_PASSWORD` non défini dans l'environnement du serveur `hasSharedWallet()` faux`AccessGateScreen` basculait sur son mode replié. Or ce mode est une **impasse** : un appareil sans wallet ne peut pas se connecter une fois l'assistance d'import masquée. En parallèle, l'ancienne notion de « username » traînait encore pour désigner l'**identité du wallet**, ce qui la confondait avec le vrai username du profil.
Observed regression: on opening, the app landed on a **bare form asking for an identifier**, without the wallet-loading assistance. Cause: `FESTIPOD_SHARED_WALLET_PASSWORD` undefined in the server environment → `hasSharedWallet()` false`AccessGateScreen` switched to its fallback mode. But that mode is a **dead end**: a device with no wallet cannot connect once the import assistance is hidden. In parallel, the old notion of "username" was still lingering to designate the **wallet identity**, which conflated it with the real profile username.
## Décision
## Decision
1. **Le wallet partagé est le seul mode supporté.** Festipod ne fonctionne pas sans lui — le polyfill `@ng-eventually/client` s'en sert comme backend de données (voir [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` n'est donc **pas un mode fonctionnel** : c'est une **mauvaise configuration**`AccessGateScreen` affiche un **écran d'erreur franc** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), jamais le formulaire nu en impasse.
1. **The shared wallet is the only supported mode.** Festipod does not work without it — the `@ng-eventually/client` polyfill uses it as its data backend (see [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` is therefore **not a functional mode**: it is a **misconfiguration**`AccessGateScreen` displays a **blunt error screen** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), never the dead-end bare form.
2. **L'identifiant de la barrière ≠ le username du profil.** L'identifiant saisi à `AccessGateScreen` est l'**id technique du wallet/espace** (normalisé en minuscules, porté par le param d'URL `?id=`), pas un username. Le **username** est un concept distinct qui vit dans `UserProfile` (`@handle`, prédicat `http://festipod.org/username`). Le code et les tests ne doivent plus étiqueter l'identité du wallet « username/user » (renommé en `identifier`). Réaffirme et prolonge [[decision_2026-07-06_identifier-at-access-barrier]].
2. **The barrier's identifier ≠ the profile username.** The identifier entered in `AccessGateScreen` is the **technical id of the wallet/space** (lowercase-normalized, carried by the `?id=` URL param), not a username. The **username** is a distinct concept living in `UserProfile` (`@handle`, predicate `http://festipod.org/username`). Code and tests must no longer label the wallet identity "username/user" (renamed to `identifier`). Reaffirms and extends [[decision_2026-07-06_identifier-at-access-barrier]].
## Conséquences
## Consequences
- `AccessGateScreen` : rendu 3-branches (erreur config / flux d'import assisté quand non connecté / champ identifiant seul quand déjà connecté).
- Renommage `username → identifier` de l'identité du wallet dans l'infra de test (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, + commentaires ; **`UserProfile.username` intact** (profil, seed, affichage, SHEX).
- `.env.example` ajouté à la racine pour rendre la config explicite (dont `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
- `AccessGateScreen`: three-branch rendering (config error / assisted import flow when not connected / identifier field alone when already connected).
- `username → identifier` rename of the wallet identity across the test infrastructure (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, plus comments; **`UserProfile.username` untouched** (profile, seed, display, SHEX).
- `.env.example` added at the root to make the configuration explicit (including `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
## Alternative écartée
## Rejected alternative
Garder le repli sans-wallet comme futur « flux wallet-propre » : écarté **pour l'instant** — aucun flux wallet-propre à court terme, et le repli silencieux créait une impasse trompeuse. À réintroduire **explicitement** le jour où un mode wallet-propre (chaque utilisateur avec son propre wallet NextGraph) existera, hors stopgap.
Keeping the wallet-less fallback as a future "own-wallet flow": rejected **for now** — no own-wallet flow in the near term, and the silent fallback created a misleading dead end. To be reintroduced **explicitly** the day an own-wallet mode (each user with their own NextGraph wallet) exists, outside the stopgap.
## Liens
## Links
- Stopgap wallet partagé : `decision_2026-06-15_shared-wallet-login-flow` (référencé par `AccessGateScreen`/`AccountContext`).
- [[decision_2026-07-06_identifier-at-access-barrier]] — l'identifiant à la barrière.
- Shared-wallet stopgap: `decision_2026-06-15_shared-wallet-login-flow` (referenced by `AccessGateScreen`/`AccountContext`).
- [[decision_2026-07-06_identifier-at-access-barrier]] — the identifier at the barrier.
- [[knowledge_authentication]], [[knowledge_trust-model]].
@@ -1,24 +1,24 @@
---
type: knowledge
summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth est déléguée au SDK, l'app n'a pas de comptes/mots de passe applicatifs
summary: A user's identity = their NextGraph wallet; every user is authenticated (no anonymous access); auth is delegated to the SDK, the app has no application-level accounts or passwords
---
# Authentification
# Authentication
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a **pas de système de comptes/mots de passe applicatif**l'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son wallet.
**A user's identity = their NextGraph wallet.** There is **no anonymous access** to the app: every user is authenticated (see concept `functional-domain`). There is **no application-level account/password system** — authentication is **delegated to the data SDK** (`@ng-eventually/client`): opening your session means opening your wallet.
## Flux
## Flow
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans du stopgap du 2026-06-15).
- **Le wallet partagé est le SEUL mode supporté** : `AccessGateScreen` a **trois branches** — (1) *erreur de configuration* si aucun wallet partagé n'est configuré (plus de formulaire nu en impasse), (2) flux d'**import assisté** tant que la session n'est pas connectée, (3) **champ identifiant seul** une fois connecté. Voir [[decision_2026-07-20_shared-wallet-only-mode]], et le piège d'ordre d'évaluation [[caveat_shared-wallet-global-before-gate-import]] (le global du mot de passe doit être posé avant le premier import de l'écran, sinon on tombe sur la branche 1).
- **Vocabulaire du code** : l'identité du wallet s'appelle `identifier` partout (`registration.ts`, `ngSession`, hooks et steps de test) — **jamais** `username`, qui désigne exclusivement le handle de profil `UserProfile.username`. Ne pas ré-étiqueter l'un en l'autre : ce sont deux espaces d'identité distincts.
- Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normali** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext``IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés.
- **Porté cross-frontière par un PARAM D'URL `?id=`** (source de vérité), PAS par localStorage. L'app tourne dans deux contextes — **top-level** (`127.0.0.1:3000` direct, `window.self === window.top`, où s'affiche la barrière) et **iframe** (embarquée sous `nextgraph.net` après le round-trip broker, `window.self !== window.top`). Le navigateur **partitionne le storage par site top-level** : le localStorage du top-level et celui de l'iframe sont **deux partitions distinctes** → localStorage NE PEUT PAS porter l'identité d'un contexte à l'autre (symptôme observé : deux valeurs divergentes selon le contexte). Le SDK redirige via `location.href = broker + encodeURIComponent(window.location.href)` (embarque l'URL app complète, query comprise, dans le `o=` rechargé en iframe), donc un **param d'URL traverse**. `AuthGate` écrit `?id=<identifiant>` (`history.replaceState`) **avant** `connect()` ; `AccountContext` résout l'identifiant par priorité **(1) `?id=` de l'URL** puis **(2) localStorage** (préremplissage/convenance same-partition uniquement). Clé localStorage : `festipod.account.identifier`.
- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement top-level, la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche la barrière tant que `status !== 'connected'`, mais le champ d'`AccessGateScreen` est **prérempli** (prop `initialIdentifier`) — jamais un champ nu et vide. Régressions gardées par `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — d'autant plus utiles que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche.
- Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `NextGraphContext`.
- The **access barrier** (`AccessGateScreen`, rendered by `src/app/AuthGate.tsx`) is the real NextGraph login: it opens the shared wallet through the broker redirect. **In the same act**, the user enters an **identifier** that names their virtual space (`onEnter`). There is **no separate "perceived login" screen any more** (the former `ConnexionScreen`, « choisissez un nom d'utilisateur », has been removed — see [[decision_2026-07-06_identifier-at-access-barrier]]; supersedes the two-screen flow of the 2026-06-15 stopgap).
- **The shared wallet is the ONLY supported mode**: `AccessGateScreen` has **three branches** — (1) *configuration error* if no shared wallet is configured (no more dead-end bare form), (2) the **assisted import** flow as long as the session is not connected, (3) the **identifier field alone** once connected. See [[decision_2026-07-20_shared-wallet-only-mode]], and the evaluation-order pitfall [[caveat_shared-wallet-global-before-gate-import]] (the password global must be set before the screen is first imported, otherwise you land on branch 1).
- **Vocabulary in the code**: the wallet identity is called `identifier` everywhere (`registration.ts`, `ngSession`, hooks and test steps) — **never** `username`, which exclusively designates the profile handle `UserProfile.username`. Do not relabel one as the other: they are two distinct identity spaces.
- This **identifier is a technical id** (a nickname in practice, **not** a Festipod username): it is **normalized** (trimmed, `@` stripped, **lowercased**) then persisted (`AccountContext``IdentityStore`), so a reload — or another device reopening the same shared wallet — lands back on the same space. It is this id that is handed to the SDK (`setCurrentUser`) and on which the caps and the shim account are keyed.
- **Carried across the boundary by a URL PARAM `?id=`** (source of truth), NOT by localStorage. The app runs in two contexts — **top-level** (`127.0.0.1:3000` directly, `window.self === window.top`, where the barrier is displayed) and **iframe** (embedded under `nextgraph.net` after the broker round-trip, `window.self !== window.top`). The browser **partitions storage by top-level site**: the top-level's localStorage and the iframe's are **two distinct partitions** → localStorage CANNOT carry the identity from one context to the other (observed symptom: two diverging values depending on the context). The SDK redirects via `location.href = broker + encodeURIComponent(window.location.href)` (embedding the full app URL, query string included, into the `o=` that is reloaded in the iframe), so a **URL param does cross over**. `AuthGate` writes `?id=<identifier>` (`history.replaceState`) **before** `connect()`; `AccountContext` resolves the identifier by priority: **(1) `?id=` from the URL** then **(2) localStorage** (prefill/convenience within the same partition only). localStorage key: `festipod.account.identifier`.
- **Entered ONLY ONCE on first access + prefilled on return.** On a top-level reload the NG session is not restored automatically (`NextGraphContext` starts back at `disconnected`): `AuthGate` shows the barrier again as long as `status !== 'connected'`, but the `AccessGateScreen` field is **prefilled** (`initialIdentifier` prop) — never a bare, empty field. Regressions guarded by `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — all the more useful because the barrier flow is **disabled** in the @e2e tests (`__FESTIPOD_ACCESS_GATE_DISABLED__`), and therefore invisible at that layer.
- Once the session is open, the current user and their access to the per-scope stores are provided by `NextGraphContext`.
## Le wallet de test
## The test wallet
Les tests `@data`/`@e2e` ouvrent un wallet réel (`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging.
The `@data`/`@e2e` tests open a real wallet (`festipod-tests`, persistent profile) — see concept `bdd-testing`. These are **plaintext test credentials**, with no security stake, dedicated to staging.
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat de l'hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
> The authorization model that will build on this identity (bilateral connections, personal data = network, host anonymity) is incubating: [[brief_2026-05-18_authorization-matrix]].
@@ -1,21 +1,21 @@
---
type: knowledge
summary: L'isolation entre périmètres (public/protected/private) est assurée par le SDK de données ; l'app lui fait confiance et n'affiche que ce qu'il retourneaucun contrôle d'accès dans les écrans, toute la confidentialité repose sur le SDK
summary: Isolation between scopes (public/protected/private) is enforced by the data SDK; the app trusts it and only displays what it returnsno access control in the screens, all privacy rests on the SDK
last_checked: 2026-07-06
---
# Modèle de confiance
# Trust model
**Posture :** l'app lit les données via les subscriptions ORM du SDK `@ng-eventually/client` et les affiche **sans logique d'autorisation côté app** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
**Stance:** the app reads data through the ORM subscriptions of the `@ng-eventually/client` SDK and displays it **with no app-side authorization logic** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
Principes :
Principles:
1. **L'isolation est déléguée au SDK.** Chaque entité vit dans le store de son **scope** (public / protected / private, cf. concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]) ; le SDK **n'expose à l'utilisateur courant que ce à quoi il a droit**. L'app suppose que ce qu'elle reçoit est déjà autorisé — la confidentialité repose sur le SDK, pas sur du code Festipod.
2. **Les écrans ne portent aucune règle d'accès.** Pas de vérification « cet utilisateur a-t-il le droit de voir cette donnée » dans les composants ni dans le contexte de données. La séparation public / réseau / privé est une propriété du **placement par scope**, pas d'un filtre applicatif.
3. **La relation entre utilisateurs (« connexions ») est une notion applicative, pas une primitive du SDK.** NextGraph n'a pas de primitive de connexion/amitié bilatérale ; côté SDK il n'existe qu'un **grant de lecture dirigé** vers une identité. L'app **possède** donc son graphe de relations (`src/shared/utils/connections.ts`) et le **traduit** en grants dirigés par document remis au SDK — elle ne délègue pas la notion de relation au SDK, seulement l'**application** de l'isolation qui en découle. Ce que l'app déclare au SDK reste minimal : **son identité** (l'identifiant, cf. [[knowledge_authentication]]) et **ces grants** ; elle ne porte toujours aucune logique d'accès dans les écrans.
1. **Isolation is delegated to the SDK.** Every entity lives in the store of its **scope** (public / protected / private, see concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]); the SDK **exposes to the current user only what they are entitled to**. The app assumes that whatever it receives is already authorized — privacy rests on the SDK, not on Festipod code.
2. **Screens carry no access rules.** No "is this user allowed to see this data" check in the components, nor in the data context. The public / network / private separation is a property of **placement by scope**, not of an application-level filter.
3. **The relationship between users ("connections") is an application-level notion, not an SDK primitive.** NextGraph has no bilateral connection/friendship primitive; on the SDK side there is only a **directed read grant** towards an identity. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and **translates** it into per-document directed grants handed to the SDK — it does not delegate the notion of a relationship to the SDK, only the **enforcement** of the isolation that follows from it. What the app declares to the SDK stays minimal: **its identity** (the identifier, see [[knowledge_authentication]]) and **those grants**; it still carries no access logic in the screens.
## Le point de vigilance
## The point to watch
Parce que l'app **affiche tout ce qu'elle reçoit**, la confidentialité tient entièrement à ce que le SDK n'expose que le légitime. C'est un choix assumé (l'app reste mince), mais il implique de **ne jamais réintroduire côté écran une donnée que le scope n'aurait pas dû laisser passer**.
Because the app **displays everything it receives**, privacy rests entirely on the SDK exposing only what is legitimate. It is a deliberate choice (the app stays thin), but it means **never reintroducing on the screen side a piece of data that the scope should not have let through**.
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité — c'est intentionnel, l'isolation vient d'en dessous.
> To check when in doubt: `useNgData` in `FestipodDataContext.tsx` contains no identity-filtering branch — that is intentional, isolation comes from below.
+18 -18
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: BDD Cucumber/Gherkin en français sur 3 couches (@ui, @data, @e2e) — setup, contrat de couches (quoi tester où), harness broker réel, et le piège des vestiges source-grep
summary: BDD Cucumber/Gherkin in French across 3 layers (@ui, @data, @e2e) — setup, layer contract (what to test where), real broker harness, and the source-grep leftovers pitfall
triggers:
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@private-wallet", "@shared-wallet", storageState, "@wip"]
paths: ["src/modules/*/features/**", "src/modules/*/steps/**", "src/shared/steps/**", "src/shared/support/**", "src/shared/test-harness/**", "cucumber.json"]
@@ -8,30 +8,30 @@ triggers:
# BDD testing
Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) sur **3 couches** de coût croissant.
BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `Alors`) across **3 layers** of increasing cost.
**À lire avant d'écrire un test :** [[rule_test-layer-contracts]] — chaque couche répond à une question distincte ; mélanger produit des tests fragiles. C'est la règle qui décide ** va une assertion.
**Read before writing a test:** [[rule_test-layer-contracts]] — each layer answers a distinct question; mixing them produces brittle tests. That is the rule which decides *where* an assertion belongs.
## Les 3 couches
## The 3 layers
```
/\ @e2e app réelle dans l'iframe broker — parcours critiques
/\ @e2e real app inside the broker iframe — critical journeys
/ \
/----\ @data mutations & persistance via broker NextGraph réel
/----\ @data mutations & persistence through the real NextGraph broker
/------\
/ @ui \ rendu d'écran in-process (happy-dom + seed) — le gros du volume
/ @ui \ in-process screen rendering (happy-dom + seed) — the bulk of the volume
/__________\
```
## Liens
## Links
- [[rule_test-layer-contracts]] — quoi tester à chaque couche (le contrat)
- [[knowledge_cucumber-setup]] — config, layout, scripts, fichiers auto-générés
- [[knowledge_ui-layer]] — couche `@ui` : render helper, fixtures, bons/anti patterns
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
- [[caveat_reconnexion-froide-local-vs-broker]] — « page fraîche » ≠ démarrage à froid : quel montage prouve la durabilité broker, et lequel relit le local
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
- [[rule_test-layer-contracts]] — what to test at each layer (the contract)
- [[knowledge_cucumber-setup]] — config, layout, scripts, auto-generated files
- [[knowledge_ui-layer]] — the `@ui` layer: render helper, fixtures, good and bad patterns
- [[knowledge_data-layer-broker]] — the `@data` layer: broker harness, wallet lifecycle, bridge
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
- [[knowledge_multibrowser-harness]] — several isolated browsers × wallet model (private/shared), storageState injection
- [[caveat_reconnexion-froide-local-vs-broker]] — a "fresh page" is not a cold start: which setup proves broker durability, and which one just re-reads local
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
- [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts`
- [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`)
@@ -1,85 +1,54 @@
---
type: caveat
summary: Une « page fraîche » ouverte via ctx.newPage() sur le contexte Chromium PERSISTANT ne prouve JAMAIS la durabilité broker — elle relit l'IndexedDB local du même profil. Seul un contexte non-persistant issu de freshBrowser, amorcé uniquement par le storageState capturé au BeforeAll, tranche broker-vs-local.
summary: A "fresh page" opened via ctx.newPage() on the PERSISTENT Chromium context NEVER proves broker durability — it re-reads the local IndexedDB of the very same profile. Only a non-persistent context spawned from freshBrowser, seeded solely by the storageState captured at BeforeAll, settles broker-vs-local.
last_checked: 2026-07-27
---
# Piège : « page fraîche » ≠ démarrage à froid (local vs broker)
# Pitfall: a "fresh page" is not a cold start (local vs broker)
Les scénarios de **reconnexion** posent tous la même question — *l'utilisateur retrouve-t-il ses
propres données après avoir fermé et rouvert ?* — mais **selon le contexte navigateur choisi, ils
ne répondent pas à la même question**. C'est le piège : le montage le plus naturel (`ctx.newPage()`)
donne un vert qui ne prouve rien sur le broker.
**Reconnection** scenarios all ask the same question — *does the user get their own data back after closing and reopening?* — but **depending on the browser context chosen, they do not answer the same question**. That is the pitfall: the most natural setup (`ctx.newPage()`) yields a green that proves nothing about the broker.
## Les deux montages, et ce que chacun prouve
## The two setups, and what each one proves
| Montage | Où | Ce qu'il prouve | Ce qu'il ne prouve PAS |
| Setup | Where | What it proves | What it does NOT prove |
|---|---|---|---|
| `this.page!.context().newPage()`page fraîche sur le contexte **persistant** (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | nouveau login broker → **session verifier fraîche** (mémoire vide), remontage complet des providers | rien sur la **durabilité broker** : le profil détient **encore les repos locaux** en IndexedDB, un lecteur « frais » peut donc rouvrir **depuis le local** |
| `spawnContext('shared')` contexte **non-persistant** issu de `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | la donnée **a atteint le broker** (ou non) | rien sur le parcours UI réel (c'est le harness, pas l'app) |
| `this.page!.context().newPage()`fresh page on the **persistent** context (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | a new broker login → **fresh verifier session** (empty memory), full remount of the providers | nothing about **broker durability**: the profile **still holds the local repos** in IndexedDB, so a "fresh" reader may well reopen **from local** |
| `spawnContext('shared')`**non-persistent** context spawned from `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | that the data **reached the broker** (or did not) | nothing about the real UI journey (this is the harness, not the app) |
**Invariant.** Toute assertion de la forme « l'écriture est durable côté broker » **exige** le second
montage. Écrire cette assertion sur une page fraîche du contexte persistant produit un test
faussement vert (ou un rouge qu'on impute au broker alors qu'il est local/timing).
**Invariant.** Any assertion of the form "the write is durable broker-side" **requires** the second setup. Making that assertion on a fresh page of the persistent context produces a false green (or a red blamed on the broker when it is really local/timing).
## Ce qui rend le verdict « no-local » valide (à ne pas casser)
## What makes the "no-local" verdict valid (do not break it)
Trois conditions, toutes vérifiées dans `reconnexion-froide-sans-local.steps.ts` :
Three conditions, all met in `reconnexion-froide-sans-local.steps.ts`:
1. **Process séparé**`freshBrowser` est un `chromium.launch` non-persistant, distinct du profil
porteur du wallet (cf. [[knowledge_multibrowser-harness]] pour l'isolation prouvée jusqu'à
l'origine broker).
2. **Partition hermétique** — chaque `newContext()` Playwright a son propre stockage ; aucun
IndexedDB partagé avec la page d'écriture.
3. **Le seul état pré-injecté est `pool.sharedWalletState`**, capturé **une fois au `BeforeAll`**,
donc **avant** que le scénario n'écrive quoi que ce soit → le snapshot **ne peut pas** contenir la
donnée sous test.
1. **Separate process**`freshBrowser` is a non-persistent `chromium.launch`, distinct from the profile carrying the wallet (see [[knowledge_multibrowser-harness]] for the isolation proven all the way to the broker origin).
2. **Hermetic partition** — every Playwright `newContext()` has its own storage; no IndexedDB shared with the writing page.
3. **The only pre-injected state is `pool.sharedWalletState`**, captured **once at `BeforeAll`**, hence **before** the scenario writes anything → the snapshot **cannot** contain the data under test.
> **Impact si on touche à la capture du storageState** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`) :
> la déplacer plus tard, la ré-capturer par scénario, ou y ajouter un warm-up qui écrit des données
> **invalide silencieusement** le verdict de tous les scénarios « à froid sans local » — ils
> passeraient au vert en relisant le snapshot. Le step **échoue franchement** si
> `sharedWalletState` est absent (c'est voulu : pas de verdict plutôt qu'un faux verdict).
> **Impact if you touch the storageState capture** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`): moving it later, re-capturing it per scenario, or adding a warm-up that writes data **silently invalidates** the verdict of every "cold, no local" scenario — they would turn green by re-reading the snapshot. The step **fails outright** when `sharedWalletState` is missing (by design: no verdict beats a false verdict).
## Reconnexion isolation : c'est l'identifiant qui décide
## Reconnection is not isolation — the identifier decides
`isolation.steps.ts` et `reconnexion.steps.ts` montent **la même mécanique** (page fraîche + un
identifiant injecté dans `localStorage['festipod.account.identifier']` via `addInitScript`, avant
tout script, sur toutes les origines). Une seule chose les sépare :
`isolation.steps.ts` and `reconnexion.steps.ts` set up **the same machinery** (fresh page plus an identifier injected into `localStorage['festipod.account.identifier']` via `addInitScript`, before any script, on every origin). Only one thing tells them apart:
- **reconnexion** : on réinjecte `this.freshIdentifier` **la MÊME identité** que la page d'écriture.
- **isolation** : on frappe un **nouvel** identifiant → identité B distincte.
- **reconnection**: `this.freshIdentifier` is re-injected — **the SAME identity** as the writing page.
- **isolation**: a **new** identifier is minted → a distinct identity B.
Changer cet identifiant transforme donc silencieusement un test de reconnexion en test d'isolation
(et réciproquement). `this.freshIdentifier` est posé par le `Before` de `hooks.ts` pour **tout**
scénario `@data`/`@e2e` mono-navigateur.
Changing that identifier therefore silently turns a reconnection test into an isolation test (and the other way round). `this.freshIdentifier` is set by the `Before` hook in `hooks.ts` for **every** single-browser `@data`/`@e2e` scenario.
## Lecture : réactive, même quand on « attend longtemps »
## Reads stay reactive, even when "waiting a long time"
Les `Then` de reconnexion lisent l'état **réactif** (`homeEventTitles` sur le bridge, via
`waitForFunction`) — jamais une boucle de re-lecture broker ([[rule_no-broker-polling]]). Le step de
diagnostic long (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») boucle bien,
mais sur **l'état réactif déjà poussé** + des **rechargements complets** de la page (chaque reload =
nouveau montage = nouvelle tentative de barrière de sync) : c'est le fallback pragmatique explicitement
autorisé par la règle, pas du polling broker. Le distinguo à garder : *observer l'état réactif*
*ré-émettre une lecture broker*.
The reconnection `Then` steps read the **reactive** state (`homeEventTitles` on the bridge, via `waitForFunction`) — never a broker re-read loop ([[rule_no-broker-polling]]). The long diagnostic step (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») does loop, but over the **reactive state already pushed** plus **full page reloads** (each reload = new mount = new sync-barrier attempt): that is the pragmatic fallback the rule explicitly allows, not broker polling. The distinction to keep in mind — *observing the reactive state* versus *re-issuing a broker read*.
## État courant des scénarios
## Current state of the scenarios
`reconnexion-froide-sans-local.feature`, le scénario `@reconnexion-pause` de
`reconnexion-meme-identite.feature` et `reconnexion-persistance-e2e.feature` sont **`@wip`** : ce sont
des **instruments de diagnostic** (ils impriment un verdict sur stdout / en pièce jointe Cucumber),
pas des gardes de régression. `@wip` est exclu du run par défaut (`cucumber.json`) — les lancer
explicitement avec `--tags`. Le scénario **non-`@wip`** de `reconnexion-meme-identite.feature`, lui,
est une vraie garde et doit rester vert.
`reconnexion-froide-sans-local.feature`, the `@reconnexion-pause` scenario of `reconnexion-meme-identite.feature` and `reconnexion-persistance-e2e.feature` are **`@wip`**: they are **diagnostic instruments** (they print a verdict to stdout / as a Cucumber attachment), not regression guards. `@wip` is excluded from the default run (`cucumber.json`) — run them explicitly with `--tags`. The **non-`@wip`** scenario of `reconnexion-meme-identite.feature`, on the other hand, is a genuine guard and must stay green.
> Le *pourquoi* côté NextGraph (ce qu'une écriture doit franchir pour être durable, comportement du
> socket, réouverture des repos) appartient au SDK `@ng-eventually/client` — pas à ce repo. Ici on ne
> décrit que **le montage de test qui rend un verdict lisible**.
> The *why* on the NextGraph side (what a write must clear to be durable, socket behaviour, repo reopening) belongs to the `@ng-eventually/client` SDK — not to this repo. Here we only describe **the test setup that produces a readable verdict**.
## Liens
## Links
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant).
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile).
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
- [[knowledge_e2e-layer]] — le pendant `@e2e` (app réelle) du montage « fermer et rouvrir ».
- [[rule_no-broker-polling]] — la ligne rouge que les steps d'attente ne doivent pas franchir.
- [[knowledge_e2e-layer]] — the `@e2e` counterpart (real app) of the "close and reopen" setup.
- [[rule_no-broker-polling]] — the red line waiting steps must not cross.
@@ -1,21 +1,21 @@
---
type: caveat
summary: world.ts garde des vestiges de l'ère « analyse de source » (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields ; hasText/hasField/hasElement à fallback source) — à supprimer une fois la migration @ui vers le DOM rendu terminée
summary: world.ts still carries leftovers from the "source analysis" era (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields; hasText/hasField/hasElement with a source fallback) — to be deleted once the @ui migration to the rendered DOM is complete
last_checked: 2026-06-15
---
# Caveat : vestiges d'analyse de source dans `world.ts`
# Caveat: source-analysis leftovers in `world.ts`
La suite `@ui` **précède** le contrat de couches ([[rule_test-layer-contracts]]). Des restes de l'ère « grep sur le code source » subsistent et **ne doivent pas être étendus** :
The `@ui` suite **predates** the layer contract ([[rule_test-layer-contracts]]). Remnants of the "grep the source code" era are still around and **must not be extended**:
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings de l'approche analyse-de-source.
- `hasText` / `hasField` / `hasElement`**préfèrent désormais le DOM rendu** mais **retombent sur la source** pour que les steps non migrés continuent de marcher pendant la transition.
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings from the source-analysis approach.
- `hasText` / `hasField` / `hasElement`they now **prefer the rendered DOM** but **fall back to the source** so that unmigrated steps keep working during the transition.
## Plan de migration (en cours)
## Migration plan (in progress)
1. Réécrire les assertions grep-source → requêtes DOM via le render helper.
2. Supprimer les tests sur détails d'implémentation (`/showDuplicateWarning/`, `/importableEvents/`, regex sur JSX).
3. Déplacer les assertions comportementales vers `@e2e` quand pas déjà couvertes.
4. Retirer les checks de contenu `@e2e` redondants avec `@ui`.
1. Rewrite grep-the-source assertions as DOM queries through the render helper.
2. Delete tests that assert implementation details (`/showDuplicateWarning/`, `/importableEvents/`, regexes over JSX).
3. Move behavioural assertions to `@e2e` where they are not already covered.
4. Drop the `@e2e` content checks that duplicate `@ui`.
Une fois la migration terminée, les 4 maps vestiges peuvent disparaître au profit d'assertions sur le DOM rendu + seed. **Tant qu'elles existent, ne pas s'appuyer dessus pour de nouveaux tests.**
Once the migration is done, the 4 leftover maps can go away in favour of assertions on the rendered DOM plus seed data. **As long as they exist, do not rely on them for new tests.**
@@ -1,35 +1,21 @@
---
type: caveat
summary: Le wallet de test partagé (.playwright-profile) accumule des données à chaque run ; passé un seuil, les sparql_query ancrées au private store hangent (>15s) et toute la suite @data échoue au setup — repartir d'un profil frais restaure des lectures ~1s
summary: The shared test wallet (.playwright-profile) accumulates data on every run; past a threshold, sparql_query calls anchored to the private store hang (>15s) and the whole @data suite fails during setup — starting from a fresh profile restores ~1s reads
last_checked: 2026-07-06
---
# Piège : le wallet de test se gonfle et fait *hang* les lectures @data
# Pitfall: the test wallet bloats and makes @data reads hang
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioIdentifier`), docs
d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim**
(résolution de compte) et est interrogé par **toute** lecture/écriture (`resolveAccount`,
`listMyEntityDocs`, …).
The persistent Chromium profile `.playwright-profile` (at the root of the working tree) carries the **shared wallet** opened by the whole `@data`/`@e2e` suite. That wallet **accumulates data on every run**: shim accounts (one per scenario, through the fresh identifier `freshScenarioIdentifier`), seeded entity docs, historical inbox deposits… The private store is the **anchor point of the shim** (account resolution) and is queried by **every** read and write (`resolveAccount`, `listMyEntityDocs`, …).
**Symptôme.** Passé un certain volume (observé ~99 Mo de profil), une `sparql_query` **ancrée au
private store** ne revient plus sous 15 s — elle *hang*. Comme la résolution de compte est sur le
chemin de **chaque** read/write, **toute la suite @data échoue au setup** (0 événement chargé,
timeouts), sans erreur explicite. Diagnostic vérifié : sur un wallet frais la même requête revient
en **~1,5 s** et le seed complète normalement.
**Symptom.** Past a certain volume (observed around 99 MB of profile), a `sparql_query` **anchored to the private store** stops returning within 15 s — it hangs. Since account resolution sits on the path of **every** read/write, **the entire @data suite fails during setup** (0 events loaded, timeouts), with no explicit error. Verified diagnosis: on a fresh wallet the same query comes back in **~1.5 s** and the seed completes normally.
**Contournement.** Mettre le profil gonflé de côté et laisser le hook d'auth (beforeAll) en
recréer un frais :
**Workaround.** Move the bloated profile aside and let the auth hook (beforeAll) recreate a fresh one:
```bash
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
```
L'identifiant frais par scénario (`freshScenarioIdentifier`) borne le *registre* des comptes mais
**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable
(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les
`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais.
The per-scenario fresh identifier (`freshScenarioIdentifier`) bounds the account *registry* but **not** the physical growth of the shared private store — hence the recurrence. Durable hygiene (periodic purge / throwaway wallet per run) still has to be put in place; until then, if the `resolveAccount failed` errors and timeouts come back, start again from a fresh profile.
> Le *pourquoi* côté broker (comment une requête ancrée touche le repo du private store) appartient
> au SDK `@ng-eventually/client`, pas ici — ce caveat ne décrit que la conséquence côté tests.
> The *why* on the broker side (how an anchored query reaches the private store repo) belongs to the `@ng-eventually/client` SDK, not here — this caveat only describes the consequence on the test side.
@@ -1,29 +1,29 @@
---
type: cookbook
summary: Procédure pour ajouter un scénario/step BDD — .feature français taggé, steps par couche, piège de sérialisation de appFrame.evaluate (passer les args, pas de closure), ajouter les helpers aux DEUX harness, tag @wip pour le non-implémenté
summary: How to add a BDD scenario/step — a tagged French .feature, steps per layer, the appFrame.evaluate serialization pitfall (pass arguments, no closures), add helpers to BOTH harnesses, @wip tag for what is not implemented yet
---
# Cookbook : ajouter un scénario / un step
# Cookbook: adding a scenario / a step
1. **Écrire le `.feature`** : `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, tag de tête `@CATEGORIE @priority-N`, et un tag de couche par scénario (`@ui` / `@data` / `@e2e`). Mots-clés FR : `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tagger `@wip` un scénario dont les steps ne sont pas encore écrits.
1. **Write the `.feature`**: `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, a header tag `@CATEGORIE @priority-N`, and one layer tag per scenario (`@ui` / `@data` / `@e2e`). French keywords: `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tag `@wip` any scenario whose steps are not written yet.
2. **Choisir la couche** (cf. [[rule_test-layer-contracts]]) : assertion de rendu `@ui` ; mutation/persistance → `@data` ; parcours complet`@e2e`.
2. **Pick the layer** (see [[rule_test-layer-contracts]]): rendering assertion → `@ui`; mutation/persistence → `@data`; full journey`@e2e`.
3. **Écrire les steps** dans `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (ou `src/shared/steps/ui/` si cross-domaine). Signature : `async function (this: FestipodWorld, …)`. Importer `FestipodWorld` depuis `../../../../shared/support/world` (ajuster le chemin relatif).
3. **Write the steps** in `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (or `src/shared/steps/ui/` if cross-domain). Signature: `async function (this: FestipodWorld, …)`. Import `FestipodWorld` from `../../../../shared/support/world` (adjust the relative path).
4. **Accès aux données selon la couche** :
- `@ui` : `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` après `navigateTo(...)` (voir [[knowledge_ui-layer]]).
- `@data`/`@e2e` : `await this.appFrame!.evaluate(fn, ...args)` sur le bridge `window.__testData` (voir [[knowledge_data-layer-broker]]).
4. **Data access, per layer**:
- `@ui`: `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` after `navigateTo(...)` (see [[knowledge_ui-layer]]).
- `@data`/`@e2e`: `await this.appFrame!.evaluate(fn, ...args)` on the `window.__testData` bridge (see [[knowledge_data-layer-broker]]).
5. **⚠️ Piège de sérialisation `appFrame.evaluate`** : la fonction passée s'exécute **dans l'iframe**, les variables du step **ne sont pas capturées** (closures perdues). **Passer toute valeur en argument** :
5. **⚠️ `appFrame.evaluate` serialization pitfall**: the function you pass runs **inside the iframe**, and the step's variables **are not captured** (closures are lost). **Pass every value as an argument**:
```ts
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title undefined
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title is undefined
// ✅ await appFrame.evaluate((t) => td.getEventByTitle(t), eventTitle)
```
Toujours `await` (oublier → assertion avant résolution).
Always `await` (forgetting it means asserting before the promise resolves).
6. **Si tu ajoutes une opération de données** : exposer le helper sur `window.__testData` dans **les deux** harness (`src/shared/test-harness/harness.tsx` ET `harness-ng.tsx`) — sinon le fallback mock diverge du broker réel.
6. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
7. **Câbler un écran testé** : si le nom français de l'écran ne se résout pas vers son `id`, ajouter un alias dans `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
7. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
8. **Lancer** : `bun run test:cucumber` (tout) ou `bun run test:data` (@data). Rapport : `reports/cucumber-report.html`. Le `@data`/`@e2e` exige le wallet de test (`bun run test:auth-setup` au premier coup si besoin, sinon création auto — cf. [[decision_2026-03-12_headless-wallet-creation]]).
8. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
@@ -1,6 +1,6 @@
---
type: decision
summary: Décision 2026-03-12 — créer le wallet de test en automatisant l'UI broker headless (Playwright) plutôt que par API NG, car ça teste le vrai flux d'auth et évite de reverse-engineer l'API d'inscription
summary: Decision 2026-03-12 — create the test wallet by automating the broker UI headlessly (Playwright) rather than through the NG API, because it exercises the real auth flow and avoids reverse-engineering the sign-up API
---
# Automated Headless Wallet Creation for CI
@@ -10,28 +10,28 @@ summary: Décision 2026-03-12 — créer le wallet de test en automatisant l'UI
## Context
Les tests `@data` exigent un wallet NextGraph dans un profil Chromium persistant. Avant, le premier run exigeait une interaction manuelle (navigateur visible, création de wallet à la main) → bloquait le CI.
`@data` tests require a NextGraph wallet inside a persistent Chromium profile. Previously, the first run required manual interaction (visible browser, wallet created by hand) → CI was blocked.
## Options Considered
### Option A: création programmatique du wallet via SDK NG
Appeler `ng.wallet_create()` depuis Node/Bun, sans UI.
- **Pour** : plus rapide, pas de navigateur.
- **Contre** : `@ng-org/web` est browser-only (WASM + postMessage) ; il faudrait reverse-engineer l'API d'inscription d'`account.nextgraph.eu` ; ne teste pas le vrai flux d'auth.
### Option A: create the wallet programmatically through the NG SDK
Call `ng.wallet_create()` from Node/Bun, without any UI.
- **Pros**: faster, no browser needed.
- **Cons**: `@ng-org/web` is browser-only (WASM + postMessage); we would have to reverse-engineer the sign-up API of `account.nextgraph.eu`; it does not exercise the real auth flow.
### Option B: automatiser le flux UI headless
Piloter via Playwright la même UI de création de wallet, en headless.
- **Pour** : teste le vrai flux auth/login de bout en bout ; pas de reverse-engineering ; même profil persistant réutilisé ; CI-ready sans étape manuelle.
- **Contre** : dépend de `nextgraph.eu`/`account.nextgraph.eu` joignables ; fragile aux changements d'UI NextGraph ; +~27s au premier run.
### Option B: automate the UI flow headlessly
Drive the very same wallet-creation UI with Playwright, in headless mode.
- **Pros**: exercises the real auth/login flow end to end; no reverse-engineering; the same persistent profile is reused; CI-ready with no manual step.
- **Cons**: depends on `nextgraph.eu`/`account.nextgraph.eu` being reachable; brittle to NextGraph UI changes; adds ~27s to the first run.
## Decision
**Option B** — automatiser l'UI broker. Le flux de création (navigate → Create Wallet → ToS → username/password → submit) est lui-même un test légitime de la feature d'auth. La dépendance aux services externes est acceptable puisque les tests dépendent déjà du broker joignable.
**Option B** — automate the broker UI. The creation flow (navigate → Create Wallet → ToS → username/password → submit) is itself a legitimate test of the auth feature. Depending on external services is acceptable since the tests already depend on the broker being reachable.
## Consequences
**Positif :** tests pleinement CI-ready (zéro interaction) ; flux auth testé en passant ; `bun run test:data` part d'un état propre.
**Négatif :** exige un accès internet (nextgraph.eu, account.nextgraph.eu) ; fragile aux changements d'UI NextGraph (textes de boutons, IDs de formulaire).
**Risque :** rate-limiting d'`account.nextgraph.eu` si le CI recrée souvent des wallets.
**Positive:** fully CI-ready tests (zero interaction); the auth flow gets tested along the way; `bun run test:data` starts from a clean state.
**Negative:** requires internet access (nextgraph.eu, account.nextgraph.eu); brittle to NextGraph UI changes (button texts, form IDs).
**Risk:** rate-limiting from `account.nextgraph.eu` if CI recreates wallets often.
> Mécanique de cycle de vie détaillée : [[knowledge_data-layer-broker]].
> Detailed lifecycle mechanics: [[knowledge_data-layer-broker]].
@@ -1,46 +1,46 @@
---
type: knowledge
summary: Config Cucumber (cucumber.json, langue fr, loader tsx), layout des features/steps colocalisés par module, steps partagés dans shared/steps/, et les scripts qui génèrent features.ts/testResults.ts/stepDefinitions.ts
summary: Cucumber config (cucumber.json, fr language, tsx loader), layout of features/steps colocated per module, shared steps under shared/steps/, and the scripts that generate features.ts/testResults.ts/stepDefinitions.ts
---
# Setup Cucumber
# Cucumber setup
26 fichiers `.feature` (US-1 à US-26), tous en **français**, taggés `@CATEGORIE @priority-N` (catégories EVENT, WORKSHOP, USER, MEETING, NOTIF).
26 `.feature` files (US-1 to US-26), all in **French**, tagged `@CATEGORIE @priority-N` (categories EVENT, WORKSHOP, USER, MEETING, NOTIF).
## Layout
Features et steps **colocalisés avec leur module** :
Features and steps are **colocated with their module**:
```
src/modules/event/features/us-13-creer-evenement.feature
src/modules/event/steps/{ui,data,e2e}/
```
Steps **partagés** (cross-domaine) dans `src/shared/steps/ui/` :
- `navigation.steps.ts` — navigation, auth, clics/sélections, assertions section/bouton/champ
- `form.steps.ts` — validation de champs, champs requis, import/duplicate
- `screen.steps.ts`contenu d'écran (participants, events, profils, QR)
**Shared** (cross-domain) steps live in `src/shared/steps/ui/`:
- `navigation.steps.ts` — navigation, auth, clicks/selections, section/button/field assertions
- `form.steps.ts` field validation, required fields, import/duplicate
- `screen.steps.ts`screen content (participants, events, profiles, QR)
Les noms français des écrans (`"accueil"`, `"détail événement"`, `"mon profil"`…) mappent vers les IDs d'écran via `screenNameMap`.
The French screen names (`"accueil"`, `"détail événement"`, `"mon profil"`…) map to screen IDs through `screenNameMap`.
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés **ou dont le comportement applicatif n'est pas encore fiable** (usage : marquer un attendu réel qui échoue à cause d'un bug produit, pas un test obsolète — ex. historique : la désinscription qui ne se reflétait pas dans l'UI, `@wip` **levé** depuis sa résolution T02.c, cf [[caveat_participation-deletion]]). **`@wip` est EXCLU du run par défaut** (`cucumber.json: "tags": "not @wip"`) : ces scénarios documentent un attendu sans casser la suite ; retirer le `@wip` quand c'est fiable. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
Scenario tags: `@ui` / `@data` / `@e2e` (layer) plus **`@wip`** for a scenario whose steps are not implemented yet **or whose application behaviour is not reliable yet** (intended use: flagging a genuine expectation that fails because of a product bug, not marking an obsolete test — historical example: sign-up cancellation not being reflected in the UI, `@wip` **lifted** once it was fixed in T02.c, see [[caveat_participation-deletion]]). **`@wip` is EXCLUDED from the default run** (`cucumber.json: "tags": "not @wip"`): those scenarios document an expectation without breaking the suite; drop the `@wip` once they are reliable. A common `Contexte` (Background) — « Étant donné que je suis connecté » — merely sets an `isAuthenticated` flag, there is no real auth in `@ui`.
## Config
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx**, pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`. ⚠️ Le runner doit pointer sur l'**entrée JS réelle du paquet** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **jamais** sur `node_modules/.bin/cucumber-js` : selon l'installeur, `.bin/` contient un **shim shell** (pas du JS) que `node --import tsx/esm` ne peut pas exécuter.
`cucumber.json`: `import` of `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**`; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (excludes WIP scenarios); `language: fr`. **Runner = Node + tsx**, not Bun — the plugins (Playwright, happy-dom) do not load under a native Bun import. Do not "bunify" `cucumber:run`/`test:data`. ⚠️ The runner must point at the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **never** at `node_modules/.bin/cucumber-js`: depending on the installer, `.bin/` holds a **shell shim** (not JS) that `node --import tsx/esm` cannot execute.
## Le harness de test est buildé à la demande
## The test harness is built on demand
Les harness `@data`/`@e2e` (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) **ne sont pas** buildés par `build.ts`. Le `BeforeAll` de `hooks.ts` les compile **à la demande** (`bun build``dist/test-harness*.js`). Le wallet de test peut être créé d'avance via `bun run test:auth-setup` (`scripts/setup-test-auth.ts`), sinon il est créé automatiquement au premier run (cf. [[decision_2026-03-12_headless-wallet-creation]]).
The `@data`/`@e2e` harnesses (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) are **not** built by `build.ts`. The `BeforeAll` hook in `hooks.ts` compiles them **on demand** (`bun build``dist/test-harness*.js`). The test wallet can be created ahead of time with `bun run test:auth-setup` (`scripts/setup-test-auth.ts`); otherwise it is created automatically on the first run (see [[decision_2026-03-12_headless-wallet-creation]]).
## Fichiers auto-générés
## Auto-generated files
Des scripts `scripts/` parsent features/steps en data TS consommée par l'outil de parcours :
Scripts under `scripts/` parse features and steps into TS data consumed by the browsing tool:
| Script | Entrée | Sortie |
| Script | Input | Output |
|---|---|---|
| `parse-features.ts` | `*/features/*.feature` | `src/shared/data/features.ts` |
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
| `extract-step-definitions.ts` | `shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
Lancer : `bun run test:cucumber` (tout), `bun run test:data` (@data). Après ajout de steps : `bun run steps:extract`.
Run: `bun run test:cucumber` (everything), `bun run test:data` (@data). After adding steps: `bun run steps:extract`.
@@ -1,71 +1,42 @@
---
type: knowledge
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock ; isolation par identifiant virtuel frais (this.freshIdentifier), plus de purge par scénario
summary: The @data layer — Playwright drives Chromium (persistent profile), which logs into the real NextGraph broker that loads harness-ng.tsx in an iframe; automated wallet lifecycle (creation + bootstrap login), window.__testData bridge, mock fallback; per-scenario isolation through a fresh virtual identifier (this.freshIdentifier), no more per-scenario purge
last_checked: 2026-07-27
---
# Couche `@data` (broker réel)
# The `@data` layer (real broker)
`@data` teste le **vrai pipeline NextGraph** via un broker, pas des données mockées.
`@data` exercises the **real NextGraph pipeline** through a broker, not mocked data.
## Architecture
```
Cucumber → Playwright (Chromium, profil persistant)
→ broker wallet login (automatisé)
→ broker charge le harness en iframe (http://127.0.0.1:{port})
Cucumber → Playwright (Chromium, persistent profile)
→ broker wallet login (automated)
→ broker loads the harness in an iframe (http://127.0.0.1:{port})
→ harness-ng.tsx (init → useShape → ORM → broker)
bridge window.__testData
→ window.__testData bridge
```
**Dual mode** : broker réel (`harness-ng.tsx`, faut) ou fallback mock (`harness.tsx`, DeepSignalSets standalone si le build NG échoue).
**Dual mode**: real broker (`harness-ng.tsx`, the default) or mock fallback (`harness.tsx`, standalone DeepSignalSets if the NG build fails).
## Cycle de vie du wallet (automatisé, CI-ready)
## Wallet lifecycle (automated, CI-ready)
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. Marker écrit.
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
- Credentials wallet : `festipod-tests` / `festipod-tests`.
- **First run**: no `.wallet-ready` marker → headless Chromium creates the wallet (`nextgraph.eu` → Create Wallet → ToS on `account.nextgraph.eu` → username/password → submit), **then logs in** — that initial login is required to bootstrap the session (saved in localStorage); without it, writes would not go through. Marker written.
- **Subsequent runs**: marker found → automated login (click Login → wallet → password → submit) → harness in the iframe → `window.__testData.ready`.
- Wallet credentials: `festipod-tests` / `festipod-tests`.
> Le choix « automatiser l'UI headless plutôt que créer le wallet par API » est tranché dans [[decision_2026-03-12_headless-wallet-creation]].
> The choice of "automating the headless UI rather than creating the wallet through an API" is settled in [[decision_2026-03-12_headless-wallet-creation]].
## Détails techniques
## Technical details
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité*
(concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData`
(`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/
`getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app**
(`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une
lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge
par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne
laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé
devient périmé après un re-rendu de seed).
- Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les
scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`,
`governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture
du profil peut retarder derrière les events publics, les steps attendent
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
soit relue.
- **Isolation par scénario = identifiant virtuel frais, PAS de purge.** Le `Before` @data mint un
identifiant unique par scénario (`freshScenarioIdentifier` dans `hooks.ts`), l'expose en
`this.freshIdentifier` sur le World, et l'injecte par `addInitScript` dans
`localStorage['festipod.account.identifier']` **sur toutes les origines** (y compris l'iframe
harness sur 127.0.0.1) — avant tout script. Le shim sert alors un **compte virtuel frais et vide**,
dont le registre part vide *par construction* : **rien à purger**. L'ancien reset par scénario
(`window.__testData.resetDataState()`, un SPARQL DELETE des records
`urn:ng-eventually:shim:Account` sur le graphe ancre) **n'est plus appelé** — il coûtait jusqu'à
10 s prélevés sur le budget 60 s du `Before`, déjà mangé par le login broker. Le helper existe
encore sur le bridge (`harness-ng.tsx`) mais n'est plus dans le chemin par défaut : ne pas le
remettre dans le `Before` sans mesurer.
- **Ce que l'identifiant frais NE borne PAS** : la croissance *physique* du wallet partagé — voir
[[caveat_wallet-bloat-hang]] (profil à mettre de côté quand les lectures ancrées se mettent à
*hang*).
- `this.freshIdentifier` est aussi ce qui distingue un test de **reconnexion** (même identifiant
réinjecté) d'un test d'**isolation** (nouvel identifiant) — cf.
[[caveat_reconnexion-froide-local-vs-broker]].
- Le seed connecté reste **allégé** (peu de docs) car chaque `docCreate` est un aller-retour broker
sériel ~2s.
- **Chromium flags** (`--disable-web-security`, `--allow-insecure-localhost`, Private Network Access turned off): necessary because the public broker loads a `http://127.0.0.1` harness in an iframe.
- **Persistent profile** `.playwright-profile/` (gitignored, wallet in localStorage) — requires the real Chrome binary, not `chrome-headless-shell`.
- **HTTP server** started in `BeforeAll` (auto-assigned port), serving the HTML plus `/harness.js` (separate files — an inline script breaks because of special characters in the bundle).
- **The bridge is the real app path (per entity).** Since the move to *one document per entity* (concept `data-layer`, [[rule_document-per-entity]]), the `window.__testData` bridge (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/`getEventParticipants`, `loadTestData`) **delegates to the app's data context** (`appData` through `FestipodDataProvider`) — this is the real per-entity path the screens use, not a read at root-store level. The harness therefore mounts the **`AccountProvider`** and logs in by default (`@mariedupont`) to establish the current identity (without it the ReadCap filter would only let public data through). It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
- Low-level probe paths are kept (root-store scope `protectedNuri`) for the ReadCap/isolation scenarios that *govern* that document: `rawJoin`/`rawParticipations`, `governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; since reading the profile can lag behind the public events, the steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise a participation is written without a user → dropped on read, and never makes the round trip) and then wait (`waitForFunction`) for the participation to be read back.
- **Per-scenario isolation = a fresh virtual identifier, NOT a purge.** The @data `Before` hook mints a unique identifier per scenario (`freshScenarioIdentifier` in `hooks.ts`), exposes it as `this.freshIdentifier` on the World, and injects it via `addInitScript` into `localStorage['festipod.account.identifier']` **on every origin** (including the harness iframe on 127.0.0.1) — before any script. The shim then serves a **fresh, empty virtual account**, whose registry starts empty *by construction*: **nothing to purge**. The old per-scenario reset (`window.__testData.resetDataState()`, a SPARQL DELETE of the `urn:ng-eventually:shim:Account` records on the anchor graph) is **no longer called** — it cost up to 10 s taken out of the 60 s budget of the `Before` hook, already eaten by the broker login. The helper still exists on the bridge (`harness-ng.tsx`) but is no longer on the default path: do not put it back into the `Before` hook without measuring.
- **What the fresh identifier does NOT bound**: the *physical* growth of the shared wallet — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when anchored reads start to hang).
- `this.freshIdentifier` is also what distinguishes a **reconnection** test (same identifier re-injected) from an **isolation** test (new identifier) — see [[caveat_reconnexion-froide-local-vs-broker]].
- The connected seed stays **lightweight** (few docs) because each `docCreate` is a serial broker round trip of about 2s.
@@ -1,107 +1,77 @@
---
type: knowledge
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock ; identité par scénario (this.freshIdentifier) + barrière d'accès désactivée par init script ; idiome « fermer et rouvrir » pour les scénarios de reconnexion
summary: The @e2e layer — Playwright boots the REAL app (not a harness) inside the broker iframe, interacts through appFrame.evaluate()/locator(), reuses setupBrokerPage() from @data; tests navigation/redirects/clicks, no mock fallback; per-scenario identity (this.freshIdentifier) plus the access barrier disabled by init script; "close and reopen" idiom for reconnection scenarios
last_checked: 2026-07-27
---
# Couche `@e2e` (app réelle)
# The `@e2e` layer (real app)
`@e2e` teste l'**UI de l'app réelle** tournant dans l'iframe broker — contrairement à `@data` qui charge un harness de test.
`@e2e` tests the **UI of the real app** running inside the broker iframe — unlike `@data`, which loads a test harness.
## Architecture
```
Cucumber → Playwright (Chromium, profil persistant)
Cucumber → Playwright (Chromium, persistent profile)
→ https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
login broker (automatisé, même mécanique que @data)
→ broker charge la VRAIE APP en iframe
→ app rend avec NextGraphProvider auto-connectant
→ steps via appFrame.evaluate() + locators Playwright
→ broker login (automated, same machinery as @data)
→ broker loads the REAL APP in an iframe
→ app renders with a self-connecting NextGraphProvider
→ steps through appFrame.evaluate() + Playwright locators
```
**Serveur app** : lancé en `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, poll jusqu'à réponse HTTP, tué en `AfterAll`). Réutilise le helper `setupBrokerPage()` de `@data` (redirect, login, découverte de l'iframe).
**App server**: started in `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, polled until it answers over HTTP, killed in `AfterAll`). It reuses the `setupBrokerPage()` helper from `@data` (redirect, login, iframe discovery).
## Step definitions
Dans les modules (ex. `src/modules/auth/steps/e2e/connexion.steps.ts`) :
- `this.appFrame!.evaluate()` — JS dans l'iframe app (navigation hash/path, checks de contenu)
- `this.appFrame!.locator()`éléments DOM
- `this.appFrame!.waitForFunction()` — poll d'état attendu
- `SCREEN_MARKERS` — map ID d'écran → texte unique de vérification
Inside the modules (e.g. `src/modules/auth/steps/e2e/connexion.steps.ts`):
- `this.appFrame!.evaluate()` — JS inside the app iframe (hash/path navigation, content checks)
- `this.appFrame!.locator()`DOM elements
- `this.appFrame!.waitForFunction()` — poll for an expected state
- `SCREEN_MARKERS` — map from screen ID to a unique verification text
Navigation : `window.history.pushState` + dispatch `popstate` (routing path-based, cf. `app-architecture`).
Navigation: `window.history.pushState` plus a `popstate` dispatch (path-based routing, see `app-architecture`).
## Différences avec `@data`
## Differences from `@data`
| Aspect | `@data` | `@e2e` |
|---|---|---|
| Chargé en iframe | harness (`harness-ng.tsx`) | app réelle (`src/index.ts`) |
| Signal ready | `window.__testData.ready` | `root.innerHTML.length > 100` |
| Interaction | bridge `evaluate()` | `evaluate()` + locators |
| Fallback mock | oui | **non** (broker réel requis) |
| Teste | opérations données | comportement UI (nav, redirects, clics) |
| Loaded in the iframe | harness (`harness-ng.tsx`) | real app (`src/index.ts`) |
| Ready signal | `window.__testData.ready` | `root.innerHTML.length > 100` |
| Interaction | `evaluate()` bridge | `evaluate()` + locators |
| Mock fallback | yes | **no** (real broker required) |
| Tests | data operations | UI behaviour (nav, redirects, clicks) |
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
> **Do not re-check in `@e2e` what `@ui` already covers** — `@e2e` must break when the *collaboration* between layers breaks, not when an icon changes (see [[rule_test-layer-contracts]]).
## Identité du scénario + barrière d'accès
## Scenario identity + access barrier
Deux réglages posés par le `Before` de `hooks.ts` conditionnent **tout** scénario `@e2e` :
Two settings applied by the `Before` hook in `hooks.ts` govern **every** `@e2e` scenario:
- **`this.freshIdentifier`** — un identifiant virtuel **unique par scénario**, injecté par
`addInitScript` dans `localStorage['festipod.account.identifier']` sur **toutes** les origines
avant tout script. L'app réelle boote donc directement sur cette identité, et chaque scénario part
d'un espace vide. C'est la **même** mécanique qu'en `@data` (même champ du World).
- **Barrière d'accès désactivée** — `browserContext.addInitScript` pose
`globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` sur le contexte **persistant** : `@e2e` voit
l'app, pas l'`AccessGateScreen`. Les contextes **frais** (`@humain`, cf.
[[knowledge_multibrowser-harness]]) n'héritent pas de ce réglage → barrière ON chez eux.
- **`this.freshIdentifier`** — a virtual identifier **unique to each scenario**, injected via `addInitScript` into `localStorage['festipod.account.identifier']` on **every** origin before any script. The real app therefore boots straight into that identity, and each scenario starts from an empty space. This is the **same** machinery as in `@data` (same World field).
- **Access barrier disabled** — `browserContext.addInitScript` sets `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` on the **persistent** context: `@e2e` sees the app, not the `AccessGateScreen`. **Fresh** contexts (`@humain`, see [[knowledge_multibrowser-harness]]) do not inherit that setting → the barrier is ON for them.
> **Impact :** toute page ouverte à la main dans un step (`ctx.newPage()`) doit **re-poser les deux
> init scripts elle-même** — `addInitScript` du contexte ne s'applique qu'aux pages du contexte, et
> l'identifiant doit être écrit **avant** le premier script de l'app.
> **Impact:** any page opened by hand inside a step (`ctx.newPage()`) must **re-apply both init scripts itself** — the context's `addInitScript` only applies to pages of that context, and the identifier must be written **before** the app's first script.
## Idiome « fermer et rouvrir » (scénarios de reconnexion)
## The "close and reopen" idiom (reconnection scenarios)
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts`
reproduisent, dans la VRAIE app, le parcours « je crée, je ferme, je reviens » :
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts` reproduce the "I create, I close, I come back" journey inside the REAL app:
1. **Création par le vrai formulaire**le step pilote l'assistant de création réel au DOM
(assistant en 3 étapes, sélection par *placeholder* : nom de l'événement, lieu ; bouton de
soumission par son libellé). ⚠️ **Ces steps sont couplés aux libellés FR de l'écran de création** :
renommer un placeholder ou le bouton de soumission casse le scénario, pas l'app.
2. **Réouverture** — seconde page sur le **même** contexte persistant, avec la **même**
`this.freshIdentifier` + la barrière désactivée, puis `pool.setupBrokerPage(page, pool.appUrl!)`
→ nouveau login broker, session verifier fraîche.
3. **Preuve** — le step capture la console des **deux** pages et publie un résumé via `this.attach`
(pièce jointe Cucumber) + stdout ; un dump brut des lignes de connexion/sync est **opt-in** par
la variable d'environnement `RECO_RAW_DUMP=1` (bruyant, coupé par défaut).
1. **Creation through the real form**the step drives the actual creation wizard at DOM level (3-step wizard, selection by *placeholder*: event name, venue; submit button by its label). ⚠️ **These steps are coupled to the French labels of the creation screen**: renaming a placeholder or the submit button breaks the scenario, not the app.
2. **Reopening** — a second page on the **same** persistent context, with the **same** `this.freshIdentifier` and the barrier disabled, then `pool.setupBrokerPage(page, pool.appUrl!)` → new broker login, fresh verifier session.
3. **Proof** — the step captures the console of **both** pages and publishes a summary through `this.attach` (Cucumber attachment) plus stdout; a raw dump of the connection/sync lines is **opt-in** through the `RECO_RAW_DUMP=1` environment variable (noisy, off by default).
> **Limite à connaître** : ce montage prouve la reconnexion *du parcours*, **pas** la durabilité
> broker de l'écriture — la seconde page partage l'IndexedDB du profil persistant. Voir
> [[caveat_reconnexion-froide-local-vs-broker]] pour le montage qui, lui, tranche broker-vs-local.
> **Limitation to know about**: this setup proves the reconnection *of the journey*, **not** the broker durability of the write — the second page shares the IndexedDB of the persistent profile. See [[caveat_reconnexion-froide-local-vs-broker]] for the setup that does settle broker-vs-local.
Le scénario est **`@wip`** (instrument de diagnostic, exclu du run par défaut).
The scenario is **`@wip`** (diagnostic instrument, excluded from the default run).
## Smoke `@smoke` — garde la classe « page blanche une fois connecté »
## `@smoke` — guarding the "blank page once connected" class
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une
CLASSE de régression : un crash de rendu qui ne survient QUE une fois l'app connectée
et montée sur des données réelles (symptôme : seul le bandeau de l'iframe broker
s'affiche, `#root` reste vide). Le smoke réutilise le boot du hook `Before` @e2e,
navigue vers l'accueil connecté et asserte DEUX choses :
1. **HomeScreen a réellement monté** — présence de marqueurs forts (`.app-navbar` +
bouton `[aria-label="Relayer un événement"]`), absents d'un spinner / du bandeau
broker. Un `throw` dans un composant/provider monté après connexion démonte l'arbre
(aucun `ErrorBoundary`) → ces marqueurs disparaissent → rouge.
2. **Zéro erreur runtime**`this.pageErrors` (voir ci-dessous) doit être vide.
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) guards a CLASS of regression: a rendering crash that happens ONLY once the app is connected and mounted on real data (symptom: only the broker iframe banner shows, `#root` stays empty). The smoke test reuses the boot from the @e2e `Before` hook, navigates to the connected home screen and asserts TWO things:
1. **HomeScreen really mounted** — presence of strong markers (`.app-navbar` plus the `[aria-label="Relayer un événement"]` button), absent from a spinner or from the broker banner. A `throw` inside a component/provider mounted after connection unmounts the tree (there is no `ErrorBoundary`) → those markers disappear → red.
2. **Zero runtime errors**`this.pageErrors` (see below) must be empty.
Le hook `Before` @e2e **collecte** désormais dans le World les `pageerror` +
`console.error` de la page app (champ `pageErrors`, réinitialisé par scénario) — c'est
ce qui rend l'assertion « pas d'erreur » possible. Le run par défaut de `bun run
validate` exécute `@smoke and not @wip` (pas tout `@e2e`, pour rester rapide).
**Preuve de détection** : un `throw` en tête de `HomeScreen` fait virer le smoke au
rouge ; sans lui, vert.
The @e2e `Before` hook now **collects** the app page's `pageerror` and `console.error` events into the World (field `pageErrors`, reset per scenario) — that is what makes the "no error" assertion possible. The default run of `bun run validate` executes `@smoke and not @wip` (not all of `@e2e`, to stay fast). **Detection proof**: a `throw` at the top of `HomeScreen` turns the smoke test red; without it, green.
## Fichiers clés
## Key files
`src/shared/support/hooks.ts` (lifecycle Playwright + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
`src/shared/support/hooks.ts` (Playwright lifecycle + `pageErrors` collection), `world.ts` (fields `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (headed debugging), `.playwright-profile{,-debug}/` (gitignored).
@@ -1,70 +1,70 @@
---
type: knowledge
summary: Harness multi-navigateur sur DEUX axes orthogonaux — nombre de navigateurs (machinerie, contextes frais isolés via un freshBrowser non-persistant) ET modèle de wallet (own/@private-wallet vs shared/@shared-wallet) ; shared provisionné par injection storageState (test) ; e2e @humain qui valide le mécanisme produit RÉEL via la vraie app staging (fichier .ngw téléchargé depuis l'écran → import nextgraph.eu « Import a Wallet File » → Entrer → connecté) ; convention @wip exclue via cucumber.json
summary: Multi-browser harness along TWO orthogonal axes — number of browsers (the machinery, isolated fresh contexts spawned from a non-persistent freshBrowser) AND wallet model (own/@private-wallet vs shared/@shared-wallet); shared is provisioned by storageState injection (test-only); an @humain e2e validates the REAL product mechanism through the real staging app (.ngw file downloaded from the screen → nextgraph.eu "Import a Wallet File" → Entrer → connected); @wip convention excluded through cucumber.json
last_checked: 2026-06-16
---
# Harness multi-navigateur (private-wallet vs shared-wallet)
# Multi-browser harness (private-wallet vs shared-wallet)
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@shared-wallet`).
The ability of the `@data`/`@e2e` harness to drive **several isolated browsers** within a single scenario, along **two orthogonal axes**. It makes it possible to test both the "everyone has their own wallet" model (`@private-wallet`) and the "wallet shared between browsers" model (`@shared-wallet`).
## Les deux axes (orthogonaux)
## The two axes (orthogonal)
| Axe | Ce qu'il décide | Exprimé par |
| Axis | What it decides | Expressed by |
|---|---|---|
| **Nombre de navigateurs** (machinerie) | 1..N contextes nommés isolés | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Modèle de wallet** | identité NG distincte vs partagée | **phrasing du step + tag** (voir ci-dessous) |
| **Number of browsers** (machinery) | 1..N isolated named contexts | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Wallet model** | distinct vs shared NG identity | **step phrasing + tag** (see below) |
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (me wallet) : on fait du multibrowser **en private** (chacun son wallet) **et en shared** (wallet partagé), et on compare les deux setups avec les **mes** steps de comportement.
Do **not** confuse `@multibrowser` (several browsers) with `@shared-wallet` (same wallet): we run multibrowser **in private** (everyone with their own wallet) **and in shared** (shared wallet), and compare both setups with the **same** behavioural steps.
## Modèle de wallet : phrasing + tags
## Wallet model: phrasing + tags
- `Étant donné un navigateur "A" avec son propre wallet` modèle **own**, tag `@private-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé` modèle **shared**, tag `@shared-wallet`.
- Tag umbrella `@multibrowser` (feature entière).
- `Étant donné un navigateur "A" avec son propre wallet`**own** model, tag `@private-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé`**shared** model, tag `@shared-wallet`.
- Umbrella tag `@multibrowser` (whole feature).
## Architecture (où vit quoi)
## Architecture (where things live)
- **`src/shared/support/browserPool.ts`** — état partagé + fabrique. Hors du contexte Chromium **persistant** porteur du wallet partagé (legacy mono-navigateur `@data`/`@e2e`, **inchangé**, cf. [[knowledge_data-layer-broker]]), le harness lance un navigateur **non-persistant** `freshBrowser` (`chromium.launch`) qui mint des contextes frais et isolés à la demande (`spawnContext(wallet)`). Module importé par `hooks.ts` (cycle de vie) et `world.ts` (usage par scénario) — pas de cycle d'import.
- **`world.ts`** — API : `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()` ; registre `browsers: Map<name, NamedBrowser>`. Navigateurs nommés fermés en `After`, `freshBrowser` en `AfterAll`.
- **`hooks.ts`** — un scénario taggé `@multibrowser` **ne reçoit pas** la page unique legacy ; les steps ouvrent les navigateurs. Exige le mode broker réel (`freshBrowser` indispo en fallback mock).
- **`src/shared/support/browserPool.ts`** — shared state + factory. Outside the **persistent** Chromium context that carries the shared wallet (legacy single-browser `@data`/`@e2e`, **unchanged**, see [[knowledge_data-layer-broker]]), the harness launches a **non-persistent** browser `freshBrowser` (`chromium.launch`) which mints fresh isolated contexts on demand (`spawnContext(wallet)`). The module is imported by `hooks.ts` (lifecycle) and `world.ts` (per-scenario use) — no import cycle.
- **`world.ts`** — API: `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()`; registry `browsers: Map<name, NamedBrowser>`. Named browsers are closed in `After`, `freshBrowser` in `AfterAll`.
- **`hooks.ts`** — a scenario tagged `@multibrowser` **does not get** the legacy single page; the steps open the browsers themselves. Requires the real broker mode (`freshBrowser` is unavailable in the mock fallback).
## Provisioning du wallet
## Wallet provisioning
- **own** : `newContext()` vide → identité NG distincte / pas de wallet.
- **shared** : `newContext({ storageState })`, `storageState` est **capturé une fois** au `BeforeAll` depuis le profil persistant (warm-up via `setupBrokerPage` puis `browserContext.storageState()`), exposé par `pool.sharedWalletState`. **Vérifié empiriquement (2026-06-16)** : les origines `nextgraph.eu` + `nextgraph.net` round-trippent dans les contextes frais, et deux navigateurs **shared** atteignent tous deux l'app **connectée** à NextGraph (`window.__testData.ready`) **sans login manuel**.
- **own**: empty `newContext()` → distinct NG identity / no wallet.
- **shared**: `newContext({ storageState })`, where `storageState` is **captured once** at `BeforeAll` from the persistent profile (warm-up through `setupBrokerPage`, then `browserContext.storageState()`), exposed as `pool.sharedWalletState`. **Empirically verified (2026-06-16)**: the `nextgraph.eu` and `nextgraph.net` origins round-trip into the fresh contexts, and two **shared** browsers both reach the app **connected** to NextGraph (`window.__testData.ready`) **without any manual login**.
> Ce provisioning est **de test** — distinct du mécanisme **produit** (import assisté par FICHIER). Le scénario shared-wallet par storageState **court-circuite l'import** ; pour valider le mécanisme RÉEL, voir l'e2e `@humain` ci-dessous.
> This provisioning is **test-only** — distinct from the **product** mechanism (FILE-assisted import). The shared-wallet scenario using storageState **bypasses the import**; to validate the REAL mechanism, see the `@humain` e2e below.
## Parcours humain — e2e du mécanisme produit (vert)
## Human journey — e2e of the product mechanism (green)
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test. Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File »retouron **saisit un identifiant** puis clic « Entrer » (nommer l'espace et ouvrir le wallet = un seul acte, cf. concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connectée, arrivée directe sur l'accueil (plus d'écran « nom d'utilisateur » séparé).
The `@humain` scenario validates the REAL wallet distribution flow **end to end, through the real app**, not through test injection. A blank browser opens the staging app → the `AccessGateScreen` offers the **file** and the **password** → the file is downloaded **from the screen**, the displayed password is checked to **equal** the wallet's own → import on `nextgraph.eu` "Import a Wallet File"back to the appan **identifier is typed in**, then a click on « Entrer » (naming the space and opening the wallet are a single act, see concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connected, landing straight on the home screen (no more separate « nom d'utilisateur » screen).
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON. L'ancien `LoginScreen` `/login` a été retiré.
- `pool.importWalletViaFile(page, filePath, password)``nextgraph.eu/#/wallet/login``setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)`moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
- **e2e wallet**: a `.ngw` file (`festipod-e2e-tests`, password = identifier) placed **at the root of the worktree**; `findE2eWalletFile()` locates it (`*.ngw`). Gitignoredeach environment has to add it (otherwise a clear error is raised).
- `pool.ensureStagingApp()` (`hooks.ts`) — an **isolated** build `bun run build.ts --outdir=dist-staging` (access barrier **ON by default**; password baked in and the **file copied** to `/shared-wallet.ngw`, see `build.ts`), served statically. Memoized and lazy (only `@humain` pays for it).
- **Barrier bypass for `@e2e`**: the harness calls `browserContext.addInitScript` on the **persistent** context to set `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (which applies to the app iframe before its scripts) → `@e2e` sees the app directly, not the barrier. Fresh contexts (`@humain`) leave it alone → barrier ON. The old `/login` `LoginScreen` has been removed.
- `pool.importWalletViaFile(page, filePath, password)``nextgraph.eu/#/wallet/login``setInputFiles('input[type=file]')` (wait for the SPA to render, otherwise `EncryptionError`) → password field → unlock.
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)`the "broker login" half extracted from `setupBrokerPage`. **Robust waiting**: after the (multi-hop) redirect, it waits for either the app iframe or the "Click here to login with your wallet" link, then unlocks with the password. Since the broker session is **not** persisted between launches, this wallet login is required on every run (warm-up + `@e2e` + `@humain`).
> **C'est l'e2e qui garantit que ça marche pour un humain réel** : Festipod fournit le BON fichier + mot de passe, et ce fichier importé donne un wallet fonctionnel sur un device vierge. Le scénario `@shared-wallet` (storageState) reste un raccourci de provisioning de test, il ne valide pas l'import.
> **The e2e is what guarantees it works for a real human**: Festipod hands out the RIGHT file plus password, and importing that file yields a working wallet on a blank device. The `@shared-wallet` scenario (storageState) remains a test provisioning shortcut, it does not validate the import.
## Isolation (garantie à 3 niveaux, prouvée par les scénarios)
## Isolation (guaranteed at 3 levels, proven by the scenarios)
1. `freshBrowser` est un **process séparé** du profil persistant porteur du wallet → un navigateur **own** démarre **sans wallet**.
2. Chaque `newContext()` est une **partition de stockage hermétique** (garantie Playwright).
3. Isolation prouvée non seulement sur l'origine **locale** (`127.0.0.1`) mais aussi sur l'**origine broker** `nextgraph.net` **où vit réellement le wallet** (sonde localStorage écrite dans A absente de B).
1. `freshBrowser` runs in a **separate process** from the persistent profile carrying the wallet → an **own** browser starts **with no wallet**.
2. Every `newContext()` is a **hermetic storage partition** (Playwright guarantee).
3. Isolation is proven not only on the **local** origin (`127.0.0.1`) but also on the **broker origin** `nextgraph.net` **where the wallet actually lives** (a localStorage probe written in A is absent from B).
## Fichiers
## Files
- Feature : `src/modules/workshop/features/multibrowser-harness.feature`.
- Steps : `src/modules/workshop/steps/data/multibrowser.steps.ts`.
- Route `/blank` ajoutée au serveur harness (`hooks.ts`) : page minimale **sans stack NG**, pour les checks d'isolation localStorage.
- Feature: `src/modules/workshop/features/multibrowser-harness.feature`.
- Steps: `src/modules/workshop/steps/data/multibrowser.steps.ts`.
- A `/blank` route added to the harness server (`hooks.ts`): a minimal page **with no NG stack**, for the localStorage isolation checks.
## Convention `@wip` (désormais appliquée)
## The `@wip` convention (now enforced)
`cucumber.json` (profile `default`) porte `"tags": "not @wip"`. Le `cookbook_add-scenario` prescrivait `@wip` pour le non-implémenté mais ce n'était **exclu nulle part** ; maintenant `not @wip` s'**AND** avec les filtres CLI (ex. `--tags @data``(not @wip) and @data`, vérifié).
`cucumber.json` (profile `default`) carries `"tags": "not @wip"`. `cookbook_add-scenario` prescribed `@wip` for anything unimplemented, but it was **excluded nowhere**; now `not @wip` is **AND**ed with the CLI filters (e.g. `--tags @data``(not @wip) and @data`, verified).
## Liens
## Links
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant) que cette capability étend.
- [[cookbook_add-scenario]] — convention `@wip`, pièges de steps.
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile) that this capability extends.
- [[cookbook_add-scenario]] — the `@wip` convention, step pitfalls.
@@ -1,57 +1,47 @@
---
type: knowledge
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes ; piège des écrans qui lisent un global injecté au build (barrière d'accès → import lazy obligatoire)
summary: The @ui layer — renderHelper.tsx renders any screen inside LocalDataProvider + happy-dom, world.renderCurrentScreen() invokes it on every navigateTo, assertions run against the rendered DOM with the deterministic seed fixtures; pitfall of screens reading a global injected at build time (access barrier → lazy import mandatory)
last_checked: 2026-07-27
---
# Couche `@ui`
# The `@ui` layer
`@ui` rend un écran avec `LocalDataProvider` (seed) + `RouterProvider` via happy-dom, puis assert sur le **DOM rendu**.
`@ui` renders a screen with `LocalDataProvider` (seed) plus `RouterProvider` through happy-dom, then asserts on the **rendered DOM**.
- Helper : `src/shared/test-harness/renderHelper.tsx` (installe les globals happy-dom, enveloppe l'écran). Invoqué depuis `world.ts:renderCurrentScreen()` à chaque `navigateTo(...)`.
- Fixtures déterministes (`src/shared/data/seedData.ts`, voir concept `data-layer`) : `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, 5 events, etc.
- Helper: `src/shared/test-harness/renderHelper.tsx` (installs the happy-dom globals, wraps the screen). Invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
- Deterministic fixtures (`src/shared/data/seedData.ts`, see concept `data-layer`): `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, 5 events, and so on.
## Bons patterns d'assertion
## Good assertion patterns
```ts
// Texte visible
// Visible text
expect(this.getDomText()).to.include('Marie Dupont');
// Présence d'élément par classe/rôle
// Element present, by class/role
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
// Rendu conditionnel (rempli vs vide)
// Conditional rendering (filled vs empty)
expect(this.renderedDoc!.querySelectorAll('.app-card').length).to.be.greaterThan(0);
// Champ requis rendu avec label + astérisque
// Required field rendered with a label and an asterisk
const labels = Array.from(this.renderedDoc!.querySelectorAll('p')).map(p => p.textContent ?? '');
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
```
## Champs & helpers de `FestipodWorld` (`src/shared/support/world.ts`)
## `FestipodWorld` fields & helpers (`src/shared/support/world.ts`)
- `renderedDoc: Document | null`le DOM happy-dom rendu (peuplé par `renderCurrentScreen()`, appelé à chaque `navigateTo(...)`).
- `currentScreenId: string | null`l'écran courant.
- Helpers d'assertion : `getDomText()` (texte du DOM), `hasText(t)`, `hasField(name)`, `hasElement(selector)`ils **préfèrent le DOM rendu** mais **retombent sur la source** des écrans pour les steps non migrés (vestige, voir [[caveat_source-grep-vestiges]]).
- `renderedDoc: Document | null`the rendered happy-dom DOM (populated by `renderCurrentScreen()`, called on every `navigateTo(...)`).
- `currentScreenId: string | null`the current screen.
- Assertion helpers: `getDomText()` (DOM text), `hasText(t)`, `hasField(name)`, `hasElement(selector)`they **prefer the rendered DOM** but **fall back to the screens' source** for unmigrated steps (a leftover, see [[caveat_source-grep-vestiges]]).
## ⚠️ Écrans qui lisent un global injecté au **build** (barrière d'accès)
## ⚠️ Screens that read a global injected at **build** time (access barrier)
`src/modules/auth/sharedWallet.ts` **capture au moment de l'évaluation du module** un global posé par
`build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). Le harness `@ui` tourne sous Node **sans passer
par le build** → ce global est absent, `hasSharedWallet()` retourne faux, et comme le **wallet
partagé est le seul mode supporté** (concept `app-security`), `AccessGateScreen` rend sa branche
**erreur de configuration** : **aucun champ identifiant** dans le DOM → tous les steps de la barrière
échouent avec un message trompeur (« champ introuvable »).
`src/modules/auth/sharedWallet.ts` **captures, at module evaluation time**, a global set by `build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). The `@ui` harness runs under Node **without going through the build** → that global is missing, `hasSharedWallet()` returns false, and since the **shared wallet is the only supported mode** (concept `app-security`), `AccessGateScreen` renders its **configuration error** branch: **no identifier field at all** in the DOM → every barrier step fails with a misleading message ("field not found").
**Le montage obligatoire** (appliqué dans `src/modules/auth/steps/ui/barriere-acces.steps.ts`) :
**The mandatory setup** (applied in `src/modules/auth/steps/ui/barriere-acces.steps.ts`):
1. poser le global **en tête du module de steps**, avant tout import de l'écran ;
2. **importer l'écran paresseusement** (`await import(...)` mémoïsé) — un `import` statique serait
**hissé au-dessus** de l'affectation et `sharedWallet.ts` capturerait une valeur vide.
1. set the global **at the top of the steps module**, before any import of the screen;
2. **import the screen lazily** (memoized `await import(...)`) — a static `import` would be **hoisted above** the assignment and `sharedWallet.ts` would capture an empty value.
> **Impacts si tu touches à ça :**
> - Ajouter un `import` statique de `AccessGateScreen` (ou de tout module qui atteint
> `sharedWallet.ts`) dans **n'importe quel** fichier de steps `@ui` ré-introduit le bug — Cucumber
> charge tous les modules de steps, l'écran serait évalué avant que le global soit posé.
> - Le déterminisme actuel repose sur le fait que **ce fichier est le seul** module `@ui` à atteindre
> `sharedWallet.ts`. Un second point d'entrée rendrait l'ordre d'évaluation non garanti → il
> faudrait alors déplacer l'injection du global dans le support partagé, pas la dupliquer.
> **Impact if you touch this:**
> - Adding a static `import` of `AccessGateScreen` (or of any module that reaches `sharedWallet.ts`) in **any** `@ui` steps file re-introduces the bug — Cucumber loads every steps module, so the screen would be evaluated before the global is set.
> - The current determinism relies on **this file being the only** `@ui` module that reaches `sharedWallet.ts`. A second entry point would make the evaluation order unguaranteed → the global injection would then have to move into the shared support, not be duplicated.
> Les classes `app-*` confirment le thème moderne (cf. `app-architecture`). Les anti-patterns (regex sur source, détails d'implémentation) sont proscrits par [[rule_test-layer-contracts]]. Pour écrire un nouveau scénario, voir [[cookbook_add-scenario]].
> The `app-*` classes confirm the modern theme (see `app-architecture`). Anti-patterns (regexes over the source, implementation details) are banned by [[rule_test-layer-contracts]]. To write a new scenario, see [[cookbook_add-scenario]].
@@ -1,52 +1,27 @@
---
type: rule
summary: Ne JAMAIS poller le broker (re-lire en boucle « c'est là ? »). NextGraph est par abonnement — la donnée arrive par PUSH, et le 1er `State` d'un `doc_subscribe` est la barrière de sync déterministe (après lui : présence garantie / absence définitive). Tests ET app attendent le push / l'état réactif settlé, jamais une boucle de re-lecture broker.
summary: NEVER poll the broker (re-reading in a loop "is it there yet?"). NextGraph is subscription-based — data arrives by PUSH, and the first `State` of a `doc_subscribe` is the deterministic sync barrier (after it — presence guaranteed, absence definitive). Tests AND app wait for the push / for the reactive state to settle, never a broker re-read loop.
last_checked: 2026-07-09
---
# Ne jamais poller le broker — attendre l'abonnement
# Never poll the broker — wait for the subscription
NextGraph est **par abonnement (réactif)**. Une lecture n'est PAS « interroge en
boucle jusqu'à ce que ça apparaisse » ; c'est « abonne-toi, réagis au push ». Le
**1er `State`** d'un `doc_subscribe` marque la fin de la synchronisation initiale
(barrière synchrone) : après lui, la **présence** d'une donnée est **garantie** et
l'**absence** est **définitive**. Contrat vérifié empiriquement côté SDK
(`@ng-eventually/client`, test e2e « CONTRAT 3 »).
NextGraph is **subscription-based (reactive)**. A read is NOT "query in a loop until it shows up"; it is "subscribe, react to the push". The **first `State`** of a `doc_subscribe` marks the end of the initial synchronization (a synchronous barrier): after it, the **presence** of a piece of data is **guaranteed** and its **absence** is **definitive**. Contract verified empirically on the SDK side (`@ng-eventually/client`, e2e test « CONTRAT 3 »).
## L'anti-pattern à bannir
## The anti-pattern to ban
```
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
```
Toute boucle qui **re-interroge le broker** (`authParticipationCount`,
`listMyEntityDocs`, `sparql_query` répétés) pour « attendre » une donnée est
proscrite : elle masque le vrai mécanisme, fragilise le test (timeout deviné), et
contredit frontalement le modèle NextGraph. C'est la remarque qui a fait supprimer
l'ancien caveat qui, à tort, érigeait le polling en pratique.
Any loop that **re-queries the broker** (repeated `authParticipationCount`, `listMyEntityDocs`, `sparql_query`) in order to "wait" for data is forbidden: it hides the real mechanism, makes the test brittle (guessed timeout), and directly contradicts the NextGraph model. That remark is what caused the deletion of the old caveat which wrongly held polling up as a practice.
## Ce qu'il faut faire
## What to do instead
Attendre le **push réactif**. En pratique (app ET test) : l'état réactif
(`AD().*` alimenté par `subscribeDoc` dans le contexte de données) se met à jour
**au push**. On attend que CET état reflète l'attendu — on **observe l'état réactif
settlé**, on ne ré-émet PAS de lecture broker. Le mécanisme de données est
l'abonnement ; l'attente ne fait qu'**observer le résultat réactif**.
Wait for the **reactive push**. In practice (app AND test): the reactive state (`AD().*` fed by `subscribeDoc` in the data context) updates **on push**. We wait for THAT state to reflect the expectation — we **observe the settled reactive state**, we do NOT re-issue a broker read. The data mechanism is the subscription; waiting only **observes the reactive result**.
- App : l'écran est déjà réactif (`subscribeDoc` → re-render au push) — pas de poll
applicatif, pas de spinner piloté par timeout deviné (si un état d'attente est
voulu, il vient de la barrière d'abonnement native, pas d'un signal ajouté).
- Test : **un helper qui attend le push/la barrière de façon fiable est bienvenu**
(fiabilise sans fragiliser). Ce qui est banni, c'est la **boucle de re-lecture**,
pas l'attente d'un signal.
- **Fallback pragmatique** : si attendre strictement le push/signal s'avère fragile
d'une manière ou d'une autre, un **intervalle court** (`setInterval` / re-check
rapproché) qui **observe l'état réactif DÉJÀ mis à jour** (l'état local alimenté
par l'abonnement — PAS une re-lecture broker) est acceptable : c'est au plus près
de ce que vit l'utilisateur, qui **attend** simplement que l'écran (réactif) se
mette à jour. La ligne rouge est invariante : **ne jamais re-interroger le broker
en boucle** ; observer l'état réactif settlé, oui.
- App: the screen is already reactive (`subscribeDoc` → re-render on push) — no application-level polling, no spinner driven by a guessed timeout (if a waiting state is wanted, it comes from the native subscription barrier, not from an added signal).
- Test: **a helper that reliably waits for the push/barrier is welcome** (it makes things reliable without making them brittle). What is banned is the **re-read loop**, not waiting for a signal.
- **Pragmatic fallback**: if strictly waiting for the push/signal turns out to be brittle one way or another, a **short interval** (`setInterval` / closely spaced re-checks) that **observes the ALREADY updated reactive state** (the local state fed by the subscription — NOT a broker re-read) is acceptable: it is as close as it gets to what the user experiences, simply **waiting** for the (reactive) screen to update. The red line is invariant: **never re-query the broker in a loop**; observing the settled reactive state, yes.
Voir aussi [[caveat_wallet-bloat-hang]] (autre source de flakiness @data,
orthogonale). Le mécanisme non-polling côté lib (`open-repo` : subscribe + attendre
le 1er State + lire) vit dans le repo `@ng-eventually/client`, pas ici.
See also [[caveat_wallet-bloat-hang]] (another source of @data flakiness, orthogonal to this one). The non-polling mechanism on the library side (`open-repo`: subscribe + wait for the first State + read) lives in the `@ng-eventually/client` repo, not here.
@@ -1,29 +1,29 @@
---
type: rule
summary: Chaque couche BDD répond à une question distincte@ui = rendu (DOM + seed), @data = mutations/persistance broker, @e2e = collaboration des couches sur un parcours ; descendre chaque assertion à la couche la plus basse qui peut y répondre
summary: Each BDD layer answers a distinct question@ui = rendering (DOM + seed), @data = broker mutations/persistence, @e2e = collaboration of the layers over a journey; push every assertion down to the lowest layer that can answer it
---
# Règle : contrat des couches de test
# Rule: the test layer contract
Chaque couche répond à **une question distincte**. Mélanger les préoccupations produit des tests fragiles qui cassent au refactor sans attraper de vraie régression. **Descendre toute assertion à la couche la plus basse qui peut y répondre.**
Each layer answers **one distinct question**. Mixing concerns produces brittle tests that break on refactors without catching any real regression. **Push every assertion down to the lowest layer that can answer it.**
- **`@ui`couche affichage.** Rend un écran avec `LocalDataProvider` (seed) + happy-dom et assert sur le DOM. Vérifie que *données connues → l'écran montre le texte et les éléments attendus*. **Ne teste pas** la navigation, les mutations, ni la persistance.
- **`@ui`display layer.** Renders a screen with `LocalDataProvider` (seed) plus happy-dom and asserts on the DOM. Checks that *given known data, the screen shows the expected text and elements*. **Does not test** navigation, mutations or persistence.
- **`@data`couche données.** Pilote des mutations ORM via le **broker NextGraph réel** (harness headless, pas d'UI app). Vérifie que *les opérations sur shapes sont persistées et observables dans le wallet*. Pas de DOM ici — utiliser le bridge `window.__testData`.
- **`@data`data layer.** Drives ORM mutations through the **real NextGraph broker** (headless harness, no app UI). Checks that *shape operations are persisted and observable in the wallet*. No DOM here — use the `window.__testData` bridge.
- **`@e2e` couche intégration.** Boote l'app réelle dans l'iframe broker (Playwright/Chromium). Vérifie que *les couches collaborent pour livrer un parcours* (créer → listermodifier → recharger → toujours là). **Rare** : 1 scénario par chemin critique ; **ne jamais dupliquer** un check de contenu `@ui`.
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe (Playwright/Chromium). Checks that *the layers collaborate to deliver a journey* (create → list → edit → reload → still there). **Rare**: 1 scenario per critical path; **never duplicate** a `@ui` content check.
## Pourquoi le coût impose la pyramide
## Why cost dictates the pyramid
`@ui` tourne in-process (instantané) ; `@data` boote un broker (~50s) ; `@e2e` boote broker + app + navigateur (~2min). Une affirmation de rendu appartient à `@ui`, pas à `@e2e`.
`@ui` runs in-process (instant); `@data` boots a broker (~50s); `@e2e` boots broker + app + browser (~2min). A rendering claim belongs to `@ui`, not to `@e2e`.
## Anti-patterns `@ui` à proscrire
## `@ui` anti-patterns to ban
```ts
// ❌ regex sur la source : couple le test à la structure du code
// ❌ regex over the source: couples the test to the structure of the code
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
// ❌ détails d'implémentation
// ❌ implementation details
expect(/showDuplicateWarning/.test(source)).to.be.true;
```
Préférer des assertions sur le **DOM rendu** + données de seed (voir [[knowledge_ui-layer]]). Les helpers/maps d'analyse de source sont des vestiges en voie de suppression : [[caveat_source-grep-vestiges]].
Prefer assertions on the **rendered DOM** plus seed data (see [[knowledge_ui-layer]]). The source-analysis helpers and maps are leftovers on their way out: [[caveat_source-grep-vestiges]].
+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]].
@@ -1,6 +1,6 @@
---
type: _overview
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
summary: Festipod's product model — the meeting point grafted onto a public event as the unit of value, its actors, its business concepts, and the confidentiality scopes (public/protected/private) per entity
triggers:
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
paths: ["src/modules/*/features/**"]
@@ -8,22 +8,22 @@ triggers:
# Functional domain
Le **domaine fonctionnel** de Festipod : ce que le produit promet et le vocabulaire métier qui le décrit. Source d'origine : `README.md §Modèle fonctionnel`.
Festipod's **functional domain**: what the product promises and the business vocabulary that describes it. Original source: `README.md §Modèle fonctionnel`.
**À lire en premier :** [[knowledge_business-model]] — sans lui, on confond l'événement (l'ancrage) et le point de rencontre (la valeur), et on modélise à l'envers.
**Read this first:** [[knowledge_business-model]] — without it, you conflate the event (the anchor) with the meeting point (the value), and you model things backwards.
## Idée pivot
## Pivotal idea
Festipod laisse les utilisateurs créer des **points de rencontre** qui se *greffent* sur des **événements publics** existants. L'événement (festival, conférence…) n'est qu'un *prétexte* et un point d'ancrage spatio-temporel ; la valeur produite, c'est le point de rencontre. **On s'inscrit à un point de rencontre, jamais à un événement.**
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
## Périmètre & confidentialité
## Scope & confidentiality
Le modèle produit de **qui voit quoi** — données personnelles réservées au réseau, événements/PdR publics, notification d'inscription identifiée-ou-anonyme — est un fait métier : voir [[knowledge_data-scopes-and-discovery]]. La matrice d'autorisations détaillée (acteur × verbe) et son incubation vivent dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, identified-or-anonymous sign-up notification — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
## Liens
## Links
- [[knowledge_business-model]] — l'inversion événement / point de rencontre
- [[knowledge_actors-and-concepts]] — référence des acteurs et concepts métier
- [[knowledge_data-scopes-and-discovery]] — périmètres public/protected/private par entité + découverte
- [[knowledge_roadmap]] — fonctionnalités actuelles vs évolutions à venir
- [[brief_2026-06-15_event-deduplication]] — défi ouvert de déduplication des événements en P2P
- [[knowledge_business-model]] — the event / meeting point inversion
- [[knowledge_actors-and-concepts]] — reference for actors and business concepts
- [[knowledge_data-scopes-and-discovery]] — public/protected/private scopes per entity + discovery
- [[knowledge_roadmap]] — current features vs upcoming evolutions
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
@@ -1,23 +1,23 @@
---
type: brief
summary: Défi ouverten infra P2P, deux utilisateurs peuvent déclarer le même événement public et fragmenter les points de rencontre greffés ; pistes non tranchées
summary: Open challengeon a P2P infrastructure, two users can declare the same public event and fragment the meeting points grafted onto it; leads not yet settled
---
# Déduplication des événements en infra décentralie
# Deduplicating events on a decentralized infrastructure
**Status:** Défi ouvert — non tranché
**Capturé:** 2026-06-15 (issu de `README.md §Défis ouverts`)
**Status:** Open challenge — not settled
**Captured:** 2026-06-15 (from `README.md §Défis ouverts`)
## Problème
## Problem
NextGraph étant P2P, rien n'empêche deux utilisateurs de **déclarer indépendamment le même événement public** (par ex. « Eurockéennes 2027 ») et de produire deux entrées distinctes. La dispersion qui en résulte **fragmente les points de rencontre greffés** et réduit leur visibilitéce qui va à l'encontre de la fonction première de l'app (cf. [[knowledge_business-model]]).
Since NextGraph is P2P, nothing prevents two users from **independently declaring the same public event** (e.g. "Eurockéennes 2027") and producing two distinct entries. The resulting dispersion **fragments the meeting points grafted onto it** and reduces their visibilitywhich runs against the app's primary purpose (see [[knowledge_business-model]]).
## Pistes envisagées (non tranchées)
## Leads considered (not settled)
- **Recherche avant création** — proposer à l'utilisateur, lors de la déclaration, les événements déjà déclarés dans son réseau / ses communautés qui correspondent à sa saisie.
- **Identifiant externe canonique** — utiliser une URL officielle de l'événement, Wikidata, ou `schema.org/Event` pour reconnaître les doublons et les présenter comme un seul événement à l'affichage.
- **Curation** — laisser des curators (humains ou communautaires) fusionner / vetter les entrées canoniques.
- **Search before create** — when declaring, offer the user the events already declared within their network / their communities that match what they are typing.
- **Canonical external identifier** — use an official event URL, Wikidata, or `schema.org/Event` to recognize duplicates and present them as a single event on display.
- **Curation** — let curators (human or community-run) merge / vet the canonical entries.
## Lien avec le modèle d'écriture
## Link with the write model
Ce défi est couplé à une question ouverte de [[brief_2026-05-18_authorization-matrix]] : **qui peut modifier un événement déclaré** (propriétaire / wiki / immuable). Un modèle *wiki* faciliterait la convergence ; un modèle *propriétaire* la complique. À arbitrer ensemble.
This challenge is coupled to an open question in [[brief_2026-05-18_authorization-matrix]]: **who may modify a declared event** (owner / wiki / immutable). A *wiki* model would make convergence easier; an *owner* model makes it harder. To be decided together.
@@ -1,31 +1,31 @@
---
type: knowledge
summary: Référence des acteurs (utilisateur, connexion, déclarant, hôte, inscrit, membre) et des concepts métier (point de rencontre, événement, communauté, liste curated, connexion)
summary: Reference for the actors (user, connection, declarer, host, participant, member) and the business concepts (meeting point, event, community, curated list, connection)
---
# Acteurs et concepts métier
# Actors and business concepts
Référence du vocabulaire. Tous les acteurs sont des spécialisations d'un **utilisateur** authentifié dans un contexte donné — pas des rôles de compte distincts.
Vocabulary reference. Every actor is a specialization of an authenticated **user** in a given context — not a separate account role.
## Acteurs
## Actors
| Acteur | Définition |
| Actor | Definition |
|---|---|
| **Utilisateur** | Toute personne ayant un compte (un wallet NextGraph). Racine de tous les autres. |
| **Connexion (« ami »)** | Un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance. Bilatérale (acceptation des deux côtés). |
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. **Il n'y a PAS de notion d'« hôte d'événement »** : l'événement est public, simplement signalé par son déclarant, qui **n'est PAS obligé de participer**à la création aucune participation n'est écrite, le compteur démarre à 0, et le déclarant peut rejoindre/quitter comme tout le monde (décision produit ; côté données cf. data-layer/[[knowledge_context-internals]] §participantCount). L'« hôte » reste un acteur au niveau du **point de rencontre** (ligne suivante), pas de l'événement. |
| **Hôte d'un point de rencontre** | L'utilisateur qui a créé un point de rencontre rattaché à un événement. |
| **Inscrit à un point de rencontre** | Un utilisateur inscrit à un point de rencontre ; de fait il devient participant à l'événement parent. |
| **Membre d'une communauté d'intérêt** | Un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence. |
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"**: the event is public, merely flagged by its declarer, who **is NOT required to attend** — at creation no participation is written, the counter starts at 0, and the declarer can join/leave like anyone else (a product decision; on the data side see data-layer/[[knowledge_context-internals]] §participantCount). The "host" remains an actor at the **meeting point** level (next row), not at the event level. |
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
## Concepts métier
## Business concepts
| Concept | Définition |
| Concept | Definition |
|---|---|
| **Point de rencontre** | *L'unité de valeur de l'app.* Un moment de rencontre proposé par un hôte à un endroit et un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit. |
| **Événement** | L'ancrage. Un événement public réel référencé dans Festipod pour servir de support à des points de rencontre. Simple prétexte (titre, dates, lieu, thèmes). |
| **Communauté d'intérêt** | Un groupement thématique d'utilisateurs. Sert surtout à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement. |
| **Liste curated** | Une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés ». Permet d'organiser/recommander. |
| **Connexion** | Lien de confiance bilatéral entre deux utilisateurs (équivalent « ami »). |
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
| **Connection** | A bilateral trust link between two users (the equivalent of "friend"). |
> Communauté, liste curated et abonnement sont en grande partie **prospectifs** (cf. [[knowledge_roadmap]]). La matrice d'autorisations détaillée par type de donnée vit dans [[brief_2026-05-18_authorization-matrix]].
> Communities, curated lists and subscriptions are largely **prospective** (see [[knowledge_roadmap]]). The detailed authorization matrix by data type lives in [[brief_2026-05-18_authorization-matrix]].
@@ -1,26 +1,26 @@
---
type: knowledge
summary: Le point de rencontre est l'unité de valeur, greffée sur un événement-prétexte ; on s'inscrit au point de rencontre, pas à l'événement
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
---
# Modèle métier : le point de rencontre greffé
# Business model: the grafted meeting point
> Festipod permet aux utilisateurs de créer des **points de rencontre** qui viennent se « greffer » sur des **événements publics existants**. L'objectif : favoriser les rencontres autour de ces événements.
> Festipod lets users create **meeting points** that come to be "grafted" onto **existing public events**. The goal: encourage people to meet around those events.
## L'inversion à comprendre
## The inversion to grasp
L'**événement public** (festival, conférence, salon, exposition…) n'est **qu'un prétexte** et un *point d'ancrage temporel et géographique*. La valeur produite par l'app, c'est le **point de rencontre** que les utilisateurs viennent y greffer pour se retrouver.
The **public event** (festival, conference, trade show, exhibition…) is **only a pretext** and a *temporal and geographical anchor point*. The value the app produces is the **meeting point** that users come and graft onto it.
Conséquences directes sur la modélisation :
Direct consequences for modelling:
- **On s'inscrit à un point de rencontre, pas à un événement.** Sans points de rencontre, un événement Festipod n'a aucun intérêt.
- Le **déclarant** d'un événement n'est *pas* (forcément) son organisateur réel — c'est juste quelqu'un qui a inséré la référence dans Festipod pour que d'autres puissent y attacher des points de rencontre.
- L'**hôte** d'un point de rencontre est celui qui l'a créé ; l'acte de créer rend hôte. De même l'acte de déclarer un événement rend déclarant.
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
## Authentification
## Authentication
**Tous les utilisateurs sont authentifiés** (chacun possède un wallet NextGraph) — il n'y a pas d'accès anonyme à l'app. Les différents « acteurs » (déclarant, hôte, inscrit, connexion…) sont des *spécialisations d'un utilisateur dans un contexte donné*, pas des comptes distincts. Voir [[knowledge_actors-and-concepts]].
**Every user is authenticated** (each one owns a NextGraph wallet) — there is no anonymous access to the app. The various "actors" (declarer, host, participant, connection…) are *specializations of a user in a given context*, not separate accounts. See [[knowledge_actors-and-concepts]].
## Stack porteuse
## Underlying stack
App web mobile-first, Bun + React + **NextGraph** (P2P, local-first, chiffré de bout en bout). Le choix P2P a une conséquence métier forte : voir le défi de [[brief_2026-06-15_event-deduplication]].
A mobile-first web app, Bun + React + **NextGraph** (P2P, local-first, end-to-end encrypted). The P2P choice has a strong business consequence: see the challenge in [[brief_2026-06-15_event-deduplication]].
@@ -1,44 +1,44 @@
---
type: knowledge
summary: Modèle produit de confidentialité et de découvertechaque entité vit dans un SCOPE (public / protected / private) selon qui doit la voir ; événements & points de rencontre = public, profil réseau & participations = protected (réseau), settings = private ; connexions bilatérales = scope dialog ; la découverte lit un index global d'événements
summary: The product model of confidentiality and discoveryevery entity lives in a SCOPE (public / protected / private) depending on who must see it; events & meeting points = public, network profile & participations = protected (network), settings = private; bilateral connections = the dialog scope; discovery reads a global event index
---
# Périmètres de données et découverte
# Data scopes and discovery
Le modèle **produit** de qui voit quoi, et comment on trouve les événements. C'est du **domaine** : le *comment* technique (documents, capabilities, index) est assuré par le SDK de données `@ng-eventually/client` — l'app décrit seulement **l'intention métier**.
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* (documents, capabilities, index) is handled by the `@ng-eventually/client` data SDK — the app only states **the business intent**.
## Trois périmètres (scopes) par donnée
## Three scopes per piece of data
Chaque entité est stockée dans le **scope** correspondant à qui doit pouvoir la lire :
Every entity is stored in the **scope** matching who must be able to read it:
| Entité | Scope | Qui lit |
| Entity | Scope | Who reads it |
|---|---|---|
| Événement (l'ancrage) | **public** | tout le monde |
| Point de rencontre (PdR) | **public** | tout le monde |
| Profil réseau (nom, avatar, bio, ville, intérêts) | **protected** | le titulaire + ses connexions |
| Participation / inscription à un PdR | **protected** | l'inscrit + ses connexions |
| Index des connexions | **protected** | le titulaire + ses connexions |
| Profil privé (settings, email, préférences) | **private** | le titulaire seul |
| Connexion A↔B (lien bilatéral, + messagerie future) | **dialog** | les deux utilisateurs |
| Event (the anchor) | **public** | everyone |
| Meeting point | **public** | everyone |
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
| Participation / sign-up to a meeting point | **protected** | the participant + their connections |
| Connection index | **protected** | the holder + their connections |
| Private profile (settings, email, preferences) | **private** | the holder alone |
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
Principe directeur : **le statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans un même utilisateur.** Les informations personnelles sont réservées au **réseau** (connexions bilatérales), jamais visibles d'un utilisateur lambda.
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
- **PdR / événement = publics universels.** Tout utilisateur peut lire et s'abonner ; créer un PdR rend hôte, créer un événement rend déclarant (aucun prérequis).
- **Hôte = seul détenteur des droits d'écriture** sur son PdR ; le déclarant n'a aucun droit particulier sur les PdR greffés sur son événement.
- **Connexion bilatérale** : `DemandeDeConnexion` (unilatérale, transitoire) → `Connexion` (bilatérale, persistante) — cette dernière ouvre l'accès aux données *protected* de l'autre.
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
Festipod **place chaque entité dans le store de son scope** ; l'isolation entre scopes est **assurée par le SDK de données**, pas par du code applicatif (cf. concept `app-security`).
Festipod **places each entity in the store of its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
## Découverte des événements
## Event discovery
Un utilisateur découvre les événements qu'il n'a pas créés via un **index global** : le SDK lit cet index, qui donne les références (NURIs) des documents-événements, puis synchronise et interroge en local. La découverte **primaire** passe par cet index ; un **axe secondaire** relationnel s'y superpose (les participations *protected* des connexions : « mes amis participent à… »).
A user discovers the events they did not create through a **global index**: the SDK reads that index, which yields the references (NURIs) of the event documents, then synchronizes and queries locally. **Primary** discovery goes through that index; a **secondary**, relational axis is layered on top (the connections' *protected* participations: "my friends are attending…").
> **Notification d'inscription (intention produit).** S'inscrire à un PdR notifie son hôte : identifié si l'inscrit fait partie des connexions de l'hôte, **anonyme sinon**. Ce « identifié si connu, anonyme sinon » est une propriété du modèle de données — l'app y compte, le mécanisme est fourni par le SDK.
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **anonymous otherwise**. This "identified if known, anonymous otherwise" is a property of the data model — the app relies on it, the mechanism is provided by the SDK.
## Questions ouvertes (métier)
## Open questions (business)
- **Modèle d'écriture de l'événement** : propriétaire (déclarant seul) / wiki (tous) / immuable ? Central pour la déduplication ([[brief_2026-06-15_event-deduplication]]).
- **Identité de l'hôte vis-à-vis d'un lambda** : un PdR est lisible par tous, mais faut-il que son hôte soit identifiable ? (pseudonyme par défaut, carte de visite par PdR, ou anonymat révélé aux seules connexions.)
- **Champs modifiables d'une inscription** ; **découvrabilité « amis d'amis »**.
- **Event write model**: owner (the declarer alone) / wiki (everyone) / immutable? Central to deduplication ([[brief_2026-06-15_event-deduplication]]).
- **The host's identity towards an ordinary user**: a meeting point is readable by all, but should its host be identifiable? (pseudonym by default, a business card per meeting point, or anonymity lifted only for connections.)
- **Which fields of a sign-up can be edited**; **"friends of friends" discoverability**.
> La matrice d'autorisations détaillée par acteur × verbe vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
> The detailed authorization matrix by actor × verb lives in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
@@ -1,27 +1,27 @@
---
type: knowledge
summary: Ce qui est implémenté aujourd'hui (cycle événement + point de rencontre, profils, connexions) vs les évolutions identifiées mais non faites (communautés, abonnements, listes curated, multi-user)
summary: What is implemented today (event + meeting point lifecycle, profiles, connections) vs the evolutions identified but not built (communities, subscriptions, curated lists, multi-user)
---
# Fonctionnalités actuelles vs évolutions à venir
# Current features vs upcoming evolutions
## Implémenté (écrans visibles via le router)
## Implemented (screens reachable through the router)
- Authentification via wallet NextGraph
- Cycle de vie d'événement (déclaration, consultation, mise à jour)
- Cycle de vie de point de rencontre (rattaché à un événement)
- Inscription / désinscription à un point de rencontre
- Liste des participants à un événement
- Profil utilisateur, mise à jour, partage de profil
- Liste d'amis (connexions), profil d'un autre utilisateur
- Authentication through a NextGraph wallet
- Event lifecycle (declaration, viewing, update)
- Meeting point lifecycle (attached to an event)
- Signing up to / withdrawing from a meeting point
- List of an event's participants
- User profile, profile update, profile sharing
- Friends list (connections), another user's profile
> L'inscription/désinscription au point de rencontre est **réellement branchée** côté données : `joinEvent` persiste une Participation, notifie l'hôte du PdR et crée une Notification ; `leaveEvent` supprime la Participation de façon autoritative (cf. concept `data-layer`, [[caveat_participation-deletion]] côté data-layer). La découverte publique — un utilisateur voit un événement public d'un autre — fonctionne aussi.
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation, notifies the meeting point's host and creates a Notification; `leaveEvent` deletes the Participation authoritatively (see concept `data-layer`, [[caveat_participation-deletion]] on the data-layer side). Public discovery — a user seeing another user's public event — works too.
> **Réserve produit — la persistance n'est pas garantie de bout en bout.** Un événement créé peut **disparaître** après une période d'inactivité puis une reconnexion sous la même identité (me wallet). C'est un **défaut ouvert**, pas une propriété du modèle produit : impact et pointeur côté `data-layer` → [[caveat_write-durability-across-disconnect]]. Conséquence pour le domaine : « mes événements / mes inscriptions » se comportent comme *implémentés* mais **pas encore comme durables**ne pas bâtir de promesse produit (rappels, historique, engagement) dessus tant que ce caveat est ouvert. Les scénarios `src/modules/event/features/reconnexion-*.feature` du module `event` sont la garde de non-régression de cette promesse (statut d'exécution : concept `bdd-testing`).
> **Product reservation — persistence is not guaranteed end to end.** An event that was created can **disappear** after a period of inactivity followed by a reconnection under the same identity (same wallet). This is an **open defect**, not a property of the product model: impact and pointer on the `data-layer` side → [[caveat_write-durability-across-disconnect]]. Consequence for the domain: "my events / my sign-ups" behave as *implemented* but **not yet as durable**do not build any product promise (reminders, history, commitment) on top of them while this caveat is open. The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are the non-regression guard for that promise (execution status: concept `bdd-testing`).
## Évolutions identifiées (non implémentées)
## Identified evolutions (not implemented)
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (discovery distribué).
- **Abonnement à un utilisateur** pour suivre ses déclarations sans être ami.
- **Listes curated** — créer/partager des sélections éditorialisées.
- **Multi-utilisateurs collaboratif** : le partage effectif d'un point de rencontre vu par plusieurs utilisateurs, appuyé sur les périmètres public/protected/private (cf. [[knowledge_data-scopes-and-discovery]]).
- **Subscribing to an interest community** in order to discover its events (distributed discovery).
- **Subscribing to a user** in order to follow their declarations without being a friend.
- **Curated lists** — creating/sharing editorialized selections.
- **Collaborative multi-user**: actually sharing a meeting point seen by several users, resting on the public/protected/private scopes (see [[knowledge_data-scopes-and-discovery]]).
+9 -9
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: Stack et outillage — Bun-first (runtime, bundler, APIs natives), build pipeline, et commandes du projet
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
triggers:
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
paths: ["build.ts", "package.json", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
@@ -8,14 +8,14 @@ triggers:
# Tech stack
Stack et outillage du projet. Principe directeur : **Bun-first** — Bun remplace Node/npm/vite/webpack/jest et fournit les APIs serveur natives.
The project's stack and tooling. Guiding principle: **Bun-first** — Bun replaces Node/npm/vite/webpack/jest and provides the native server APIs.
**À lire en premier :** [[rule_bun-first]] — la convention qui décide quel outil utiliser.
**Read this first:** [[rule_bun-first]] — the convention that decides which tool to use.
## Liens
## Links
- [[rule_bun-first]] — utiliser Bun, pas Node/npm/vite/jest/express/ws/pg…
- [[knowledge_bun-apis]] — APIs natives Bun (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, serveur, harness buildé à part, Storybook
- [[knowledge_stack-and-commands]] — composants de la stack + scripts réels (+ quirks)
- [[knowledge_deployment]] — Dockerfile, prod depuis src/, pas de CI, `portless` en dev
- [[rule_bun-first]] — use Bun, not Node/npm/vite/jest/express/ws/pg…
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
@@ -1,41 +1,26 @@
---
type: caveat
summary: Firefox 151+ bloque (Local Network Access) le broker hébergé qui embarque l'app de dev locale dans son iframe → iframe blanche, zéro log app, aucune erreur. Ce n'est PAS un bug de code. Fix navigateur — about:config network.lna.enabled=false.
summary: Firefox 151+ blocks (Local Network Access) the hosted broker embedding the local dev app in its iframe → blank iframe, zero app logs, no error at all. This is NOT a code bug. Browser-side fix — about:config network.lna.enabled=false.
last_checked: 2026-07-13
---
# Firefox LNA bloque l'iframe app du broker en dev local
# Firefox LNA blocks the broker's app iframe in local dev
## Symptôme
## Symptom
En dev local, l'app tourne DANS l'iframe du broker hébergé (`nextgraph.eu`/`nextgraph.net`
en HTTPS embarque `festipod.localhost``127.0.0.1`). Sur **Firefox 151+**, l'iframe reste
**blanche** : **aucun log `[FestipodData]`/`[NG]`** (l'app JS n'est jamais exécutée), et
**aucune erreur** rouge (le blocage est une décision de politique réseau, pas un throw). Facile
à prendre pour un crash de rendu Festipod — ce n'en est PAS un.
In local dev, the app runs INSIDE the hosted broker's iframe (`nextgraph.eu`/`nextgraph.net` over HTTPS embeds `festipod.localhost``127.0.0.1`). On **Firefox 151+**, the iframe stays **blank**: **no `[FestipodData]`/`[NG]` log at all** (the app's JS never runs), and **no red error** either (the block is a network policy decision, not a throw). Easy to mistake for a Festipod rendering crash — it is NOT one.
## Cause
**Local Network Access (LNA)** : Firefox 151+ (activé par défaut, cf. rollout 149→151) interdit
à un **site public** (le broker HTTPS) d'atteindre une **ressource du réseau local**
(`127.0.0.1`) — y compris l'embarquer en iframe. Le log révélateur (console) :
**Local Network Access (LNA)**: Firefox 151+ (on by default, see the 149→151 rollout) forbids a **public site** (the HTTPS broker) from reaching a **local network resource** (`127.0.0.1`) — including embedding it in an iframe. The telltale console log:
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
Deux corollaires qui trompent :
- **Le top-level charge très bien** : ta navigation directe vers `https://festipod.localhost:1355`
(la barrière AccessGateScreen) n'est PAS soumise à LNA. Seul l'**embarquement iframe** par le
broker l'est. Donc « le cert est déjà accepté / l'app se lance » avant l'iframe ≠ l'iframe passera.
- **HTTPS n'y change rien** : LNA vise l'**adresse locale cible**, pas le protocole. Passer
`portless proxy start --https` (app en `https://festipod.localhost`) ne débloque pas.
Two corollaries that mislead:
- **The top level loads just fine**: navigating directly to `https://festipod.localhost:1355` (the AccessGateScreen barrier) is NOT subject to LNA. Only **iframe embedding** by the broker is. So "the cert is already accepted / the app starts up" before the iframe does not mean the iframe will go through.
- **HTTPS changes nothing**: LNA targets the **local destination address**, not the protocol. Switching to `portless proxy start --https` (app on `https://festipod.localhost`) does not unblock it.
## Fix (navigateur, pas code)
## Fix (browser, not code)
`about:config`**`network.lna.enabled` = `false`** (drapeau maître : désactive tous les
contrôles LNA). Alternative ciblée : **`network.lna.skip-domains`** avec `nextgraph.eu`,
`nextgraph.net` (garde la protection ailleurs). Autres prefs LNA : `network.lna.blocking`,
`network.lna.block_trackers`.
`about:config`**`network.lna.enabled` = `false`** (master flag: disables every LNA check). Targeted alternative: **`network.lna.skip-domains`** with `nextgraph.eu`, `nextgraph.net` (keeps the protection everywhere else). Other LNA prefs: `network.lna.blocking`, `network.lna.block_trackers`.
Ne PAS chasser un bug de rendu Festipod tant qu'il n'y a **aucun log `[FestipodData]`** dans la
console : sans logs app, l'app n'a pas tourné → c'est l'environnement (LNA, cert non approuvé,
serveur dev éteint), pas le code. Le smoke `@e2e` ne peut PAS attraper ça : Playwright n'applique
pas LNA comme un vrai Firefox.
Do NOT go hunting for a Festipod rendering bug as long as there is **no `[FestipodData]` log** in the console: with no app logs, the app never ran → it is the environment (LNA, untrusted cert, dev server down), not the code. The `@e2e` smoke test CANNOT catch this: Playwright does not enforce LNA the way a real Firefox does.
@@ -1,26 +1,26 @@
---
type: knowledge
summary: Dev en bun --hot, build prod via build.ts (bundler Bun + plugin Tailwind) vers dist/, alias @/* → ./src/*
summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/*
---
# Build pipeline
- **Dev** : `bun --hot src/index.ts` (via `bun run dev`) — HMR, port 3000.
- **Prod** : `bun run build``build.ts` (bundler Bun + plugin Tailwind) → `dist/`.
- **Alias de chemin** : `@/* → ./src/*` (déclaré dans `tsconfig.json`).
- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000.
- **Prod**: `bun run build``build.ts` (Bun bundler + Tailwind plugin) → `dist/`.
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`).
Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-architecture` §app-shell). Le bundler transpile le TSX et bundle le CSS sans outil externe — pas de Vite/webpack/esbuild (cf. [[rule_bun-first]]).
The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app-architecture` §app-shell). The bundler transpiles the TSX and bundles the CSS without any external tool — no Vite/webpack/esbuild (see [[rule_bun-first]]).
## Détails de `build.ts` et du serveur
## Details of `build.ts` and of the server
- `build.ts` scanne `src/**/*.html` comme entrypoints (aujourd'hui un seul : `src/index.html`), `target: 'browser'`, minify + sourcemap linked, plugin `bun-plugin-tailwind`. Ajouter un 2e `.html` créerait un 2e bundle.
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, `/festipod-config.json` + `/shared-wallet.ngw` (config runtime, voir ci-dessous), et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
- `build.ts` scans `src/**/*.html` for entrypoints (today just one: `src/index.html`), `target: 'browser'`, minify plus linked sourcemap, `bun-plugin-tailwind` plugin. Adding a second `.html` would create a second bundle.
- `src/index.ts` (`Bun.serve`) serves: `/reports/cucumber` (HTML report), `/api/hello*` stubs, `/festipod-config.json` plus `/shared-wallet.ngw` (runtime config, see below), and a **catch-all `/*` → `src/index.html`** (SPA routing, which must stay last). HMR when `NODE_ENV !== 'production'`, port from `PORT`.
## Globals de build vs config runtime (piège du wallet partagé)
## Build-time globals vs runtime config (the shared wallet pitfall)
`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, et `__FESTIPOD_AUTO_SEED__` depuis `FESTIPOD_AUTO_SEED`l'auto-seed de dev, OFF si absent). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin.
`build.ts` injects **compile-time globals** through `define` (e.g. `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED`the dev auto-seed, OFF when absent). **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
Pour ces chemins servis depuis `src/`, la config passe donc au **runtime** : `src/index.ts` expose `/festipod-config.json` (lu depuis l'env), et l'entrée `src/app/frontend.tsx` la **fetch d'abord**, pose le global, **puis importe l'app dynamiquement** (`await import('./App')`) — ainsi `sharedWallet.ts` lit la valeur à son évaluation. Dans un bundle `build.ts` la valeur est déjà inline par `define`, donc le fetch est court-circuité (`NODE_ENV === 'production'`). Conséquence pratique : pour exercer le flux « portefeuille partagé » en dev **de bout en bout** (téléchargement + import qui fonctionne), passer le VRAI mot de passe du wallet e2e **et** le fichier — le mot de passe affiché à l'écran doit correspondre au `.ngw` importé, sinon l'import échoue (une valeur factice comme `1` fait juste apparaître l'écran) :
For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
```
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
@@ -28,10 +28,10 @@ FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
bun run dev
```
## Le harness de test est buildé à part
## The test harness is built separately
⚠️ `build.ts` ne build **pas** les harness de test. Les hooks Cucumber (`src/shared/support/hooks.ts`) lancent un `bun build` **à la demande** pour `src/shared/test-harness/harness.tsx` (et `harness-ng.tsx`) → `dist/test-harness*.js`. C'est un entrypoint séparé du build app — voir concept `bdd-testing`.
⚠️ `build.ts` does **not** build the test harnesses. The Cucumber hooks (`src/shared/support/hooks.ts`) run a `bun build` **on demand** for `src/shared/test-harness/harness.tsx` (and `harness-ng.tsx`) → `dist/test-harness*.js`. It is an entrypoint separate from the app build — see concept `bdd-testing`.
## Storybook
`storybook dev -p 6006`**webpack5 + SWC** (pas Vite). Les décorateurs (`.storybook/`) injectent la pile complète de providers (Theme > NextGraph > FestipodData > Router) et importent `src/index.css` ; viewport mobile par défaut. Couplage dur au contexte projet (pas réutilisable hors Festipod).
`storybook dev -p 6006`**webpack5 + SWC** (not Vite). The decorators (`.storybook/`) inject the full provider stack (Theme > NextGraph > FestipodData > Router) and import `src/index.css`; mobile viewport by default. Tightly coupled to the project context (not reusable outside Festipod).
@@ -1,15 +1,15 @@
---
type: knowledge
summary: APIs natives Bun utilisées par le projet — Bun.serve (HTTP/WS/routes), HTML imports bundlés, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
summary: Native Bun APIs used by the project — Bun.serve (HTTP/WS/routes), bundled HTML imports, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
---
# APIs natives Bun
# Native Bun APIs
Référence des APIs Bun à privilégier (cf. [[rule_bun-first]]). Doc complète : `node_modules/bun-types/docs/**.mdx`.
Reference for the Bun APIs to favour (see [[rule_bun-first]]). Full documentation: `node_modules/bun-types/docs/**.mdx`.
## Serveur — `Bun.serve()`
## Server — `Bun.serve()`
Supporte WebSockets, HTTPS et routes. Pas besoin d'`express`/`ws`.
Supports WebSockets, HTTPS and routes. No need for `express`/`ws`.
```ts
import index from "./index.html"
@@ -23,19 +23,19 @@ Bun.serve({
})
```
C'est le mécanisme de `src/index.ts` (voir concept `app-architecture` §app-shell).
This is the mechanism behind `src/index.ts` (see concept `app-architecture` §app-shell).
## HTML imports (frontend)
`Bun.serve()` sert des HTML imports ; le bundler Bun transpile/bundle automatiquement `.tsx`/`.jsx`/`.js` et le CSS (Tailwind inclus). Un `<script type="module" src="./frontend.tsx">` dans le HTML suffit — pas de Vite.
`Bun.serve()` serves HTML imports; the Bun bundler automatically transpiles and bundles `.tsx`/`.jsx`/`.js` and the CSS (Tailwind included). A `<script type="module" src="./frontend.tsx">` in the HTML is enough — no Vite.
## Stockage & shell
## Storage & shell
- **`bun:sqlite`** pour SQLite (pas `better-sqlite3`)
- **`Bun.redis`** pour Redis (pas `ioredis`)
- **`Bun.sql`** pour Postgres (pas `pg`/`postgres.js`)
- **`WebSocket`** intégré (pas `ws`)
- **`Bun.file`** plutôt que `node:fs` readFile/writeFile
- **`Bun.$\`ls\`** plutôt qu'`execa`
- **`bun:sqlite`** for SQLite (not `better-sqlite3`)
- **`Bun.redis`** for Redis (not `ioredis`)
- **`Bun.sql`** for Postgres (not `pg`/`postgres.js`)
- **`WebSocket`** built in (not `ws`)
- **`Bun.file`** rather than `node:fs` readFile/writeFile
- **`Bun.$\`ls\`** rather than `execa`
Bun charge `.env` automatiquement → ne pas utiliser `dotenv`.
Bun loads `.env` automatically → do not use `dotenv`.
@@ -1,32 +1,32 @@
---
type: knowledge
summary: Déploiement — Dockerfile multi-stage Bun Alpine ; install via pnpm (git+node dans l'image) mais runtime bun ; lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000, env PORT/NODE_ENV; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-07-14
---
# Déploiement & infra
# Deployment & infra
## Dockerfile
Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm, mais le runtime/build/test restent bun** (cf. [[knowledge_stack-and-commands]]) :
- `FROM oven/bun:1-alpine`, stage `install` : `apk add --no-cache git nodejs npm` puis `npm install -g pnpm@10.26.0` (l'image bun n'a ni Node ni pnpm ; l'`apk nodejs` d'Alpine n'embarque pas corepack), `COPY package.json pnpm-lock.yaml`, puis `pnpm install --frozen-lockfile`. `git` est requis car `@ng-eventually/client` est une dépendance **git+https** publique (Gitea, sans auth). Stage `release` : copie `node_modules` + source.
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/client` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
**Piège `bun` peer** : `bun-plugin-tailwind` déclare `bun` en peerDependency → pnpm matérialise le paquet npm `bun` et **crée un shim `node_modules/.bin/bun`** qui shadow le `bun` du PATH sous `bun run`/`pnpm run`. Son postinstall est ignoré par défaut → shim cassé`bun run start` échoue. Corrigé en approuvant le build : `pnpm.onlyBuiltDependencies: ["bun"]` dans `package.json` (le postinstall télécharge le vrai binaire). Sans ça, toute la migration pnpm casse le démarrage.
**`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency → pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default → broken shim`bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup.
**Quirk** : `start` = `NODE_ENV=production bun src/index.ts`le conteneur **exécute la source TypeScript directement** (Bun transpile à la volée), il **n'utilise pas `dist/`**. Le `bun run build` (→ `dist/`) n'est donc **pas** sur le chemin de prod par défaut. Si on veut servir le build, il faut changer l'entrypoint.
**Quirk**: `start` = `NODE_ENV=production bun src/index.ts`the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint.
## CI/CD
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique.
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
## Variables d'environnement
## Environment variables
- `PORT` (défaut 3000), `NODE_ENV` (active/désactive HMR et l'auto-seed dev — cf. concept `data-layer`).
- Aucun `.env*` committé (`.env` est gitignored). Pas de gestion de secrets dans le repo.
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
- No `.env*` is committed (`.env` is gitignored). No secret management in the repo.
## Dev
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — passe par le wrapper **`portless`** (outil externe de gestion de port), pas un `bun --hot` nu. HMR actif hors production.
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
**Lien local réactif du polyfill** : en prod la dépendance `@ng-eventually/client` vient de Gitea (git+https, figée par `pnpm-lock.yaml`). Pour éditer le polyfill localement et voir les changements en direct, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, stratégie S2) remplace `node_modules/@ng-eventually/client` par une **copie réelle** de la source locale (`…/ng-eventually-js/packages/client`) — **sans** son propre `node_modules/@ng-org`et resynchronise `src/` à chaque édition. C'est ce qui garantit **une seule instance `@ng-org/web`** (un seul verifier) : un symlink vers le checkout monorepo, lui, embarque son `@ng-org`2ᵉ instance → SDK cassé. Revenir à l'état committé : `pnpm install`.
**Reactive local link to the polyfill**: in production the `@ng-eventually/client` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`). To edit the polyfill locally and see the changes live, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, strategy S2) replaces `node_modules/@ng-eventually/client` with a **real copy** of the local source (`…/ng-eventually-js/packages/client`) — **without** its own `node_modules/@ng-org`and resyncs `src/` on every edit. That is what guarantees **a single `@ng-org/web` instance** (a single verifier): a symlink to the monorepo checkout would carry its own `@ng-org`a 2nd instance → broken SDK. To go back to the committed state: `pnpm install`.
@@ -1,43 +1,43 @@
---
type: knowledge
summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local, link:polyfill pour le lien local réactif)
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, build:orm pointing at a stale path, build:ng for the local fork, link:polyfill for the reactive local link)
---
# Stack & commandes
# Stack & commands
## Composants
## Components
| Couche | Techno |
| Layer | Technology |
|---|---|
| Runtime / bundler / test | **Bun** (cf. [[rule_bun-first]]) |
| **Installation des deps** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **seule** l'install passe à pnpm ; runtime/build/test restent bun. Motif : `@ng-eventually/client` est résolu depuis Gitea en **git+https** (pnpm gère proprement `git+…#main&path:/packages/client` + le dédoublonnage des peers `@ng-org`). Ne pas rebasculer l'install vers bun/npm. |
| UI | **React** (mobile-first, largeur max 768px — style dans concept `app-architecture`) |
| Données | **NextGraph** P2P local-first (concept `data-layer`) |
| Build CSS | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — présent dans le build, mais les écrans stylent via `app-*`/inline, pas d'utilitaires Tailwind (cf. concept `app-architecture`) |
| Exploration UI | **Storybook** (webpack5 + SWC, port 6006) |
| Tests | **Cucumber/Gherkin** FR multi-couches + Playwright + happy-dom + chai (concept `bdd-testing`) |
| Runtime / bundler / test | **Bun** (see [[rule_bun-first]]) |
| **Dependency installation** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **only** installation moves to pnpm; runtime/build/test stay on bun. Reason: `@ng-eventually/client` is resolved from Gitea over **git+https** (pnpm handles `git+…#main&path:/packages/client` cleanly, along with deduplication of the `@ng-org` peers). Do not switch installation back to bun/npm. |
| UI | **React** (mobile-first, max width 768px — styling covered by concept `app-architecture`) |
| Data | **NextGraph** P2P local-first (concept `data-layer`) |
| CSS build | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — present in the build, but the screens style themselves with `app-*`/inline, no Tailwind utilities (see concept `app-architecture`) |
| UI exploration | **Storybook** (webpack5 + SWC, port 6006) |
| Tests | **Cucumber/Gherkin** in French, multi-layer, plus Playwright, happy-dom and chai (concept `bdd-testing`) |
## Scripts `package.json` (réels)
## `package.json` scripts (the real ones)
| Script | Commande / rôle |
| Script | Command / role |
|---|---|
| `dev` | `portless festipod bun --hot src/index.ts` — dev HMR via wrapper `portless` (cf. [[knowledge_deployment]]) |
| `start` | `NODE_ENV=production bun src/index.ts` — prod, depuis `src/` (pas `dist/`) |
| `build` | `bun run build.ts` — bundler Bun + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
| `test:cucumber` | enchaîne `cucumber:run``cucumber:report``features:parse``steps:extract` |
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js`**via Node+tsx, pas Bun** (compat plugins Playwright/happy-dom), et via l'**entrée JS réelle du paquet**, pas le shim `.bin/` (voir Pièges) |
| `test:data` | idem `--tags @data` |
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstrap wallet de test persistant |
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
| `build` | `bun run build.ts` Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
| `test:cucumber` | chains `cucumber:run``cucumber:report``features:parse``steps:extract` |
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js`**through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
| `test:data` | same, with `--tags @data` |
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstraps the persistent test wallet |
| `cucumber:report` | `bun scripts/parse-test-results.ts``cucumber-report.json` → HTML |
| `features:parse` | `bun scripts/parse-features.ts``features.ts` |
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` |
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)build des paquets NextGraph depuis une source locale (outil optionnel) |
| `link:polyfill` | `bun scripts/link-polyfill.ts` lien local **réactif** du polyfill `@ng-eventually/client` (stratégie S2 : copie-overlay + watcher), préserve l'instance `@ng-org` unique. Détails dans [[knowledge_deployment]]. |
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) |
| `link:polyfill` | `bun scripts/link-polyfill.ts`**reactive** local link to the `@ng-eventually/client` polyfill (strategy S2: copy-overlay + watcher), preserving the single `@ng-org` instance. Details in [[knowledge_deployment]]. |
| `storybook` / `build-storybook` | Storybook dev (6006) / static build |
## Pièges
## Pitfalls
- **`cucumber:run`/`test:data` tournent sous Node+tsx**, pas Bun — les plugins de test ne chargent pas en import Bun natif. Ne pas « bunifier » ces scripts.
- **Ne jamais faire pointer un script sur `node_modules/.bin/*`.** L'install passe par pnpm ([[rule_bun-first]] §exception), qui y place des **shims shell** et non des entrées JS : `node --import tsx/esm node_modules/.bin/cucumber-js` échoue. Invoquer l'**entrée JS réelle du paquet** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). Vaut pour tout script npm qui lancerait un binaire de dépendance sous `node`.
- **`build:orm` cible `./src/shapes/shex` et `./src/shapes/orm`**, alors que les shapes réelles vivent sous **`src/shared/shapes/`** — le chemin du script est vraisemblablement **périmé** (à corriger ou exécuter avec les bons chemins ; vérifier avant de régénérer l'ORM).
- **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts.
- **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`.
- **`build:orm` targets `./src/shapes/shex` and `./src/shapes/orm`**, whereas the real shapes live under **`src/shared/shapes/`** — the script's path is most likely **stale** (either fix it or run it with the right paths; check before regenerating the ORM).
+15 -15
View File
@@ -1,39 +1,39 @@
---
type: rule
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas bun install
summary: By default use Bun and its native APIs, never the Node equivalents — bun instead of node/ts-node, bun test/build, bunx, and no express/ws/pg/dotenv. EXCEPTION — package installation goes through pnpm (in both repos), not bun install
---
# Règle : Bun-first
# Rule: Bun-first
Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents Node.js.
By default, use **Bun** and its native APIs rather than the Node.js equivalents.
| Au lieu de… | Utiliser |
| Instead of… | Use |
|---|---|
| `node <file>`, `ts-node` | `bun <file>` |
| `jest`, `vitest` | `bun test` |
| `npm/yarn install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
| `npm/yarn install`, `bun install` | **`pnpm install`** (see the exception below) |
| `npm run <script>` | `bun run <script>` |
| `npx <pkg>` | `bunx <pkg>` |
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
| `webpack`, `esbuild`, `vite` | `bun build` / the Bun bundler (HTML imports) |
| `express` | `Bun.serve()` |
| `better-sqlite3` | `bun:sqlite` |
| `ioredis` | `Bun.redis` |
| `pg`, `postgres.js` | `Bun.sql` |
| `ws` | `WebSocket` (intégré) |
| `ws` | `WebSocket` (built in) |
| `node:fs` readFile/writeFile | `Bun.file` |
| `execa` | `Bun.$\`...\`` |
| `dotenv` | (inutile — Bun charge `.env` automatiquement) |
| `dotenv` | (pointless — Bun loads `.env` automatically) |
Détail des APIs : [[knowledge_bun-apis]].
API details: [[knowledge_bun-apis]].
## Exception : l'installation des paquets passe par pnpm
## Exception: package installation goes through pnpm
**L'installation des dépendances se fait avec `pnpm install`pas `bun install`dans les DEUX repos** (Festipod *et* le polyfill `@ng-eventually/client`). Tout le reste reste Bun : **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Seule l'étape d'installation change de gestionnaire.
**Dependencies are installed with `pnpm install`not `bun install`in BOTH repos** (Festipod *and* the `@ng-eventually/client` polyfill). Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
**Pourquoi.** Le polyfill est installé en prod depuis un dépôt Gitea comme dépendance git à **sous-répertoire** : `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) résout ce format `#<ref>&path:/…` et garantit une **seule** instance de `@ng-org/*` (un seul verifier) ; `bun install` ne couvre pas ce workflow proprement. Le lockfile de référence est donc `pnpm-lock.yaml`, et le lien local réactif du polyfill passe par `pnpm run link:polyfill` (voir [[knowledge_deployment]]).
**Why.** In production the polyfill is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*` (a single verifier); `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local link to the polyfill goes through `pnpm run link:polyfill` (see [[knowledge_deployment]]).
**Conséquence pratique.** Les scripts npm qui reposaient sur `node_modules/.bin/*` peuvent casser (pnpm y place des shims shell, pas des entrées JS) — appeler l'entrée JS réelle du paquet (ex. `node_modules/@cucumber/cucumber/bin/cucumber.js`) plutôt que le shim `.bin/`.
**Practical consequence.** npm scripts that relied on `node_modules/.bin/*` may break (pnpm puts shell shims there, not JS entries) — call the package's actual JS entry (e.g. `node_modules/@cucumber/cucumber/bin/cucumber.js`) rather than the `.bin/` shim.
## Pourquoi (Bun pour tout le reste)
## Why (Bun for everything else)
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique. L'exception d'installation ci-dessus est le seul écart, et il est motivé par la dépendance git à sous-répertoire.
The project is all-Bun (runtime, bundler, test, server). Reintroducing a redundant Node tool adds a dependency, diverges from the repo's conventions, and breaks the native integration (HMR, automatic TS transpilation, `.env` loading). This is a consistency choice, not a cosmetic preference. The installation exception above is the only deviation, and it is motivated by the subdirectory git dependency.