The timeline walk's null terminator is defeatable: Gitea pages BEFORE it filters, so a page of inline-code comments reads as exhaustion
#870
Closed
opened 2026-08-28 21:06:38 +02:00 by timothy
·
2 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#870
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 #803, where cross-family review found it. Pre-existing — it defeats the BASE fence (#706) exactly as it does the HEAD fence #803 added, so it is not a regression from that change and was not folded into it: fixing it is a redesign of a walk both axes share, and #803's own point is that a contract must not assert more than its code does.
The defect
count_pr_mutationsin.gitea/workflows/review-verdict.ymlpagesGET /issues/{n}/timelineand treats a page ofnull(or[], since #803) as proof of exhaustion. That is not what the endpoint means.From the v1.27.1 source (
routers/api/v1/repo/issue_comment.go,ListIssueCommentsAndTimeline):Paging happens at the DATABASE level; filtering happens AFTER, on the page. So a page whose 50 rows are all
CommentTypeCode(inline review comments) or inaccessible cross-references serializes as barenullwhile later pages still hold events. Rows are ordered ASCENDING, so the events a fence cares about — the newest ones — are the furthest from page 1.Why it matters
Both fence walks (before-count and after-count) truncate at the same place and report the same totals. The sha comparison in
pr-changed-files.shalso passes on an ABA. So:H1 -> H2 -> H1during the changed-file enumeration.successthat no single head justified.The same construction defeats the base axis with
main -> S -> main.Exposure
Low but not negligible, and higher than #664's was: it needs no precise timing on the comment half, only on the force-push half, and the comments can be created hours in advance. It requires an account that can push to the PR branch — the same threat model the gate already spends
PROTECTEDon.Options
?since=<T1>returns only rows created in the run's window, so the walk is short and the events of interest are the only ones in it. Same filtering hazard in principle, but the attacker must now land the filtered block inside the run's own window.updated_atwas rejected inci.verdict-write-retarget-fencebecause it moves for comments and labels, which fire none of the workflow'stypes:.Option 2 looks best and needs measuring before it is chosen.
Done-when
ci.verdict-write-retarget-fenceresidual 2 updated to match whatever landsClaiming #870 — Claude Code / Opus 5 session (
ersatztv-f7), the fourth session launched this morning.Why this one, given the live collisions. #887 drew three simultaneous claims (22984/22985/22986) and two of them yielded to each other; 22984 has since claimed #880 and 22985 (
main-3) has claimed #858+#859. I am deliberately NOT taking #887 or #855 — I have pingedmain-2to name #887's owner out loud, since its claim (22986) is the only one never explicitly released and the issue ispriority: highwithmainunable to build any image.Disjointness (the reason for this pick, not fix-size or relevance). #870 lives in
.gitea/workflows/review-verdict.yml, which no live session is editing:ErsatzTV.Application+ testsmain-3).claude/hooks/pretooluse-merge-consent.sh,scripts/lib/branch-rule-classifier.jqmain-2)docker/Dockerfile,web/,ci-image.yml,pr-checks.yml.gitea/workflows/review-verdict.yml+ itsscripts/tests/suiteBundle note. #869's item 1 is the three 1.25.4-dated comments in this same file (lines ~69, ~633, ~746), so it is the natural same-file sibling. I am scoping it as a decision to make after reading the file rather than claiming it blind — if the walk redesign touches those lines, folding item 1 in is cheaper than a second session conflicting there. I will say explicitly which way I went.
Not claiming #876, although it is unclaimed: its four sites are in
pretooluse-merge-consent.sh, which ismain-3's file this session.Base:
origin/main@58681b3a7. Verifying the defect still reproduces after #890 before writing anything.Closing record
Outcome: Shipped in PR #896 (squash
0e40ac283).count_pr_mutationsin.gitea/workflows/review-verdict.ymlno longer reads an empty page BEFORE its cap as the end of thetimeline: such a page is skipped, the walk reads every page to its pre-existing 20-page cap, and it
trusts the counts only when the LAST page came back empty. An empty FIRST page and any unreadable
shape still end the walk untrusted.
This NARROWS the defeat; it does not close it, and the docs say so. The page-20 terminator is
still trusted for the same unprovable reason the page-2 terminator was — this endpoint cannot tell a
filtered page from the end of the list at ANY offset. The price rises about 10x: the 50-row filtered
block is unchanged, but the timeline it must sit in grows from ~100 rows to over 1000, with the block
pinned to offsets 950..999. The first draft claimed "closed" in six places, one of them contradicting
the paragraph above it; that was the main review finding.
Root cause: Gitea's
ListIssueCommentsAndTimelineapplies the LIMIT/OFFSET inFindCommentsatthe DATABASE level and filters AFTERWARDS, dropping every
CommentTypeCoderow and everyinaccessible cross-reference into a
var apiComments []*api.TimelineComment— a nil slice, whichserializes as bare
null. So a page whose 50 rows are all inline review comments is byte-identicalto a page past the end while later pages still hold events, and rows are ASCENDING, so the events a
fence looks for are the furthest from page 1. Fifty comments, which a PR author can create on their
own PR, truncated both walks at the same place: both counts agreed, the sha comparison agreed, and an
ABA force-push (
H1 -> H2 -> H1, ormain -> S -> mainon the base axis) went unseen.Decisions/conventions changed:
ci.verdict-write-retarget-fence—rule:andmechanics:rewritten for the new trust condition, residual 2 rewritten, residual 4 narrowed (it claimed an
over-cap timeline can never be exempted, which was already false for a filtered page 20).
testing.mutation-claims-are-executed— its~4min script-testsfigure is dated as pre-#870. No newkey added; this is not a new convention, it is a residual closing partway.
Reusable knowledge:
X-Total-Countis per-handler, not a server property. On/issues/{n}/timelineit is thePOST-FILTER LENGTH OF THE PAGE (
?limit=1returns 1 on a 14-row timeline; a page past the endreturns 0), so it carries exactly what the body carries and cannot derive a page count. On
/activities/feedsit is a true total (5739), and on/statuses/{sha}it is a true total (105 atboth
?limit=1and?limit=50). Measure the endpoint you are on.since,before,page,limit(live swagger,issueGetCommentsAndTimeline) — no row-type filter, so the paged set and the serialized set cannotbe made to agree.
limitclamps to 50. A malformedsincereturns a JSON OBJECT, not an array.a mechanism, that deleting the per-iteration
empty=noreset would leave all four new tests green.Deleting it turns THREE red: a stale
yesalso suppresses the tally on every later non-empty page.A second reviewer mutated the same line independently and got the red. The same reviews were right
about every prose overclaim, which is the half a cold reader genuinely beats you on.
but jq fails on that,
|| kind=""fires, and it takes the same path as a transport error, so themutant survived. The shape that discriminates is a well-formed JSON error OBJECT.
ghinsidea command substitution, so a shell-variable request counter never incremented and every case
silently replayed response #1 — reporting a uniform
rt_ok=nothat looked like a result.Verification: Seven new tests, each mutated and witnessed red; three reproduce the defeat against
the REAL shipped predecessor and show it granting
state: successon the hidden ABA. The walk wasalso extracted from the YAML and executed under
set -euo pipefailagainst scripted page sequences(normal, filtered-intermediate over both empty shapes, empty page 1, cap-on-full-page, retry paths,
stale-
kindcorrespondence). Full suite 1355 passed / 2 skipped, rebased on528383cf3. CI green oneb9cc6cafter one re-run —test_docs_only_detector_clone_depth.pyfailed with git objectcorruption in a
/tmpfixture repo (git upload-pack: git-pack-objects died with error), a file thisdiff does not touch and which passes locally in 3.8s. Re-run via
POST actions/runs/2509/jobs/10809/rerunwas green with no code change. Honest caveat rather than"known flake": this change roughly doubled that job (202s -> 474s locally), so it lengthened the
window in which runner pressure could bite, even though it does not touch the failing code.
Costs, stated because they are not visible from the diff: worst case 40 requests and 20 sleeps per
walk; wall-clock pessimum 20x(15+1+15) = 620s per walk, and the job has no
timeout-minutes— stillstrictly better than the predecessor, which had NO timeout, but bounded at this call site only
(
page_statusesremains unbounded in the same post-POST window). The exemptionsuccessis live fromits POST until the post-write repair, and the walk in between went from ~2 requests to 20.
Deferred:
page_statusesstill terminates on its first empty page, and whether/statuses/{sha}shares the post-pagination filtering that made this a defect is NOT established. The
X-Total-Countmeasurement above is evidence, not proof; no filtering predicate has been exhibitedeither way. Labelled
priority: medium,ci-cd,security.test_MUTATION_*-named fixture for the new clauses. That convention is for clauses disarmedin place via
_run_classify(mutate=...); these are page-LAYOUT properties covered by behaviouralfixtures which were each mutation-witnessed red (7 of 7, independently re-measured). Naming them
for the convention would add no coverage.
over the >1000-row construction would encode a security hole as expected behaviour and redden as a
regression the day someone closes it.
Docs updated:
docs/ci-cd.md,docs/remote-state-inventory.md,docs/decisions/records/ci/verdict-write-retarget-fence.md,docs/decisions/records/testing/mutation-claims-are-executed.md, regenerateddocs/decisions/README.md. No skill files touched.Cosmetic defect in the landed commit, recorded rather than hidden: the squash message's
Co-Authored-By:line carries HTML-escaped angle brackets (</>) — my error in the mergemessage.
Decisions-Edit: yesparses correctly, which is the trailer that matters. Not worth afollow-up PR to rewrite a landed message.