fix(data): reset the read set + emulated caps on identity switch (isolation)

The shared-wallet stopgap keeps ONE React tree across a faux-logout + re-login
under a different identifier (AccountContext.login only rewrites a localStorage
id; AuthGate never remounts, no page reload). FestipodDataContext's by-need read
set accumulates the current identity's scope docs and was never reset on identity
change, so the PREVIOUS identity's PROTECTED docs (its participations) survived in
the new identity's read set and leaked through the union read — the in-memory cap
gate can't filter a doc it doesn't govern this session. Symptom: user B saw A's
participation, and A's event surfaced on B's home (home = getUserEvents(currentUserId)).

Treat every identifier change as a fresh session: a ref-guarded useEffect([username])
clears publicDocs/protectedDocs, resetCaps(), resetRegistryCache(), then bumps the
read tick so the listing effect rebuilds the set bounded to the new identity.
Isolation stays per-document/emulated; the reset only drops cross-identity carryover.
Documented in knowledge_context-internals.

Validated (@data, real broker): after an A→B switch, B does not participate and
does not read A's participation; protected-isolation/read-filter/auth scenarios pass.
tsc + build green; lib untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 17:29:18 +02:00
parent 3dfd549af3
commit 6ceec5e161
2 changed files with 39 additions and 1 deletions
@@ -25,6 +25,12 @@ Un auto-seed se déclenche **uniquement hors production** (`process.env.NODE_ENV
`joinEvent`/`leaveEvent`/`updateEvent` **mutent directement** `ngEvent.participantCount` (`+1`/`-1`) — c'est un **cache** du nombre de `Participation`, pas une valeur recalculée. Il peut **désynchroniser** des objets `Participation` réels (ex. après un crash, un rejeu, ou la suppression partielle décrite dans [[caveat_participation-deletion]]). Ne pas s'y fier comme source de vérité du nombre de participants.
## 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`).
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([username])` (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.
## 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.
+33 -1
View File
@@ -29,7 +29,8 @@ import { useAccount, normalizeUsername } from './AccountContext';
// Relationship is a Festipod concept: the app keeps its own bilateral registry
// and hands the SDK only directed read grants (see shared/utils/connections).
import { declareConnections } from '../utils/connections';
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry';
import { resetCaps } from '@ng-eventually/client/polyfill';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { readEntities } from '../data/readEntities';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
@@ -253,6 +254,37 @@ function useNgData(): FestipodDataContextValue {
setReadTick(t => t + 1);
}, []);
// IDENTITY SWITCH = FRESH SESSION (isolation). The by-need read set
// (publicDocs/protectedDocs) ACCUMULATES the current identity's own scope docs
// (`listMyEntityDocs(username, …)`) so a just-created doc isn't dropped before
// the re-list. But the shared-wallet stopgap keeps ONE React tree across a faux
// logout + re-login under a DIFFERENT identifier (no page reload — see
// AuthGate/AccountContext), so 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 when the cap registry
// does not govern that doc THIS session (a doc persisted in a prior run, or a
// fresh load where caps are empty). Treat every identity change as a fresh
// session: drop the accumulated read set (the listing effect rebuilds it bounded
// to the NEW identity), and reset the emulated caps + registry cache so nothing
// from the old identity lingers. Ref-guarded so it fires only on a real change,
// not on the first mount (empty sets already).
const prevOwnerRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (prevOwnerRef.current === undefined) {
prevOwnerRef.current = username;
return;
}
if (prevOwnerRef.current === username) return;
prevOwnerRef.current = username;
// Fresh session for the new identity: clear the previous identity's read set
// and the emulated isolation state, then let the listing effect rebuild.
setPublicDocs([]);
setProtectedDocs([]);
resetCaps();
resetRegistryCache();
setReadTick(t => t + 1);
}, [username]);
// Resolve the by-need doc NURIs — READ BY NEED, never an all-accounts fan-out
// (the OLD `listEntityDocs('public'|'protected')` enumerated EVERY account and
// tried to open/sync other accounts' unsynced docs → HANG ~75s; see