Problem
mapShowToTubafrenzy in the legacy mirror computes the value written to tubafrenzy's public DJ_HANDLE field as:
// shared/legacy-mirror/src/http-mirror.ts:512 (pre-fix)
const djHandle = override && override.length > 0 ? override : dj.djName || dj.name;
The terminal dj.name is auth_user.name. That column is a hidden second copy of the DJ's legal name: dj-site's admin provisioning sends name: realName || username, the auth provision route stores it verbatim, and nothing ever maintains it afterwards (roster edits go straight to better-auth's admin.updateUser and touch real_name/dj_name only). The history is written up at shared/database/src/dj-name.ts:1-13 and :33-40.
So for every DJ who has not set a stage handle, starting a show writes their legal name into DJ_HANDLE.
This is the fifth dj_name-family writer. Commit 2a37bbc6 removed the auth_user.name fallback from what it described as "all 4 dj_name writers"; the mirror's outbound djHandle was not among them, and it has been reading name ever since.
Why it matters
Two callers reach it: apps/backend/middleware/legacy/flowsheet.mirror.ts:168 (live show-start path) and jobs/legacy-mirror-reconcile/orchestrate.ts:285 (reconcile sweep 1). Both select() the full auth_user row, so no query change is needed.
Fix
djHandle resolves as: per-show override -> resolveDjDisplayName(djName) (the canonical helper in @wxyc/database, which trims and treats the literal "Anonymous" as absent) -> auth_user.username -> ''. It never reads name, and it does not re-derive the trim/Anonymous logic locally.
username is the terminal fallback rather than name: it is the least-surprising non-PII stable identifier and it matches the auth_user.name := handle, else username policy chosen for the broader safeguards work. MirrorDJ gains username?: string | null.
The all-absent case is pinned to '' (mirroring the sibling showName ?? '') rather than left as emergent undefined on a field tubafrenzy treats as required. It is unreachable in practice — every provisioned user has a username, and anonymous users cannot own shows.
Deliberately not changed: http-mirror.ts:514 (djName: dj.realName || dj.name). That targets tubafrenzy's separate DJ_NAME legal-name field, where name is still the correct fallback until the auth_user.name backfill makes real_name reliably populated. It is not the leak, and folding it in here would change a field this issue is not about.
Also corrects the shows.legacy_dj_name docblock at shared/database/src/schema.ts:2497-2510, which described the outbound chain this change removes — a stale doctrine comment on exactly this column is how the last incident propagated.
Scope
This is Track 0 of plans/dj-name-pii-safeguards.md — the "stop the live writer" slice, sized to ship alone and immediately. The plan file itself lands in a sibling PR; the rest of it (PII field registry, COMMENT ON COLUMN migration, the databaseHooks.user derivation choke point, the auth_user.name backfill, the wxyc/restricted-real-name lint rule, and the sentinel wire spec) is out of scope here.
Acceptance criteria
Problem
mapShowToTubafrenzyin the legacy mirror computes the value written to tubafrenzy's publicDJ_HANDLEfield as:The terminal
dj.nameisauth_user.name. That column is a hidden second copy of the DJ's legal name: dj-site's admin provisioning sendsname: realName || username, the auth provision route stores it verbatim, and nothing ever maintains it afterwards (roster edits go straight to better-auth'sadmin.updateUserand touchreal_name/dj_nameonly). The history is written up atshared/database/src/dj-name.ts:1-13and:33-40.So for every DJ who has not set a stage handle, starting a show writes their legal name into
DJ_HANDLE.This is the fifth
dj_name-family writer. Commit2a37bbc6removed theauth_user.namefallback from what it described as "all 4dj_namewriters"; the mirror's outbounddjHandlewas not among them, and it has been readingnameever since.Why it matters
DJ_HANDLEis a public field. It renders on wxyc.info's playlist pages — the same class of exposure as PII leak: flowsheet ETL writes DJ_NAME (real name) into shows.legacy_dj_name, surfaced on v2 marker wire #1393.DJ_HANDLEintoshows.legacy_dj_name, so the leak also lands in our own database, whereresolveShow's COALESCE chain can surface it on the v2 marker wire.shows.legacy_dj_nameremediation would otherwise have to chase.Two callers reach it:
apps/backend/middleware/legacy/flowsheet.mirror.ts:168(live show-start path) andjobs/legacy-mirror-reconcile/orchestrate.ts:285(reconcile sweep 1). Bothselect()the fullauth_userrow, so no query change is needed.Fix
djHandleresolves as: per-show override ->resolveDjDisplayName(djName)(the canonical helper in@wxyc/database, which trims and treats the literal"Anonymous"as absent) ->auth_user.username->''. It never readsname, and it does not re-derive the trim/Anonymous logic locally.usernameis the terminal fallback rather thanname: it is the least-surprising non-PII stable identifier and it matches theauth_user.name := handle, else usernamepolicy chosen for the broader safeguards work.MirrorDJgainsusername?: string | null.The all-absent case is pinned to
''(mirroring the siblingshowName ?? '') rather than left as emergentundefinedon a field tubafrenzy treats as required. It is unreachable in practice — every provisioned user has a username, and anonymous users cannot own shows.Deliberately not changed:
http-mirror.ts:514(djName: dj.realName || dj.name). That targets tubafrenzy's separateDJ_NAMElegal-name field, wherenameis still the correct fallback until theauth_user.namebackfill makesreal_namereliably populated. It is not the leak, and folding it in here would change a field this issue is not about.Also corrects the
shows.legacy_dj_namedocblock atshared/database/src/schema.ts:2497-2510, which described the outbound chain this change removes — a stale doctrine comment on exactly this column is how the last incident propagated.Scope
This is Track 0 of
plans/dj-name-pii-safeguards.md— the "stop the live writer" slice, sized to ship alone and immediately. The plan file itself lands in a sibling PR; the rest of it (PII field registry,COMMENT ON COLUMNmigration, thedatabaseHooks.userderivation choke point, theauth_user.namebackfill, thewxyc/restricted-real-namelint rule, and the sentinel wire spec) is out of scope here.Acceptance criteria
mapShowToTubafrenzy'sdjHandleprovably never readsnameorrealName, proven by a sentinel unit test across the override/handle/username/all-absent matrix.''.http-mirror.ts:514(DJ_NAME) is unchanged.username.shows.legacy_dj_namedocblock no longer describes the removed chain.