Skip to content

Canonical discovery account erasure

eraseDiscoveryAccountData(tx, userId, erasedThrough?: Date) runs inside permanentlyDeleteAccount immediately before its user DELETE. It uses only the supplied transaction and never reads the measurement feature flag. The helper does not run when requesting a deletion email or checking eligibility. Its default cutoff is captured after it acquires the account actor lock.

Writer API and transaction ordering

The controller's canonical write paths import these exports from lib/discovery-erasure.ts:

ts
interface DiscoveryErasureEvent {
  actorId: string;
  userId?: string | null;
  eventType: string;
  occurredAt: Date;
  receivedAt?: Date;
  payload?: Record<string, unknown> | null;
}

lockDiscoveryActors(tx, actors: readonly string[]): Promise<void>;
filterActiveDiscoveryEvents<T extends DiscoveryErasureEvent>(
  tx, events: readonly T[],
): Promise<T[]>;

filterActiveDiscoveryEvents automatically takes sorted user-row FOR KEY SHARE locks, then all relevant actor advisory locks, then reads tombstones in a separate statement. It rejects events with a missing user dependency or any marked user actor, regardless of event/receipt time. A marked guest rejects an event when occurredAt <= erasedThrough or a supplied receivedAt <= erasedThrough. Canonical stored rows include receivedAt; exposure guards may omit it and must retain the original served time plus the existing-origin validation. It returns the remaining original objects in input order and never writes events. The controller must insert only those objects in the same READ COMMITTED transaction. Nonempty writes fail closed if the tombstone table is absent. Empty batches do nothing. No feature flag bypasses the check. REPEATABLE READ and SERIALIZABLE are rejected because a snapshot taken before waiting could miss the erasure that released the lock.

The account deletion caller already locks user rows in ORDER BY id FOR UPDATE before calling erasure. Writers use the same ordering with FOR KEY SHARE so a later user FK check cannot invert the locks against account deletion. The order for controller preparation is user rows → actor advisory locks → world locks. Run the filter before world locks and before successful-mutation attribution.

lockDiscoveryActors is the lower-level advisory-only API; callers using it directly must already hold required user-row locks. Advisory keys use separate integer namespaces for user and guest actors (0x594401, 0x594402) and PostgreSQL hashtext(actorId). Distinct actual keys are sorted by namespace and key before sequential pg_advisory_xact_lock calls; a hash collision only adds contention. Locks last through commit/rollback. Erasure locks its user actor before reading the explicit link set, then takes all linked guest locks before any marker or event mutation. Link writers must include the payload's user and guest actors.

Supply the complete batch once. If a transaction needs several checks, acquire the complete union of required user-row/actor locks at its first filter call; later calls may reuse subsets. Do not discover a new user actor after guest or world locks have been acquired.

For a delayed outcome, checking only its new timestamp cannot detect an erased origin. The controller checks one exposure guard containing the receipt actor and current user, at the original signed servedAt, before taking world locks:

ts
const exposureGuard = {
  actorId: receipt.actorId, userId, eventType: "attribution",
  occurredAt: new Date(receipt.servedAt),
};
if (!(await filterActiveDiscoveryEvents(tx, [exposureGuard])).length) return null;
// Continue in this transaction; the guard itself is not inserted.

This locks the origin and current user in one call, rejects a missing current account, and cannot renew an erased exposure by changing the event timestamp. The filter also considers payload.originActorId for all event types, plus both payload.userActorId and payload.guestActorId for identity_link. Callers must require every guard for an attribution operation if they use several guards, not treat one surviving guard as authorization.

Reviewed integration

Read-only inspection found all production canonical event INSERTs in discovery-measurement.ts and discovery-outcomes.ts; the controller has wired them as follows:

PathGuard and transaction
Page/serve persistencerecordDiscoveryServe wraps persistDiscoveryServe in database.transaction. A page guard checks the actor, user and original page time before page/served INSERTs; all those rows share that actor and timestamp.
Client observations/feed/events supplies db.transaction. Ingestion filters the complete parsed batch before reading served origins, filters the origins at their stored timestamps, then inserts only matched active observations and their derived compatibility rows. The origin check reuses the locked serving actors.
Save, session, hide and undoEach route calls prepareDiscoveryOutcome, its successful mutation, and recordDiscoveryOutcome with the same transaction. Preparation filters the exposure guard before the world SHARE lock, then checks the stored origin against the signed receipt.

recordDiscoveryOutcome does not need another actor-lock call: preparation has already locked the current user and serving actor through the mutation commit, including both actors of any ensuing guest-to-user identity link. The later outcome timestamp cannot bypass the earlier exposure-time check. There is no remaining unguarded late-writer gap in these inspected canonical paths.

Schema and migration handoff

Appended export: discoveryErasedActors in packages/server/src/db/schema.ts. Existing discoveryEvents is unchanged by this work.

sql
CREATE TABLE public.discovery_erased_actors (
  actor_id text PRIMARY KEY,
  erased_through timestamptz NOT NULL
);

These two fields are the entire allowlist. There is no user FK, JSON payload, receipt, secret, or chat content. The markers must survive account deletion and hot-event retention. Repeated upserts retain the greatest cutoff per actor.

The helper probes exact public.discovery_events and public.discovery_erased_actors names with to_regclass on every call. It does not catch and suppress missing-table errors in a transaction or create tables.

Events tableErasure tableBehavior
AbsentAbsentNo-op; existing account deletion proceeds normally.
PresentAbsentFail and roll back; never remove events without durable archive erasure instructions.
AbsentPresentPersist the user actor marker for any archived events.
PresentPresentPersist all applicable markers, then delete applicable events.

Install both tables before enabling collection; install the erasure table before this code if events already exist. A disabled DISCOVERY_MEASUREMENT_ENABLED flag never bypasses previously collected data. Other query, permission, or schema errors propagate so the caller rolls back the account-deletion transaction.

Actor and cutoff contract

The actor set contains user:<userId> plus distinct nonempty guest: actors in explicit identity_link events whose payload userActorId exactly matches that user actor, regardless of either timestamp. Only a string guestActorId with that prefix qualifies; other users are never followed transitively. Sharing a guest browser never adds another user actor to the marker set.

Markers for this set are written in a separate statement before events or links are removed. User markers are permanent: immutable user IDs never regain eligibility after a cutoff. All rows associated by actor, user_id, payload.originActorId, or either explicit actor field on an identity_link are deleted regardless of their timestamps. Null user_id does not preserve a matching actor's events. Guest markers are inclusive on both clocks: delete when occurred_at <= erased_through OR received_at <= erased_through. This erases already-received observations even if a client's permitted +4.5-second clock skew puts their occurrence after deletion. A surviving cookie can produce later unrelated visits only when both timestamps exceed the cutoff. A later actual deletion of an account explicitly linked to the same guest may advance it. References to erased guests (including another account's shared-browser link) are removed through the cutoff; that other account's unrelated events remain.

Both mutations share the account transaction: errors later in account deletion restore events and the prior marker state. Successful repeated erasure does not need the already-deleted identity links, and it does not extend guest cutoffs merely because the same cookie still exists.

Warehouse and retention handoff

Mirror discovery_erased_actors durably, merge each actor with max(erased_through), and use permanent user erasure plus the inclusive occurred-or-received guest cutoff for both exclusion and physical archive removal. Apply the marker on actor_id, on user:<user_id> when user_id is present, on payload.originActorId, and on both identity_link.payload.userActorId and identity_link.payload.guestActorId for link rows. This mirrors the hot-table deletion predicates, including guest-attributed rows that carry the deleted account's user_id. Retention deletions by themselves intentionally do not erase archived events; these privacy markers are a separate signal.

The helper can discover guest links only while those links exist in Postgres. Herschel's source retention implementation in scripts/prune-discovery-events.ts now excludes identity_link in both candidate selection and the final DELETE; the PGlite retention test checks that those links survive. Keep those exclusions. Herschel confirmed the warehouse update will apply these semantics to all five references (including origin-actor and link-guest), plus interval user erasure. Do not infer ownership from unrelated visits or propagate through another user account. These are contract handoffs; this change does not edit those owned files.

Canonical events have no user FK. The inspected insertion sites now enforce erasure with this helper in their insertion transactions. With the shared locks, a writer that commits first is visible to deletion; a writer that follows deletion sees its markers or absent user row and cannot resurrect erased events. Late backdated archive replays must still be excluded/removed using durable markers as part of the archive contract; consumers cannot rely only on the absence of rows in the current Postgres snapshot. The controller owns collection wiring, and the warehouse owner owns archival.

Local OSS handoff

account-deletion.ts now imports ./discovery-erasure.js. The export controller must replace that module with a no-op stub having the same asynchronous eraseDiscoveryAccountData(tx, userId, erasedThrough?) signature, and exclude the hosted helper tests/document as appropriate. Schema export pruning should handle the new discoveryErasedActors export alongside discoveryEvents. Manifest and stub changes are owned by the controller. If any exported core file imports the writer helpers, its replacement must expose those signatures too.

Verification

The isolated server launcher runs the helper against independently constructed in-memory PGlite databases. Coverage includes unmigrated and partial schemas, linked guests, other-account isolation, the inclusive cutoff and future cookie visits, malformed links, a real trigger proving marker-before-delete ordering, rollback after erasure, database error rollback, idempotent monotonic markers, and collection being disabled after data exists. A source-level integration check verifies the call site without invoking account deletion or external cleanup. No real account is deleted by these tests.

The serialization suite additionally checks actual transaction-scoped advisory locks in pg_locks, deterministic acquisition and release, user-row-before-actor ordering followed by a real FK insertion, missing users, stale origin guards, both legal writer/deleter schedules, cutoff filtering, and rollback. PGlite has one backend: these are real SQL and ordering tests, not a claim of independent PostgreSQL-backend lock-contention testing.

powershell
pnpm --filter @yumina/server test src/lib/discovery-erasure.test.ts src/lib/discovery-erasure-serialization.test.ts

The clock-skew regression was reproduced before changing the helper: future user rows, received-before-cutoff guest rows, and timestamped user links survived. After correction all 24 isolated erasure/serialization tests pass, including permanent user associations, both guest timestamp boundaries, genuine later visits, future-dated explicit links, and optional guard receipt times. The table shape and locking protocol are unchanged.

The follow-up measurement, HTTP attribution, dismissal and source-retention run passed all 25 tests, including erased-exposure replay rejection and the corrected wrong-actor fixture. Those integration files were inspected but not edited by this task. Server typecheck currently reports only TS18048 errors in the independently owned discovery-store-admission.test.ts:50–52 (meta possibly undefined); this run is not a passing server typecheck. The earlier full build passed before this correction; no full build was rerun for this bounded predicate change.