refactor(data): canonical event-id matching for the owner-materializer (defensive)

Guard the Option-B owner-materializer against overlay-form drift: match inbox
deposits to owned events on the CANONICAL base repo id (canonicalEventId strips any
✌️<overlay> suffix), applied at the matching boundary in materializeAttendance /
readRegistrationNotifications and to dedup ownedEventIds (ownedKey). The count is
still WRITTEN on the real owned NURI — a stripped id is never a write/anchor target.

Honest framing: this is DEFENSIVE, not a fix for an active bug. On the current tree
create-time, listMyEntityDocs and the read @id already carry the identical NURI
(readUnion pins the subject to the input NURI, 63ecfee) — verified: the count
converges for an event owned via listMyEntityDocs. A prior investigation's 'never
matches' reading was the seeded-but-not-owned artifact (a prior-run identity owned
the seed → reached via discovery, not ownedEventIds — correct behavior).

Un-@wip the @data convergence scenario (asserts the just-joined uid enters the
owner-derived active set — deterministic despite shared-inbox accumulation); it
now passes. Fix authParticipationCount already landed separately. Doctrine:
knowledge_context-internals (canonical id-form invariant). Build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 15:14:25 +02:00
parent 767a18e98c
commit 0f164300f0
6 changed files with 111 additions and 42 deletions
@@ -68,19 +68,18 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
# --- Data : compteur dérivé (Option B) ---
#
# @wip : la CONVERGENCE de participantCount vers la valeur dérivée exige la
# matérialisation par la session du PROPRIÉTAIRE de l'événement. En @data
# mono-session, deux obstacles la rendent non-déterministe : (1) l'inbox de
# l'événement est ancrée à un compte-sentinelle partagé (les dépôts s'accumulent
# sur la vie du wallet de test, donc |actif| n'est pas borné au scénario) ;
# (2) l'événement seedé est CRÉÉ sous un NURI (non-versionné, celui de
# ownedEventIds) mais RELU sous un NURI versionné (:v:) — l'inscrit dépose sous
# le NURI relu, alors que le matérialiseur itère les NURI possédés non-versionnés,
# donc le compteur ne bouge jamais pour un événement seedé. La convergence 1→2
# réactive est prouvée par le scénario @multibrowser réactif (propriétaire A +
# inscrit B, e2e-multibrowser.feature). Voir data-layer/knowledge_context-internals
# § participantCount et le brief brief_2026-07-06_reactive-reads-and-attendance §B.
@data @wip
# La matérialisation par le PROPRIÉTAIRE dérive l'ensemble actif de l'inbox de
# l'événement. L'id d'événement est apparié sur sa forme CANONIQUE (id de repo de
# base, en retirant tout suffixe `:v:<overlay>`) à travers ownedEventIds / la clé
# de dépôt / le filtre du matérialiseur — donc le propriétaire matérialise les
# dépôts d'un événement qu'il possède quelle que soit la voie d'id (create OU
# listMyEntityDocs OU après reload). Ce scénario assère le DELTA : l'inscrit qui
# vient de rejoindre EST dans l'ensemble actif dérivé du propriétaire (déterministe
# même si l'inbox partagée accumule des dépôts, rendant le compteur ABSOLU
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
@data
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -150,27 +150,32 @@ Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async
});
Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', async function (this: FestipodWorld, eventTitle: string) {
// OPTION B — the count is DERIVED and OWNER-materialized (see the feature's @wip
// rationale + data-layer/knowledge_context-internals §participantCount). In
// single-session @data it does NOT converge deterministically (shared inbox
// anchor accumulates deposits across the wallet's life; the seeded event is
// CREATED under an unversioned NURI but READ under a versioned one, so the
// joiner's deposit and the owner-materializer's owned-id never match). This step
// encodes the INTENT (the derived count reflects the join reactively) but the
// scenario is @wip — the real validation lives in the @multibrowser reactive
// scenario (owner A + joiner B, e2e-multibrowser.feature). Waits reactively on
// the owner-materialized count moving above the host baseline.
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === title);
if (!ev) return false;
const rs = td.reactiveEventState(ev['@id']);
return rs.found && rs.participantCount > 1; // host(1) + at least this join
},
eventTitle,
{ timeout: 20000 },
);
// OPTION B — the count is DERIVED by the OWNER materializing the event's inbox
// (see data-layer/knowledge_context-internals §participantCount). The event-id is
// matched on its CANONICAL form (base repo id, stripping any `:v:<overlay>`) across
// ownedEventIds / the deposit key / the materializer filter, so an owner
// materializes deposits for an event it owns regardless of the id-form path (create
// OR listMyEntityDocs OR after reload) — the fix that makes this converge.
//
// ASSERT THE DELTA, not an absolute count: the shared inbox anchor accumulates
// deposits across the wallet's life, so |active| is not bounded to this scenario —
// but "the just-joined user IS in the owner's derived active set" is deterministic.
// (The absolute 1→2 convergence stays proven end-to-end by the @multibrowser
// reactive scenario with a real owner A + joiner B.) Poll (the deposit's index
// append + broker sync lag), bounded.
const inActive = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === title);
const uid = await td.ensureCurrentUser();
if (!ev || !uid) return false;
for (let i = 0; i < 20; i++) {
const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']);
if (users.includes(uid)) return true;
await new Promise(r => setTimeout(r, 750));
}
return false;
}, eventTitle);
expect(inActive, `the just-joined user must be in the owner-derived active set for "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
+15 -4
View File
@@ -17,6 +17,7 @@ import {
materializeAttendance,
deleteParticipation,
countUserParticipations,
canonicalEventId,
} from '../data/registration';
import { inbox } from '@ng-eventually/client';
import {
@@ -541,10 +542,20 @@ function useNgData(): FestipodDataContextValue {
// while the owner is disconnected the count doesn't advance for others (the
// deposits persist in the inbox — nothing is lost; a future service will
// materialize on the owner's behalf).
const ownedKey = React.useMemo(
() => [...new Set(ownedEventIds)].sort().join('|'),
[ownedEventIds],
);
// Dedup owned events by their CANONICAL id-form (base repo id, stripping any
// `:v:<overlay>` suffix): the SAME event can enter `ownedEventIds` under two
// overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating
// both would materialize + count-write the same event twice. Keep ONE real NURI
// per canonical id as the write/anchor target (the count is written on a live
// doc NURI — never a stripped id). See `canonicalEventId` in registration.ts.
const ownedKey = React.useMemo(() => {
const byCanon = new Map<string, string>();
for (const nuri of ownedEventIds) {
const c = canonicalEventId(nuri);
if (!byCanon.has(c)) byCanon.set(c, nuri);
}
return [...byCanon.values()].sort().join('|');
}, [ownedEventIds]);
// Last count written per owned event, so we only persist a genuine change.
const materializedCountRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
+38 -4
View File
@@ -85,6 +85,33 @@ function mintDepositUid(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* CANONICAL event-id — the id-form the owner-materializer matches deposits ON.
* DEFENSIVE invariant, not a fix for an active bug.
*
* An event's `@id` is its document NURI, a `did:ng:o:<repo>[:v:<overlay>]`. On the
* current tree the SAME event carries the IDENTICAL NURI (incl. any `:v:<overlay>`)
* across every boundary — create-time / `listMyEntityDocs` and the read `@id` all
* agree, because `readUnion` pins the subject to the input doc NURI (lib
* `read-model.ts`). So matching already works. This canonicalization GUARDS that:
* `ownedEventIds` (what the materializer iterates), the joiner's DEPOSIT key
* (`payload.eventId`) and the count-write target are all matched on ONE canonical
* form — the BASE repo id (strip any `:v:<overlay>` suffix) — so that should an
* overlay-form ever diverge across those paths, the owner-materializer still
* matches instead of silently returning 0 (a no-op count that never converges).
* Only the MATCHING uses the stripped form — the count is still WRITTEN on the real
* (owned) NURI, a live openable doc NURI (a stripped id must never be a write/anchor
* target).
*
* A NURI with no `:v:` overlay (or a non-`did:ng:o:` id) passes through unchanged.
*/
export function canonicalEventId(id: string): string {
// did:ng:o:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) has no `:v:`.
const i = id.indexOf(':v:');
return i === -1 ? id : id.slice(0, i);
}
/**
* Resolve the inbox document NURI for a meeting point / host.
*
@@ -216,13 +243,18 @@ export async function materializeAttendance(
eventId: string,
): Promise<ActiveRegistration[]> {
const deposits = await inbox.read(targetInbox);
// Match deposits to this event on the CANONICAL id-form (base repo id, stripping
// any `:v:<overlay>` suffix). On the current tree the forms already agree, but
// matching on the canonical base id GUARDS against a future overlay-form drift
// between `payload.eventId` and this owned `eventId` (see `canonicalEventId`).
const canonId = canonicalEventId(eventId);
// First pass: collect distinct joins by uid; collect leave cancellations.
const joins = new Map<string, ActiveRegistration>();
const cancelledUids = new Set<string>();
const leaveUserIds: Array<string | null> = [];
for (const d of deposits) {
const p = d.payload as Partial<RegistrationPayload> | null;
if (!p || !p.eventId || p.eventId !== eventId || !p.uid) continue;
if (!p || !p.eventId || canonicalEventId(p.eventId) !== canonId || !p.uid) continue;
if (p.kind === NOTIF_TYPE_NEW_PARTICIPANT) {
if (!joins.has(p.uid)) {
joins.set(p.uid, {
@@ -260,13 +292,15 @@ export async function readRegistrationNotifications(
recipientEventId: string,
): Promise<FpNotificationData[]> {
const deposits = await inbox.read(targetInbox);
const canonRecipient = recipientEventId ? canonicalEventId(recipientEventId) : '';
const notifs: FpNotificationData[] = [];
for (const d of deposits) {
const p = d.payload as Partial<RegistrationPayload> | null;
if (!p || p.kind !== NOTIF_TYPE_NEW_PARTICIPANT || !p.eventId) continue;
// Keep only deposits for the event whose host is reading.
// `recipientEventId` doubles as the recipient.
if (recipientEventId && p.eventId !== recipientEventId) continue;
// Keep only deposits for the event whose host is reading, matched on the
// CANONICAL id-form (base repo id) so an overlay difference never drops a
// deposit. `recipientEventId` doubles as the recipient.
if (canonRecipient && canonicalEventId(p.eventId) !== canonRecipient) continue;
const built = buildNotification(recipientEventId, p.eventId, d.from ?? null, d.ts);
// F5 dedup: prefer the stable per-deposit uid carried in the payload so
// same-ms / anonymous deposits never collide. Fall back to the legacy
+12
View File
@@ -287,6 +287,18 @@ function ConnectedHarness() {
(d: any) => d?.payload?.kind === 'new-participant' && d?.payload?.eventId === eventId,
);
},
/** OPTION B — the owner's DERIVED active-registration set for an event
* (`materializeAttendance`), matched on the CANONICAL event-id form. Used
* by the @data convergence check to assert the DELTA (the just-joined user
* is in the active set) rather than an absolute count — the shared inbox
* anchor accumulates deposits across the wallet's life, so |active| is not
* bounded to one scenario, but "contains this uid" IS deterministic. */
async activeRegistrationUsers(eventId: string) {
const regmod = await import('../data/registration');
const target = await regmod.hostInboxNuri('');
const active = await regmod.materializeAttendance(target, eventId);
return active.map(r => r.userId);
},
/** Host-facing notifications currently surfaced by the data context. */
appNotifications() {
return AD().notifications;