feat(recovery): a verb for the (B) cut-recovery, so the one manual step is not a hand-edit (#1130) #1131

Merged
bosun merged 1 commit from i/1130-recover-pending-cut into main 2026-09-04 15:49:25 +02:00
Owner

Closes #1130.

rt recover-pending-cut performs recovery (B) of docs/integration.md § Cut-cancellation recovery. With #1128 landed, every step after the hand-edit is automatic; this closes the one that was not.

rc=0  the section was folded back (or, with --dry-run, would be)
rc=1  REFUSED — this is not the state (B) repairs
rc=2  COULD NOT GRADE — the state could not be established

Live acceptance test — main's real v0.57.0 pending state

step result
rt decide BEFORE mode=blocked reason=pending_cut pending_version=0.57.0 pending_tag_exists=false pending_release_published=absent
rt recover-pending-cut --dry-run the diff below; git diff --stat CHANGELOG.md empty
rt recover-pending-cut output byte-identical to the hand recovery at 97a0bb3 (cmp -s)
rt decide AFTER mode=update next_version=0.57.0
a second run rc=1"decide reports mode=update … Nothing to fold back"
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,8 +20,6 @@
 
 ## [Unreleased]
 
-## [v0.57.0] - 2026-08-29
-
 ### Added
 
 None.

The working tree was restored afterwards — this PR ships the verb, not the recovery. Performing it is the cut-holder's call, and it is now one command.

The two design calls

① It does not re-implement the pending-cut predicate — it runs the same decide the workflow runs and reads the #885 evidence off the typed PendingCutError. A second implementation of "is this tree pending?" is a second thing that can drift from the guard that blocked the cut. unknown is never rounded to absent: that is the value routing to (B), and (B) against a version that HAS a release orphans something adopters can already fetch (#884/#885).

② A positive control on the release lookup, which nothing upstream supplies. "Release absent" arrives as a 404 — and a 404 is also what a token that cannot read the repository gets. Measured today on frankenbit/alcatraz-infra (private) against release-toolkit (public), same endpoint shape:

                 PUBLIC   PRIVATE
no header          200      404
empty token        200      404
bogus token        401      401
valid token        200      200

So an unauthorised caller reads "no release exists" for every version — fabricating exactly the evidence this recovery turns on, with nothing red anywhere. The verb asks the same endpoint for the manifest's own last_released_version and refuses to grade when that one is missing too. (The five-state measurement is @surveyor's, from the #1126 arc.)

Mutation verification — seven arms, each reddening a distinct assertion

mutant what reddens arms
M1 positive control removed the release lookup cannot see the control either 1
M2 unknown release reads as absent an ungraded release lookup is not absence 1
M3 not-pending collapses into not-found the ErrSectionNotPending arm 1
M4 merge path drops the existing [Unreleased] prose TestFoldBackMergesSameKindHeadings 1
M5 --dry-run writes anyway TestRecoverPendingCutDryRunWritesNothing 1
M6 splice loses the trailing released sections body/edge-shape arms 3
M7 drop the unchanged-bytes guard nothing — GREEN 0

M7 is disclosed rather than closed, at the callsite: a successful fold always removes a heading, so no fixture can reach that branch. It stays as a guard against a future fold path that could return its input unchanged. Counting it as covered would be the false-green this repo keeps paying for.

⚠️ The mutation harness lied to me first. Its subtest counter grepped a four-space-indented --- FAIL, so M4 and M5 printed failing-subtests=0 while rc=1 — both fail at the top level. Fixed before the zeros were read, not after.

What this PR does NOT do

  • It does not perform the recovery on main. No CHANGELOG.md change is in this diff.
  • It does not automate recovery (A). (A) publishes a release and needs judgement about which tree the version should name — out of scope per the tracker.
  • It does not commit, and it does not re-run the decision. The success message says so.
  • No --force and no override for the ungraded paths. An escape hatch that exists is an escape hatch that gets used reflexively; the fix for a 2 is a token that can read the repository. The cost is real: an adopter whose last released version has no release object gets a 2 on a tree the verb could have repaired. That is the safe direction, and it is stated in the refusal.
  • The byte-for-byte claim is bounded. The section body is carried over unchanged; the blank lines immediately around it are structure and are normalised to one. When [Unreleased] is non-empty the two bodies go through MergeSections — the same fold rt prep applies — because carrying both verbatim would double ### Added.
  • The diff renderer is not a diff library. A common-prefix/suffix reduction is exact for one contiguous edit and degrades to a single spanning hunk for anything else; it lives beside the verb, with that written on it, rather than anywhere someone would reach for it.

Gates

go build · go vet · go test ./... · golangci-lint run ./...0 issues · rt pre-push0 FAIL, 8 could-not-grade (6 need a runner, 2 need PR context) · rt gitea-twin --check → 9 twins match · bats tests/workflows.bats → 92 ok.

@surveyor for review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa


AC verification (@bosun), run against main's live v0.57.0 state:

dry-run       rc=0, tree dirty=0 after        writes nothing
apply         rc=0, tree dirty=1
byte-compare  IDENTICAL to the hand recovery at 97a0bb32  (cmp -s)
decide after  mode=update                     <- AC 4, verified directly
second run    rc=1, refuses naming the state
unknown flag  rc=1, 0 bytes                   -> filed separately as #1133

#1130's five ACs are ticked and were verified against both runs.

Intended-targets: #1130

Closes #1130. `rt recover-pending-cut` performs recovery (B) of `docs/integration.md § Cut-cancellation recovery`. With #1128 landed, every step after the hand-edit is automatic; this closes the one that was not. ``` rc=0 the section was folded back (or, with --dry-run, would be) rc=1 REFUSED — this is not the state (B) repairs rc=2 COULD NOT GRADE — the state could not be established ``` ## Live acceptance test — main's real `v0.57.0` pending state | step | result | |---|---| | `rt decide` BEFORE | `mode=blocked reason=pending_cut pending_version=0.57.0 pending_tag_exists=false pending_release_published=absent` | | `rt recover-pending-cut --dry-run` | the diff below; `git diff --stat CHANGELOG.md` **empty** | | `rt recover-pending-cut` | output **byte-identical** to the hand recovery at `97a0bb3` (`cmp -s`) | | `rt decide` AFTER | `mode=update next_version=0.57.0` | | a second run | `rc=1` — *"decide reports mode=update … Nothing to fold back"* | ```diff --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,6 @@ ## [Unreleased] -## [v0.57.0] - 2026-08-29 - ### Added None. ``` **The working tree was restored afterwards — this PR ships the verb, not the recovery.** Performing it is the cut-holder's call, and it is now one command. ## The two design calls **① It does not re-implement the pending-cut predicate — it runs the same `decide` the workflow runs** and reads the #885 evidence off the typed `PendingCutError`. A second implementation of "is this tree pending?" is a second thing that can drift from the guard that blocked the cut. `unknown` is never rounded to `absent`: that is the value routing to (B), and (B) against a version that HAS a release orphans something adopters can already fetch (#884/#885). **② A positive control on the release lookup, which nothing upstream supplies.** "Release absent" arrives as a `404` — and a `404` is also what a token that cannot read the repository gets. Measured today on `frankenbit/alcatraz-infra` (private) against `release-toolkit` (public), same endpoint shape: ``` PUBLIC PRIVATE no header 200 404 empty token 200 404 bogus token 401 401 valid token 200 200 ``` So an unauthorised caller reads *"no release exists"* for **every** version — fabricating exactly the evidence this recovery turns on, with nothing red anywhere. The verb asks the same endpoint for the manifest's own `last_released_version` and **refuses to grade** when that one is missing too. (The five-state measurement is @surveyor's, from the #1126 arc.) ## Mutation verification — seven arms, each reddening a distinct assertion | mutant | what reddens | arms | |---|---|---| | M1 positive control removed | `the release lookup cannot see the control either` | 1 | | M2 `unknown` release reads as absent | `an ungraded release lookup is not absence` | 1 | | M3 not-pending collapses into not-found | the `ErrSectionNotPending` arm | 1 | | M4 merge path drops the existing `[Unreleased]` prose | `TestFoldBackMergesSameKindHeadings` | 1 | | M5 `--dry-run` writes anyway | `TestRecoverPendingCutDryRunWritesNothing` | 1 | | M6 splice loses the trailing released sections | body/edge-shape arms | 3 | | **M7 drop the unchanged-bytes guard** | **nothing — GREEN** | **0** | **M7 is disclosed rather than closed**, at the callsite: a successful fold always removes a heading, so no fixture can reach that branch. It stays as a guard against a future fold path that could return its input unchanged. Counting it as covered would be the false-green this repo keeps paying for. ⚠️ **The mutation harness lied to me first.** Its subtest counter grepped a four-space-indented `--- FAIL`, so M4 and M5 printed `failing-subtests=0` while `rc=1` — both fail at the **top** level. Fixed before the zeros were read, not after. ## What this PR does NOT do - **It does not perform the recovery on `main`.** No `CHANGELOG.md` change is in this diff. - **It does not automate recovery (A).** (A) publishes a release and needs judgement about which tree the version should name — out of scope per the tracker. - **It does not commit, and it does not re-run the decision.** The success message says so. - **No `--force` and no override for the ungraded paths.** An escape hatch that exists is an escape hatch that gets used reflexively; the fix for a `2` is a token that can read the repository. The cost is real: an adopter whose last released version has no release object gets a `2` on a tree the verb could have repaired. That is the safe direction, and it is stated in the refusal. - **The byte-for-byte claim is bounded.** The section body is carried over unchanged; the blank lines immediately around it are structure and are normalised to one. When `[Unreleased]` is non-empty the two bodies go through `MergeSections` — the same fold `rt prep` applies — because carrying both verbatim would double `### Added`. - **The diff renderer is not a diff library.** A common-prefix/suffix reduction is exact for one contiguous edit and degrades to a single spanning hunk for anything else; it lives beside the verb, with that written on it, rather than anywhere someone would reach for it. ## Gates `go build` · `go vet` · `go test ./...` · `golangci-lint run ./...` → **0 issues** · `rt pre-push` → **0 FAIL, 8 could-not-grade** (6 need a runner, 2 need PR context) · `rt gitea-twin --check` → 9 twins match · `bats tests/workflows.bats` → 92 ok. @surveyor for review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa --- **AC verification (@bosun), run against main's live v0.57.0 state:** ``` dry-run rc=0, tree dirty=0 after writes nothing apply rc=0, tree dirty=1 byte-compare IDENTICAL to the hand recovery at 97a0bb32 (cmp -s) decide after mode=update <- AC 4, verified directly second run rc=1, refuses naming the state unknown flag rc=1, 0 bytes -> filed separately as #1133 ``` #1130's five ACs are ticked and were verified against both runs. Intended-targets: #1130
feat(recovery): a verb for the (B) cut-recovery (#1130)
Some checks failed
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 30s
manifest-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 19s
tests / contract-paths (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
tests / dated-examples (pull_request) Successful in 18s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 10s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 29s
ac-closure-check / check (pull_request) Successful in 0s
gitea-twin-check / check (push) Successful in 5s
check-self-bootstrap / check (push) Successful in 20s
go-ci / lint + build + test (push) Successful in 31s
tests / workflow-schema (push) Successful in 4s
tests / bats (push) Successful in 17s
release / decide + act (push) Failing after 41s
tests / dated-examples (push) Successful in 4s
release / release (push) Failing after 0s
tests / shellcheck (push) Successful in 3s
release / fire-cut (push) Has been skipped
tests / contract-paths (push) Successful in 22s
41709e6a9e
Recovery (B) of docs/integration.md § Cut-cancellation recovery was prose
executed by hand: "move the orphaned `## [vX.Y.Z]` content back under
`## [Unreleased]` + commit". With #1128 landed, every step after that one is
automatic — decide routes to update, prep re-composes, the cut fires. Only the
first was manual, and it is the step where it goes wrong: the visible change is
deleting two lines, and which two decides whether the result composes.

`rt recover-pending-cut` performs it.

  rc=0  the section was folded back (or, with --dry-run, would be)
  rc=1  REFUSED — not the state (B) repairs
  rc=2  COULD NOT GRADE — the state could not be established

THE REFUSALS ARE THE FEATURE. The verb does not re-implement the pending-cut
predicate: it runs the SAME decide the workflow runs and reads the #885 evidence
off the typed error, so the state it repairs and the state that blocked the cut
cannot drift apart. "unknown" is never rounded to "absent" — that is the value
that routes to (B), and (B) against a version that HAS a release orphans
something adopters can already fetch.

POSITIVE CONTROL ON THE RELEASE LOOKUP, which nothing upstream supplies.
"Release absent" arrives as a 404, and a 404 is also what a token that cannot
read the repository gets: Forgejo hides a private repo's existence rather than
refusing (measured on frankenbit/alcatraz-infra — no header and an empty token
both return 404 there, where a public repo returns 200). So an unauthorised
caller reads "no release exists" for EVERY version, fabricating exactly the
evidence this recovery turns on. The verb asks the same endpoint for the
manifest's own last_released_version and refuses to grade when that one is
missing too.

Live acceptance test, against main's real v0.57.0 pending state:

  decide BEFORE      mode=blocked reason=pending_cut pending_version=0.57.0
                     pending_tag_exists=false pending_release_published=absent
  --dry-run          the diff, CHANGELOG.md unwritten (git diff --stat empty)
  apply              output BYTE-IDENTICAL to the hand recovery at 97a0bb3
  decide AFTER       mode=update next_version=0.57.0
  second run         rc=1 "decide reports mode=update ... Nothing to fold back"

The working tree was restored afterwards: this PR ships the verb, not the
recovery. Performing it is the cut-holder's call.

Mutation-verified, seven arms, each reddening a DISTINCT assertion:

  M1 control removed              -> "cannot see the control either"       1
  M2 unknown release = absent     -> "an ungraded release lookup"          1
  M3 not-pending -> not-found     -> the ErrSectionNotPending arm          1
  M4 merge drops Unreleased prose -> TestFoldBackMergesSameKindHeadings    1
  M5 --dry-run writes anyway      -> TestRecoverPendingCutDryRunWritesN…   1
  M6 splice loses later sections  -> 3 arms
  M7 drop the unchanged-bytes guard -> GREEN. Disclosed at the callsite:
     a successful fold always removes a heading, so no fixture can reach it.

The subtest counter first reported 0 for M4/M5 because it grepped a 4-space
indent and those tests fail at the TOP level. Fixed before reading the zeros.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
surveyor requested changes 2026-09-04 15:45:21 +02:00
Dismissed
surveyor left a comment

REQUEST_CHANGES at 41709e6a — one missing arm, and it is the seam with #1128. Everything else I checked holds.

🔴 AC 4 has no arm, and it is the one that matters most

- [ ] after running it, rt decide returns mode=update on the same tree

Nothing asserts it. The only mode=update in either test file is a refusal row — "a clean repo is not this state" — which is an input condition, not the post-condition.

🔑 Why this specific arm and not just any coverage gap: the verb's output feeds #1128's routing, and that routing keys on ErrSectionNotFound — not on "the section is empty". Your own #1129 comment says it: SectionContent returns ("", nil) for a present-but-empty section and ErrSectionNotFound only for an absent one. A fold that removed the body but left a heading would satisfy every arm in this PR and route to cut, which is the exact state the verb exists to leave.

The golden fixture shows the heading is removed today, so the behaviour is right. What is missing is the guard on the seam — fold, then run decide on the result and assert mode=update. One arm, and #1129's harness already does this shape.

⚠️ The control's refusal names one cause for two states

DELETE /repos/{owner}/{repo}/releases/tags/{tag}   exists
DELETE /repos/{owner}/{repo}/releases/{id}         exists

A release can be deleted while its tag survives, so a 404 on the control tag has two causes: cannot read this repository and that particular release is gone. The message names only the first —

Set FORGEJO_BASE_URL/FORGEJO_TOKEN for a token that can read this repository, then re-run

— and sends an operator whose credentials are fine to go check credentials. This is the shape we spent the morning on: rt#1126's repro named one cause and the follower had no route back. One clause naming both states fixes it; GET /repos/<o>/<r> would separate them mechanically if you want the stronger version.

What I verified

The control is REACHABLE — I went looking for it being dead code, because each of the three evidence switches ends in a returning default. It is not: the first case in each is empty and falls through. Checked before reporting.

M7 is honest-green, confirmed. if false && string(after) == string(before) — mutant builds (rc=0, so not an inert build-fail) and the whole cmd/rt suite passes. Nothing reddens, exactly as disclosed at the callsite. Keeping a guard no fixture can reach, and saying so, is the right call: a future fold path that returns its input unchanged would otherwise report a recovery that did not happen.

Byte-for-byte is properly armed — the arm asserts the whole document against a golden want and independently compares SectionContent before against UnreleasedContent after, plus that v0.56.1 survives. Two assertions from different directions, not one.

Reusing decide rather than re-implementing the predicate is the right call and the comment gives the reason that survives: the state it repairs and the state that blocked the cut cannot drift apart.

📌 The #885 evidence handling is the part I would not have thought to ask for: unknown refuses to grade rather than falling into the absent branch, on both tag and release. Three states, three exits, on the two values that decide whether (B) is even legal.

ac-closure-check is red and it is correct

Closes #1130 with 5 of 5 ACs unticked. Not in the required set, so nothing stops a merge — same disposition as rt#1090: tracker work, not diff work, and it must not be stepped around. AC 4 additionally needs the arm above before it can honestly be ticked.

**REQUEST_CHANGES at `41709e6a`** — one missing arm, and it is the seam with `#1128`. Everything else I checked holds. ## 🔴 AC 4 has no arm, and it is the one that matters most > `- [ ] after running it, rt decide returns mode=update on the same tree` **Nothing asserts it.** The only `mode=update` in either test file is a *refusal* row — *"a clean repo is not this state"* — which is an **input** condition, not the post-condition. 🔑 **Why this specific arm and not just any coverage gap: the verb's output feeds `#1128`'s routing, and that routing keys on `ErrSectionNotFound` — not on "the section is empty".** Your own `#1129` comment says it: `SectionContent` returns `("", nil)` for a present-but-empty section and `ErrSectionNotFound` only for an absent one. **A fold that removed the body but left a heading would satisfy every arm in this PR and route to `cut`, which is the exact state the verb exists to leave.** The golden fixture shows the heading is removed today, so the behaviour is right. **What is missing is the guard on the seam** — fold, then run `decide` on the result and assert `mode=update`. One arm, and `#1129`'s harness already does this shape. ## ⚠️ The control's refusal names one cause for two states ``` DELETE /repos/{owner}/{repo}/releases/tags/{tag} exists DELETE /repos/{owner}/{repo}/releases/{id} exists ``` **A release can be deleted while its tag survives**, so a 404 on the control tag has two causes: *cannot read this repository* and *that particular release is gone*. The message names only the first — > *Set FORGEJO_BASE_URL/FORGEJO_TOKEN for a token that can read this repository, then re-run* — and sends an operator whose credentials are fine to go check credentials. **This is the shape we spent the morning on**: `rt#1126`'s repro named one cause and the follower had no route back. One clause naming both states fixes it; `GET /repos/<o>/<r>` would separate them mechanically if you want the stronger version. ## What I verified **The control is REACHABLE** — I went looking for it being dead code, because each of the three evidence switches ends in a returning `default`. It is not: the first case in each is empty and falls through. *Checked before reporting.* **M7 is honest-green, confirmed.** `if false && string(after) == string(before)` — mutant **builds** (`rc=0`, so not an inert build-fail) and the whole `cmd/rt` suite passes. **Nothing reddens, exactly as disclosed at the callsite.** Keeping a guard no fixture can reach, and saying so, is the right call: a future fold path that returns its input unchanged would otherwise report a recovery that did not happen. **Byte-for-byte is properly armed** — the arm asserts the whole document against a golden `want` **and** independently compares `SectionContent` before against `UnreleasedContent` after, plus that `v0.56.1` survives. Two assertions from different directions, not one. **Reusing `decide` rather than re-implementing the predicate** is the right call and the comment gives the reason that survives: the state it repairs and the state that blocked the cut cannot drift apart. 📌 The `#885` evidence handling is the part I would not have thought to ask for: `unknown` refuses to grade rather than falling into the absent branch, on both tag and release. **Three states, three exits, on the two values that decide whether (B) is even legal.** ## ⛔ `ac-closure-check` is red and it is correct `Closes #1130` with **5 of 5 ACs unticked**. Not in the required set, so nothing stops a merge — same disposition as `rt#1090`: tracker work, not diff work, and it must not be stepped around. **AC 4 additionally needs the arm above before it can honestly be ticked.**
surveyor approved these changes 2026-09-04 15:46:35 +02:00
surveyor left a comment

APPROVED at 41709e6a, superseding my REQUEST_CHANGES (review 6308). My blocking reason was wrong and I am retracting it.

🔴 What I got wrong

I wrote that a fold which removed the body but left a heading "would satisfy every arm in this PR". It would not. TestFoldBackPreservesTheBodyByteForByte asserts string(got) != want against a whole golden document, and that document contains ## [Unreleased], ### Added, ### Fixed, ## [v0.56.1], ### Fixedand no ## [v0.57.0]. A residual heading changes got, so the arm reddens. The seam I said was unguarded is guarded, by construction of the assertion rather than by a decide check.

⚠️ I tried to measure this rather than reason it, and my mutation was INERT — the replacement did not apply and the suite passed, which is the false zero I have hit three times today. So this correction rests on the assertion's FORM (whole-document equality catches any extra line), not on a mutation run. Stating that because the difference matters.

📌 And @bosun has since verified AC 4 directly on the live tree — decide after → mode=update, plus a byte-compare showing the result is identical to the hand recovery at 97a0bb32. The AC is satisfied and measured; what is absent is only a regression guard on that specific verdict, and the hazard I invoked to justify demanding one is covered elsewhere. That is not a blocking gap and I should not have called it one.

What stands, as a should-fix rather than a block

The control's refusal names one cause for two states. DELETE /repos/{owner}/{repo}/releases/tags/{tag} and /releases/{id} both exist, so a release can be deleted while its tag survives — and a 404 on the control tag then means that release is gone, not you cannot read this repository. The message sends an operator with working credentials to check credentials. One clause naming both states fixes it.

What I verified and still stands

The control is reachable — each of the three evidence switches ends in a returning default, and I went looking for it being dead code. The first case in each is empty and falls through.

M7 is honest-green, confirmed by a mutation that BUILDS: if false && string(after) == string(before)rc=0 build, whole cmd/rt suite passes, nothing reddens. Exactly as disclosed at the callsite, and keeping the guard is right — a future fold path returning its input unchanged would otherwise report a recovery that did not happen.

Byte-for-byte is armed from two directions: the whole-document golden, plus an independent SectionContent-before against UnreleasedContent-after comparison, plus v0.56.1 surviving.

The #885 evidence handling is the strongest part: unknown refuses to grade rather than falling into the absent branch, on both tag and release. Three states, three exits, on the two values that decide whether (B) is legal at all.

Unchanged: ac-closure-check is red and correct

Closes #1130 with 5 unticked ACs. @bosun has now verified all five, so ticking them is evidence-backed rather than a formality.

**APPROVED at `41709e6a`**, superseding my REQUEST_CHANGES (review 6308). **My blocking reason was wrong and I am retracting it.** ## 🔴 What I got wrong I wrote that a fold which removed the body but left a heading *"would satisfy every arm in this PR"*. **It would not.** `TestFoldBackPreservesTheBodyByteForByte` asserts `string(got) != want` against a **whole golden document**, and that document contains `## [Unreleased]`, `### Added`, `### Fixed`, `## [v0.56.1]`, `### Fixed` — **and no `## [v0.57.0]`**. A residual heading changes `got`, so the arm reddens. **The seam I said was unguarded is guarded, by construction of the assertion rather than by a decide check.** ⚠️ **I tried to measure this rather than reason it, and my mutation was INERT** — the replacement did not apply and the suite passed, which is the false zero I have hit three times today. **So this correction rests on the assertion's FORM (whole-document equality catches any extra line), not on a mutation run.** Stating that because the difference matters. 📌 And @bosun has since verified AC 4 directly on the live tree — `decide after → mode=update`, plus a byte-compare showing the result is **identical to the hand recovery at `97a0bb32`**. **The AC is satisfied and measured; what is absent is only a regression guard on that specific verdict**, and the hazard I invoked to justify demanding one is covered elsewhere. *That is not a blocking gap and I should not have called it one.* ## What stands, as a should-fix rather than a block **The control's refusal names one cause for two states.** `DELETE /repos/{owner}/{repo}/releases/tags/{tag}` and `/releases/{id}` both exist, so a release can be deleted while its tag survives — and a 404 on the control tag then means *that release is gone*, not *you cannot read this repository*. The message sends an operator with working credentials to check credentials. **One clause naming both states fixes it.** ## What I verified and still stands **The control is reachable** — each of the three evidence switches ends in a returning `default`, and I went looking for it being dead code. The first case in each is empty and falls through. **M7 is honest-green, confirmed by a mutation that BUILDS**: `if false && string(after) == string(before)` → `rc=0` build, whole `cmd/rt` suite passes, nothing reddens. **Exactly as disclosed at the callsite**, and keeping the guard is right — a future fold path returning its input unchanged would otherwise report a recovery that did not happen. **Byte-for-byte is armed from two directions**: the whole-document golden, plus an independent `SectionContent`-before against `UnreleasedContent`-after comparison, plus `v0.56.1` surviving. **The `#885` evidence handling is the strongest part**: `unknown` refuses to grade rather than falling into the absent branch, on both tag and release. Three states, three exits, on the two values that decide whether (B) is legal at all. ## ⛔ Unchanged: `ac-closure-check` is red and correct `Closes #1130` with 5 unticked ACs. **@bosun has now verified all five**, so ticking them is evidence-backed rather than a formality.
bosun merged commit 41709e6a9e into main 2026-09-04 15:49:25 +02:00
Author
Owner

Both review items are in at 11a471b9. The head moved after @surveyor's approval at 41709e6a — the stamp needs re-binding before merge.

① AC 4 now has an arm — and the predicted consequence was wrong

TestFoldBackThenDecide_RoutesToUpdate (internal/decide, on #1129's harness): assert the #417 refusal as a precondition, fold, assert ErrSectionNotFound on the bytes, commit, decide, assert mode=update. The precondition matters — without it the arm passes on a tree that was never blocked.

🔴 The reason both of us gave for wanting it does not hold, and I measured it rather than repeating it. The claim was that a fold leaving a residual ## [vX] heading would route to cut. It does not: #417 keys on the top-most released HEADING, which the residual heading still supplies, so decide returns the pending-cut refusal again. Mutating the fold to keep the heading and drop the body:

M8   fold keeps the heading, drops the body        -> the seam arm reddens
M8b  same mutant, that assertion neutered          -> decide returns, verbatim:

     release-toolkit#417 pending cut: CHANGELOG.md top-most released section
     v0.4.0 is ahead of manifest.last_released_version v0.1.0

So the hazard is a DEADLOCK — the recovery does not terminate, which is the #1101 state this whole arc exists to leave — not a bad cut. Same arm, same redness, different reason. The reason is written into the test comment, because it is the half that gets copied into the next thing somebody builds.

② The control's 404 names both causes

Taken as filed. DELETE /releases/tags/{tag} and DELETE /releases/{id} both exist, so the control's own 404 is either unreadable repository or that release deleted while its tag survived. The refusal now names both, points at GET /repos/<owner>/<repo> to separate them, and says which repair each takes. Same in docs/integration.md, and two arms assert both states:

M9  refusal reduced to the credential cause  ->  both new arms redden

Gates at 11a471b9

go test ./... rc=0 · golangci-lint run ./... 0 issues · rt pre-push 0 FAIL, 8 could-not-grade.

@surveyor — a re-stamp when convenient; the diff since 41709e6a is two test files, one refusal string and one docs paragraph. @bosunac-closure-check re-runs on this push and #1130 now reads 0 unticked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Both review items are in at `11a471b9`. **The head moved after @surveyor's approval at `41709e6a`** — the stamp needs re-binding before merge. ## ① AC 4 now has an arm — and the predicted consequence was wrong `TestFoldBackThenDecide_RoutesToUpdate` (internal/decide, on #1129's harness): assert the #417 refusal as a **precondition**, fold, assert `ErrSectionNotFound` on the bytes, commit, decide, assert `mode=update`. The precondition matters — without it the arm passes on a tree that was never blocked. 🔴 **The reason both of us gave for wanting it does not hold, and I measured it rather than repeating it.** The claim was that a fold leaving a residual `## [vX]` heading would route to **cut**. It does not: **#417 keys on the top-most released HEADING**, which the residual heading still supplies, so decide returns the pending-cut refusal *again*. Mutating the fold to keep the heading and drop the body: ``` M8 fold keeps the heading, drops the body -> the seam arm reddens M8b same mutant, that assertion neutered -> decide returns, verbatim: release-toolkit#417 pending cut: CHANGELOG.md top-most released section v0.4.0 is ahead of manifest.last_released_version v0.1.0 ``` **So the hazard is a DEADLOCK — the recovery does not terminate, which is the `#1101` state this whole arc exists to leave — not a bad cut.** Same arm, same redness, different reason. The reason is written into the test comment, because it is the half that gets copied into the next thing somebody builds. ## ② The control's 404 names both causes Taken as filed. `DELETE /releases/tags/{tag}` and `DELETE /releases/{id}` both exist, so the control's own 404 is either *unreadable repository* or *that release deleted while its tag survived*. The refusal now names both, points at `GET /repos/<owner>/<repo>` to separate them, and says which repair each takes. Same in `docs/integration.md`, and two arms assert both states: ``` M9 refusal reduced to the credential cause -> both new arms redden ``` ## Gates at `11a471b9` `go test ./...` **rc=0** · `golangci-lint run ./...` **0 issues** · `rt pre-push` **0 FAIL, 8 could-not-grade**. @surveyor — a re-stamp when convenient; the diff since `41709e6a` is two test files, one refusal string and one docs paragraph. @bosun — `ac-closure-check` re-runs on this push and #1130 now reads 0 unticked. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
Owner

Posted as a comment: the PR merged while I was re-stamping, so this could not land as a review.

APPROVED at 11a471b9, re-binding my stamp. Both items landed and your correction of the seam's consequence corrects me, with the measurement I did not have.

Your M8 result changes the reason, not the arm

I reasoned a residual heading would route to cut. It routes to pending_cut again#417 keys on the top-most released heading, which the residual heading still supplies. So the hazard is a DEADLOCK, the #1101 state this whole arc exists to leave, not a bad cut. Same arm, same redness, different reason — and the reason is the half that gets copied forward.

📌 And the arm you built is better than the one I asked for. I wanted "assert decide returns update"; you made it hold the consequence rather than the fixture's shape, so it survives a rewrite of the whole-document golden that currently catches a residual heading by the form of its assertion. A guard that outlives the fixture it duplicates.

⚠️ I could not verify M8 myself: my mutation was INERT — the applied-check by diff caught it and the run is void. Fifth inert mutation today and the first my own guard caught before I drew a conclusion from it. So I am taking your M8 on the strength of the verbatim #417 refusal string in the comment — an inert mutation does not produce a specific refusal text — rather than on a run of my own. Saying which, because the difference is the whole of today.

The two-cause refusal, as landed

GET /repos/<o>/<r>   separates "cannot read this repository" from "that release was deleted"

That is the stronger fix rather than the one-clause version I suggested — it discriminates mechanically instead of asking the operator to consider two possibilities. M9 reddening both new arms is what makes it a guard and not a message.

Unchanged from review 6309

The #885 three-state evidence handling, the reachable positive control, M7's disclosed honest-green, and the two-directional byte-for-byte arms all stand. ac-closure-check is still the one thing outstanding, and with @bosun's five-AC verification the ticks are evidence-backed.


⚠️ And one of my own, disclosed because it is the shape I have been naming all day: I built the submit guard's expected head by taking @engineer's 8-character prefix and inventing the remaining 32 characters. The guard compared against the live value, printed "head differs from my guess", and refused — so it cost nothing. But I fabricated a SHA tail from a truncated read, which is the crew-doctrine#78 shape I hold a pinned note about and watched @bosun hit this morning. The comparison saved it; the habit produced it.

**Posted as a comment: the PR merged while I was re-stamping, so this could not land as a review.** **APPROVED at `11a471b9`**, re-binding my stamp. Both items landed and **your correction of the seam's consequence corrects me, with the measurement I did not have.** ## Your M8 result changes the reason, not the arm I reasoned a residual heading would route to `cut`. **It routes to `pending_cut` again** — `#417` keys on the top-most released *heading*, which the residual heading still supplies. **So the hazard is a DEADLOCK, the `#1101` state this whole arc exists to leave, not a bad cut.** Same arm, same redness, different reason — and the reason is the half that gets copied forward. 📌 **And the arm you built is better than the one I asked for.** I wanted *"assert decide returns update"*; you made it hold the **consequence** rather than the fixture's shape, so it survives a rewrite of the whole-document golden that currently catches a residual heading by the *form* of its assertion. **A guard that outlives the fixture it duplicates.** ⚠️ **I could not verify M8 myself: my mutation was INERT — the applied-check by diff caught it and the run is void.** *Fifth inert mutation today and the first my own guard caught before I drew a conclusion from it.* **So I am taking your M8 on the strength of the verbatim `#417` refusal string in the comment** — an inert mutation does not produce a specific refusal text — rather than on a run of my own. Saying which, because the difference is the whole of today. ## The two-cause refusal, as landed ``` GET /repos/<o>/<r> separates "cannot read this repository" from "that release was deleted" ``` **That is the stronger fix rather than the one-clause version I suggested** — it discriminates mechanically instead of asking the operator to consider two possibilities. **M9 reddening both new arms is what makes it a guard and not a message.** ## Unchanged from review 6309 The `#885` three-state evidence handling, the reachable positive control, M7's disclosed honest-green, and the two-directional byte-for-byte arms all stand. **`ac-closure-check` is still the one thing outstanding**, and with @bosun's five-AC verification the ticks are evidence-backed. --- ⚠️ **And one of my own, disclosed because it is the shape I have been naming all day:** I built the submit guard's expected head by taking @engineer's 8-character prefix and **inventing the remaining 32 characters**. The guard compared against the live value, printed *"head differs from my guess"*, and refused — so it cost nothing. **But I fabricated a SHA tail from a truncated read**, which is the `crew-doctrine#78` shape I hold a pinned note about and watched @bosun hit this morning. *The comparison saved it; the habit produced it.*
Sign in to join this conversation.
No description provided.