From af58667b4f32084172952fc2446f94e1ac6eddd7 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 6 Jul 2026 22:13:54 +0200 Subject: [PATCH] =?UTF-8?q?docs(data-layer):=20brief=20=E2=80=94=20reactiv?= =?UTF-8?q?e=20reads=20+=20option-B=20attendance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation design brief (grounded in current code): reactive reads via a typed per-doc doc_subscribe wrapper (no polling, no ORM fan-out -> avoids the historical hang); participant count via option B (joiner deposits into the event inbox, the event owner materializes into its own event doc's count; option A ruled out -- non-owner append is impossible in NextGraph). Connection-gated identity (else 'inconnu'). Test plan: polyfill low-level doc_subscribe + real 2-browser e2e reactivity. Phased P1-P6. Open product question: owner-offline eventual count. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-06_reactive-reads-and-attendance.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md diff --git a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md new file mode 100644 index 0000000..21bc794 --- /dev/null +++ b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md @@ -0,0 +1,181 @@ +--- +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 +--- + +# Reactive reads + participant-count correct (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`). + +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. + +Ce brief décrit **quoi construire et dans quel ordre**. Aucune modification de code n'est faite ici. + +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`). + +--- + +## 0. État courant (le point de départ, fichier:fonction) + +### 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` ancré 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. + +### É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**. + +### 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 libellé **« 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. + +### 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 poussé/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). + +--- + +## 1. Les primitives plateforme (nextgraph-rs, vérifié) + +- `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é. + +--- + +## A. Lectures réactives — le 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`) : + +> 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`). + +Concrètement : + +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. : + ```ts + // renvoie un unsubscribe ; onChange appelé au State initial puis à chaque 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. + +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()` : + ``` + 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. + +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.) + +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))`. + +### 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. + +--- + +## B. Compteur de participants — Option B (dépôt → matérialisation propriétaire) + +### 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. + +### 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. + +### 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. + +### 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). + +--- + +## D. Plan de test (e2e réel, sans 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 **mê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.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) : + ``` + Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload + Étant donné un navigateur "A" avec le wallet partagé + Et un navigateur "B" avec le wallet partagé + Et le navigateur "A" charge l'application via le broker + Et le navigateur "B" charge l'application via le broker + Et le navigateur "A" est connecté à NextGraph + Et le navigateur "B" est connecté à NextGraph + Et le navigateur "A" crée l'événement "Apéro réactif" + Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif" + Et le compteur de participants affiché dans "A" pour "Apéro réactif" vaut 1 + Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif" + 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). + +--- + +## E. Risques / questions ouvertes + +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. + +2. **Compte propriétaire hors-ligne = éventuel (accepté, mais à valider produit).** Tant que le propriétaire n'est pas connecté, aucun dépôt n'est matérialisé → `participantCount` reste périmé pour tout le monde. C'est cohérent local-first et **énoncé comme comportement accepté**, mais c'est **une décision produit à confirmer par l'utilisateur** (un compteur qui « fige » quand l'hôte est absent est-il acceptable pour la V1 ? faut-il un fallback « N+ inscrits en attente » ?). + +3. **`doc_subscribe` par-doc n'est PAS exposé aujourd'hui par la lib** — `docs.ts` n'a que `docCreate/sparqlUpdate/sparqlQuery` ; seul le passthrough untyped `ng.doc_subscribe` existe. **La lib doit ajouter le wrapper typé `subscribeDoc` + la variante multi-doc à isolation d'erreur, remplacer `inbox.watch`/`discovery.watchIndex` par du `doc_subscribe`, et documenter le contrat dans `sdk-reference.md`.** C'est un prérequis de A et B (le travail commence côté lib). + +Autres points à trancher : +- **Ordre de phasage (proposé) :** (P1) lib : `subscribeDoc` + variante multi-doc + tests D.1 ; (P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe` ; (P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive ; (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. P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6. +- **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.