Merge-gate branch-protection read: a malformed rule states a cause that did not happen, and the endpoint is now fetched twice #859
Closed
opened 2026-08-27 22:01:24 +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#859
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.
Two residuals found by cold review during #787, both deliberately left out of that PR: the first is
pre-existing behaviour in code #787 only moved, and the second is cost rather than unsafety.
1. A rule missing both name fields becomes a valid empty name
scripts/lib/branch-rule-classifier.jq(extracted verbatim from the hook in #787 — byte-identical,so this predates it):
An unreadable rule therefore becomes the empty string, a valid name that simply matches nothing.
Measured: a payload whose rule carries neither field classifies as
none, so the scheduled pathDENIES while claiming "the full rule list was read and none matches" — about a list it did not
understand. That is the same states-a-cause-that-did-not-happen defect the surrounding comments were
written to fix, one field deeper.
Direction matters: on #787's caller this surfaces as
nomatch→ ask, which is safe. On thepre-existing caller it is a deny with a false stated cause.
2.
branch_protectionsis fetched twice on the scheduled path#787 added a second read for the guard-scope freshness arm. On a scheduled auto-merge the endpoint is
now hit twice (measured: the test stub recorded 2 URLs) — an extra round trip inside the hook's
15s budget, plus a window between the reads. Both fail conservatively, so this is cost, not unsafety.
Reusing the first read's payload is the obvious fix; the reason it was not done in #787 is that the
two reads ask different questions (one about
$base_ref, one aboutmain), so sharing them needscare rather than a variable rename.
Done-when
unreadable, never as a name that matches nothingClaiming as a bundle with #858 — Claude Code session, worktree
~/orca/workspaces/ersatztv/main-3, branchfix/858-859-merge-consent-hook-residualsofforigin/main@58681b3a7.Same rationale as posted on #858: both issues are #787 cold-review residuals in the merge-consent hook and its classifier, so they are one session's work, not two.
Parallel-session note: I yielded #887 to an earlier claim this morning (three sessions collided on it); the 3-way split is recorded on #887.
Pre-claim checks clear: no remote branch matching
*858*/*859*/*787*, no open PR referencing either, no prior comments,git fetch origin main→58681b3a7.Closing record
Outcome: Both items shipped in PR #897 (squash
cf5f42edf). This issue was filed as a wrong stated cause and turned out to be masking a live false-open in the merge gate — that is the headline, not the wording fix.Root cause:
(.branch_name // .rule_name // ""). jq's//fires onnullandfalsebut not on"". Measured 2026-08-30 on a scratch repo against Gitea 1.27.1 (one rule of each kind, read back):So a glob rule reached the classifier as the empty string — a name carrying no metacharacters — and the glob test, which is the whole basis of the undecidable-first ordering, never saw it. Measured on the predecessor:
main)m*(no h10) + plainmain(h10)exact→ AUTO-GRANTundecidable→ askm*alonenone→ DENY "none matches"undecidable→ askThe first row is #622's hole reached through the ordering written to close it: the gate reads the plain rule's contexts and arms a scheduled merge while Gitea, ordering by Priority then plain-name-ness, may be applying
m*. The issue's own described defect (both name fields absent →""→none→ a deny stating a finding nobody established) is the same mechanism with a milder payload.Item 2's root cause is simpler: #787 added a second consumer of the same endpoint and gave it its own GET.
Decisions/conventions changed: added
process.hook-resolves-inputs-from-repo-root(from #858, same PR). Nosched.*/api.*keys touched.docs/remote-state-inventory.md's row for the second read now describes one shared read.Reusable knowledge:
branch_name. Any code reading that endpoint must takerule_nameas canonical. jq's//will not fall through an empty string for you.aseagerly. An arm placed first in anifchain does not run first if a binding above it already touched the bad value — the guard must be structural (nested in theelse), not merely first.grepon the dev Mac is ugrep: a pattern containing${...}matches nothing and exits 0. Pass-F; cross-check any population count against a second derivation.Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected, disjoint reddened sets, including reverting each fix to its real predecessor; classifier executed over a 27-payload matrix on jq 1.8.2 and jq 1.6 (the CI floor) with identical results; live Gitea probed on a scratch repo, deleted afterwards. CI green first run (14 green, 1 skipped, 0 failures). Four cold review rounds + a bounded prose check.
Deferred: #891 (the other 12 hooks' sourced fire-log path —
priority: high+security); #895 ("all N tests green" claims, 4 instances, candidate detector). Neither is a residual of this fix; both are classes this work surfaced.Docs updated:
docs/remote-state-inventory.md,docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md(new), regenerateddocs/decisions/README.md.