Isolation deux-identités: test permanent + le créateur ne participe plus

Deux corrections produit/tests demandées, empiriquement validées au broker réel.

1. Créateur ≠ hôte (décision produit). Il n'y a PAS de notion d'hôte : un
   événement est public, simplement signalé par le créateur, qui n'est PAS
   obligé de participer. `createEvent` n'écrit plus de participation-hôte et
   `participantCount` démarre à 0 ; le matérialiseur du propriétaire dérive
   `participantCount = |inscriptions actives|` (plus de base « +1 hôte »).

2. Isolation deux-identités : le trou réel était l'ABSENCE d'un test de
   régression, pas un bug de code actif. Reproduction empirique (DIAG instrumenté,
   retiré) : la fuite n'apparaît QUE si le reset `useEffect([username])` est
   désactivé ET les caps vides (docs persistés d'une session antérieure sur wallet
   gonflé) — le reset en place la neutralise. La sighting live venait d'un état
   wallet pré-fix + identifiant réutilisé. Ajout du test permanent manquant :
   - isolation-deux-identites.feature (@data) : A crée+rejoint E, une identité
     fraîche B sur le même wallet ne voit E ni sur son accueil, ni via
     isParticipating(E,B), et ne lit aucune participation portant le principal de A.
   - us-13 : « Le créateur ne participe pas automatiquement » (count 0,
     isParticipating false autoritatif, puis join→1, leave→0).

Harness: 4 helpers permanents (switchIdentity, currentIdentifier, homeEventTitles,
currentParticipations) pour piloter/observer l'identité en test.
Scénarios @multibrowser/us-7 réalignés (compteur 0→1 au lieu de 1→2).
Doctrine mise à jour (context-internals, actors-and-concepts).

Gates: build OK, tsc propre, @data verts (inscription, désinscription,
idempotence, compteur dérivé, auth ×4), lib @ng-eventually/client non touchée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 18:55:42 +02:00
parent 0f164300f0
commit 65bd67cc20
11 changed files with 282 additions and 30 deletions
+11 -13
View File
@@ -533,8 +533,8 @@ function useNgData(): FestipodDataContextValue {
// IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
// active registrations (`materializeAttendance`: distinct join uids MINUS
// cancelled ones), never an unbounded ±1. A broker re-sync replays the same
// deposits → same set → same count. `participantCount = 1 (host self, the
// create-time baseline) + activeRegistrations.size`. The write is GUARDED
// deposits → same set → same count. `participantCount = activeRegistrations.size`
// (no host baseline — the creator does not auto-participate). The write is GUARDED
// (write only when the value actually changes) so re-materializing an unchanged
// inbox does not thrash the doc / loop the reactive read.
//
@@ -575,7 +575,7 @@ function useNgData(): FestipodDataContextValue {
// (1) COUNT — derive the distinct active-registration set for this event
// and write it on MY OWN event doc (only when it changed).
const active = await materializeAttendance(targetInbox, evId);
const nextCount = 1 + active.length; // 1 = host self (create baseline)
const nextCount = active.length; // no host baseline (creator not auto-in)
if (materializedCountRef.current.get(evId) !== nextCount) {
materializedCountRef.current.set(evId, nextCount);
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
@@ -682,22 +682,20 @@ function useNgData(): FestipodDataContextValue {
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
location: str(event.location), distance: flt(event.distance),
participantCount: int(event.participantCount || 1),
// No host notion: the creator merely SIGNALS a public event and is NOT
// obliged to participate, so the count starts at 0 (the owner-materializer
// derives it from the active-registration set — |active|, no host baseline).
participantCount: int(event.participantCount || 0),
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
});
registerDoc('public', eventGraph);
// OPTION B: this event's doc is MINE (I just created it), so track it as owned
// → the owner-materializer subscribes to its inbox and maintains its count.
setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph]));
if (currentUserId) {
// The host's participation is its OWN document in the PROTECTED scope.
const partGraph = await createEntityDoc(owner, 'protected');
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
setSelectedEventId(eventId);
}
// The creator does NOT auto-participate (no host notion — settled product
// decision): NO participation is written on create. The creator sees "J'y
// serai" and may join/leave their own event like anyone else.
if (currentUserId) setSelectedEventId(eventId);
const addedEvent = { "@id": eventId, title: event.title };
// Make the PUBLIC event discoverable: submit its reference to the SDK global
// discovery index (an SDK act — the app holds no index/store id). The SDK
+3 -3
View File
@@ -234,9 +234,9 @@ export interface ActiveRegistration {
* set — it can never resurrect a phantom count.
*
* The owner then writes `participantCount` on its OWN event doc as
* `1 (host self, from create) + activeRegistrations.size`. The host's own
* participation is the create-time baseline (never deposited into the inbox), so
* it is added here rather than derived from a deposit.
* `activeRegistrations.size`. There is NO host baseline: the creator merely
* signals a public event and is NOT obliged to participate (no host notion), so
* the count is 0 until someone joins, and the creator may join/leave like anyone.
*/
export async function materializeAttendance(
targetInbox: string,
+36
View File
@@ -87,6 +87,15 @@ function HarnessRouter() {
function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// Identity switch (two-identity isolation): the app has no page reload on a
// faux-logout+re-login (shared-wallet stopgap), so switching identity here
// means calling AccountContext.login() with a new identifier — which drives the
// `prevOwnerRef` reset effect in FestipodDataContext. Exposed to steps so a @data
// scenario can bring up identity A, then a genuinely-different identity B on the
// SAME wallet and assert B is isolated.
const account = useAccount();
const accountRef = useRef(account);
accountRef.current = account;
// The bridge is built once inside an effect (below) and its getters close over
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
// the latest per-entity reads), so a captured snapshot goes STALE — after
@@ -169,6 +178,33 @@ function ConnectedHarness() {
get currentUserId() { return AD().currentUserId || currentUserId; },
session,
// --- IDENTITY SWITCH (two-identity isolation) ----------------------
/** Faux-logout + re-login under a NEW identifier on the SAME wallet (no
* page reload), exactly as the real app's AccessGate/Settings flow does.
* Drives AccountContext.login → setCurrentUser + the FestipodDataContext
* `prevOwnerRef` reset. Returns the normalized id now in effect. */
switchIdentity(identifier: string) {
accountRef.current.login(identifier);
return normalizeUsername(identifier);
},
/** The current app-level identifier (localStorage-backed). */
currentIdentifier() {
return accountRef.current.username;
},
/** Titles of the events the CURRENT user PARTICIPATES in — exactly what the
* HOME screen shows (`getUserEvents(currentUserId)`). Used by the
* two-identity isolation test to assert a fresh identity's home is empty. */
homeEventTitles() {
const ad = AD();
return ad.getUserEvents(ad.currentUserId).map(e => e.title);
},
/** The current user's participation rows (userId+eventId), the reactive set
* the screens read. Used to assert a fresh identity reads NONE of another
* identity's protected participations. */
currentParticipations() {
return AD().participations.map(p => ({ userId: p.userId, eventId: p.eventId }));
},
// --- App-level view (through real providers, same as what screens see) ---
appData,
ngStatus: ngCtx.status,