review-verdict.yml post-write verification: three routes that leave an exemption success over a human failure #849
Closed
opened 2026-08-27 00:25:17 +02:00 by timothy
·
5 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
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.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#849
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 a cold cross-family (Codex/GPT-5.6) review of the #742 branch. All three are PRE-EXISTING on
main, not regressions from #742 — verified by diffing the branch againstorigin/main: the three guards involved (if [ "$state" = "success" ] && [ "$max_id_before" -ge 0 ], the two "mark could not be established" warnings, and the unreadable-post-history warning) are byte-identical tomainand #742 touches none of them. They are filed rather than fixed there because #742 is a provenance change and these are the post-write verification's own residuals.Each ends with an exemption
successstanding over a real humanfailure— the outcome the workflow's own comments call "the worst outcome this gate can produce".1. An unreadable history read leaves an exemption green over a raced rejection
review-verdict.yml— the post-write race check is gated on[ "$max_id_before" -ge 0 ].hist_beforeis unreadable or its max id is non-numeric,max_id_before=-1, and the job emits a::warning::and skips the check entirely. A reviewer'sfailurelanding after the last-moment re-read is then overwritten by the exemptionsuccessand never repaired.review-verdict/h10=successstands" and does exactly that.Both are the fail-toward-SUCCESS direction, on the one path the design says must fail toward
pending. The existing comment argues a read failure "does NOT fail the job" because the status is already posted — true, but the conclusion should be a stickypendingrepair, not leaving the green.Suggested: withhold the exemption
successwhen no high-water mark could be established, and treat an unreadable post-write history exactly like the uncertain-pagination case already does — POST the stickyREPAIR_DESCsentinel, retry, and scream if the repair fails.2. A
pendingwrite can bury a rejection, which then goes green on a later runThe post-write race check runs only
if [ "$state" = "success" ].Trace: a docs-only run hits a transient enumeration failure, so it selects the generic
pending(not the sentinel). A reviewer postsfailurebetween the last-moment re-read and the POST. The genericpendingmasks it, and no post-write verification runs because the state is notsuccess. On a later event enumeration succeeds, the ordinary machine-writtenpendingis re-derived, and the exemptionsuccessis posted — with the buriedfailurenow below the new high-water mark, so it is never repaired.This is the same shape as the #706 round-3 sentinel finding (a stale run burying a rejection), reached through the
pendingpath instead.Suggested: run post-write race detection after every status write, not only
success. When apendingwrite raced an attributable verdict, rewrite it withREPAIR_DESCso it becomes the sticky fixed point the classifier already refuses to grant an exemption over.3. The retarget fence is pre-POST only, so a stale exemption can become permanent
The fence compares the timeline's retarget count taken before classification against one taken before the write. Nothing re-checks after the POST.
Trace:
maincarries a realfailurefor head H. Retarget to a scratch base where H is docs-only; run S derivessuccessand passes its final fence check. Retarget back tomainwhile S is paused before its POST. Successor run M sees the base-matchingfailureand short-circuits, posting nothing. S resumes and posts its stalesuccess. The rejection predates S's high-water mark, so post-write verification ignores it — and no event remains to reclassify. The forged green is permanent.ci.verdict-write-retarget-fencedescribes the surviving window as the sub-round-trip one that no API without compare-and-set can close. This scenario says the window is wider than that: the fence narrows writing while overtaken, but not being overtaken after writing.Suggested: re-count retargets after the POST. If the count moved, or cannot be trusted, immediately replace the status with the sticky
pendingsentinel — a retarget after that post-check then necessarily queues a successor that starts after the stalesuccessexists and can re-derive it.Testing note
The reviewer also observed that
test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGHasserts only a one-run payload from an initially statusless head, and that every post-write race test derivessuccess— which is why thestate = successgate in (2) survives the suite. A chained test is the right shape (this repo has the pattern recorded asfixed-point-properties-need-chained-tests): run N with an enumeration failure plus a raced human verdict, assert the sentinel repair; feed that resulting status into run N+1 with successful docs enumeration, and assert it stays the sentinel.Done-when
Consolidated 2026-08-29 — the merge-consent hook reads THIS list, not the revisions that were
left in comments, so the two later lists (the 01:05 revision and the 02:57 addition) are folded in
here and the original eight are superseded. The comments stay as the record of how the scope moved.
state = successsuccessjqresolves toraced=1rather than killing the job or "not acting"successstandingci.verdict-write-retarget-fence's residual 1 was a PERMANENT green, not a sub-round-trip window)Scope narrowed — two of the three are being fixed in #742 after all
The original framing here ("all three are pre-existing, #742 touches none of them") was only textually right, and a follow-up cross-family review said so. The guards are byte-identical to
main, but #742 changes their reachability: before it, an attributablesuccessfrom any account short-circuited and the job wrote nothing, so the unverified-write paths were unreachable for that entire class of head. Re-deriving an off-listsuccessis the point of #742 — and it turns "no write" into "a write", which is what reaches them.So two of the three are fixed on that branch rather than deferred here:
casewas hoisted out of the readable branch so both paths converge on it.Both are mutation-proven (restoring either old behaviour reddens its test), and both cost the run its exemption on a transient API failure — the price this file already pays for the same reason in #751's page-2 rule.
Implementation note worth keeping: the withholding guard has to sit after the high-water mark is established, not with the classification.
$max_id_beforedoes not exist there, and referencing it early is an unbound variable underset -u— the step dies and every PR is stranded with no status. Found by running it, not by reading it.What REMAINS in this issue
if [ "$state" = "success" ], so a genericpendingwrite can bury a raced rejection that a later run then re-derives to green. Still open, and reachable from #742's class too, not only from a statusless head.The
## Done-whenboxes for (1) and (2) can be ticked when #742 merges; the rest stand.Scope CORRECTED again — the two "fixed in #742" boxes are un-ticked, and the issue grows two routes
I previously said routes (1) and (2) were fixed on the #742 branch. They have been withdrawn. A further cross-family round showed the fix was worse than the gap on both counts, on grounds that generalise:
pending— and a genericpendingis precisely what a later run re-derives intosuccess. It did not close the burial route, it moved which run greens it. Closing it properly needs a distinct sticky "unverified write" sentinel, i.e. a new state in a machine that already has three (Exempt:, the repair sentinel, generic awaiting).pendinghas NO RETRY PATH.review-verdict.ymltriggers only onpull_request_targettypes — verified: noschedule, noworkflow_dispatch. So a transient API failure on a PR's last event leaves an exempt PR stalled until a human nudges it. Trading a race needing BOTH a read failure AND a reviewer posting inside the write window, for a stall needing only the read failure, is not obviously the safe direction — and I had described it as a "one-run cost", which it is not.Two lines inside a provenance change was the wrong size for this. It belongs here, designed once.
Two further routes found in the same round
(4) A malformed status row kills the verifier instead of repairing. The post-write
raced=$(... jq ...)assignment is unguarded. A row with"creator": "timothy"(a string, not an object) makes.creator.loginerror; a numeric.descriptionmakesstartswitherror. Underset -euo pipefailthe job exits immediately, skipping the repair entirely and leaving the exemption green. The non-numeric branch below it likewise logs "not acting on it" rather than repairing. Every parse/schema failure in that expression should resolve toraced=1.(5) An unreadable combined-status read leaves an off-list green standing. If an off-list credential has already posted
review-verdict/h10=successand the initial or last-moment combined read fails,read_existing_verdictexits without replacing it. The job goes red — but the workflow's own job result is not a required check, so branch protection still sees the forged green. This is the exact status #742 exists to revoke, surviving a read failure. Fix shape: a separate classifier-health required context, or durably replacing unknown state with a sentinel.Why #742 still ships
Its own change is sound and unbroken after seven rounds: an existing
successis inherited only from an allow-listed creator, an existingfailureon a weaker attributability test. What #742 does is make these residuals reachable from a new class — before it, an attributablesuccessfrom any account short-circuited and the job wrote nothing. That reachability is recorded inci.exemption-provenancerather than left implicit, andci.verdict-write-retarget-fencehas had its false "transient forged green" claim corrected in the same PR (the successor run can finish first, consuming theeditedevent, after which the stale run'ssuccesslands last and nothing remains to correct it).Done-when (revised)
state = successsuccessjqresolves toraced=1rather than killing the job or "not acting"successstandingci.verdict-write-retarget-fence's residual is now stated as a PERMANENT green, corrected in #742)Scope boundary for the DOCUMENTATION half — recorded so it stops re-opening
Four review rounds on #742 kept finding paraphrases of two claims that the residuals in this issue disprove:
pendingwrite is harmless" — a genericpendingmasks a rejection landing in its own write window, gets no post-write verification, and a later run re-derives it green.Each round corrected the sites it could see and the next round found more, because the claims exist in paraphrase across a corpus written before #742 — including a record's
rule:frontmatter, an induction argument ("the last run writes the final answer"), a workflow comment heading ("THE PROBLEM THIS SOLVES"), and test docstrings.#742 has now corrected every site that is either its own text or directly about the allow-list, plus the fence/pending claims in
ci.verdict-write-retarget-fence,ci.exemption-provenance,ci.actions-credential-scoping,docs/ci-cd.md,CLAUDE.md, the classify comments and the affected test docstrings.docs/decisions/README.mdis regenerated from those.What is explicitly NOT in #742's scope, and belongs here: any remaining corpus prose describing the #706 fence's or the post-write verification's guarantees. Those sentences were true-as-written for the design they document; what makes them wrong is the residual analysis in this issue, so they should be corrected by whoever closes it — at which point the corrected behaviour and the corrected prose land together, instead of prose promising a property no code yet has.
One false positive worth recording so it is not "fixed":
process.check-and-use-pins-a-version's title contains "the gap is stated and fenced". That is the generic check/action version-binding rule, unrelated to the retarget fence. Leave it.Added to Done-when
ci.verdict-write-retarget-fence,ci.exemption-provenance,docs/ci-cd.md,CLAUDE.md, the classify comments, and any test whose NAME asserts a guarantee (e.g...._is_still_NEVER_overwritten, which is scoped in its docstring but not in its name)Claiming this (Claude Code / Opus 5 orchestrator session, 2026-08-29).
Pre-claim checks per
process.parallel-session-claim: no open PR references #849,git ls-remote --heads origin '*849*'is empty, the three existing comments are scope corrections (theselect-queue.shCLAIM?flag fired on the word "claims" in prose, not on a pickup claim), andorigin/mainis freshly fetched at736649b3b.Scope taken = the five routes as revised in the 2026-08-27T01:05 comment, plus the documentation half scoped by the 02:57 comment.
Sibling scanned and deliberately NOT bundled: #869 also edits
review-verdict.yml's comment block (the 1.25.4-dated claims at lines ~69/~633/~746). Different work — it is a re-probe/re-date exercise needing live Gitea version reads — so it stays separate. Flagging the textual adjacency so whoever takes #869 next expects a moved comment block rather than a conflict they did not cause.Closing record
Outcome: Shipped in PR #890 (nine commits, merged as
58681b3a7). All five routes closed. Every path that cannot establish what the head carries now REPLACES that unknown state with a sticky sentinel instead of leaving it standing; post-write verification runs after every write; the retarget count is re-taken after the POST; and a write no high-water mark can cover becomes the sentinel before the POST rather than a status a later run re-derives.Root cause: One mistake in three costumes — uncertainty resolving toward
success. The gate had a rule ("uncertainty on the write path resolves topending") and applied it unevenly: the post-write check was gated onstate = successbecause "apendingcannot turn a rejection green" (false — it carries no marker, so the NEXT run re-derives it); an unreadable read "declined to write" to protect a real verdict (which also protects a FORGED one); and the mark on a declined row was decided from the opening snapshot while the POST replaced the current one. The #742 attempt failed because a GENERICpendingis exactly what a later run re-derives — so the fix needs sticky (a later run cannot re-derive it) and reconcilable (a blip does not cost a head its exemption permanently), and neither the repair sentinel nor a genericpendinghas both.Decisions/conventions changed: New record
ci.verdict-unverified-write-sentinel. Corrected by CONCEPT, not phrase:ci.verdict-write-retarget-fence(itsrule:frontmatter, the "resolves it" opener, "the fence above closes", the truncating-block claim, residual 1),ci.exemption-provenance(the post-final-count window is transient now, not permanent),docs/ci-cd.md,docs/remote-state-inventory.md,docs/guard-inventory.md,CLAUDE.md.Reusable knowledge:
^def test_acrossHEAD~1before committing any test-file change.gitparses only the LAST paragraph as trailers. A blank line beforeCo-Authored-BydemotedDecisions-Edit: yeson all nine commits; CI caught it, local runs did not.POST /actions/runs/{run}/jobs/{job}/rerun→ 201./actions/jobs/{id}/rerunis 404 and a whole-run rerun 400s while any job is pending.Verification:
scripts/tests/green (1346); CI fully green ond4b36ac2(13 success, 1 skipped, 0 red) including both required contexts and the job that executes all 229 workflow-body tests. Every reachable clause carries atest_MUTATION_…bound by a count assertion, so a reworded clause fails loudly rather than measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points. Measured on this instance (Gitea 1.27.1):/commits/{sha}/statusrows carryid, which the row-identity comparison depends on.Six clauses ship deliberately unproven and are ENUMERATED by name in the record with per-clause unreachability arguments (path-predicate failure; empty-
rowrefusal; page 2's non-numeric length; the$witnessnormalisation; both unusable-count arms). The sweep independently confirmed that set is exactly right. That number went two → five → six across three rounds as the sweep widened; naming them beats a smaller number that reads better.Review: nine rounds. Cross-family (Codex/GPT-5.6) on rounds 1–2 and 8–9; cold same-family reviewers throughout; plus the sweep. Rounds 4, 8 and 9 each found a defect introduced by the PREVIOUS round's fix — including one regression against
origin/main(a malformed.creatorread as "unattributable", greening a humanfailurethatmainfail-closed on). Codex was quota-limited for rounds 3–7; the sweep stood in, and that is a substitute rather than an equivalent.Deferred: none for this issue. Adjacent and untouched:
ci.verdict-write-retarget-fence's head-axis residual (#803's text, re-bound here but not re-litigated); the registry cleanup rule that evicts pinned CI images (server-management#842).Docs updated:
docs/decisions/records/ci/verdict-unverified-write-sentinel.md(new),ci/verdict-write-retarget-fence.md,ci/exemption-provenance.md,docs/decisions/README.md(regenerated),docs/ci-cd.md,docs/guard-inventory.md,docs/remote-state-inventory.md,CLAUDE.md.Unrelated incident resolved mid-session
CI went red on a preflight, and it was not this diff: the pinned CI toolchain image
ersatztv-ci:32747a0had been evicted from the registry (HTTP 404), which kills everycontainer:job indocker-build.yml— both required contexts included — so no PR in this repo could merge. Verified independently (manifest 404; five other tags present; the pin is the last commit to touchdocker/ci, so missing, not stale). Recovered perdocs/ci-cd.md→ "When the pinned tag disappears": republished the same tag from the same commit, built on jazz (bumblebee was at load 14 running this PR's own CI). The repo's own preflight then reportedresolves (HTTP 200). This is the ersatztv#772 failure mode; the durable fix is registry-side (server-management#842).A second red was runner-side:
Set up Pythonfailed on a corrupt act action cache (lstat …/eslint.config.mjs: no such file or directory) before pytest ran at all. Cleared by a selective job rerun.