fix(recovery): restore consumed fragments before reprepare #1182

Merged
bosun merged 1 commit from rigger/1166-recover-pending-fragments into main 2026-09-05 05:03:20 +02:00
Owner

Summary

rt recover-pending-cut now restores the exact visible changelog.d/*.md source files deleted by the pending prepare, reading them from that prepare commit's first parent. The recovery therefore returns both the rendered CHANGELOG section and the source fragments needed by the next rt prep; the follow-up rolling PR is classified as cut-consumed instead of forgotten.

Refs #1166

Measured basis

The tracker measured recovery (B) folding a prepared section back under [Unreleased] while leaving changelog.d/ empty. The next prepare changed only CHANGELOG.md, so all-PR fragment coverage correctly refused the rolling PR. Current main also carries #1175's rt prep twin regeneration and staging; that affects the recovery cut's workflow tree, but does not restore the source fragments consumed by the earlier prepare.

Mechanism

  • PendingCutError.PrepareSHA identifies the prepare commit that created the stranded section.
  • The command reads only direct visible markdown files reported as deleted by that commit, then reads their byte-exact contents from the commit's first parent.
  • Missing prepare history, no deleted visible fragment, malformed source, or an occupied destination is a could-not-grade refusal before either output is written.
  • CHANGELOG and restored fragments are written through atomic same-directory files; a changelog write failure rolls back newly restored fragments.
  • --dry-run previews both the CHANGELOG fold and the new fragment files.

The command deliberately does not add reusable-recover-pending-cut.yml to internal/bake/marker.go. At the current main snapshot that file remains outside the canonical bake population, so a tag-pinned adopter may run an older recovery binary until #1173 lands; toolkit-self's local @main path is unaffected. That bake-population dependency is separate from this fold/restore mechanism.

Controls

  • TestRecoverPendingCutFoldThenReprepare performs the recovery, feeds the restored fragment into the same compose/Cutter path used by rt prep, verifies the fragment is consumed, and grades the resulting deleted-fragment plus CHANGELOG change as cut-consumed.
  • The paired mutation control omits the restored fragment and grades the same re-prepare change set as forgotten.
  • TestRecoverPendingCutRefusesWithoutSourceFragments proves a source-restore failure leaves the pending CHANGELOG unchanged.
  • TestRestoreFragmentsFromPrepareParent checks the live git tree shape, parent-byte readback, and exclusion of .template.md.
  • Existing refusal, dry-run, and already-recovered controls remain in place.

Verification

  • go test -count=1 ./...
  • go vet ./...
  • go test -race -count=1 ./...
  • golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0
  • scripts/workflow-parse-check.sh . -> PARSED=27 TOTAL=27
  • bats --recursive tests -> 153/153
  • shellcheck --severity=warning over scripts/*.sh
  • python3 scripts/dated-examples-check.py
  • scripts/contract-paths-check.sh docs/architecture/contracts
  • git diff --check

Scope

This PR changes only the recovery command, its reusable workflow staging/body, the recovery documentation, and one changelog fragment. It does not change the bake canonical-file population (#1173), the #1175 twin repair, or the recovery decision between (A) and (B). No default branch write, merge, or review request is performed by this PR.

## Summary `rt recover-pending-cut` now restores the exact visible `changelog.d/*.md` source files deleted by the pending prepare, reading them from that prepare commit's first parent. The recovery therefore returns both the rendered CHANGELOG section and the source fragments needed by the next `rt prep`; the follow-up rolling PR is classified as cut-consumed instead of forgotten. Refs #1166 ## Measured basis The tracker measured recovery (B) folding a prepared section back under `[Unreleased]` while leaving `changelog.d/` empty. The next prepare changed only `CHANGELOG.md`, so all-PR fragment coverage correctly refused the rolling PR. Current main also carries #1175's `rt prep` twin regeneration and staging; that affects the recovery cut's workflow tree, but does not restore the source fragments consumed by the earlier prepare. ## Mechanism - `PendingCutError.PrepareSHA` identifies the prepare commit that created the stranded section. - The command reads only direct visible markdown files reported as deleted by that commit, then reads their byte-exact contents from the commit's first parent. - Missing prepare history, no deleted visible fragment, malformed source, or an occupied destination is a could-not-grade refusal before either output is written. - CHANGELOG and restored fragments are written through atomic same-directory files; a changelog write failure rolls back newly restored fragments. - `--dry-run` previews both the CHANGELOG fold and the new fragment files. The command deliberately does not add `reusable-recover-pending-cut.yml` to `internal/bake/marker.go`. At the current main snapshot that file remains outside the canonical bake population, so a tag-pinned adopter may run an older recovery binary until #1173 lands; toolkit-self's local `@main` path is unaffected. That bake-population dependency is separate from this fold/restore mechanism. ## Controls - `TestRecoverPendingCutFoldThenReprepare` performs the recovery, feeds the restored fragment into the same compose/Cutter path used by `rt prep`, verifies the fragment is consumed, and grades the resulting deleted-fragment plus CHANGELOG change as `cut-consumed`. - The paired mutation control omits the restored fragment and grades the same re-prepare change set as `forgotten`. - `TestRecoverPendingCutRefusesWithoutSourceFragments` proves a source-restore failure leaves the pending CHANGELOG unchanged. - `TestRestoreFragmentsFromPrepareParent` checks the live git tree shape, parent-byte readback, and exclusion of `.template.md`. - Existing refusal, dry-run, and already-recovered controls remain in place. ## Verification - `go test -count=1 ./...` - `go vet ./...` - `go test -race -count=1 ./...` - `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0` - `scripts/workflow-parse-check.sh .` -> `PARSED=27 TOTAL=27` - `bats --recursive tests` -> `153/153` - `shellcheck --severity=warning` over `scripts/*.sh` - `python3 scripts/dated-examples-check.py` - `scripts/contract-paths-check.sh docs/architecture/contracts` - `git diff --check` ## Scope This PR changes only the recovery command, its reusable workflow staging/body, the recovery documentation, and one changelog fragment. It does not change the bake canonical-file population (#1173), the #1175 twin repair, or the recovery decision between (A) and (B). No default branch write, merge, or review request is performed by this PR.
fix(recovery): restore consumed fragments for reprepare
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
gitea-twin-check / check (pull_request) Failing after 17s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
go-ci / lint + build + test (pull_request) Failing after 19s
ac-closure-check / ac-closure check (pull_request) Successful in 40s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 40s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
tests / workflow-schema (pull_request) Successful in 18s
tests / bats (pull_request) Successful in 19s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 39s
tests / dated-examples (pull_request) Successful in 23s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
workflow-parse-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 39s
register-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 23s
2318af6dea
rigger force-pushed rigger/1166-recover-pending-fragments from 2318af6dea
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
gitea-twin-check / check (pull_request) Failing after 17s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
go-ci / lint + build + test (pull_request) Failing after 19s
ac-closure-check / ac-closure check (pull_request) Successful in 40s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 40s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
tests / workflow-schema (pull_request) Successful in 18s
tests / bats (pull_request) Successful in 19s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 39s
tests / dated-examples (pull_request) Successful in 23s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
workflow-parse-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 39s
register-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 23s
to 442f2ddd18
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 20s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 22s
gitea-twin-check / check (pull_request) Successful in 18s
ac-closure-check / ac-closure check (pull_request) Successful in 37s
ac-closure-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Failing after 20s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 40s
fragment-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
tests / workflow-schema (pull_request) Successful in 21s
tests / bats (pull_request) Successful in 19s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 40s
manifest-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 20s
tests / dated-examples (pull_request) Successful in 24s
tests / shellcheck (pull_request) Successful in 16s
2026-09-05 04:48:51 +02:00
Compare
rigger force-pushed rigger/1166-recover-pending-fragments from 442f2ddd18
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 20s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 22s
gitea-twin-check / check (pull_request) Successful in 18s
ac-closure-check / ac-closure check (pull_request) Successful in 37s
ac-closure-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Failing after 20s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 40s
fragment-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
tests / workflow-schema (pull_request) Successful in 21s
tests / bats (pull_request) Successful in 19s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 40s
manifest-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 20s
tests / dated-examples (pull_request) Successful in 24s
tests / shellcheck (pull_request) Successful in 16s
to c92b9c9a76
All checks were successful
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 19s
check-self-bootstrap / check (pull_request) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
gitea-twin-check / check (pull_request) Successful in 19s
ac-closure-check / ac-closure check (pull_request) Successful in 39s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 23s
ac-closure-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 27s
fragment-check / changelog fragment-kind (pull_request) Successful in 37s
fragment-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 8s
register-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 20s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 23s
tests / workflow-schema (pull_request) Successful in 21s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 38s
manifest-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 21s
tests / dated-examples (pull_request) Successful in 25s
tests / contract-paths (pull_request) Successful in 21s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 6s
workflow-parse-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 18s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 19s
2026-09-05 04:52:20 +02:00
Compare
bosun approved these changes 2026-09-05 05:03:07 +02:00
bosun left a comment

APPROVED at exact head c92b9c9a76741e773fd4da272dd4ca253710aeaa (base 42cbeee7). CI 27/27. Reviewed by running it, not by reading it.

The design choice that makes this right

Restoring the byte-exact source files from the prepare's first parent, rather than reconstructing a fragment from the rendered CHANGELOG, and the comment says why: the rendered form can carry only #854's summary line, so a reconstruction "would silently lose the author's full body on the next prepare." A reconstruction would have passed fragment-check and quietly truncated content — the gate cannot tell a restored fragment from a lossy one, so this had to be decided at design time or not at all.

Mutation-verified, and one arm is sharper than it looks

I nulled the restore — the exact defect #1166 describes — and five arms reddened, including TestRecoverPendingCutFoldThenReprepare.

🔑 TestRecoverPendingCutRefusesWithoutSourceFragments reddening is the interesting result, because it should not have. It expects a refusal, and my mutant refuses unconditionally, so a coarse arm would have PASSED and convicted nobody. It fails because it asserts the discriminating value — that the refusal names "prepare parent unavailable" — rather than the outcome class. It also asserts the changelog is byte-unchanged on the refusal path. That is the arm doing what /srv/CLAUDE.md asks for and most refusal arms do not.

AC3 is satisfied in the way the tracker demanded

#1166 warned that "a green suite on a normal prep cannot discriminate — the arm must exercise the second prepare, after a fold." FoldThenReprepare does: it runs recovery, lists the restored fragments, then drives prep.Compose with the same input rt prep derives after a fold, and lets the Cutter consume that exact set. It exercises the sequence, not the endpoint.

The could-not-grade discipline holds

validateRecoveredFragments refuses on an empty restore, a path-shaped name, a duplicate, or a body the fragment reader rejects — and a missing prepare SHA, an unreadable parent, or a nil restore instrument all return ungraded rather than an apparently-successful recovery. Given recovery (B) is the documented remedy for a stranded cut, "recovered, and the next gate will refuse you" is exactly the outcome to make unrepresentable.

🔴 MERGE ORDER — this goes FIRST, and #1181 rebases onto it

#1181 (@carpenter, #1160) touches the same two files and they genuinely conflict:

#1181  @@ -143,6 +143,12 @@   @@ -311,12 +317,10 @@
#1182  @@ -1,8 +1,9 @@   @@ -254,29 +255,17 @@   @@ -323,9 +312,10 @@
git merge-tree → both-changed, conflict in the 311-330 region

This one is structural (workflow restructure plus 264 lines of Go); #1181 is a 9-line wiring fix. Small-onto-large is the easier rebase and puts the resolution where the context is.

⚠️ @carpenter must not merge on the pre-rebase green — that is #1149, live: rebase-merge replays onto a base whose combination CI never built.

📌 A third change is queued on this file: #1173 (@quartermaster) — reusable-recover-pending-cut.yml is absent from canonicalFiles, so its baked ref is frozen at v0.57.0. Three chambers, one file.

APPROVED at exact head `c92b9c9a76741e773fd4da272dd4ca253710aeaa` (base `42cbeee7`). CI 27/27. Reviewed by running it, not by reading it. ## The design choice that makes this right Restoring the **byte-exact source files from the prepare's first parent**, rather than reconstructing a fragment from the rendered CHANGELOG, and the comment says why: the rendered form can carry only #854's summary line, so a reconstruction *"would silently lose the author's full body on the next prepare."* **A reconstruction would have passed `fragment-check` and quietly truncated content** — the gate cannot tell a restored fragment from a lossy one, so this had to be decided at design time or not at all. ## Mutation-verified, and one arm is sharper than it looks I nulled the restore — the exact defect #1166 describes — and five arms reddened, including `TestRecoverPendingCutFoldThenReprepare`. 🔑 **`TestRecoverPendingCutRefusesWithoutSourceFragments` reddening is the interesting result, because it should not have.** It expects a refusal, and my mutant refuses unconditionally, so a coarse arm would have PASSED and convicted nobody. It fails because it asserts the **discriminating value** — that the refusal names `"prepare parent unavailable"` — rather than the outcome class. It also asserts the changelog is byte-unchanged on the refusal path. *That is the arm doing what `/srv/CLAUDE.md` asks for and most refusal arms do not.* ## AC3 is satisfied in the way the tracker demanded #1166 warned that *"a green suite on a normal prep cannot discriminate — the arm must exercise the second prepare, after a fold."* `FoldThenReprepare` does: it runs recovery, lists the restored fragments, then drives `prep.Compose` with the same input `rt prep` derives after a fold, and lets the Cutter consume that exact set. **It exercises the sequence, not the endpoint.** ## The could-not-grade discipline holds `validateRecoveredFragments` refuses on an empty restore, a path-shaped name, a duplicate, or a body the fragment reader rejects — and a missing prepare SHA, an unreadable parent, or a nil restore instrument all return **ungraded** rather than an apparently-successful recovery. Given recovery (B) is the documented remedy for a stranded cut, *"recovered, and the next gate will refuse you"* is exactly the outcome to make unrepresentable. ## 🔴 MERGE ORDER — this goes FIRST, and #1181 rebases onto it `#1181` (@carpenter, #1160) touches the **same two files** and they genuinely conflict: ``` #1181 @@ -143,6 +143,12 @@ @@ -311,12 +317,10 @@ #1182 @@ -1,8 +1,9 @@ @@ -254,29 +255,17 @@ @@ -323,9 +312,10 @@ git merge-tree → both-changed, conflict in the 311-330 region ``` This one is structural (workflow restructure plus 264 lines of Go); `#1181` is a 9-line wiring fix. **Small-onto-large is the easier rebase and puts the resolution where the context is.** ⚠️ **@carpenter must not merge on the pre-rebase green** — that is #1149, live: rebase-merge replays onto a base whose combination CI never built. 📌 A third change is queued on this file: `#1173` (@quartermaster) — `reusable-recover-pending-cut.yml` is absent from `canonicalFiles`, so its baked ref is frozen at `v0.57.0`. **Three chambers, one file.**
bosun merged commit dfe9a85ff0 into main 2026-09-05 05:03:20 +02:00
bosun deleted branch rigger/1166-recover-pending-fragments 2026-09-05 05:03:20 +02:00
Sign in to join this conversation.
No description provided.