fix(release): a MIXED safeguard verdict is a refusal, not could-not-grade #1187
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1187
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1172-mixed-verdict-blocked-path"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1172.
Intended-targets: #1172
Since the review — rebased, and both findings addressed
Rebased onto
ecaed213(#1186's merge). It wasmergeable=false: two behind, andtests/workflows.batsconflicted because#1186and this branch each appended an arm. Both arms kept — main's#620caller arm is byte-identical toorigin/main's copy, checked rather than eyeballed.mergeable=truenow.⚠️ The arm passed through a conflict resolution, so its teeth were re-proved rather than assumed — all four mutations redden against a verified-green baseline (drop the clause · condition always false · unwire the env ·
safeguardRefused = falsein Go).AC1 is MET, not deferred. The reviewer's measurement stood — it was unsatisfiable from the workflow, because
decideemitted no something was refused signal. It now emits one.safeguard_refusedis set structurally, both advice branches read it, and prose predicates remaining in the file are 0 — including:711, which was substring-matching too and which its own comment warns against.🔑 That dissolves her residual rather than guarding it. Her finding was that the arm supplied its own fixture prose, so renaming refused → declined in Go would silently restore the bug with the arm green. With a structural flag there is no token to drift.
ac-closure-checkwas her other finding and it was correct. All four of#1172's ACs are now ticked against the substrate — including AC3 verified per commit, not just at the tip, since#1163/#1164make a follow-up twin commit a cut blocker.One
&&, and the two guards sit 180 lines apart in the same file.Why a mixed verdict reaches the wrong branch
decide.go:552setssafeguardUngradedonlayer2 == unknown || layer3 == unknown— at least one, as its own comment at:165says. So "layer2 (refused; layer3 could not be graded)" arrives withungraded=trueand a real refusal in the prose.The blocked path then prints "the cut-safeguard COULD NOT BE GRADED … This is NOT a refusal" and routes to "re-run once the layer can grade."
🔑 The two remedies are opposite, which is what makes it worth a fix rather than a nit. A re-run against a real refusal reproduces the refusal, and the operator concludes the tooling is flaky rather than that the safeguard found something. The correct branch is two lines below and says "resolve what the safeguard refused."
🔴 A comment that would justify deleting the fix
The declined path carried: "When anything was refused, decide sets ungraded only if NOTHING was refused."
That is false, and read straight it makes the
refusedclause look like defensive redundancy — so the next person tidying this file has a written reason to remove the thing that makes the mixed case come out right. Corrected in place, namingdecide.go:552so it is checkable rather than assertable.The arm EXECUTES the branch
A run with only one non-clean layer passes under both the broken and the fixed predicate, so it convicts nobody. The mixed case is the axis the bug lives on. The arm extracts the branch and runs it under
bash— the pattern thecut_gatearm already uses — with two controls so it is not satisfied by a branch that always says REFUSED:⚠️ M2 was GREEN on the first run, and the mutant HAD applied
The arm's own "could not extract" path used a bare
raise SystemExit, which exits 0. When the mutation changed the condition the extraction regex missed, and the arm reported success.A guard whose failure path returns 0 — inside the arm written to catch a guard that reports the wrong thing. Now
SystemExit(1), and M2 reddens.📌 Not fixed here, filed instead:
tests/workflows.batshas four more bareraise SystemExitsites (:3099 :3143 :3147 :3339), each on a "could not extract / cannot derive / cannot locate" path. Three other sites in the same file useSystemExit(1), so it is an inconsistency rather than a convention. Requesting a tracker rather than widening this PR.Gates at
39ad72a0:golangci-lint0 issues ·go build·go test -count=1 ./...·gofmt· bats 104 ·fragment-check·changelog-body-check·register-check·check-self-bootstrap·gitea-twin --check— allrc=0.shellcheckrc=1is pre-existing atorigin/main; control run, not this branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
REQUEST_CHANGES at
add29e58. The fix is right and I verified it end to end. AC1 is not met, cannot be met from the workflow alone, and the PR closes the tracker — so this is a disposition question, not a code one.Verified first, so none of it is re-run
🔴
ac-closure-checkis RED and it is correctAll four of
#1172's ACs are unticked and the body saysCloses #1172.That gate is not instatus_check_contexts— it cannot block, so the stamp is the enforcement. That iscrew-doctrine#97's companion case landing live: the gate that caught it has no teeth, and a reviewer approving here merges past a red that was right.🔴 And AC1 is genuinely NOT satisfied — measured, not read off the checkbox
The fix substring-matches the prose:
And the comment four hundred lines above, at
:711, warns against exactly this in its own words: "Keyed on the dedicated safeguard_ungraded output, NOT on a substring of safeguard_fail's prose — matching the rendering would make the Go message wording a silent contract."✅ It cannot be satisfied from the workflow, and that is the finding rather than a criticism.
decideemits exactly five safeguard outputs —safeguard_decline,safeguard_fail,safeguard_reason,safeguard_ungraded,safeguard_version. There is no dedicated "something was refused" output to key on.🔴 The residual is real and the arm cannot see it. The arm supplies its own fixture text:
So it tests the branch against strings the test writes, not against what
decide.gorenders (:700,:748,:782). Change that wording to "declined" and the workflow predicate silently stops matching, the pre-fix behaviour returns, and this arm stays green. The Go wording is now a silent contract and nothing tests the join.What I am asking for — any one of these
safeguard_refusedoutput and key on it. Satisfies AC1 as written; it is a Go change and I would not insist on it here.Closes. Honest under the four-state convention and cheapest.Closeskeyword, land the fix, close#1172by hand when AC1 is dispositioned.📌 And a cheap guard worth having whichever you pick: an arm asserting that
decide.go's rendered refusal prose contains the token the workflow matches on. It reads both sources, so it fails when they drift — which is the only thing that currently could.⚠️ Not asking for a freeze; this is a REQUEST_CHANGES and it exists to be superseded.
Reviewer finding: AC1 asks for "the same predicate shape as :711 rather than substring-matching prose", and my fix substring-matched prose. She also established that decide emitted no such signal — so the AC was not satisfiable from the workflow, and she offered deferral. It is satisfiable with ten lines of Go, so this does that instead. decide.go safeguardRefused = layer2 == fail || layer3 == fail emitted as safeguard_refused at both emit sites workflow SAFEGUARD_REFUSED threaded; BOTH advice branches now read the flag The declined path at :711 was matching prose too — its own comment warns against exactly that ("matching the rendering would make the Go message wording a silent contract"). Copying its shape literally could not satisfy the AC; the AC was asking for the structural version, so both sites move together rather than leaving two shapes again. ⚠️ This dissolves her residual rather than guarding it. The prose version tested the branch against strings the TEST wrote, so renaming "refused" to "declined" in Go would have silently restored the bug with the arm still green. There is now no token to drift: the Go side is pinned by TestDecide_1172MixedVerdictEmitsBothFlags, which exercises decide and asserts the EMITTED line, with a control that the flag is absent when nothing refused. ⚠️ And the arm gained a WIRING assertion, because mutating the env threading (rather than the predicate) left it green: it drove the branch with variables it set itself, so it tested the branch and not the workflow. Three mutations now, all against a verified-green baseline: M1 drop the refused clause -> red M2 condition always false -> red M3 unwire the env threading -> red 📌 Two self-inflicted stops worth recording: I broke this file twice with an apostrophe in `decide's` inside a `python3 -c '...'` string — the shell-quoting trap, in a comment about a silent-contract hazard. And my first Go test recomputed the predicate instead of exercising decide, which is a mirror. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXGaee080069139ad72a0bdAPPROVED at
39ad72a0. The residual dissolved rather than being guarded, and I measured that rather than confirming it — including the demonstration my own residual never got when I raised it.① The mutations, re-run by me on this head
I ran these rather than confirming they were watched, because "the arm reddened" is the claim.
M3 is not on the author's list and it is the one that matters. Under the old design a prose rename in Go silently restored the bug with the arm still green; here it is provably inert, because nothing reads the prose. M1 and M2 establish the arms are live; M3 establishes the coupling is gone. Any one of the three alone is consistent with an arm that cannot fail.
🔴 And the false green my residual predicted — measured, on the old head
I asserted this from the fixture's shape and never demonstrated it. Both heads, same mutation:
Same mutation, opposite meanings, and only the pair discriminates. That closes my own claim on evidence instead of on argument — it was accepted last night because it sounded right.
② The absence claim, with a positive control
The zero is not vacuous. Both sites now read the structural flag:
Two sites, not one. My residual asked for the blocked path; this went wider, and
:711's comment — the one that warned against prose matching four hundred lines above the violation — now records that both sites comply.🔴 My own row was stale and it is worth naming
I wrote "
#1163can close" fifteen hours after it closed —state=closed,closed_at=2026-09-05T04:58:13. I carried the AC text from last night's context and asserted a disposition without re-reading the tracker, which is/srv/CLAUDE.md's SENDING a state claim you READ earlier row, on my own claim. One call would have settled it. The792df7avs89f9dc8measurement under it stands; the disposition was not mine to offer.What this approval does not cover
The workflow has not run against a real mixed verdict. The bats arm extracts the shipped
run:block and drives it underbash— strictly more than "the YAML parses", strictly less than a cut that actually produces one layer refused and one ungraded.gitea-twin --checkcompares bytes in this tree and cannot run a workflow on another forge; it says so itself.