|
|
|
@@ -1,94 +1,91 @@
|
|
|
|
|
---
|
|
|
|
|
type: knowledge
|
|
|
|
|
summary: Internal pitfalls of FestipodDataContext — currentUserId is the profile document read back in the protected scope (empty until it lands), the legacy principal space resolveParticipantUser still resolves on read, OPT-IN auto-seed, owner-derived participantCount, local-mode no-op mutations
|
|
|
|
|
last_checked: 2026-08-10
|
|
|
|
|
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
|
|
|
|
|
last_checked: 2026-08-16
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# Internals & pitfalls of `FestipodDataContext`
|
|
|
|
|
|
|
|
|
|
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
|
|
|
|
|
|
|
|
|
|
## Who am I — `currentUserId` is a document you read back, not a value you were given
|
|
|
|
|
## Identity and profile are TWO things — never join them
|
|
|
|
|
|
|
|
|
|
**The app names no identity of its own** (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]): `ensureIdentity()` takes nothing, and nothing switches identity afterwards. So the provider cannot *derive* a principal from an input. What the current user **is**, is the **profile document it reads back in its own protected scope**: `currentUserId` = that profile's `@id`, a doc NURI — the very same value as `currentUser?.id`.
|
|
|
|
|
**The identity** is what `ensureIdentity()` returns: an opaque value, published to the tree by `src/shared/utils/currentPrincipal.ts` (a module store, not a context — the component that awaits sits *inside* the data provider, so a context it published would be invisible to its own consumer). It is **for display and log attribution only**. It is never parsed, never rendered as a name, never written into an entity, and **never passed to a data-layer call** — placement is named by scope alone, so handing it back would recreate the parameter the surface deliberately removed ([[contract_polyfill-surface]]).
|
|
|
|
|
|
|
|
|
|
**The pitfall that follows**: it is **empty until the protected read lands**, and empty is an ordinary string that raises nothing. Mutations needing it **refuse** (`joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` rather than writing an entity that would be dropped on read); queries keyed on it return **empty results** that render as "you have nothing". Treat `''` as *not ready*, never as *no data* — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
|
|
|
|
**The profile** — pseudo, name, initials — is **Festipod's own object**, in a document the app creates and writes. `currentUserId` is that document's NURI, the same value as `currentUser?.id`, and the only value a mutation may write into a `Participation`'s `fp:user`.
|
|
|
|
|
|
|
|
|
|
> The `currentUser` object is picked with a **fallback** (`@mariedupont`, then `users[0]`) — a leftover of the demo seed, and a silent one: on a wallet holding several profiles it can settle on the wrong person. Worth a look whenever "the app thinks I am someone else".
|
|
|
|
|
**There is no join between the two, and there must not be one.** The identity says nothing about the profile. Never compare the principal to an entity id, and never match it against a profile field to decide who the current user is.
|
|
|
|
|
|
|
|
|
|
## The legacy principal space — resolved on READ only
|
|
|
|
|
## "My profile" is the profile document I OWN
|
|
|
|
|
|
|
|
|
|
A `Participation` written **today** carries `currentUserId` in `fp:user`, i.e. a profile doc NURI, so a direct `participation.userId === profile.id` join matches. Participations written under the **earlier** scheme carry a principal of the form `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
|
|
|
|
|
`listMyEntityDocs('protected')` answers *which documents are mine*, and the UserProfile among them is mine. **No field of any profile takes part**: no username comparison, no normalization, no positional pick.
|
|
|
|
|
|
|
|
|
|
**`resolveParticipantUser`** (`FestipodDataContext`) is the single join point, and it tries, in order: (1) a **direct match** `u.id === userId` — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and match the remainder against `normalizeIdentifier(profile.username)` — the legacy space. Never join by direct comparison at a call site: the symptom of getting it wrong is every participant rendering as « participant inconnu », which shipped once already.
|
|
|
|
|
A failed listing is **UNKNOWN, never "none"** — the set stays unresolved, no profile is chosen and none is created, and the failure is retried then said loudly. Reading a rejection as "I own nothing" would create a second profile for someone who already has one.
|
|
|
|
|
|
|
|
|
|
`USER_PRINCIPAL_PREFIX` is now **read-side only** — nothing mints it any more. It is kept so old data still resolves; it is not a shape to write against.
|
|
|
|
|
Four outcomes, and *somebody else's profile* is not one of them:
|
|
|
|
|
|
|
|
|
|
A further id space takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
|
|
|
|
|
|
|
|
|
|
> **Horizon.** This paragraph describes **what is implemented**. The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
|
|
|
|
|
|
|
|
|
|
### Which space each query expects (the `buildQueries` contract)
|
|
|
|
|
|
|
|
|
|
| Query | What it expects / returns |
|
|
|
|
|
| Owned profiles | Answer |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | filter on `participation.userId` / `friendship.userId`; their default is `currentUserId`, which is correct |
|
|
|
|
|
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData` → `id` = NURI), the join being done internally |
|
|
|
|
|
| listing unresolved | UNKNOWN — nothing resolved, nothing created |
|
|
|
|
|
| none | I have no profile yet → one is created (below) |
|
|
|
|
|
| exactly one | that is me |
|
|
|
|
|
| several, none created by this session | the **first by document reference** — stable across reloads, openly arbitrary, warned about once |
|
|
|
|
|
|
|
|
|
|
**Screen-side impact**: `currentUserId` and `currentUser?.id` are now the same value, so filtering yourself out of a participant list works either way. What still bites is passing an id **before it resolves** — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
|
|
|
|
The last row is a reloaded wallet carrying a fixture seed. The pick carries no meaning, so it is logged as demo data rather than presented as you; **every candidate is a document I own**, which is what separates it from the impersonation that was removed — that one reached for a profile by *name* and could land on a stranger's document. Delete the branch the day a profile is really created and known.
|
|
|
|
|
|
|
|
|
|
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
|
|
|
|
|
> **Two impersonation fallbacks are gone**, including one in `updateProfile` that would have written your pseudo into a stranger's document. Having no profile now resolves to *having no profile*. Do not reintroduce a "pick something plausible" fallback anywhere on this path.
|
|
|
|
|
|
|
|
|
|
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
|
|
|
|
|
## A profile is created at sign-in when there is none
|
|
|
|
|
|
|
|
|
|
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]).
|
|
|
|
|
Gated on **both** the protected read having settled (`isSuccess` — synced-and-empty, not still-syncing) **and** the owned-document set being known, because "I have no profile" is only true when both have answered. Single-shot per session; on failure the guard is released so a later change retries.
|
|
|
|
|
|
|
|
|
|
## Dev auto-seed
|
|
|
|
|
The UserProfile shape makes `name`, `initials` and `username` **mandatory**, so the profile cannot be written empty. The three fields carry **placeholders that read on screen as "not filled in yet"** — never a plausible human name, never a handle, and **never anything derived from the opaque identity**. The user replaces them through `updateProfile`.
|
|
|
|
|
|
|
|
|
|
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
|
|
|
|
|
## Nothing succeeds in silence
|
|
|
|
|
|
|
|
|
|
When it is enabled, the auto-seed fires if events AND users are both empty — **gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
|
|
|
|
|
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
|
|
|
|
|
- The seed writes under the **connected session**, so the session that seeds **holds** what it seeded and its protected fixtures round-trip. Seeded users are fixtures, not accounts — nobody has signed in as them, which matters because `inbox.share` refuses a recipient nobody has ever been. Only **events** get an inbox opened at seed time (`openDocumentInbox`), because events are what people deposit into.
|
|
|
|
|
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
|
|
|
|
|
Mutations on the create/participate path **reject** rather than returning quietly, and the screen's confirmation **follows** the write:
|
|
|
|
|
|
|
|
|
|
## `participantCount` — derived and owned by the owner
|
|
|
|
|
- `joinEvent` refuses when no profile of mine is resolved: a `Participation` needs `fp:user`, and one written without it is dropped on read — a sign-up that wrote nothing, threw nothing, and let the screen congratulate the user. It now throws, naming the cause. `leaveEvent` likewise, because withdrawal must be authoritative ([[caveat_participation-deletion]]).
|
|
|
|
|
- Idempotence is checked **authoritatively against the broker**, not against the reactive set, which can lag a just-written participation. A **failed** count is UNKNOWN and is deliberately *not* swallowed — reading it as zero is exactly how a duplicate gets written.
|
|
|
|
|
- **The deposit IS the delivery.** A host-facing notification is no longer minted at join time. It used to be written into the *joiner's* own protected scope with `recipient` set to the event — a document the host can never read — and pushed into the joiner's own list, so the joiner saw a "new participant" notice addressed to someone else. Both are gone: `inbox.postToDocument(doc, …)` carries the news, and the owner builds the notification from the deposits it reads on its own event's inbox.
|
|
|
|
|
- The creator signs up through the **common path** — no owner branch, no special case, the same deposit and the same derived count.
|
|
|
|
|
|
|
|
|
|
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read through `inbox.readSynced` instead of `inbox.read` — the two differ by contract, and only the former is the synced view ([[contract_polyfill-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
|
|
|
|
|
## The legacy participation id space — resolved on READ only
|
|
|
|
|
|
|
|
|
|
**Since 2026-07-07**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
|
|
|
|
|
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc — **only a document's owner writes to it**, so a participant cannot touch someone else's. The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
|
|
|
|
|
- The event **owner's** session does the materializing: it watches (`inbox.watch`, no polling) the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs('public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
|
|
|
|
|
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant` — by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|` — **no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a replay converges — never double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
|
|
|
|
|
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations and deposits stay persisted — nothing is lost).
|
|
|
|
|
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by what the protected scope hands back ([[caveat_participation-deletion]] for the authoritative deletion, unchanged).
|
|
|
|
|
A `Participation` written **today** carries the profile document's NURI in `fp:user`, so the direct join `u.id === userId` matches. Participations written under the **earlier** scheme carry `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
|
|
|
|
|
|
|
|
|
|
### Id-form invariant: match on the CANONICAL form of the event id
|
|
|
|
|
**`resolveParticipantUser`** is the single join point and tries, in order: (1) the direct id match — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and compare the remainder to `normalizeIdentifier(profile.username)`. Never join by direct comparison at a call site: getting it wrong renders every participant as « participant inconnu », which shipped once. `USER_PRINCIPAL_PREFIX` is **read-side only** — nothing mints it any more; it is not a shape to write against. The inbox deposit `uid` (`mint…`) is a third space that takes **no** part: it identifies a deposit for the counter, never a user.
|
|
|
|
|
|
|
|
|
|
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
|
|
|
|
|
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
|
|
|
|
|
|
|
|
|
|
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
|
|
|
|
|
## Reads = `watchShape`, writes = an optimistic overlay
|
|
|
|
|
|
|
|
|
|
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
|
|
|
|
|
Reads go through `useShapeQuery(shape, scope)` — three scoped reads (events/public, profiles/protected, participations/protected) mapped by `shapeAdapters.ts`; `ready` combines their `isSuccess` flags. The app resolves, lists and re-queries nothing ([[rule_document-per-entity]] §Reads).
|
|
|
|
|
|
|
|
|
|
## There is no identity switch any more
|
|
|
|
|
**Immediate visibility of a mutation is a pure optimistic overlay**: `createEvent`/`joinEvent`/`leaveEvent`/profile creation feed `pendingAdd*` / `pendingRemoveIds`; the exposed state is merge(reactive, adds) minus removes, deduped by id. Reconciliation is automatic on push — never a poll ([[rule_no-broker-polling]]).
|
|
|
|
|
|
|
|
|
|
The app settles its identity **once**, before anything renders (`ensureIdentity()` in `AuthGate`), and offers no way to change it — the surface stopped publishing one (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). So the provider carries **no identity-change reset**: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Those symbols are gone; do not reintroduce a reset for a transition that cannot happen.
|
|
|
|
|
## `participantCount` — derived, and written only by the event's owner
|
|
|
|
|
|
|
|
|
|
> **Why there is nothing left to reset.** An identity-change reset only made sense while a single React tree could outlive a change of identity. It cannot: one page hosts exactly one identity for its whole life, so session state (the read set, the optimistic overlay, the owner-materializer's doc set) has no second identity to leak into.
|
|
|
|
|
>
|
|
|
|
|
> The **cross-identity isolation** behaviour is still a real Festipod requirement, but proving it needs **two genuinely separate browser contexts**, each signing in for itself. `event/isolation-deux-identites.feature` is `@wip` for exactly that reason (concept `bdd-testing`, [[rule_tests-validate-festipod-not-the-sdk]]).
|
|
|
|
|
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
|
|
|
|
|
|
|
|
|
|
## `useShapeQuery` instrumentation — global spinner + timing
|
|
|
|
|
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
|
|
|
|
|
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
|
|
|
|
|
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Because it is a pure function of the inbox, a replay converges: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change.
|
|
|
|
|
- **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0.
|
|
|
|
|
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
|
|
|
|
|
|
|
|
|
|
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]` → an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
|
|
|
|
|
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
|
|
|
|
|
|
|
|
|
|
## Logging convention — identity-first prefix, and counter before→after
|
|
|
|
|
## Logging convention — identity-first, and the counter before→after
|
|
|
|
|
|
|
|
|
|
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: a run often drives **several sessions at once** (two tabs, a multi-browser scenario) and their lines end up read side by side — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
|
|
|
|
|
Every DATA log goes through **`logPrefix`**: `[<currentUserId or principal>][app][data]`. A run often drives several sessions at once and their lines are read side by side, so a line must say *whose* it is. Adding a DATA log means reusing `logPrefix`, not a bare `console.log`.
|
|
|
|
|
|
|
|
|
|
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
|
|
|
|
|
Two measurement points are laid down **as a pair**: the owner's materializer logs `participantCount` before → after its write, and the display read logs the value as exposed to the render. Together they separate a **data** problem (never incremented) from a **display** problem (incremented but not re-read). Do not remove one without the other — alone they diagnose nothing.
|
|
|
|
|
|
|
|
|
|
## There is no identity switch, and nothing to reset
|
|
|
|
|
|
|
|
|
|
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
|
|
|
|
|
|
|
|
|
|
## Mutations are no-ops in local mode
|
|
|
|
|
|
|
|
|
|
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
|
|
|
|
|
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
|
|
|
|
|