Revised 2026-09-09. The original body claimed a stored-vs-served discrepancy that does not exist, and understated three of the four defects. Both corrected below; the investigation that produced the corrections is in this comment. Figures are now from a 40-week scan rather than a 12-week one.
Problem
The DJ identity served on the public playlist archive renders 354 distinct strings for 311 distinct DJs. One DJ appears as two different people, co-hosted shows attribute to a single fabricated composite handle, 4% of shows are nameless, and a cohort of shows renders raw HTML numeric character references as literal text. None of this blocked the tubafrenzy cutover; all of it makes the archive — and any roster query built on it — quietly wrong.
This became listener-visible on 2026-09-08, when WXYC/website#233 restored wxyc.org/playlists/archive (~1,546 req/day plus its ~1,906/day drill-in) and wxyc.org/airplay-search (~181/day). Both render this data.
Measured 2026-09-09 against production
Via GET https://api.wxyc.org/flowsheet/range over 40 consecutive 7-day windows, 2025-12-03 → 2026-09-09 PT — 2,399 shows, 354 distinct non-null dj_name values. Reproducible with the script in "Notes for implementer". These figures postdate the #2281 scrub of 2026-09-08.
1. Case-fold duplicates — 38 collisions across 354 names. Same DJ, two identities. Three of the 38 are three-way rather than two-way. A twelve-week slice of the same method gives 17 collisions across 177 names, so this scales with the window rather than being a fixed backlog. Examples:
annie / Annie DJ Diggler / DJ DIGGLER
augustine / Augustine dj eureka! / DJ Eureka!
dj almost famous / DJ Almost Famous Dj Made to Measure / DJ Made to Measure
DJ Beaux / DJ BEAUX dj mothership / DJ Mothership
DJ blue / DJ Blue dj ovni / DJ OVNI
Dj Derpa / DJ Derpa DJ shadowboxer / DJ Shadowboxer
A listener browsing the archive sees DJ Diggler and DJ DIGGLER as two DJs, and a roster query counting distinct DJs over-counts by 38. This is also what made the pre-cutover accountless-DJ cohort measurement need hand-deduplication.
2. Co-host shows collapse two DJs into one string, using four different join tokens. 20 distinct composite strings across 34 shows in the 40-week scan:
| Join token |
distinct composites |
and |
7 |
& |
6 |
+ |
4 |
, |
3 |
Commas are the minority form — an earlier version of this ticket treated this as a comma problem, and a splitter written to that assumption would miss most of the cohort. Upstream since 2025-01-01 the ratio is 42 ampersand-joined shows against 15 comma-joined.
The same pair is not stable across shows. Dj double hockey sticks & dj palomita (5 shows) and dj palomita + dj double hockey sticks (6 shows) are the same two people under a different token and a different order. Splitting on all four tokens is necessary but not sufficient; member order has to be canonicalized before pairs will match each other.
Neither DJ in a composite is findable by searching their own name, and each composite is a phantom entry in every distinct-name list.
3. Nameless shows — 96 of 2,399 (4.0%). dj_name is null on the show record, so the archive renders a show with no attribution. The twelve-week slice gives 15 of 695, so this is a rate rather than a backlog — any acceptance criterion should name a window or say "all history".
4. A real HTML-entity cohort — 41 shows, and it is upstream. DJ_HANDLE values carrying numeric character references, e.g. DJ 4𝄽. They run 2012-04 to 2022-07 PT and stop dead there, which reads like a pre-UTF-8 JSP form encoding non-Latin-1 input on submit. These pass through to the current wire unchanged — GET /flowsheet/range for 2022-07-10 → 07-17 returns shows 62312 and 62359 with dj_name = DJ 4𝄽, so wxyc.org/playlists/archive renders the literal entity text today. Finite, frozen, and the only place in the DJ-name data where HTML entities actually exist.
Investigated and closed: there is no stored-vs-served discrepancy
An earlier version of this ticket asserted that dj pipe dreams & dj slime and a Dj digitalprincess whitespace variant appeared in the stored column but not in the served output. That was wrong on both counts.
Checked 2026-09-09 against tubafrenzy DJ_HANDLE (as hex), GET /flowsheet/range over 40 weeks, and flowsheet.dj_name via a byte-equality dj:"…" search. All three agree. The & was an escaping artifact of the runbook write-up this ticket was drawn from — the stored and served value is dj pipe dreams & dj slime with a bare ampersand, and the show (1951243, 2026-08-30) is inside the original twelve-week window and is served; the earlier claim that the API returned dj pipe dreams for it was a scan-pattern error, not a data finding. Dj digitalprincess has exactly one byte form across all 28 upstream shows, all 16 served shows, and all 45 stored entry rows for her two most recent shows — one classic-origin, one dj-site-native.
shows.legacy_dj_name is a verbatim copy of the upstream DJ_HANDLE and is authoritative for the legacy cohort. The sharpest evidence is a corrupted row: upstream has exactly two mojibake handles in its whole history (two consecutive shows on 2025-11-17 where Dj Fabergé is stored as Dj Faberg + a double-encoded U+FFFD), and /flowsheet/range serves them with those exact bytes while the preceding show that day carries the correct value. Backend reproduces upstream faithfully enough to preserve a defect it could have papered over.
One residual, stated rather than hidden: if the Dj digitalprincess observation came from an aggregated query rather than the per-show one, a byte difference could still hide in auth_user.dj_name, the one column whose whitespace the API trims away on every read path. Closing that needs a single query against Backend Postgres, which was not reachable: SELECT id, dj_name, length(dj_name), md5(dj_name) FROM auth_user WHERE dj_name ILIKE '%digitalprincess%';
Why this is a bounded, closing window
The upstream writer is dead. The tubafrenzy flowsheet webhook — sole filler of shows.legacy_dj_name per #1858 — was disabled 2026-09-07 19:50 PDT as Phase 6a step E4 (WXYC/wiki#88). No new rows of this shape can be created. This is a finite, frozen cohort, not an ongoing leak.
But the ground truth has a shelf life. The only source that can disambiguate a co-host composite or a case variant is tubafrenzy's wxycmusic MySQL, queryable until Phase 6b (WXYC/wiki#94, dateless) and after that surviving only as the archived dump from #1543.
Desired end state
The archive renders one identity per DJ. Case variants of one handle resolve to one displayed name, co-hosted shows either attribute to both DJs or are explicitly marked as co-hosted rather than carrying a fabricated composite handle, nameless shows carry their upstream handle or a deliberate label, and the 41 numeric-character-reference handles render as the characters they encode.
Part of this is a policy question, not a repair. Case-folding DJ blue to DJ Blue picks a winner among a DJ's own self-typed spellings. Deciding which spelling is canonical — most recent, most frequent, or the account's dj_name where one exists — should be settled before the migration, not inside it.
Where
shows.legacy_dj_name, shows.dj_name, flowsheet.dj_name (Backend Postgres, wxyc_schema)
resolveShowDjName / resolveDjDisplayName — shared/database/src/dj-name.ts
resolveShow — apps/backend/routes/internal.route.ts (writes COALESCE(auth_user.dj_name, shows.legacy_dj_name) onto marker rows)
buildDjNameMatch — apps/backend/services/search.service.ts (a quoted dj:"…" term compiles to byte equality against flowsheet.dj_name, which is the probe used above)
- Read paths:
GET /flowsheet/range, GET /flowsheet/playlist, GET /flowsheet/search
- Consumers:
wxyc.org/playlists/archive, wxyc.org/airplay-search, the iOS/Android v2 playlist
- Ground truth:
FLOWSHEET_RADIO_SHOW_PROD.DJ_HANDLE in tubafrenzy wxycmusic
- Regenerating queries:
plans/cutover-gate.sql, plans/cutover-roster.sql
Constraints
Acceptance criteria
Restructured 2026-09-10 per the three-treatments decision: case variants fold with no writes, the 41 entities are repaired, composites are deferred. There is deliberately no canonical-spelling criterion.
Case variants — fold for aggregation, write nothing
The 41 numeric character references — repair
Cross-cutting
Explicitly out of scope
Notes for implementer
The scan that produced the figures above (no auth required; /flowsheet/range rejects windows wider than ~7 days with a 400):
NOW=$(python3 -c "print(int(__import__('time').time()*1000))")
for w in $(seq 1 40); do
E=$((NOW - (w-1)*7*86400000)); S=$((E - 7*86400000))
curl -s "https://api.wxyc.org/flowsheet/range?start=$S&end=$E" \
| jq -r '.shows[]? | "\(.id)\t\(.dj_name // "NULL")"'
done > djname-scan.txt
Then group by unicodedata.normalize("NFKC", " ".join(n.split())).casefold() and report groups of size > 1. Detect composites on all of &, , , and, + — a comma-only pattern finds 3 of the 20.
Worth checking whether wxyc.org/airplay-search's DJ-name search is case-sensitive — if it is, these variants also split search results, compounding the problem on the surface that was just restored. #2398 reports exact-mode search using = where the rest of the system folds, and #2400 reports every dj_name search being a full seq scan; both are adjacent to this on the same restored pages.
Related
Problem
The DJ identity served on the public playlist archive renders 354 distinct strings for 311 distinct DJs. One DJ appears as two different people, co-hosted shows attribute to a single fabricated composite handle, 4% of shows are nameless, and a cohort of shows renders raw HTML numeric character references as literal text. None of this blocked the tubafrenzy cutover; all of it makes the archive — and any roster query built on it — quietly wrong.
This became listener-visible on 2026-09-08, when WXYC/website#233 restored
wxyc.org/playlists/archive(~1,546 req/day plus its ~1,906/day drill-in) andwxyc.org/airplay-search(~181/day). Both render this data.Measured 2026-09-09 against production
Via
GET https://api.wxyc.org/flowsheet/rangeover 40 consecutive 7-day windows, 2025-12-03 → 2026-09-09 PT — 2,399 shows, 354 distinct non-nulldj_namevalues. Reproducible with the script in "Notes for implementer". These figures postdate the #2281 scrub of 2026-09-08.1. Case-fold duplicates — 38 collisions across 354 names. Same DJ, two identities. Three of the 38 are three-way rather than two-way. A twelve-week slice of the same method gives 17 collisions across 177 names, so this scales with the window rather than being a fixed backlog. Examples:
A listener browsing the archive sees
DJ DigglerandDJ DIGGLERas two DJs, and a roster query counting distinct DJs over-counts by 38. This is also what made the pre-cutover accountless-DJ cohort measurement need hand-deduplication.2. Co-host shows collapse two DJs into one string, using four different join tokens. 20 distinct composite strings across 34 shows in the 40-week scan:
and&+,Commas are the minority form — an earlier version of this ticket treated this as a comma problem, and a splitter written to that assumption would miss most of the cohort. Upstream since 2025-01-01 the ratio is 42 ampersand-joined shows against 15 comma-joined.
The same pair is not stable across shows.
Dj double hockey sticks & dj palomita(5 shows) anddj palomita + dj double hockey sticks(6 shows) are the same two people under a different token and a different order. Splitting on all four tokens is necessary but not sufficient; member order has to be canonicalized before pairs will match each other.Neither DJ in a composite is findable by searching their own name, and each composite is a phantom entry in every distinct-name list.
3. Nameless shows — 96 of 2,399 (4.0%).
dj_nameisnullon the show record, so the archive renders a show with no attribution. The twelve-week slice gives 15 of 695, so this is a rate rather than a backlog — any acceptance criterion should name a window or say "all history".4. A real HTML-entity cohort — 41 shows, and it is upstream.
DJ_HANDLEvalues carrying numeric character references, e.g.DJ 4𝄽. They run 2012-04 to 2022-07 PT and stop dead there, which reads like a pre-UTF-8 JSP form encoding non-Latin-1 input on submit. These pass through to the current wire unchanged —GET /flowsheet/rangefor 2022-07-10 → 07-17 returns shows62312and62359withdj_name=DJ 4𝄽, sowxyc.org/playlists/archiverenders the literal entity text today. Finite, frozen, and the only place in the DJ-name data where HTML entities actually exist.Investigated and closed: there is no stored-vs-served discrepancy
An earlier version of this ticket asserted that
dj pipe dreams & dj slimeand aDj digitalprincesswhitespace variant appeared in the stored column but not in the served output. That was wrong on both counts.Checked 2026-09-09 against tubafrenzy
DJ_HANDLE(as hex),GET /flowsheet/rangeover 40 weeks, andflowsheet.dj_namevia a byte-equalitydj:"…"search. All three agree. The&was an escaping artifact of the runbook write-up this ticket was drawn from — the stored and served value isdj pipe dreams & dj slimewith a bare ampersand, and the show (1951243, 2026-08-30) is inside the original twelve-week window and is served; the earlier claim that the API returneddj pipe dreamsfor it was a scan-pattern error, not a data finding.Dj digitalprincesshas exactly one byte form across all 28 upstream shows, all 16 served shows, and all 45 stored entry rows for her two most recent shows — one classic-origin, one dj-site-native.shows.legacy_dj_nameis a verbatim copy of the upstreamDJ_HANDLEand is authoritative for the legacy cohort. The sharpest evidence is a corrupted row: upstream has exactly two mojibake handles in its whole history (two consecutive shows on 2025-11-17 whereDj Fabergéis stored asDj Faberg+ a double-encoded U+FFFD), and/flowsheet/rangeserves them with those exact bytes while the preceding show that day carries the correct value. Backend reproduces upstream faithfully enough to preserve a defect it could have papered over.One residual, stated rather than hidden: if the
Dj digitalprincessobservation came from an aggregated query rather than the per-show one, a byte difference could still hide inauth_user.dj_name, the one column whose whitespace the API trims away on every read path. Closing that needs a single query against Backend Postgres, which was not reachable:SELECT id, dj_name, length(dj_name), md5(dj_name) FROM auth_user WHERE dj_name ILIKE '%digitalprincess%';Why this is a bounded, closing window
The upstream writer is dead. The tubafrenzy flowsheet webhook — sole filler of
shows.legacy_dj_nameper #1858 — was disabled 2026-09-07 19:50 PDT as Phase 6a step E4 (WXYC/wiki#88). No new rows of this shape can be created. This is a finite, frozen cohort, not an ongoing leak.But the ground truth has a shelf life. The only source that can disambiguate a co-host composite or a case variant is tubafrenzy's
wxycmusicMySQL, queryable until Phase 6b (WXYC/wiki#94, dateless) and after that surviving only as the archived dump from #1543.Desired end state
The archive renders one identity per DJ. Case variants of one handle resolve to one displayed name, co-hosted shows either attribute to both DJs or are explicitly marked as co-hosted rather than carrying a fabricated composite handle, nameless shows carry their upstream handle or a deliberate label, and the 41 numeric-character-reference handles render as the characters they encode.
Part of this is a policy question, not a repair. Case-folding
DJ bluetoDJ Bluepicks a winner among a DJ's own self-typed spellings. Deciding which spelling is canonical — most recent, most frequent, or the account'sdj_namewhere one exists — should be settled before the migration, not inside it.Where
shows.legacy_dj_name,shows.dj_name,flowsheet.dj_name(Backend Postgres,wxyc_schema)resolveShowDjName/resolveDjDisplayName—shared/database/src/dj-name.tsresolveShow—apps/backend/routes/internal.route.ts(writesCOALESCE(auth_user.dj_name, shows.legacy_dj_name)onto marker rows)buildDjNameMatch—apps/backend/services/search.service.ts(a quoteddj:"…"term compiles to byte equality againstflowsheet.dj_name, which is the probe used above)GET /flowsheet/range,GET /flowsheet/playlist,GET /flowsheet/searchwxyc.org/playlists/archive,wxyc.org/airplay-search, the iOS/Android v2 playlistFLOWSHEET_RADIO_SHOW_PROD.DJ_HANDLEin tubafrenzywxycmusicplans/cutover-gate.sql,plans/cutover-roster.sqlConstraints
flowsheet.dj_nameis a denormalized second copy on every entry row, and it is what/flowsheet/searchmatches. Any canonicalization migration must update both it andshows.legacy_dj_name, or the archive listing and the search results will disagree about the same DJ. Convert flowsheet-etl and the tubafrenzy webhook's dj_name writes from a re-derived COALESCE to the canonical resolveShowDjName helper #2313'sCOALESCEre-derivation is the mechanism by which the two drift.DJ_NAMEin tubafrenzy is the DJ's real name and is PII.DJ_HANDLEis the on-air alias by convention only — Mirror writes DJ legal names into tubafrenzy's public DJ_HANDLE for handle-less DJs #2291 established the mirror wrote legal names into it for handle-less DJs, so it is not a safe source without filtering. Never import a real name into a public column. See WXYC/wiki#123.DJ_HANDLEthemselves — remains by design, and 994 of 995 affected shows were verified to have been public on tubafrenzy already. A "clean up the names" migration that touches Cohort C re-litigates a settled decision.UPDATEonflowsheetmust respect the 2-second bridge budget. The 2026-09-08 scrub breached it and returned 503 on 42% of/recentEntriesrequests for 43 minutes (see Scrub historical denormalized dj_name values to match the current resolution policy #2281). Samplehttp://wxyc.info/playlists/recentEntries?v=2&n=50during the drain, not only before and after.flowsheetcarries aSTORED GENERATEDtsvector column and migration 0053 wedged on locks once (Migration 0053 wedge: split DDL from backfill, harden migration runner #511).SELECTwith the sameWHEREclause and confirm the row count before anyUPDATE.Acceptance criteria
Case variants — fold for aggregation, write nothing
fold_dj_nameequivalent exists and is used for grouping, reusingfoldArtistName/wxyc_schema.fold_artist_namesemantics rather than inventing new ones. Do not re-litigate Unicode-form artist duplication at the catalog write boundary (artistIdFromName) #1897's decisions (accent-folding, non-decomposing letters deliberately unfolded).CONCURRENTLY, following migration 0134's pattern, and EXPLAIN confirms an index scan.=where the rest folds) is a prerequisite, not a neighbour.shows.legacy_dj_nameorflowsheet.dj_nameis rewritten for a case variant.The 41 numeric character references — repair
DJ_HANDLEvalues carrying numeric character references render as the characters they encode, verified against two known shows (62312,62359).shows.legacy_dj_nameandflowsheet.dj_nameare updated, or the archive listing and search will disagree about the same DJ.SELECTwith the sameWHEREclause confirms the row count before theUPDATE./recentEntries2 s bridge budget — sampled during the run, not only before and after. At 41 shows this should be trivial, which is itself worth confirming rather than assuming.Cross-cutting
Explicitly out of scope
Notes for implementer
The scan that produced the figures above (no auth required;
/flowsheet/rangerejects windows wider than ~7 days with a 400):Then group by
unicodedata.normalize("NFKC", " ".join(n.split())).casefold()and report groups of size > 1. Detect composites on all of&,,,and,+— a comma-only pattern finds 3 of the 20.Worth checking whether
wxyc.org/airplay-search's DJ-name search is case-sensitive — if it is, these variants also split search results, compounding the problem on the surface that was just restored. #2398 reports exact-mode search using=where the rest of the system folds, and #2400 reports everydj_namesearch being a full seq scan; both are adjacent to this on the same restored pages.Related
flowsheet-etlre-derivesdj_namewith aCOALESCEinstead of the canonical helper; the mechanism by which the two copies driftμ-ZiqU+FFFD residue; the same mojibake class that appears in the two 2025-11-17Dj FabergéhandlesDJ_HANDLEholds legal names for handle-less DJslegacy_dj_namepopulation defects