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 qbefore 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:
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'é%'
ÉdithandEdith — over-match
ai_ci user variable
ÉdithandEdith
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:
ſ justification for the fold direction was backwards
two guards that could not fail if what they named was deleted
a stale parenthetical asserting a mutation the test does not catch
a copied "CI sets ETV_REQUIRE_MYSQL_TESTS=1" — nothing sets it
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 narrowsapi.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.
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
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
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
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
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.
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.
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
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
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
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 main2026-07-27 23:08:29 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
qbefore SQL, so a stored lowercaseédithwas always reachable from bothq=éandq=É. The issue body saysq=Éfails againstédith; that is false, and a test pins the passing case beside the fixed one. The## Done-whenbox was stated correctly regardless, so scope is unchanged.The fix
A second, additive query taken only when
isSqlite && q contains a non-ASCII character:etv_upperis aSqliteConnection.CreateFunctionscalar implementingToUpperInvariant. Every other case — all-ASCIIq, and MySQL for allq— 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/Actorcarry one row per media item, so a large library would leaveÉdithunreachable — the bug restated. The cost objection to a managed per-row fold is weak becauseLOWER(v) LIKEis non-sargable and no index on any of theseNamecolumns 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.
ToUpperInvariantis safe becauseOrdinalIgnoreCaseequality is a strict subset of invariant-uppercase equality. Note it is not true thatOrdinalIgnoreCaseis "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_cicollation 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:q=é'é%'ÉdithandEdith— over-matchÉdithandEdithÉdithonlyÉdithonlyMySqlConnector 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 itsLOWER()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
ErsatzTV.Testswith the live MySQL lane armed: 1953 passed, 0 skippedErsatzTV.Architecture.Tests: 7/7 — the newTvContextprovider static is wired in both composition roots asProviderStaticsWiringTestsrequiresscripts/tests: 338 passed ·decisions_validate: OK · format gate: clean · no BOMs%/_escaping failsUnicode_Fold_Escaping_Prevents_Limit_Crowding; removing the ordinal filter failsUnicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filterwhile the eight agreement cases stay green (which is how one false docstring was found)SearchFieldValuesProviderTestsexecuted against a realmysql:8.4: 4 passed / 0 skipped; without the connection string 2 visible skips; withETV_REQUIRE_MYSQL_TESTS=1and no connection it fails rather than skippingReview
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:
ſjustification for the fold direction was backwardsETV_REQUIRE_MYSQL_TESTS=1" — nothing sets itStated 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 narrowsapi.search-field-values-sourcesrather than superseding it; the predecessor's two now-false claims about #668 being open were corrected in place, henceDecisions-Edit: yes. Catalog regenerated. No API contract change, so no OpenAPI regeneration.Follow-ups filed
decisions_validateceiling 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 thep90 <= ceiling <= p95calibration test in the blockingscript-testsjob reds at ≥61 prose lines —main's p90 is 59, so a 60-line record makesp90 == ceilingand 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.)ETV_REQUIRE_MYSQL_TESTS=1" sentence exists onmaininLibraryFolderDedupeMigrationTests(the source I copied from). Out of scope here; filed separately.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: yesThe 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: yesReview-verdict: MERGEABLE @
7ca058fTwo 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 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: yesFinal 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: yesReview-verdict: MERGEABLE @
f2d9c0dFive 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.