Head-ABA: a force-push H1->H2->H1 during paging defeats pr-changed-files.sh, and three contracts still deny it #803
Closed
opened 2026-08-16 14:11:25 +02:00 by timothy
·
3 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#803
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.
Split out of #778 (PR #802), where three cold-review rounds surfaced it but it sits outside that issue's scope.
The residual
scripts/pr-changed-files.shbinds a paged enumeration to.head.sha,.base.refand.base.sha, captured before paging and re-checked after, failing closed on any observed movement. That is #707's fix and it works for a MONOTONIC move. It does not survive an ABA:main -> scratch -> main. Fenced, but only for the one caller that also runs the monotonicchange_target_branchevent counter (ci.verdict-write-retarget-fence).H1 -> H2 -> H1during pagination. Unfenced anywhere. Page 1 is enumerated at H1, middle pages at H2, and the final.head.shacomparison passes because the head is H1 again. No counter moves. A mixed file list can then produce a docs-only exemptionsuccessthat no single head ever justified.#778 classified this honestly in
docs/remote-state-inventory.md(both affected rows now say the fence covers the base axis only). What it did not do is correct the older contracts that still assert more:scripts/pr-changed-files.shheader — still says the enumeration is "bound to one head".gitea/workflows/review-verdict.yml— still describes the result as verified-completedocs/decisions/records/release/verdict-status-check.md— absolute safety claim, now qualified byprocess.check-and-use-pins-a-version; needs the bounded-window wording and aDecisions-Edit: yestrailerWhy it was deferred rather than folded in
#778's scope was "enumerate every read-then-write site against remote state and classify it", plus fixes for what that enumeration found. The head-ABA predates #778 and lives in #707's mechanism; correcting three pre-existing contracts (one of them another active decision record) in a PR already at three review rounds is how a scoped change stops being reviewable. The inventory rows are accurate today, so the state is documented-and-tracked, not silently wrong.
Options
Option 1 is the recommendation; option 3 is the floor and should land regardless.
Done-when
docs/remote-state-inventory.mdAdding a third item found while closing #778, same family as the head-ABA above.
$shais captured once and used all run inpretooluse-merge-consent.shshacomes from the PR snapshot at the top of the hook. Every later check — the CI combined status, thereview-verdict/h10status, the verdict-comment classification — is evaluated against that captured value, so a push landing mid-run is checked against the commit it replaced.This is exactly the defect that was live for
$base_refuntil #778 re-read it immediately before the branch-protection lookup (PR #802,fix(778): round 4). The base got the fix because the stale read was inside the code that PR introduced; the sha case spans the pre-existing H10 logic, so it was documented indocs/remote-state-inventory.mdand deferred here rather than folded into a PR already at five review rounds.Bounded, not closed: a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses the merge. Nothing in the hook bounds it.
.head.shaand compare before the grant, symmetric with the base re-read, or record why the asymmetry is rightClaiming — Claude Code session, 2026-08-28. Bundling #664 with this: it is the SAME head-ABA defect, filed independently from the #649 cold review, and both cannot be closed apart.
Two measurements taken against the live instance before starting, because both bear on which option is available:
GET /compare/{base}...{head}still returnstotal_commits+commitsand nofileskey (probed againstorigin/main~3...origin/main, 12 commits). Sopr-changed-files.sh's header claim is still TRUE — option 2 (pin the diff to two shas) remains unavailable — but its version stamp is stale and will be re-dated.pull_pushevent carrying{"is_force_push": bool, "commit_ids": [...]}. PR #761 has three withis_force_push: true; PR #802 has eighteen, all false. That count is monotonic and cannot alias, exactly likechange_target_branch.So option 1 is implementable and is what this will do, with option 3 (qualify the three contracts) landing alongside it rather than instead of it. A force-push fires
synchronize, which is in this workflow'stypes:, so the abstain-and-hand-off termination argument carries over fromci.verdict-write-retarget-fenceunchanged.Closing record
Outcome: Shipped in PR #873. The HEAD alias is now fenced:
count_retargetsbecomescount_pr_mutations, countingpull_pushalongsidechange_target_branchon one timeline walk, captured before classification and re-counted before the status POST — either count moving withholds the write. The advisory hook re-reads.head.shaat the same hoist and off the same response as the base re-read (a moved head denies, an unreadable one asks). All three overclaiming contracts are corrected, plus four paraphrases the first sweep missed. Two pre-existing fail-opens in the shared walk were found by review and fixed: an empty array first page was trusted on any page while thenullarm requiredpage > 1, and no row was validated before.typewas selected on.Root cause: Every binding
scripts/pr-changed-files.shperforms compares a value against itself, which is ABA-vulnerable by construction. The base axis had been fenced with a monotonic counter since #706; the head axis had nothing, and three contracts asserted otherwise. Option 2 (pin the diff to two shas) stays unavailable: re-probed at Gitea 1.27.1 on 2026-08-28,GET /compare/{base}...{head}returnstotal_commits/commitsand nofileskey.Decisions/conventions changed:
ci.verdict-write-retarget-fenceamended in place (both axes, the head-axis section, two new residuals);ci.shared-pr-file-enumeration— the head binding is movement DETECTION, one-way, never a binding, and the enforced caller adds the fence;release.verdict-status-check— the "by construction" claim scoped to the head that has NO status, since a head can still be GIVEN a machine-written exemption. No new key; the key was kept because it is cited in six places and #763 set the precedent of amending this record for a larger addition.Reusable knowledge:
pull_pushevent with{"is_force_push", "commit_ids"}. Its COUNT is monotonic and is the only key an alias cannot defeat. Do not filter onis_force_push: an ordinary push also invalidates a mid-flight enumeration, and an ABA's restoring push can be non-forced when H1 is an ancestor.8798a1d -> 830a407 -> 8798a1d. Ordinary force-push-and-revert produces the alias; no attacker required.pull_pushcount, since it is created by a push. That is the #751 shape, and it is the first test to write.started_atis a lower bound (69s end to end on run 2385).run:block's common indentation, so a line written at the block's base indent executes at COLUMN 0. That is what makes a backslash-continuation evasion reachable, and it is why a mutant placed at column 0 in the file proves nothing — it just breaks the YAML.Verification: 1224 passed, 2 skipped (
PYTHONPATH=. python3 -m pytest scripts/tests -q); ruff check + format clean;decisions-validate: OK; YAML andbash -nclean on every touched shell body. No.cstouched, so the BOM/format gate does not apply. Rebased onto #871/#872 mid-flight and re-verified green together (a whole-tree gate is only as green as its base). Fourteen mutations verified to redden the right tests, each mutant confirmed to PARSE first — a mutant that does not parse is not a measurement.Deferred: #870 — the walk's
nullterminator is defeatable.ListIssueCommentsAndTimelineapplies LIMIT/OFFSET inFindCommentsand filters afterwards, droppingCommentTypeCoderows into a nil slice that serialises as barenull, so a page of inline review comments reads as exhaustion while later pages hold events. Pre-existing; defeats the BASE fence identically. The fence is therefore documented as closing the ABA on a timeline with no such truncating block, NOT the ABA outright. Also noted: a flag reaching jq through a variable is outside thejq -eguard's declared scope, and that guard is function-scoped by design.Docs updated:
docs/decisions/records/ci/verdict-write-retarget-fence.md,docs/decisions/records/ci/shared-pr-file-enumeration.md,docs/decisions/records/release/verdict-status-check.md,docs/decisions/records/ci/actions-credential-scoping.md(rename annotation on a dated measurement quote),docs/decisions/README.md(regenerated),docs/remote-state-inventory.md(4 rows),docs/guard-inventory.md(row + summary count),docs/ci-cd.md.