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
+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;