One shared verdict vocabulary instead of two shell copies plus a parity test #788
Closed
opened 2026-08-13 22:21:30 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#788
Reference in New Issue
Block a user
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.
Raised by the Codex cold review of #774. The H10 verdict vocabulary lives in two hand-written shell copies —
post-review-verdict.sh'scasearms (write) andcheck-review-verdict.sh'sPOS_RE/NEG_RE(read). #774 shippedscripts/tests/test_review_verdict_vocabulary_parity.py, which extracts both with regexes and asserts set equality.That is detector A applied to a duplication that detector C says should not exist. The reviewer's objection is correct on the merits: regex-scraping two executable shell representations cannot prove semantic equality, only textual agreement in the shapes the extractors happen to model. #774 already had to patch one hole (a
SHIP*)arm the strict pattern could not read, leaving a non-empty set identical to the read side) and add a single-assignment check (the extractor unionsPOS_RE=assignments while the classifier runs the last one). Each patch is a round in the string-predicate treadmill this repo has budgeted 3+ rounds for before.The end state is one declarative source both scripts consume — a small file sourced by both, or a generated fragment.
Why it was not done in #774: introducing a shared-sourcing convention between two scripts on the merge-consent path is a larger change to a security-relevant surface than the drift it prevents, and it deserves its own review rather than an addendum at the end of a long session.
The parity test is the interim measure and should be DELETED by this issue, not kept alongside.Superseded: #774 deleted it after six review rounds (see the comment). There is nothing left for this issue to remove — it now has to BUILD the shared vocabulary, with no interim guard standing in the meantime.Done-when
— done in Guard convention: derive the expected set from the authoritative source and assert equality — never filter, never sample (#774)test_review_verdict_vocabulary_parity.pydeleted, its inventory row removedUpdate (2026-08-13, from #774): the interim mitigation named in this issue has been withdrawn, so the duplication is currently unmitigated.
scripts/tests/test_review_verdict_vocabulary_parity.pyextracted both vocabularies from shell source with regexes and compared them. Six cold-review rounds each found another shell construction that either escaped the extractor or made it red on a correct tree:SHIP*)— glob in the arm label, invisible to `[A-ZPOS_RE=reassignment; alternative outside the captured group#FOO) state=bar— compact comment false-REDSHIP-IT) state=success— unquoted value, missed by BOTH strict and loose<word>) state=in a heredoc / unrelated case — false-REDesacin a string truncates the scoped match, silently dropping a real armEach fix was locally correct and the sequence converged on nothing, because a regex over shell source is not a shell parser. It was deleted rather than patched a seventh time — see
docs/decisions/records/testing/guard-derives-population-from-source.md, which now carries this as the worked example of "a weak detector is itself the symptom-keyed mistake".This raises the priority of the real fix. Only a comment in
post-review-verdict.shnow holds the two lists together, and that comment had already gone stale once (it named.claude/hooks/pretooluse-merge-consent.shas the home of the regex; the hook carries no copy and delegates tocheck-review-verdict.sh).Do not reintroduce a text-comparison test as a stopgap. The lesson of those six rounds is that this specific shape does not converge; dedup by construction is the only thing that closes it.
Claiming this (Claude Code session, 2026-08-26).
Pre-claim checks per
process.parallel-session-claim, all four run against fresh state:git ls-remote --heads origin).git fetch origin main@35affecd2.Three other sessions are active on #746, #734 and #732 — all disjoint from this scope (
docker-build.yml, SPA screens).Scope I am NOT folding in: siblings #742 and #763 both touch
.gitea/workflows/review-verdict.yml. This issue is aboutscripts/post-review-verdict.sh+scripts/check-review-verdict.shsharing one declarative vocabulary; its own body argues this surface deserves its own review rather than a bundled addendum, so they stay separate.Holding the line from the #774 comment: no text-comparison stopgap. Dedup by construction, plus a mutation proof that a word added to the shared source reaches both sides.
Closing record
Outcome: Shipped — PR #846, squash-merged as
469d19852.scripts/lib/review-verdict-vocabulary.shdeclares the H10 verdict words once as two bash arrays;post-review-verdict.shclassifies throughetv_verdict_classandcheck-review-verdict.shbuildsPOS_RE/NEG_REfrometv_verdict_alternation. Neither script enumerates a verdict word any more — including the usage banner and the unknown-word error, which were themselves further copies. Only the WORD SET is shared; the grammar (column-0 anchor, whole-word boundary, anchored@ <sha>field) stays in the classifier, where every #629 false-open actually lived.Root cause: The vocabulary was duplicated because the read and write sides were written at different times against different needs — a
casestatement wants words, a classifier wants a regex — and the shapes are different enough that neither author saw a shared representation. What kept it alive was that the duplication is invisible until the two disagree, and they had not yet.Decisions/conventions changed: Added
release.verdict-vocabulary-shared. Correctedrelease.review-verdict-gate, whoserule:(and hence the catalog) enumerated the vocabulary withoutLGTM— a word the code has accepted since #629. Same omission fixed inCLAUDE.mdand one merge-consent-hook comment.Reusable knowledge — three things worth carrying:
set -uabort inside a function skips BOTH branches ofif ! f. This shipped a real fail-open in the first commit and cold review caught it.${#arr[@]}is nounset-safe only for a declared-empty array — on an unset name or a scalar it is fatal — and becausecheck-review-verdict.shdeliberately runs withoutset -e, the abort skipped validation and let unvalidated words reach the regex: an explicitBLOCKED @ <head>classifiedpositive, exit 0, with the only signal on a stderr the hook discards. The general form: a control-flow gate can be skipped by an abort; a data dependency cannot. Validation now sets a sentinel on its last line and the derived views refuse without it.declare -parray assertion removed the abort, so validation began returning 1 cleanly and the ordinaryiffired — meaning every refusal test passed with the sentinel deleted. 150 tests stayed green with the headline mechanism removed. The fix was to disarm the other guard first, so the sentinel is the only thing left standing. Of the three sentinel copies only the read side's has a behavioural alone-deletion red; that is now stated rather than implied.Verification: 989 passed / 2 skipped (
pytest scripts/tests); ruff,ruff format --check, shellcheck clean; catalog regenerated;decisions-validate: OK; full CI green one66196f. Behavioural equivalence: a 4788-case differential fuzz of the new classifier againstorigin/main's found 0 divergences, and independent reviewers ran further 2612- and 7560-payload fuzzes, also 0 — including every #629 false-open case. Fail-closed matrix: missing, unreadable, truncated, syntax error, top-levelexit, unset array, scalar list, duplicate word, empty list, regex metacharacter and glob all refuse on both sides — reader exit 2, writer exit 1 with nothing posted, checked against a POST-logging curl stub. The declared clause mutation is executed by the repo's harness every run.Review: six cold adversarial rounds, each from a fresh review-only brief in an isolated worktree; the author reviewed none of their own work. Round 1 found the fail-open above. Rounds 2–6 found no fail-open; what they kept finding was inaccurate prose — every commit carried at least one false statement, several introduced while correcting false statements. A Codex cross-family pass was attempted and is not counted: it exhausted its account quota before reaching a verdict, though its partial run did independently confirm behavioural equivalence and the absence of glob/split defects.
Deferred: Prose copies of the vocabulary remain in the merge-consent hook and a few docs. They are inert — nothing outside the library classifies a word — and the record carries the sweep that re-derives them rather than a number. The library is located relative to
${BASH_SOURCE[0]}without resolving symlinks, so a script reached through a symlink looks beside the link; that fails closed and is recorded as a known limitation. Siblings #742 and #763 (bothreview-verdict.yml) were deliberately not bundled — this issue's own body argues the merge-consent surface deserves its own review.Also observed, not fixed:
test_hook_fire_log.py::test_the_suite_does_not_write_to_the_PRODUCTION_logfailed once and did not reproduce on a re-run of the identical tree; an independent reviewer saw the same failure and the same non-reproduction. It compares mtimes of the shared~/.cache/ersatztv/hook-fire/directory across a window, so any concurrent writer fails it — a concurrency manifestation distinct from the stale-env one #809 describes. Noted on #809 rather than fixed here.Docs updated:
docs/ci-cd.md(Review-verdict gate), newdocs/decisions/records/release/verdict-vocabulary-shared.md+ regenerated catalog,docs/guard-inventory.md(new row + summary counts),docs/remote-state-inventory.md, plus theLGTMcorrections inCLAUDE.mdandrelease.review-verdict-gate.