You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
14 values in wxyc_schema.compilation_track_artist hold a literal U+FFFD REPLACEMENT CHARACTER where tubafrenzy's MySQL holds the real character:
Table.column
Rows
wxyc_schema.compilation_track_artist.track_title
11
wxyc_schema.compilation_track_artist.artist_name
3
Verified at the byte level, not inferred from a terminal — client_encoding and server_encoding are both UTF8, so this is stored data, not a display artifact. Found by the first end-to-end catalog-parity run against the live pair on 2026-08-13 (WXYC/discogs-etl#346 step-8a measurement).
CTA has never been swept for this corruption class.#863's table set was rotation / library / flowsheet; #2114's was library + artists. The Phase 4 script's own informational postlude reads CTA and expected 0 against the 2026-08-12 clone — the live count is not 0, so that expectation was wrong and this is the follow-up it asked for.
Scope: this is the uncovered half of a 28-value sweep
The 2026-08-13 sweep found 28 U+FFFD values catalog-wide. Half are already covered; this ticket is the other half.
Values
Covered by
library.artist_name 10 + library.album_title 1 + artists.artist_name 1 (id 656)
rotation and flowsheet need no sweep here — the Phase 4 script already measured them: flowsheet 0, rotation 5 (ids 10789, 13703, 16683, 21149, 21335), and all five are in Phase 3.5's deliberately-unrecovered bucket (empty curated-canonical column in audit/bs_replacement_char_phase35.csv). Do not write an acceptance criterion asserting rotation reaches 0; that bucket is intentional.
#1996 is double-encoding in the same table — the original bytes survive, so it is mechanically reversible, and the damage is upstream in MySQL. This is substitution: the source byte is gone (ef bf bd is the UTF-8 encoding of U+FFFD itself). No Backend-side transform recovers it, and unlike #1996 the corruption is Backend-only — tubafrenzy holds the true character.
The two overlap on one table and must not fight each other. #1996's fix is overwhelmingly delete the mangled twin; this one is rewrite the substituted character. Sequencing note under Constraints.
This is frozen residue, not an active corruption source
The lossy read was fixed on 2026-04-24 by commit 1eab07f5 ("Fix Unicode garbling in MirrorSQL by setting utf8 client charset", #454) — MirrorSQL.makeSqlCommand now passes --default-character-set=utf8, so the live ETL reads the true bytes. #2114 independently confirmed the per-row (not per-column) damage pattern: La Forêt survives with the same 0xEA byte that was destroyed in La Bête.
The repair is durable.importCompilationTracks (jobs/library-etl/job.ts:765) inserts CTA rows with .onConflictDoNothing() on cta_unique_idx. Post-ETL: Missing --default-character-set=utf8 in MirrorSQL causes Unicode garbling #454 the ETL derives the correct string, so once a row is repaired the ETL's next insert conflicts with it and no-ops.
The trap: cta_unique_idx makes a blanket UPDATE unsafe
cta_unique_idx is UNIQUE on (library_id, artist_name, track_title) (shared/database/src/schema.ts:731, and per #801 D7 it is permanent — do not touch the index). If a corrupt row's correctly-spelled twin already exists in the same compilation, UPDATE-ing the corrupt row to the true value raises a unique violation instead of repairing anything.
This is not hypothetical for this table: #1996 measured that 98.5% of its damaged CTA rows already have a correctly-encoded twin at the same track position, because the insert-only writer kept both copies whenever the strings differed. The same double-ingest shape can apply here.
So the repair is twin-aware, per row:
twin exists → DELETE the U+FFFD row (the clean row already carries the truth)
no twin → UPDATE in place
Also note compilation_track_artist.artist_name is free text, not an FK to artists — so neither fold_artist_name (migration 0134) nor migration 0060's cascade_library_artist_name trigger reaches these rows. There is no cascade to lean on the way PR #2121 did for library.artist_name; every CTA row must be written directly.
The corrupt side needs a live prod read — the local clone cannot see it
dev_env/seed-clone.sql contains no compilation_track_artist rows. The table appears exactly once in that file, in the TRUNCATE list at line 39; there is no COPY wxyc_schema.compilation_track_artist … FROM stdin block. The populated tables are artists, format, genre_artist_crossreference, library, and rotation.
That explains the discrepancy in the Phase 4 script's postlude, and it matters for anyone reusing that script's method: its "0 in the 2026-08-12 clone" for CTA was a false negative measured against an empty table, not evidence of a clean table. The live count is 14. Do not treat the clone as covering CTA.
Consequences for implementation:
Corrupt side (which 14 rows) — requires a read against prod PG, or another snapshot that actually carries CTA rows. This is the one step that cannot be done from the repo.
Ground truth (what the strings should be) — read Kattare wxycmusic.COMPILATION_TRACK_ARTIST directly. --default-character-set=utf8 is mandatory (without it the server returns latin1 and reintroduces the corruption), and it needs a MariaDB client — the Homebrew MySQL 9.x client segfaults against the 5.1 server.
A library.db snapshot will not serve as ground truth here, contrary to an earlier revision of this ticket. Every local snapshot checked (lml-cutover-snapshots/prod-20260719, lml-cutover-snapshots/staging-20260718, library-metadata-lookup/library.db, discogs-etl/library.db) contains only library, streaming_links, and the FTS shadow tables — one flat row per release, no per-track or per-compilation-credit data at all. If a current daily build turns out to carry CTA rows, it is usable; do not assume it from these.
So the script and its test can be built and verified in full against synthetic fixtures in a throwaway schema; the 14 (row, true value) pairs are a separate capture step. Do not invent row ids or values to fill that gap — an unresolved pair is a blocker to report, not a guess to make.
cta.library_id is a Backend library.id; tubafrenzy keys on LIBRARY_RELEASE.ID, so every join to ground truth routes through library.legacy_release_id.
Approach
Follow the Phase 1/2, Phase 3.5, and Phase 4 shape — a hand-applied operator script under scripts/audit/, not a Drizzle migration (docs/migrations.md keeps migrations DDL-only; this is DML), plus an integration spec that executes the script's real statements against a throwaway schema.
Enumerate the 14 rows with their legacy_release_id and track_position.
Resolve each true string from ground truth. Read it; never reconstruct it — these are exactly the diacritic-bearing names where a plausible guess is wrong (Csillagrablók, Bête, µ-Ziq). Record the codepoint: PR fix(library): repair the 11 residual U+FFFD mojibake rows from #863 #2121's µ is U+00B5 MICRO SIGN, not U+03BC GREEK SMALL LETTER MU.
Classify each row twin / no-twin against cta_unique_idx.
Human-review the 14 pairs before writing the script literals.
Script with an audit prelude and postlude, matching bs_replacement_char_phase4.sql.
Capturing the 14 true strings is the only deadline-bound step. Once they are recorded in the script literals, the rest of this ticket is deadline-free and the deadline:tubafrenzy-turndown label can come off. Note the deadline is softer than it reads elsewhere: 2026-09-07 is a self-imposed target, Kattare hosting does not end on that date, and a MySQL dump capture is planned under the data-finalization umbrella. Capture the values early anyway — it costs an hour and removes the question entirely.
Milestone 1 moved from 2026-08-31 to 2026-09-07 on 2026-08-28 — 24 scheduled DJs still lack a usable Backend account and the classic flowsheet UI is their only surface. Engineering readiness was not the gate. See WXYC/wiki#125.
Problem
14 values in
wxyc_schema.compilation_track_artisthold a literal U+FFFD REPLACEMENT CHARACTER where tubafrenzy's MySQL holds the real character:wxyc_schema.compilation_track_artist.track_titlewxyc_schema.compilation_track_artist.artist_nameVerified at the byte level, not inferred from a terminal —
client_encodingandserver_encodingare both UTF8, so this is stored data, not a display artifact. Found by the first end-to-end catalog-parity run against the live pair on 2026-08-13 (WXYC/discogs-etl#346 step-8a measurement).CTA has never been swept for this corruption class. #863's table set was
rotation/library/flowsheet; #2114's waslibrary+artists. The Phase 4 script's own informational postlude reads CTA and expected0against the 2026-08-12 clone — the live count is not 0, so that expectation was wrong and this is the follow-up it asked for.Scope: this is the uncovered half of a 28-value sweep
The 2026-08-13 sweep found 28 U+FFFD values catalog-wide. Half are already covered; this ticket is the other half.
library.artist_name10 +library.album_title1 +artists.artist_name1 (id 656)artists.artist_name2 (ids 22025, 23162) +artists.alphabetical_name1 (id 22025)compilation_track_artist11 + 3 = 14rotationandflowsheetneed no sweep here — the Phase 4 script already measured them: flowsheet 0, rotation 5 (ids 10789, 13703, 16683, 21149, 21335), and all five are in Phase 3.5's deliberately-unrecovered bucket (empty curated-canonical column inaudit/bs_replacement_char_phase35.csv). Do not write an acceptance criterion asserting rotation reaches 0; that bucket is intentional.Why this is not #1996
#1996 is double-encoding in the same table — the original bytes survive, so it is mechanically reversible, and the damage is upstream in MySQL. This is substitution: the source byte is gone (
ef bf bdis the UTF-8 encoding of U+FFFD itself). No Backend-side transform recovers it, and unlike #1996 the corruption is Backend-only — tubafrenzy holds the true character.The two overlap on one table and must not fight each other. #1996's fix is overwhelmingly delete the mangled twin; this one is rewrite the substituted character. Sequencing note under Constraints.
This is frozen residue, not an active corruption source
The lossy read was fixed on 2026-04-24 by commit
1eab07f5("Fix Unicode garbling in MirrorSQL by setting utf8 client charset", #454) —MirrorSQL.makeSqlCommandnow passes--default-character-set=utf8, so the live ETL reads the true bytes. #2114 independently confirmed the per-row (not per-column) damage pattern:La Forêtsurvives with the same0xEAbyte that was destroyed inLa Bête.Two consequences, both load-bearing:
library-etlbecause its corruption is live upstream. Do not inherit that block by analogy.importCompilationTracks(jobs/library-etl/job.ts:765) inserts CTA rows with.onConflictDoNothing()oncta_unique_idx. Post-ETL: Missing --default-character-set=utf8 in MirrorSQL causes Unicode garbling #454 the ETL derives the correct string, so once a row is repaired the ETL's next insert conflicts with it and no-ops.The trap:
cta_unique_idxmakes a blanket UPDATE unsafecta_unique_idxis UNIQUE on(library_id, artist_name, track_title)(shared/database/src/schema.ts:731, and per #801 D7 it is permanent — do not touch the index). If a corrupt row's correctly-spelled twin already exists in the same compilation,UPDATE-ing the corrupt row to the true value raises a unique violation instead of repairing anything.This is not hypothetical for this table: #1996 measured that 98.5% of its damaged CTA rows already have a correctly-encoded twin at the same track position, because the insert-only writer kept both copies whenever the strings differed. The same double-ingest shape can apply here.
So the repair is twin-aware, per row:
DELETEthe U+FFFD row (the clean row already carries the truth)UPDATEin placeAlso note
compilation_track_artist.artist_nameis free text, not an FK toartists— so neitherfold_artist_name(migration 0134) nor migration 0060'scascade_library_artist_nametrigger reaches these rows. There is no cascade to lean on the way PR #2121 did forlibrary.artist_name; every CTA row must be written directly.The corrupt side needs a live prod read — the local clone cannot see it
dev_env/seed-clone.sqlcontains nocompilation_track_artistrows. The table appears exactly once in that file, in the TRUNCATE list at line 39; there is noCOPY wxyc_schema.compilation_track_artist … FROM stdinblock. The populated tables areartists,format,genre_artist_crossreference,library, androtation.That explains the discrepancy in the Phase 4 script's postlude, and it matters for anyone reusing that script's method: its "0 in the 2026-08-12 clone" for CTA was a false negative measured against an empty table, not evidence of a clean table. The live count is 14. Do not treat the clone as covering CTA.
Consequences for implementation:
Corrupt side (which 14 rows) — requires a read against prod PG, or another snapshot that actually carries CTA rows. This is the one step that cannot be done from the repo.
Ground truth (what the strings should be) — read Kattare
wxycmusic.COMPILATION_TRACK_ARTISTdirectly.--default-character-set=utf8is mandatory (without it the server returns latin1 and reintroduces the corruption), and it needs a MariaDB client — the Homebrew MySQL 9.x client segfaults against the 5.1 server.A
library.dbsnapshot will not serve as ground truth here, contrary to an earlier revision of this ticket. Every local snapshot checked (lml-cutover-snapshots/prod-20260719,lml-cutover-snapshots/staging-20260718,library-metadata-lookup/library.db,discogs-etl/library.db) contains onlylibrary,streaming_links, and the FTS shadow tables — one flat row per release, no per-track or per-compilation-credit data at all. If a current daily build turns out to carry CTA rows, it is usable; do not assume it from these.So the script and its test can be built and verified in full against synthetic fixtures in a throwaway schema; the 14
(row, true value)pairs are a separate capture step. Do not invent row ids or values to fill that gap — an unresolved pair is a blocker to report, not a guess to make.Enumeration predicate:
cta.library_idis a Backendlibrary.id; tubafrenzy keys onLIBRARY_RELEASE.ID, so every join to ground truth routes throughlibrary.legacy_release_id.Approach
Follow the Phase 1/2, Phase 3.5, and Phase 4 shape — a hand-applied operator script under
scripts/audit/, not a Drizzle migration (docs/migrations.mdkeeps migrations DDL-only; this is DML), plus an integration spec that executes the script's real statements against a throwaway schema.legacy_release_idandtrack_position.Csillagrablók,Bête,µ-Ziq). Record the codepoint: PR fix(library): repair the 11 residual U+FFFD mojibake rows from #863 #2121'sµis U+00B5 MICRO SIGN, not U+03BC GREEK SMALL LETTER MU.cta_unique_idx.bs_replacement_char_phase4.sql.ANALYZE wxyc_schema.compilation_track_artistoutside any transaction (#934 — omitting it after Mojibake recovery: U+FFFD-form rows in Backend PG (rotation, library, flowsheet) #863's migration regressed/flowsheet/suggest/*to 5s timeouts).Constraints
deadline:tubafrenzy-turndownlabel can come off. Note the deadline is softer than it reads elsewhere: 2026-09-07 is a self-imposed target, Kattare hosting does not end on that date, and a MySQL dump capture is planned under the data-finalization umbrella. Capture the values early anyway — it costs an hour and removes the question entirely.library-etlstopping. Land this first, and record the 14 repaired rows so Repair 5,310 double-encoded (CP1252 mojibake) rows in compilation_track_artist #1996's twin analysis re-derives after, not before.cta_unique_idx([Epic] Per-track artist identity on compilation_track_artist (cross-cache-identity §3.2 step 2) #801 D7).SELECTwith the sameWHEREclause before anyUPDATE/DELETE, per the org data-safety convention.Acceptance criteria
legacy_release_id,track_position, and column, recorded in the script's audit prelude.DELETE) or no-twin (UPDATE); no statement can raise acta_unique_idxviolation.ANALYZE wxyc_schema.compilation_track_artist, outside any transaction.Related
library/artistshalf, repaired by PR #2121artistsrowsANALYZEregression it causedMilestone 1 moved from 2026-08-31 to 2026-09-07 on 2026-08-28 — 24 scheduled DJs still lack a usable Backend account and the classic flowsheet UI is their only surface. Engineering readiness was not the gate. See WXYC/wiki#125.