docs(data-layer): brief — reactive reads + option-B attendance

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 22:13:54 +02:00
parent 01d65238ce
commit af58667b4f
@@ -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 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.