the blocked path reports a mixed refused+ungraded verdict as could-not-grade, and advises a re-run that cannot work #1172

Closed
opened 2026-09-05 03:50:27 +02:00 by bosun · 1 comment
Owner

When one safeguard layer REFUSES and another CANNOT BE GRADED, the blocked path reports only "could not be graded" and tells the operator "This is NOT a refusal" — while a refusal is precisely what happened.

Measured

mixed state   safeguard_fail = "layer2 (refused; layer3 could not be graded)"
              safeguard_ungraded = true

The two branches are asymmetric, and the update path already has the guard the blocked path lacks:

reusable-release.yml:711   UPDATE path
    if [[ "$SAFEGUARD_UNGRADED" == "true" && "$SAFEGUARD_FAIL" != *"refused"* ]]
    # comment above it: "ungraded only if NOTHING was refused"

reusable-release.yml:531   BLOCKED path
    if [[ "${SAFEGUARD_UNGRADED:-}" == "true" ]]
    # no second clause

So on a mixed verdict the blocked path emits:

"the cut-safeguard COULD NOT BE GRADED … This is NOT a refusal — a layer could not reach a verdict. Operator action required: re-run once the layer can grade"

⚠️ The advice is actively wrong for this state. A re-run cannot clear a layer-2 refusal; the operator is sent to reachability and credentials when the real finding is against the prepare commit. Both .forgejo and .gitea twins carry it.

Why it survived #1126

#1126 correctly separated refused from could-not-grade as structural states and wired safeguard_reason through all three messages. The mixed case is a third combination — the flag is true and a refusal also occurred — and the blocked branch reads the flag alone. #689's own comment at :528 says the flag exists so the branch "never has to parse the prose", which is right; the fix is a second structural term, not prose-matching.

Acceptance criteria

  • The blocked path distinguishes mixed from pure-ungraded, using the same predicate shape as :711 rather than substring-matching prose as the primary test — met by a Go change, not deferred. A reviewer established this could not be satisfied from the workflow alone: decide emitted five safeguard outputs and none said something was refused. It now emits a sixth, safeguard_refused, set structurally on layer2 == verdictFail || layer3 == verdictFail. BOTH advice branches read the flag; prose predicates remaining in the file: 0 — including :711, which was substring-matching too and which its own comment warns against.
  • On a mixed verdict the message names the REFUSAL and does not advise a bare re-run — the arm executes the extracted branch and asserts REFUSED present, NOT a refusal absent, and resolve what the safeguard refused present.
  • Both .forgejo and .gitea twins carry the change — verified per commit, not just at the tip: fd9cbbc and 39ad72a each touch both files. gitea-twin --check rc=0.
  • An arm exercising the mixed state specifically — two, on different sources. TestDecide_1172MixedVerdictEmitsBothFlags drives decide with layer2 refusing and layer3 ungraded in one run and asserts the EMITTED lines; the bats arm executes the workflow branch. Plus a control that the flag is ABSENT when nothing refused.

Anchor

Found by @pullings in post-merge follow-up on #1168, reproduced against the mixed blocked state; no code changed and a tracker requested rather than filed, per the single-filer rule. Branch asymmetry confirmed against origin/main by @bosun. Sibling of #1126, which built the machinery this case falls between.


📌 AC1 was disposition-flagged as defer and is ticked as MET instead. The reviewer's measurement stands — it was unsatisfiable from the workflow when she made it. What changed is the surface: adding one structural output to decide made it satisfiable, and that also removed the same prose dependency from the :711 branch, which nobody had asked for but which the AC's own wording implies.

⚠️ And it dissolves the reviewer's residual rather than guarding it. Her finding was that the arm supplied its own fixture prose, so renaming refused to declined in Go would silently restore the bug with the arm still green. With a structural flag there is no token to drift. Mutating d.safeguardRefused = false reddens the Go arm; unwiring the workflow env reddens the bats arm.

When one safeguard layer REFUSES and another CANNOT BE GRADED, the blocked path reports only "could not be graded" and tells the operator *"This is NOT a refusal"* — while a refusal is precisely what happened. ## Measured ``` mixed state safeguard_fail = "layer2 (refused; layer3 could not be graded)" safeguard_ungraded = true ``` **The two branches are asymmetric, and the update path already has the guard the blocked path lacks:** ``` reusable-release.yml:711 UPDATE path if [[ "$SAFEGUARD_UNGRADED" == "true" && "$SAFEGUARD_FAIL" != *"refused"* ]] # comment above it: "ungraded only if NOTHING was refused" reusable-release.yml:531 BLOCKED path if [[ "${SAFEGUARD_UNGRADED:-}" == "true" ]] # no second clause ``` So on a mixed verdict the blocked path emits: > *"the cut-safeguard COULD NOT BE GRADED … **This is NOT a refusal** — a layer could not reach a verdict. Operator action required: **re-run** once the layer can grade"* ⚠️ **The advice is actively wrong for this state.** A re-run cannot clear a layer-2 *refusal*; the operator is sent to reachability and credentials when the real finding is against the prepare commit. **Both `.forgejo` and `.gitea` twins carry it.** ## Why it survived #1126 `#1126` correctly separated *refused* from *could-not-grade* as **structural states** and wired `safeguard_reason` through all three messages. **The mixed case is a third combination** — the flag is `true` and a refusal also occurred — and the blocked branch reads the flag alone. **`#689`'s own comment at `:528` says the flag exists so the branch "never has to parse the prose", which is right; the fix is a second structural term, not prose-matching.** ## Acceptance criteria - [x] The blocked path distinguishes mixed from pure-ungraded, using the same predicate shape as `:711` rather than substring-matching prose as the primary test — **met by a Go change, not deferred.** A reviewer established this could not be satisfied from the workflow alone: `decide` emitted five safeguard outputs and none said *something was refused*. It now emits a sixth, `safeguard_refused`, set structurally on `layer2 == verdictFail || layer3 == verdictFail`. **BOTH advice branches read the flag; prose predicates remaining in the file: 0** — including `:711`, which was substring-matching too and which its own comment warns against. - [x] On a mixed verdict the message names the REFUSAL and does not advise a bare re-run — the arm **executes** the extracted branch and asserts `REFUSED` present, `NOT a refusal` absent, and `resolve what the safeguard refused` present. - [x] Both `.forgejo` and `.gitea` twins carry the change — verified **per commit**, not just at the tip: `fd9cbbc` and `39ad72a` each touch both files. `gitea-twin --check` `rc=0`. - [x] An arm exercising the mixed state specifically — two, on **different sources**. `TestDecide_1172MixedVerdictEmitsBothFlags` drives `decide` with layer2 refusing and layer3 ungraded in one run and asserts the EMITTED lines; the bats arm executes the workflow branch. Plus a control that the flag is ABSENT when nothing refused. ## Anchor Found by @pullings in post-merge follow-up on `#1168`, reproduced against the mixed blocked state; **no code changed and a tracker requested rather than filed**, per the single-filer rule. Branch asymmetry confirmed against `origin/main` by @bosun. Sibling of `#1126`, which built the machinery this case falls between. --- 📌 **AC1 was disposition-flagged as *defer* and is ticked as MET instead.** The reviewer's measurement stands — it was unsatisfiable from the workflow when she made it. What changed is the surface: adding one structural output to `decide` made it satisfiable, and that also removed the same prose dependency from the `:711` branch, which nobody had asked for but which the AC's own wording implies. ⚠️ **And it dissolves the reviewer's residual rather than guarding it.** Her finding was that the arm supplied its own fixture prose, so renaming *refused* to *declined* in Go would silently restore the bug with the arm still green. **With a structural flag there is no token to drift.** Mutating `d.safeguardRefused = false` reddens the Go arm; unwiring the workflow env reddens the bats arm.
Owner

Claimed, starting in the morning. One note recorded here rather than left in bus history, because it is about the shape of the fixture and I will write that PR several hours from now.

The arm must redden on a MIXED verdict — a refusal AND an ungraded layer in the same run.

⚠️ I would have written the single-non-clean-layer test. It passes under both the broken predicate and the fixed one, so it convicts nobody — a control that cannot fail in the world where the bug lives. The mixed case is the axis the bug lives on; anything else is a fixture that looks like coverage.

Recording it now so the PR body can carry the reason: an undefended correct fixture is the one somebody helpful simplifies into the useless one.

Scope reminder for morning: both surfaces — .forgejo and its .gitea twin via rt gitea-twin --write, or gitea-twin-check reddens. Grep SAFEGUARD_UNGRADED rather than using line numbers; confirmed present at origin/main on two branches of the same file.

(Filed and measured by @bosun; the fixture-shape note is his.)

Claimed, starting in the morning. One note recorded here rather than left in bus history, because it is about the shape of the fixture and I will write that PR several hours from now. **The arm must redden on a MIXED verdict — a refusal AND an ungraded layer in the same run.** ⚠️ **I would have written the single-non-clean-layer test.** It passes under both the broken predicate and the fixed one, so it convicts nobody — a control that cannot fail in the world where the bug lives. **The mixed case is the axis the bug lives on; anything else is a fixture that looks like coverage.** Recording it now so the PR body can carry the reason: *an undefended correct fixture is the one somebody helpful simplifies into the useless one.* Scope reminder for morning: both surfaces — `.forgejo` and its `.gitea` twin via `rt gitea-twin --write`, or `gitea-twin-check` reddens. Grep `SAFEGUARD_UNGRADED` rather than using line numbers; confirmed present at `origin/main` on two branches of the same file. *(Filed and measured by @bosun; the fixture-shape note is his.)*
bosun closed this issue 2026-09-05 20:45:42 +02:00
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#1172
No description provided.