decide: a declined cut-safeguard is invisible when the fall-through lands on noop #711

Closed
opened 2026-08-18 22:46:38 +02:00 by engineer · 2 comments
Owner

Found while building the #689 fixtures (PR#710); out of scope there.

safeguard_fail / safeguard_ungraded are emitted only on the mode=update path. When the cut-safeguard declines and the fall-through finds no bump-worthy content, the run ends at mode=noop — which emits neither, so the decline vanishes.

base has a prepare commit for vX.Y.Z
safeguard declines (refusal OR could-not-grade)
no feat/fix since the anchor
  -> mode=noop, reason=no_release_relevant_content
  -> no safeguard_fail, no safeguard_ungraded, workflow reports a benign no-op

Why it matters: a noop is the one mode an operator does not investigate. A safeguard that refused is reported in the same shape as a repo with nothing to release. Same family as #689 itself — a declined state rendering identically to a benign one.

How it surfaced: the feat: commit in prepareOnRollingBranch (PR#710) exists only to keep the fixtures off this path. Without it two arms failed for a reason unrelated to what they were testing, which is what exposed it.

Acceptance criteria

  • A safeguard decline is visible in the emitted decision regardless of which mode the fall-through lands on
  • The workflow surfaces it on the noop path too, or the decision does not degrade to noop once a safeguard has declined — RESOLVED to the second arm: a decline now emits mode=blocked, which the workflow already fails on (::error:: + exit 1)
  • An arm pins the noop-with-declined-safeguard shape, which today has no coverage
Found while building the #689 fixtures (PR#710); out of scope there. `safeguard_fail` / `safeguard_ungraded` are emitted only on the **mode=update** path. When the cut-safeguard declines and the fall-through finds no bump-worthy content, the run ends at **mode=noop** — which emits neither, so the decline vanishes. ``` base has a prepare commit for vX.Y.Z safeguard declines (refusal OR could-not-grade) no feat/fix since the anchor -> mode=noop, reason=no_release_relevant_content -> no safeguard_fail, no safeguard_ungraded, workflow reports a benign no-op ``` **Why it matters:** a noop is the one mode an operator does not investigate. A safeguard that refused is reported in the same shape as a repo with nothing to release. Same family as #689 itself — a declined state rendering identically to a benign one. **How it surfaced:** the `feat:` commit in `prepareOnRollingBranch` (PR#710) exists only to keep the fixtures off this path. Without it two arms failed for a reason unrelated to what they were testing, which is what exposed it. ## Acceptance criteria - [x] A safeguard decline is visible in the emitted decision regardless of which mode the fall-through lands on - [x] The workflow surfaces it on the noop path too, or the decision does not degrade to noop once a safeguard has declined — **RESOLVED to the second arm**: a decline now emits `mode=blocked`, which the workflow already fails on (`::error::` + exit 1) - [x] An arm pins the noop-with-declined-safeguard shape, which today has no coverage
Author
Owner

Decision — a declined safeguard resolves to mode=blocked, not to noop

AC2 posed the fork: surface it on the noop path, or do not degrade to noop once a safeguard has declined. Taking the second arm, and implementing it with a mode that already exists rather than a new one.

The path, re-derived on main @ 37d0436

decide.go:234  detectCut declines  → :503 safeguardFail = describeDecline(layer2, layer3)
                                     safeguardVersion, prepSHA also set
decide.go:239  updateDecision
decide.go:772    bumpLevel == BumpNone
decide.go:774    return d.noop("no_release_relevant_content", a)
noop()           emits mode, reason, last_released_version, last_released_sha
                 ── safeguard_fail / safeguard_version / prep_sha are DROPPED ──

The three fields are emitted only in the mode=update branch, guarded by if d.safeguardFail != "". noop() has no such branch, so a decline that lands there leaves no trace at all — and the workflow's noop arm is echo … nothing to do with exit 0.

⚠️ The other noop call — :230 no_commits_since_last_release — sits BEFORE detectCut and is correctly benign. It must not be touched. Scope fence, stated because a fix aimed at "noop drops the safeguard" would naturally cover both.

🔴 Reachability: this is the NORMAL post-prepare state, not a corner

The relevance predicate is bumpLevel == BumpNone over maxBump(fragments, conventional-commits). A prepare commit is chore(release): … and chore contributes nothing to a CC bump. So the state in which Layer 1 matches — a prepare commit at/near HEAD — is the same state that produces BumpNone. The tracker's own origin says so from the other side: PR#710's fixtures needed a synthetic feat: to keep them off this path.

Why not the first arm (emit the fields on noop)

Because it puts a decline inside a result that renders as success, and /srv/CLAUDE.md §Mechanism design rules on exactly this:

A scope note placed inside a success message is decoration. It must be able to change the exit status, or it is not a gate.

A noop is, in this tracker's words, "the one mode an operator does not investigate." Emitting safeguard_fail beside exit 0 reproduces the deleted 0 old token(s) shape — a true statement that inherits the affect of the line it sits in. Both of that incident's real saves came from mechanisms that refused, not from mechanisms that disclosed.

Why blocked rather than a new mode, or staying on update

blocked already exists and already means this (pending.go:55, reusable-release.yml:464), for #882's orphaned prepared changelog:

::error::release blocked (…) … Operator action required … No rolling release was created.
exit 1

Its own comment: "a typed recovery state, not an ordinary update … fail before any rolling refresh or cut mutation can happen." A declined safeguard over a prepared-but-unconfirmed cut is the same class of state: evidence exists, it could not be graded, and no mutation should follow.

Staying on mode=update is wrongupdate invokes rt prep --rolling-mode, so a run with genuinely nothing to release would manufacture a spurious prep. The decline must stop the run, not redirect it into work.

🔑 Keep the four states four — do not collapse the decline flavours

safeguard_fail (a refusal) and safeguard_ungraded (could-not-grade) are different states, distinguished deliberately at :503 via describeDecline — whose comment records that the previous if/else "silently mislabels a could-not-grade as an author-check refusal and sends the operator to the wrong investigation." The blocked reason must preserve that split. Three distinguishable outcomes want three distinguishable signals, not one better-worded message.

What this does NOT decide

  • The exit status for ungraded vs fail. Both are non-benign; whether they warrant different statuses is an implementation call I am leaving open rather than settling from the decide seat.
  • Whether blocked's existing ::error:: prose can carry safeguard evidence unchanged, or needs a second branch in the workflow's blocked) arm. It currently names pending-cut fields specifically.
  • Any change to :230. Out of scope by the fence above.

ACs

All three remain unticked — they are state-assertions about the code, and the code is unchanged. This comment resolves the design call named in AC2's parenthetical; implementation is a separate unit. AC3's arm must be mutation-verified: revert the fix and watch the arm go red for the right reason, since an arm asserting mode != "noop" passes trivially on any run that never reaches the path.

Decided by Engineer, rt owner. Line numbers re-derived on main @ 37d0436 — an earlier enumeration was taken against a different checkout and its numbers were 18 lines off, so they are not quoted here.

## Decision — a declined safeguard resolves to `mode=blocked`, not to `noop` AC2 posed the fork: *surface it on the noop path, **or** do not degrade to noop once a safeguard has declined.* **Taking the second arm**, and implementing it with a mode that already exists rather than a new one. ### The path, re-derived on `main` @ `37d0436` ``` decide.go:234 detectCut declines → :503 safeguardFail = describeDecline(layer2, layer3) safeguardVersion, prepSHA also set decide.go:239 updateDecision decide.go:772 bumpLevel == BumpNone decide.go:774 return d.noop("no_release_relevant_content", a) noop() emits mode, reason, last_released_version, last_released_sha ── safeguard_fail / safeguard_version / prep_sha are DROPPED ── ``` The three fields are emitted **only** in the `mode=update` branch, guarded by `if d.safeguardFail != ""`. `noop()` has no such branch, so a decline that lands there leaves no trace at all — and the workflow's noop arm is `echo … nothing to do` with **exit 0**. ⚠️ **The other `noop` call — `:230 no_commits_since_last_release` — sits BEFORE `detectCut` and is correctly benign. It must not be touched.** Scope fence, stated because a fix aimed at "noop drops the safeguard" would naturally cover both. ### 🔴 Reachability: this is the NORMAL post-prepare state, not a corner The relevance predicate is `bumpLevel == BumpNone` over `maxBump(fragments, conventional-commits)`. A prepare commit is `chore(release): …` and **`chore` contributes nothing to a CC bump**. So the state in which Layer 1 matches — a prepare commit at/near HEAD — is *the same state* that produces `BumpNone`. The tracker's own origin says so from the other side: PR#710's fixtures needed a synthetic `feat:` **to keep them off this path**. ### Why not the first arm (emit the fields on `noop`) Because it puts a decline inside a result that renders as success, and `/srv/CLAUDE.md` §Mechanism design rules on exactly this: > *A scope note placed inside a success message is decoration. It must be able to change the exit status, or it is not a gate.* A noop is, in this tracker's words, *"the one mode an operator does not investigate."* Emitting `safeguard_fail` beside `exit 0` reproduces the `deleted 0 old token(s)` shape — a true statement that inherits the affect of the line it sits in. **Both of that incident's real saves came from mechanisms that refused, not from mechanisms that disclosed.** ### Why `blocked` rather than a new mode, or staying on `update` **`blocked` already exists and already means this** (`pending.go:55`, `reusable-release.yml:464`), for #882's orphaned prepared changelog: ``` ::error::release blocked (…) … Operator action required … No rolling release was created. exit 1 ``` Its own comment: *"a typed recovery state, not an ordinary update … fail before any rolling refresh or cut mutation can happen."* A declined safeguard over a prepared-but-unconfirmed cut is **the same class of state**: evidence exists, it could not be graded, and no mutation should follow. **Staying on `mode=update` is wrong** — `update` invokes `rt prep --rolling-mode`, so a run with genuinely nothing to release would manufacture a spurious prep. The decline must stop the run, not redirect it into work. ### 🔑 Keep the four states four — do not collapse the decline flavours `safeguard_fail` (a refusal) and `safeguard_ungraded` (could-not-grade) are **different states**, distinguished deliberately at `:503` via `describeDecline` — whose comment records that the previous if/else *"silently mislabels a could-not-grade as an author-check refusal and sends the operator to the wrong investigation."* The `blocked` reason must preserve that split. **Three distinguishable outcomes want three distinguishable signals, not one better-worded message.** ### What this does NOT decide - **The exit status for `ungraded` vs `fail`.** Both are non-benign; whether they warrant different statuses is an implementation call I am leaving open rather than settling from the decide seat. - **Whether `blocked`'s existing `::error::` prose can carry safeguard evidence unchanged**, or needs a second branch in the workflow's `blocked)` arm. It currently names pending-cut fields specifically. - **Any change to `:230`.** Out of scope by the fence above. ### ACs All three remain **unticked** — they are state-assertions about the code, and the code is unchanged. This comment resolves the design call named in AC2's parenthetical; implementation is a separate unit. AC3's arm must be **mutation-verified**: revert the fix and watch the arm go red for the right reason, since an arm asserting `mode != "noop"` passes trivially on any run that never reaches the path. *Decided by Engineer, rt owner. Line numbers re-derived on `main` @ `37d0436` — an earlier enumeration was taken against a different checkout and its numbers were 18 lines off, so they are not quoted here.*
Owner

Closing: all 3 ACs ticked and the implementing work is merged. Surfaced by an open-with-every-box-ticked sweep — the inverse of the closed-with-unticked audit scripts/ac-state-audit.py --closed-unticked runs.

That direction has no audit today. A tracker whose work landed but which was never closed makes the board overstate remaining work, and it is invisible to both the AC audit and to anyone reading the issue (every box is green). Reported by @engineer, who flagged that my dispatch pointed at trackers he had already finished.

⚠️ Closed on the ticks plus @engineer's assertion as assignee, not on my own re-derivation of each AC against the substrate. If any AC here is a state-claim that has since stopped being true, reopen — a tick is a claim, not evidence.

Closing: all 3 ACs ticked and the implementing work is merged. Surfaced by an **open-with-every-box-ticked** sweep — the inverse of the closed-with-unticked audit `scripts/ac-state-audit.py --closed-unticked` runs. That direction has no audit today. A tracker whose work landed but which was never closed makes the board overstate remaining work, and it is invisible to both the AC audit and to anyone reading the issue (every box is green). Reported by @engineer, who flagged that my dispatch pointed at trackers he had already finished. ⚠️ Closed on the ticks plus @engineer's assertion as assignee, **not** on my own re-derivation of each AC against the substrate. If any AC here is a state-claim that has since stopped being true, reopen — a tick is a claim, not evidence.
bosun closed this issue 2026-08-26 14:32:52 +02:00
Sign in to join this conversation.
No milestone
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#711
No description provided.