fix(668): reach accented facet values via a registered Unicode fold on SQLite #687

Merged
timothy merged 9 commits from fix/668-accented-facet-values into main 2026-07-27 23:08:29 +02:00
Owner

fixes #668

What was wrong

SQLite's LOWER() folds ASCII only — lower('Édith') returns 'Édith' unchanged — so the EF-sourced facet fields under-matched any stored value whose prefix carries an uppercase non-ASCII character. An under-match is unrecoverable: no later stage can reintroduce a row SQL never returned.

Two corrections to the issue's framing, both verified rather than reasoned:

  • The defect is one-sided. The handler lowercases q before SQL, so a stored lowercase édith was always reachable from both q=é and q=É. The issue body says q=É fails against édith; that is false, and a test pins the passing case beside the fixed one. The ## Done-when box was stated correctly regardless, so scope is unchanged.
  • MySQL was never broken — but not for the reason I first wrote here. See below; this bullet previously claimed MySQL over-matches, and that was wrong.

The fix

A second, additive query taken only when isSqlite && q contains a non-ASCII character:

SELECT DISTINCT <col> AS Value FROM <table>
WHERE [<discriminator> AND] etv_upper(<col>) LIKE @Pattern ESCAPE '\'
ORDER BY <col> LIMIT @Limit

etv_upper is a SqliteConnection.CreateFunction scalar implementing ToUpperInvariant. Every other case — all-ASCII q, and MySQL for all q — runs today's EF query unchanged. Exact and unconditional: no row budget, no accepted loss, no migration, both provider model snapshots untouched.

Why a fold rather than reusing #578's bounded keyset walk: that walk is best-effort at 20,000 rows, and Genre/Actor carry one row per media item, so a large library would leave Édith unreachable — the bug restated. The cost objection to a managed per-row fold is weak because LOWER(v) LIKE is non-sargable and no index on any of these Name columns exists (every index is on the foreign key), so the query is already a full scan running a per-row fold.

Correctness property: the SQL stage may over-match freely (the ordinal filter runs after) but must never under-match. ToUpperInvariant is safe because OrdinalIgnoreCase equality is a strict subset of invariant-uppercase equality. Note it is not true that OrdinalIgnoreCase is "invariant-upper then ordinal" — ToUpperInvariant('ſ') is 'S', yet "ſweet".StartsWith("S", OrdinalIgnoreCase) is false.

MySQL: corrected

An earlier revision of this PR, the handler comment and the decision record all claimed MySQL's accent-insensitive utf8mb4_0900_ai_ci collation makes the predicate over-match, with the in-memory ordinal filter discarding the extras. That is wrong, and it was caught by mutation-testing: deleting the ordinal filter left the MySQL test green. Measured on a live 8.4:

pattern binding result for q=é
SQL literal 'é%' Édith and Edith — over-match
ai_ci user variable Édith and Edith
binary-collated parameter Édith only
the EF query, executed Édith only

MySqlConnector fixes the connection collation to utf8mb4_bin, so the executed comparison is accent-sensitive and MySQL does not over-match through the real code path. MySQL reaches the accented value purely because its LOWER() is Unicode-aware. This is driver-contingent, not a law — a ci-collated pattern would restore the over-match — and the code is safe either way, since the ordinal filter stays regardless.

The original error came from probing with a literal pattern, which is a different query from the one the code runs. The record now carries that lesson explicitly.

Verification

  • Full ErsatzTV.Tests with the live MySQL lane armed: 1953 passed, 0 skipped
  • ErsatzTV.Architecture.Tests: 7/7 — the new TvContext provider static is wired in both composition roots as ProviderStaticsWiringTests requires
  • scripts/tests: 338 passed · decisions_validate: OK · format gate: clean · no BOMs
  • Red-first, and guards mutation-tested: removing the %/_ escaping fails Unicode_Fold_Escaping_Prevents_Limit_Crowding; removing the ordinal filter fails Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter while the eight agreement cases stay green (which is how one false docstring was found)
  • New SearchFieldValuesProviderTests executed against a real mysql:8.4: 4 passed / 0 skipped; without the connection string 2 visible skips; with ETV_REQUIRE_MYSQL_TESTS=1 and no connection it fails rather than skipping

Review

Five review rounds, each re-reviewing the previous fix commit. No under-match, SQL, injection or lifecycle defect was ever found — the code has been correct since round 1. Every finding was a false or overstated claim in prose:

  1. ſ justification for the fold direction was backwards
  2. two guards that could not fail if what they named was deleted
  3. a stale parenthetical asserting a mutation the test does not catch
  4. a copied "CI sets ETV_REQUIRE_MYSQL_TESTS=1" — nothing sets it
  5. the MySQL over-match story above, including in this body

Stated deviation (process.independent-review-rubric): reviews are same-model-family, not cross-family — Codex quota was exhausted until Aug 1. Mitigated with cold, review-only briefs in isolated worktrees.

Docs

New record api.search-field-values-unicode-fold, which narrows api.search-field-values-sources rather than superseding it; the predecessor's two now-false claims about #668 being open were corrected in place, hence Decisions-Edit: yes. Catalog regenerated. No API contract change, so no OpenAPI regeneration.

Follow-ups filed

  • #688 — the decisions_validate ceiling calibration is one line from red. Two things key off 60 and only one can red CI: the per-record ceiling warning is non-blocking by design (17 records already exceed it), while the p90 <= ceiling <= p95 calibration test in the blocking script-tests job reds at ≥61 prose lines — main's p90 is 59, so a 60-line record makes p90 == ceiling and passes. This record was trimmed to exactly 60 rather than re-derive a corpus-wide constant here. (An earlier version of this bullet, and of #688 itself, got both facts wrong; corrected.)
  • The same false "CI sets ETV_REQUIRE_MYSQL_TESTS=1" sentence exists on main in LibraryFolderDedupeMigrationTests (the source I copied from). Out of scope here; filed separately.
fixes #668 ## What was wrong SQLite's `LOWER()` folds ASCII only — `lower('Édith')` returns `'Édith'` unchanged — so the EF-sourced facet fields **under-matched** any stored value whose prefix carries an uppercase non-ASCII character. An under-match is unrecoverable: no later stage can reintroduce a row SQL never returned. **Two corrections to the issue's framing**, both verified rather than reasoned: - The defect is **one-sided**. The handler lowercases `q` *before* SQL, so a stored **lowercase** `édith` was always reachable from both `q=é` and `q=É`. The issue body says `q=É` fails against `édith`; that is false, and a test pins the passing case beside the fixed one. The `## Done-when` box was stated correctly regardless, so scope is unchanged. - **MySQL was never broken** — but *not* for the reason I first wrote here. See below; this bullet previously claimed MySQL over-matches, and that was wrong. ## The fix A **second, additive** query taken only when `isSqlite && q contains a non-ASCII character`: ```sql SELECT DISTINCT <col> AS Value FROM <table> WHERE [<discriminator> AND] etv_upper(<col>) LIKE @Pattern ESCAPE '\' ORDER BY <col> LIMIT @Limit ``` `etv_upper` is a `SqliteConnection.CreateFunction` scalar implementing `ToUpperInvariant`. Every other case — all-ASCII `q`, and MySQL for all `q` — runs today's EF query unchanged. Exact and unconditional: **no row budget, no accepted loss, no migration, both provider model snapshots untouched.** Why a fold rather than reusing #578's bounded keyset walk: that walk is best-effort at 20,000 rows, and `Genre`/`Actor` carry one row per media item, so a large library would leave `Édith` unreachable — the bug restated. The cost objection to a managed per-row fold is weak because `LOWER(v) LIKE` is non-sargable and **no index on any of these `Name` columns exists** (every index is on the foreign key), so the query is already a full scan running a per-row fold. **Correctness property**: the SQL stage may over-match freely (the ordinal filter runs after) but must never under-match. `ToUpperInvariant` is safe because `OrdinalIgnoreCase` equality is a strict **subset** of invariant-uppercase equality. Note it is *not* true that `OrdinalIgnoreCase` is "invariant-upper then ordinal" — `ToUpperInvariant('ſ')` is `'S'`, yet `"ſweet".StartsWith("S", OrdinalIgnoreCase)` is **false**. ## MySQL: corrected An earlier revision of this PR, the handler comment and the decision record all claimed MySQL's accent-insensitive `utf8mb4_0900_ai_ci` collation makes the predicate **over**-match, with the in-memory ordinal filter discarding the extras. **That is wrong**, and it was caught by mutation-testing: deleting the ordinal filter left the MySQL test green. Measured on a live 8.4: | pattern binding | result for `q=é` | |---|---| | SQL **literal** `'é%'` | `Édith` **and** `Edith` — over-match | | ai_ci user variable | `Édith` **and** `Edith` | | binary-collated parameter | `Édith` only | | **the EF query, executed** | **`Édith` only** | MySqlConnector fixes the connection collation to `utf8mb4_bin`, so the executed comparison is accent-**sensitive** and MySQL does **not** over-match through the real code path. MySQL reaches the accented value purely because its `LOWER()` is Unicode-aware. This is driver-contingent, not a law — a ci-collated pattern would restore the over-match — and the code is safe either way, since the ordinal filter stays regardless. The original error came from probing with a **literal** pattern, which is a different query from the one the code runs. The record now carries that lesson explicitly. ## Verification - Full `ErsatzTV.Tests` with the live MySQL lane armed: **1953 passed, 0 skipped** - `ErsatzTV.Architecture.Tests`: 7/7 — the new `TvContext` provider static is wired in **both** composition roots as `ProviderStaticsWiringTests` requires - `scripts/tests`: 338 passed · `decisions_validate`: OK · format gate: clean · no BOMs - **Red-first**, and guards **mutation-tested**: removing the `%`/`_` escaping fails `Unicode_Fold_Escaping_Prevents_Limit_Crowding`; removing the ordinal filter fails `Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter` while the eight agreement cases stay green (which is how one false docstring was found) - New `SearchFieldValuesProviderTests` executed against a real `mysql:8.4`: 4 passed / 0 skipped; without the connection string 2 visible skips; with `ETV_REQUIRE_MYSQL_TESTS=1` and no connection it **fails** rather than skipping ## Review Five review rounds, each re-reviewing the previous fix commit. No under-match, SQL, injection or lifecycle defect was ever found — **the code has been correct since round 1.** Every finding was a false or overstated *claim* in prose: 1. `ſ` justification for the fold direction was backwards 2. two guards that could not fail if what they named was deleted 3. a stale parenthetical asserting a mutation the test does not catch 4. a copied "CI sets `ETV_REQUIRE_MYSQL_TESTS=1`" — nothing sets it 5. the MySQL over-match story above, including in this body **Stated deviation** (`process.independent-review-rubric`): reviews are same-model-family, not cross-family — Codex quota was exhausted until Aug 1. Mitigated with cold, review-only briefs in isolated worktrees. ## Docs New record `api.search-field-values-unicode-fold`, which **narrows** `api.search-field-values-sources` rather than superseding it; the predecessor's two now-false claims about #668 being open were corrected in place, hence `Decisions-Edit: yes`. Catalog regenerated. No API contract change, so no OpenAPI regeneration. ## Follow-ups filed - **#688** — the `decisions_validate` ceiling calibration is one line from red. Two things key off 60 and only one can red CI: the per-record ceiling **warning** is non-blocking by design (17 records already exceed it), while the `p90 <= ceiling <= p95` calibration test in the blocking `script-tests` job reds at **≥61** prose lines — `main`'s p90 is 59, so a 60-line record makes `p90 == ceiling` and passes. This record was trimmed to exactly 60 rather than re-derive a corpus-wide constant here. (An earlier version of this bullet, and of #688 itself, got both facts wrong; corrected.) - The same false "CI sets `ETV_REQUIRE_MYSQL_TESTS=1`" sentence exists on `main` in `LibraryFolderDedupeMigrationTests` (the source I copied from). Out of scope here; filed separately.
timothy added 3 commits 2026-07-27 20:51:29 +02:00
Stored 'Édith' is unreachable from q=é and q=É on SQLite, because SQL
LOWER() folds ASCII only. The stored-LOWERCASE pair is pinned alongside
it and passes today, so the fix must supplement that path, not replace it.

Red: both uppercase cases return []. Refs #668
SQLite's LOWER() folds ASCII only -- lower('Édith') is 'Édith' unchanged --
so the EF-sourced facet fields UNDER-matched any stored value whose prefix
carries an uppercase non-ASCII character. An under-match is unrecoverable:
no later stage can reintroduce a row SQL never returned.

Adds a SECOND, ADDITIVE query taken only when the provider is SQLite and q
contains a non-ASCII character: raw Dapper SQL folding through etv_upper(),
a SqliteConnection.CreateFunction scalar implementing ToUpperInvariant.
Every other case -- all-ASCII q, and MySQL for all q -- runs the existing
EF query byte-identically.

MySQL needed no change and gets none: verified on MySQL 8.4 that its LOWER()
is Unicode-aware and its ci collation makes the predicate OVER-match, which
the existing ordinal filter already discards.

The fold is ToUpperInvariant because OrdinalIgnoreCase equality is a strict
SUBSET of invariant-uppercase equality, so the SQL stage yields a superset of
the final filter's matches and can never under-match. Note OrdinalIgnoreCase
is NOT "invariant-upper then ordinal": ToUpperInvariant('ſ') is 'S', yet
"ſweet".StartsWith("S", OrdinalIgnoreCase) is false. Tests pin that.

No migration, no model change; both provider snapshots are untouched.

Refs #668
Decisions-Edit: yes
fix(668): review round 1 -- make two guards actually guard
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for ac67c9e
PR Gates / decisions lifecycle (pull_request) Failing after 30s
PR Gates / Script tests (pytest) (pull_request) Successful in 43s
Review verdict / Set review-verdict status (pull_request) Successful in 43s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m57s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ac67c9ee74
Independent review found both new guard tests weaker than they read.

1. A false docstring. It claimed the SQL stage "genuinely returns 'ſweet'
   for q='S'". It does not: 'S' is ASCII, so ContainsNonAscii is false and
   the fold branch is SKIPPED. Those three negative cases exercise the
   ASCII fast path, which is worth pinning but is not what the comment
   said -- and the consequence was that NO test drove a row through the
   fold for the ordinal filter to discard, i.e. the harmless over-match
   direction the whole design rests on was untested. Comment corrected and
   Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter added
   (stored "Sword", q="ſ" -> fold runs, SQL pattern S%, SQLite returns the
   row, filter drops it, response empty).

2. Unicode_Fold_Escapes_Like_Wildcards could not fail if the %/_ escaping
   it names were deleted -- the in-memory filter masks the over-match, so
   the counts stay right. The escaping's real role is preventing LIMIT
   crowding, so Unicode_Fold_Escaping_Prevents_Limit_Crowding pins that
   instead. Verified by mutation: with the %/_ replaces removed the new
   test fails while the original two still pass.

Also: the crowding residual in the decision record was attributed to MySQL
alone; the SQLite fold shares it in principle, so "no accepted loss" is
narrowed to mean no unreachable VALUE rather than a guaranteed count. And
a comment says why the provider check is derived per-context instead of
reading TvContext.IsSqlite (that static is scoped host-only by
ProviderStaticsWiringTests, and reading it here would falsify the
exemption).

Refs #668
Decisions-Edit: yes
timothy added 2 commits 2026-07-27 21:02:36 +02:00
The two preceding commits both END with:

    Refs #668
    Decisions-Edit: yes

`Refs #668` has no colon, so git does not recognise it as a trailer -- and a
single non-trailer line in the final paragraph voids the WHOLE block, taking
the valid `Decisions-Edit: yes` with it. Confirmed with interpret-trailers:
the pair parses to nothing, while `Decisions-Edit: yes` alone (or `Refs: #668`
with a colon) parses fine. `%(trailers:key=Decisions-Edit,valueonly,unfold)`
-- exactly what scripts/decisions_validate.py reads -- returned empty, so the
`decisions lifecycle` job failed for a real reason, not the known flake.

The branch is already pushed, so amending is out (process.pr-routine-sequence).
The guard accepts the trailer on ANY non-merge commit in the merge-base range,
so this empty commit carries a well-formed one. It is deliberately empty rather
than bundled onto an invented change.

Decisions-Edit: yes
fix(668): review round 2 -- remove a second false comment, harden a vacuous assertion
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 46s
Review verdict / Set review-verdict status (pull_request) Successful in 1m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 7ca058f (base: main)
7ca058f83b
Re-review of the round-1 fix commit returned MERGEABLE with three findings,
all about claims rather than behaviour. All three applied.

1. A stale FALSE parenthetical survived round 1. The docstring on
   Unicode_Fold_Agrees_With_The_Ordinal_Filter still claimed it catches "one
   that stops filtering the extras out". It does not. Mutation-verified:
   delete the Where in FilterSortTake and all EIGHT cases stay green, because
   each is either a positive SQL alone returns or an ASCII-query negative SQL
   alone rejects. The same mutation turns the new over-match test RED, so the
   pair does cover both directions -- but only the corrected wording says so.
   This is the same species of error round 1 fixed, one paragraph above it;
   swept by subject this time.

2. Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter asserts an EMPTY
   result, so it passes vacuously if the fold never runs. Its premises are now
   asserted explicitly (the query is non-ASCII, and ToUpperInvariant maps ſ to
   S), so a fold that quietly stopped mapping ſ would fail rather than go green
   for the wrong reason.

3. The comment on IsSqlite overstated its enforcement. ProviderStaticsWiringTests
   parses the composition roots for ASSIGNMENTS only; nothing mechanically stops
   a read of TvContext.IsSqlite here. The real reason stands -- such a read would
   falsify that test's prose exemption while the test stayed green -- so the
   comment now says that instead of implying a guard that does not exist.

Decisions-Edit: yes
Author
Owner

Review-verdict: MERGEABLE @ 7ca058f

Two independent cold review rounds plus a re-review of each fix commit. No under-match, SQL, injection or lifecycle defect found. Round 1 fixed two weak guards; round 2 removed a second false comment and hardened a vacuous assertion, both mutation-verified. Full suite 1947 passed, arch 7/7, CI green on this head with Build and test running 21m27s (not the docs-only fast path). Cross-family review was unavailable (Codex quota exhausted until Aug 1); reviews were same-family, cold-briefed, in isolated worktrees - stated deviation from process.independent-review-rubric.

Review-verdict: MERGEABLE @ 7ca058f Two independent cold review rounds plus a re-review of each fix commit. No under-match, SQL, injection or lifecycle defect found. Round 1 fixed two weak guards; round 2 removed a second false comment and hardened a vacuous assertion, both mutation-verified. Full suite 1947 passed, arch 7/7, CI green on this head with Build and test running 21m27s (not the docs-only fast path). Cross-family review was unavailable (Codex quota exhausted until Aug 1); reviews were same-family, cold-briefed, in isolated worktrees - stated deviation from process.independent-review-rubric.
timothy added 1 commit 2026-07-27 21:31:39 +02:00
test(668): execute the accented-value claim on a REAL MySQL server
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
review-verdict/h10 Awaiting review verdict for 1f6802b
PR Gates / decisions lifecycle (pull_request) Successful in 27s
PR Gates / Script tests (pytest) (pull_request) Successful in 45s
Review verdict / Set review-verdict status (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m43s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
1f6802bb62
The PR's central claim is "reachable on BOTH providers", and on MySQL that
rests on the server's collation rather than on any code this repo owns --
exactly the kind of assumption worth executing rather than asserting.

Adds SearchFieldValuesProviderTests, parameterized over Sqlite and MySql on
the LibraryFolderDedupeMigrationTests contract: opt-in via
ETV_TEST_MYSQL_CONNECTION, a VISIBLE skip without it, and a hard failure
instead of a skip when ETV_REQUIRE_MYSQL_TESTS is set, so the lane cannot
degrade into "connected to nothing and passed". Fresh never-reused database
per test, dropped with its pool cleared.

Verified for real, not just written: run against a live mysql:8.4 it reports
4 passed / 0 skipped and the stored 'Édith' is returned for both q=é and q=É.
Without the connection string it skips (2 skipped); with REQUIRE set and no
connection it FAILS. All three paths exercised.

The MySql half wires RegisterUnicodeCaseFunctions to an explicit no-op, so the
test proves MySQL reaches the value through its own Unicode-aware LOWER() and
not through SQLite's custom fold.

Known and deliberate: CI does NOT arm this lane, so it will skip there. Per the
note in docker-build.yml, running MySQL fixtures against the live service was
implemented and removed as non-deterministic (ersatztv#627) on the grounds that
an intermittently-red gate is worse than none. Re-arming it is that issue's job,
not this PR's -- so this fixture is opt-in exactly like its sibling.
timothy added 1 commit 2026-07-27 21:52:20 +02:00
fix(668): review round 3 -- MySQL does NOT over-match; correct the claim everywhere
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for dda98ef
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Review verdict / Set review-verdict status (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 55s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
dda98efcc4
Review BLOCKED on a false CI claim I copied from the sibling fixture ("CI sets
ETV_REQUIRE_MYSQL_TESTS=1"). Nothing sets it; the doc now says plainly that CI
does not arm this lane and points at ersatztv#627. That was the blocker.

Chasing the reviewer's second finding then overturned something bigger. It
predicted that seeding an unaccented "Edith" would make the in-memory ordinal
filter load-bearing on MySQL, since utf8mb4_0900_ai_ci treats é as e. Mutation
test says otherwise: with the filter deleted the MySQL test stays GREEN.

Measured against a live 8.4 to find out why:

    LOWER(Name) LIKE 'é%'          (literal)          -> Édith AND Edith
    LOWER(Name) LIKE @v            (ai_ci variable)   -> Édith AND Edith
    LOWER(Name) LIKE @v COLLATE _bin                  -> Édith only
    the EF query, executed                            -> Édith only

The driver binds the pattern with a BINARY collation, so the executed
comparison is accent-SENSITIVE and MySQL does not over-match at all. MySQL's
correctness rests on its Unicode-aware LOWER(), not on the collation.

My earlier probe used a LITERAL pattern -- a different query from the one the
code runs -- and I wrote its result into the handler comment, the decision
record and the PR body. All three now say what actually happens, and the record
carries the lesson: measure the query the CODE runs, not one you type.

The "Edith" row stays as a near-miss control, with a docstring that says what it
does and does not prove rather than the over-match story it was added for.

Also moved EnsureCreatedAsync out of [SetUp]: NUnit skips [TearDown] when
[SetUp] throws, so a mid-create failure would strand the database.

Decisions-Edit: yes
timothy added 1 commit 2026-07-27 22:09:29 +02:00
fix(668): review round 4 -- sweep the retracted claim by SUBJECT, not by memory
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Script tests (pytest) (pull_request) Successful in 44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m55s
Review verdict / Set review-verdict status (pull_request) Failing after 10m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m54s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
07723e418b
Round 3 said "correct the claim everywhere" and missed two places, which is
the same mistake in a new coat: I fixed the spots I remembered instead of
grepping for the subject.

- The class-level summary of the very file round 3 edited still said MySQL's
  "ci collation OVER-matches instead", contradicting the method docstring
  forty lines below it. Reworded.
- The PR body still carried the retracted over-match story -- and round 3's
  commit message claimed it had been corrected. It had not. Now corrected,
  with the measurement table and the retraction stated openly.

This time the sweep was `grep -i over-match` across every file the branch
touches; the remaining hits are the SQLite-fold invariant and #578 history,
which are correct and stay.

Also softened two overclaims the reviewer flagged. Round 3 deleted the
predecessor's "configuration-incidental, not designed" hedge and replaced it
with a firmer statement than the evidence supports: that MySQL cannot
over-match is contingent on MySqlConnector fixing the connection collation to
utf8mb4_bin, not a property of MySQL. A driver, protocol or prepared-statement
change could restore it. The record and the fixture docstring now say
"driver-contingent, not a law"; the code is safe either way because the ordinal
filter stays regardless.

Filed #689 for the source of the copied falsehood -- LibraryFolderDedupeMigrationTests
on main carries the same "CI sets ETV_REQUIRE_MYSQL_TESTS=1" sentence.

Decisions-Edit: yes
timothy added 1 commit 2026-07-27 22:21:58 +02:00
fix(668): review round 5 -- three prose nits, including an off-by-one I filed
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Review verdict / Set review-verdict status (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / Script tests (pytest) (pull_request) Successful in 53s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ f2d9c0d (base: main)
f2d9c0dc8e
Final sweep confirmed the retracted MySQL over-match claim survives in no file
on the branch (only in two immutable commit messages, which stay -- rewriting
history would invalidate every sha-bound review verdict). Three nits remained.

- The fixture's class docstring said the on-MySQL claim "rests on the server's
  collation", which is the one thing the decision record says it does NOT rest
  on. It rests on Unicode-aware LOWER(); the executed comparison bypasses the
  collation entirely. Reworded.
- The record's `rule:` enumerated the covered fields but omitted show_genre,
  which GetSource and the fold both handle ("genre" or "show_genre"). Added.
- My own #688 write-up was wrong twice: the 60-line ceiling warning is
  NON-blocking by design, and the calibration pytest reds at >=61, not >=60 --
  main's p90 is 59, so a 60-line record makes p90 == ceiling and PASSES. The
  bullet even contradicted itself, since the next sentence relies on 60 passing.
  Corrected in the PR body and in the issue.

Decisions-Edit: yes
Author
Owner

Review-verdict: MERGEABLE @ f2d9c0d

Five independent cold review rounds, each re-reviewing the previous fix commit; final sweep returned MERGEABLE. No under-match, SQL, injection or lifecycle defect was found in any round - every finding was a false or overstated claim in prose, and each is now corrected. Verified: full suite 1953 passed 0 skipped with the live MySQL lane armed; CI green on this head with Build and test running 22m and Functional E2E 17m (not fast paths); guards mutation-tested, not merely asserted. Cross-family review unavailable (Codex quota exhausted until Aug 1) - reviews were same-family, cold-briefed, in isolated worktrees; stated deviation from process.independent-review-rubric.

Review-verdict: MERGEABLE @ f2d9c0d Five independent cold review rounds, each re-reviewing the previous fix commit; final sweep returned MERGEABLE. No under-match, SQL, injection or lifecycle defect was found in any round - every finding was a false or overstated claim in prose, and each is now corrected. Verified: full suite 1953 passed 0 skipped with the live MySQL lane armed; CI green on this head with Build and test running 22m and Functional E2E 17m (not fast paths); guards mutation-tested, not merely asserted. Cross-family review unavailable (Codex quota exhausted until Aug 1) - reviews were same-family, cold-briefed, in isolated worktrees; stated deviation from process.independent-review-rubric.
timothy merged commit d4ea1584c0 into main 2026-07-27 23:08:29 +02:00
timothy deleted branch fix/668-accented-facet-values 2026-07-27 23:08:30 +02:00
Sign in to join this conversation.