docs(concept): solder la doc-debt des 6 concepts

Dette accumulée depuis le 13/07 (27 marqueurs). Au-delà du vidage, trois
corrections de doctrine réellement fausse — c'est ce que le reconcile devait
attraper :

- app-security : `sharedWallet.ts` capture le mot de passe à l'ÉVALUATION du
  module. Tant qu'un repli existait, un global posé trop tard ne faisait que
  dégrader ; depuis que le wallet partagé est l'unique mode, il rend la barrière
  INUTILISABLE (écran d'erreur, aucun champ). Conséquence non anticipée de la
  décision shared-wallet-only → nouveau caveat.
- bdd-testing : la doctrine rendait des tests faux-verts. `ctx.newPage()` sur le
  profil persistant relit l'IndexedDB local et ne prouve JAMAIS la durabilité
  broker ; seul un contexte partagé neuf tranche. Un agent suivant la doctrine
  écrivait un test qui passe sans rien vérifier → nouveau caveat.
- app-architecture : `knowledge_routing` décrivait encore une route `/login`
  disparue, et `knowledge_screen-pattern` citait `LoginScreen` qui n'existe
  plus. Nouveau caveat sur les deux espaces d'id vus depuis un écran.

Aussi : data-layer/knowledge_context-internals décrit la jointure
participation→profil et corrige un mécanisme de changement d'identité périmé ;
tech-stack raccroche la table des scripts au vrai point d'entrée cucumber ;
functional-domain note qu'« implémenté » ≠ « durable ».

Trois marqueurs soldés comme sans objet : ils visaient
`reconnexion-socket-mort.{feature,steps.ts}`, absents de l'arbre ET de tout
l'historique — expérience abandonnée avant tout commit. Ce qu'elle devait
établir est capturé ailleurs (caveat de durabilité, post-mortem polyfill, fiche
INBOX socket-death).

Liens morts vers une décision disparue avec le concept `nextgraph-platform`
réparés. Reste au lint : le brief 07-06 (superseded) porte des file:line et des
références aux internes NextGraph — laissé intact, il décrit l'Option-B encore
implémentée et se dissoudra à la graduation.

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-27 14:43:09 +02:00
parent a8401bd143
commit 96e28a702f
28 changed files with 306 additions and 105 deletions
-8
View File
@@ -1,8 +0,0 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngSession.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+1 -1
View File
@@ -18,7 +18,7 @@ Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chif
- [[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, auto-seed dev, `participantCount` cache, no-op local)
- [[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)
## Règles d'écriture
@@ -1,7 +1,7 @@
---
type: knowledge
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, 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), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast
last_checked: 2026-07-07
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
last_checked: 2026-07-27
---
# Internals & pièges de `FestipodDataContext`
@@ -14,6 +14,27 @@ En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas
- 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.
## DEUX espaces d'id se rencontrent — joindre une participation à son profil
**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.
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.
**`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 **là** : sinon écriture et lecture divergent en silence et la jointure retombe sur « inconnu » sans lever d'erreur.
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.
> **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.
### Quel espace attend chaque query (contrat de `buildQueries`)
| Query | Ce qu'elle attend / rend |
|---|---|
| `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 |
**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]].
## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke
**Depuis 2026-07-10** : `useNgData` lit via `useShapeQuery(shape, scope)` (binding
@@ -66,16 +87,28 @@ Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<ove
## Changement d'identité = session fraîche (isolation)
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** 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`).
> **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.
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. 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.
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`).
**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 ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **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).
**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.
**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()`.
> **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.
**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).
## Instrumentation `useShapeQuery` — spinner global + 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.
## Convention de log — préfixe identité-first, et compteur avant→après
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.
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.
## Mutations no-op en mode local
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.