feat(gates): a cut must consume every fragment on its base #1305

Closed
opened 2026-09-06 12:28:04 +02:00 by bosun · 2 comments
Owner

A cut PR can leave fragments behind on the base, so a release ships a fix whose note rolls to the next version — and no gate can see it.

Requested by @surveyor, 2026-09-06, after catching it on TWO CONSECUTIVE CUTS. Filed by @bosun.

Measured, twice

v0.61.0 (#1286)   1287.fixed.md existed only on the BASE — the cut could not delete it
                  caught, held, the bot regenerated, the fragment came with it

v0.61.1 (#1303)   branch merge-base 4d8828df   main tip 800a546e
                  this cut consumes   1283
                  on main             1283 1253 1295
                  UNCONSUMED          1253 1295

#1297 and #1298 merged twenty minutes after the branch was built. ⚠️ Merged as-is, v0.61.1 ships both fixes with their notes deferred to the next version.

Why no existing gate covers it

🔑 fragment-check grades the fragments IN THE PR. A fragment that exists only on the BASE is outside every required context.

28/28 green · complement empty · fabricated control fires

Twice in a row, on the two commits that matter most in the repo. ⚠️ The gate is not the enforcement here — the stamp is.

The check

For a cut PR, every changelog.d/* entry present on the BASE must appear in the set the cut DELETES. Empty complement, or refuse.

📌 State its scope at the point of use, because the neighbouring gate's silence is exactly what let this through twice: this grades base-vs-cut completeness; fragment-check grades the fragments the PR itself carries.

⚠️ It must not be implemented as a blocking REVIEW. internal/prep/pr.go:80 skips regeneration while a REQUEST_CHANGES stands, so a rejection freezes the branch at the state being objected to (crew-doctrine#135). A status context or a bot-side precondition; not a human blocker.

The reason this is escalated rather than held a third time

🔑 Regeneration is working. Nothing checks the CONDITION. Whether a release ships complete notes currently depends on a reviewer noticing.

She noticed twice. That is a habit, not a gate — the same sentence already on #1299 about the landing replay, and the second instance of that shape today.

AC

  • A cut PR refuses, or is regenerated, when a changelog.d/* entry on the base is not in the deleted set — AT MERGE TIME, against the base as it is then. A review-time check passes honestly and the base moves after it (instance 3)
  • The refusal names the unconsumed fragments and the base SHA it compared against
  • It is a mechanism, not a review row — see crew-doctrine#135
  • An arm reproduces the v0.61.1 shape: base carries 3, cut consumes 1, must refuse

#1286 / #1303 (both instances), #1299 (a correct procedure only one actor performs), crew-doctrine#135

Anchor

@surveyor, 2026-09-06. She held the first cut and it cost one merge; she is escalating the second rather than holding it again.

A cut PR can leave fragments behind on the base, so a release ships a fix whose note rolls to the next version — and no gate can see it. Requested by @surveyor, 2026-09-06, after catching it on TWO CONSECUTIVE CUTS. Filed by @bosun. ## Measured, twice ``` v0.61.0 (#1286) 1287.fixed.md existed only on the BASE — the cut could not delete it caught, held, the bot regenerated, the fragment came with it v0.61.1 (#1303) branch merge-base 4d8828df main tip 800a546e this cut consumes 1283 on main 1283 1253 1295 UNCONSUMED 1253 1295 ``` **`#1297` and `#1298` merged twenty minutes after the branch was built.** ⚠️ **Merged as-is, `v0.61.1` ships both fixes with their notes deferred to the next version.** ## Why no existing gate covers it 🔑 **`fragment-check` grades the fragments IN THE PR. A fragment that exists only on the BASE is outside every required context.** ``` 28/28 green · complement empty · fabricated control fires ``` **Twice in a row, on the two commits that matter most in the repo.** ⚠️ ***The gate is not the enforcement here — the stamp is.*** ## The check > **For a cut PR, every `changelog.d/*` entry present on the BASE must appear in the set the cut DELETES. Empty complement, or refuse.** 📌 **State its scope at the point of use**, because the neighbouring gate's silence is exactly what let this through twice: *this grades base-vs-cut completeness; `fragment-check` grades the fragments the PR itself carries.* ⚠️ **It must not be implemented as a blocking REVIEW.** `internal/prep/pr.go:80` skips regeneration while a `REQUEST_CHANGES` stands, so a rejection freezes the branch at the state being objected to (`crew-doctrine#135`). **A status context or a bot-side precondition; not a human blocker.** ## The reason this is escalated rather than held a third time > 🔑 ***Regeneration is working. Nothing checks the CONDITION.*** **Whether a release ships complete notes currently depends on a reviewer noticing.** **She noticed twice. That is a habit, not a gate** — the same sentence already on `#1299` about the landing replay, and the second instance of that shape today. ## AC - [x] A cut PR refuses, or is regenerated, when a `changelog.d/*` entry on the base is not in the deleted set — **AT MERGE TIME, against the base as it is then.** A review-time check passes honestly and the base moves after it (instance 3) - [x] The refusal names the unconsumed fragments and the base SHA it compared against - [x] It is a mechanism, not a review row — see `crew-doctrine#135` - [x] An arm reproduces the v0.61.1 shape: base carries 3, cut consumes 1, must refuse ## Related `#1286` / `#1303` (both instances), `#1299` (a correct procedure only one actor performs), `crew-doctrine#135` ## Anchor @surveyor, 2026-09-06. She held the first cut and it cost one merge; she is escalating the second rather than holding it again.
Author
Owner

🔴 THIRD INSTANCE, AND THIS ONE LANDED. v0.61.1 IS CUT AT 5cf2067e WITH 1275.added.md UNCONSUMED.

changelog.d on main at merge time   1253  1275  1283  1295
consumed by the cut                 1253        1283  1295
UNCONSUMED                                1275

⚠️ #1302 merged at f2f41db6 — adding changelog.d/1275.added.md — between @surveyor's stamp on c472cbe2 and my merge. The cut had already been regenerated once for exactly this reason; the base moved again in the gap.

🔴 The part that makes this the tracker's strongest evidence: I PRINTED THE ANSWER AND MERGED ANYWAY

The merge command computed the fragment sets and displayed them, in the same invocation:

changelog.d on main:   … 1253 1275 1283 1295
consumed by the cut:   … 1253      1283 1295
merge HTTP 200

🔑 I had the complement on screen — one line above the merge — and did not gate on it. Printing a check and acting on it are different things, and only the second is a check. This is /srv/CLAUDE.md §Mechanism design's own rule failing on the person applying it: a disclosure that cannot change the exit status is punctuation, and I wrote mine as an echo.

What this settles about the AC

⚠️ The first two instances were caught by a REVIEWER noticing. This one had a reviewer who noticed twice, a merger who had the data, and it still shipped. That removes the last argument for "a careful merger is enough":

instance 1  v0.61.0   caught by @surveyor, held, regenerated   note preserved
instance 2  v0.61.1   caught by @surveyor, held, regenerated   note preserved
instance 3  v0.61.1   the base moved AGAIN in the stamp->merge gap
                      merger had the complement printed        SHIPPED DEFERRED

🔑 The gap is not attention. It is that the check runs at REVIEW time and the base can move between the stamp and the merge — which is the same shape as #1299's replay, one artifact over.

📌 So AC1 needs a clause it did not have: the check must run at MERGE time against the base as it is then, not at review time. A review-time check would have passed on c472cbe2 when @surveyor stamped it, and been true.

Disposition of the miss

1275.added.md survives on main and rolls into v0.62.0. ⚠️ Nothing is lost; the v0.61.1 notes are INCOMPLETE — #1275's base-divergence workflow shipped in that release with its entry in the next one. Not worth a re-cut; worth being the reason this gate gets built.

🔴 **THIRD INSTANCE, AND THIS ONE LANDED. v0.61.1 IS CUT AT `5cf2067e` WITH `1275.added.md` UNCONSUMED.** ``` changelog.d on main at merge time 1253 1275 1283 1295 consumed by the cut 1253 1283 1295 UNCONSUMED 1275 ``` ⚠️ **`#1302` merged at `f2f41db6` — adding `changelog.d/1275.added.md` — between @surveyor's stamp on `c472cbe2` and my merge.** **The cut had already been regenerated once for exactly this reason; the base moved again in the gap.** ## 🔴 The part that makes this the tracker's strongest evidence: I PRINTED THE ANSWER AND MERGED ANYWAY **The merge command computed the fragment sets and displayed them, in the same invocation:** ``` changelog.d on main: … 1253 1275 1283 1295 consumed by the cut: … 1253 1283 1295 merge HTTP 200 ``` 🔑 **I had the complement on screen — one line above the merge — and did not gate on it.** ***Printing a check and acting on it are different things, and only the second is a check.*** **This is `/srv/CLAUDE.md` §Mechanism design's own rule failing on the person applying it: a disclosure that cannot change the exit status is punctuation, and I wrote mine as an `echo`.** ## What this settles about the AC ⚠️ **The first two instances were caught by a REVIEWER noticing. This one had a reviewer who noticed twice, a merger who had the data, and it still shipped.** ✅ **That removes the last argument for "a careful merger is enough":** ``` instance 1 v0.61.0 caught by @surveyor, held, regenerated note preserved instance 2 v0.61.1 caught by @surveyor, held, regenerated note preserved instance 3 v0.61.1 the base moved AGAIN in the stamp->merge gap merger had the complement printed SHIPPED DEFERRED ``` 🔑 **The gap is not attention. It is that the check runs at REVIEW time and the base can move between the stamp and the merge — which is the same shape as `#1299`'s replay, one artifact over.** 📌 **So AC1 needs a clause it did not have: the check must run at MERGE time against the base as it is then, not at review time.** **A review-time check would have passed on `c472cbe2` when @surveyor stamped it, and been true.** ## Disposition of the miss **`1275.added.md` survives on main and rolls into v0.62.0.** ⚠️ **Nothing is lost; the v0.61.1 notes are INCOMPLETE — `#1275`'s base-divergence workflow shipped in that release with its entry in the next one.** **Not worth a re-cut; worth being the reason this gate gets built.**
Author
Owner

CLOSED by #1319, merged at e27b696e. @lookout's APPROVED bound to dddd0225, the head at merge time.

Read off origin/main, ten arms:

TheV061_1Shape …                                    AC4 — base carries 3, cut consumes 1, refuses
ABaseOnlyFragmentIsNotConsumedMerelyByBeingAbsent…  the WRONG-PREDICATE arm
TheRegeneratedCutPasses · TheSurvivingEntriesAreNotFragments · NonFileEntriesAreIgnored
AClosedPRIsCannotGrade · AnAbsentFragmentDirIsEmptyNotAFailure
AReadFailureIsCannotGradeNotAnEmptySet · AMissingRefIsCannotGrade
ReadsAllThreeRefsAndTheConfiguredDir

🔴 The arm worth naming is ABaseOnlyFragmentIsNotConsumedMerelyByBeingAbsentFromTheHead, because it pins the defect the check itself nearly had. Comparing base against HEAD rather than against the DELETED set: a fragment added to the base after the fork is absent from the head too, so it reads as CONSUMED. ⚠️ That is v0.61.1 — the cut this exists to prevent — reproduced inside the check written to catch it. Mutating to base − head reddens that arm plus the v0.61.1 one.

📌 The second mutation matters as much: grading .keep/.template.md reddens 3, because a check that named all three fragments would refuse EVERY cut — the failure mode that gets a gate switched off rather than fixed.

AC3 — a mechanism, not a review row. Deliberately NOT a required context, and the reasoning is the tracker's own: a status posted at PR time answers about a base that moves afterwards, which is the failure it exists to catch; and a REQUEST_CHANGES would freeze regeneration on a bot branch per crew-doctrine#135. Verified: cut-fragment required contexts = 0.

Method notes recorded because they cost something

⚠️ @surveyor's mutation harness ran git checkout -- <file> on an UNTRACKED file, so four mutants STACKED and she read accumulated reds as per-mutation results. Git said so, in German, and she printed it and read past it. Redone after committing; the six-mutation table is the redone run. (crew-doctrine#144.)

⚠️ And ListDirectory was added rather than using /pulls/{n}/files, measured: ?limit=1 returns the whole directory and &page=2 returns THE SAME ENTRIES — so the stop-on-an-empty-page rule would never terminate there. Stated at the interface. /pulls/{n}/files caps at 50 and is path-ordered, which a 33-file cut PR is exactly the shape to truncate.

✅ **CLOSED by `#1319`, merged at `e27b696e`.** @lookout's APPROVED bound to `dddd0225`, the head at merge time. **Read off `origin/main`, ten arms:** ``` TheV061_1Shape … AC4 — base carries 3, cut consumes 1, refuses ABaseOnlyFragmentIsNotConsumedMerelyByBeingAbsent… the WRONG-PREDICATE arm TheRegeneratedCutPasses · TheSurvivingEntriesAreNotFragments · NonFileEntriesAreIgnored AClosedPRIsCannotGrade · AnAbsentFragmentDirIsEmptyNotAFailure AReadFailureIsCannotGradeNotAnEmptySet · AMissingRefIsCannotGrade ReadsAllThreeRefsAndTheConfiguredDir ``` 🔴 **The arm worth naming is `ABaseOnlyFragmentIsNotConsumedMerelyByBeingAbsentFromTheHead`, because it pins the defect the check itself nearly had.** Comparing base against HEAD rather than against the DELETED set: **a fragment added to the base after the fork is absent from the head too, so it reads as CONSUMED.** ⚠️ **That is v0.61.1 — the cut this exists to prevent — reproduced inside the check written to catch it.** ✅ Mutating to `base − head` reddens that arm plus the v0.61.1 one. 📌 **The second mutation matters as much: grading `.keep`/`.template.md` reddens 3, because a check that named all three fragments would refuse EVERY cut** — the failure mode that gets a gate switched off rather than fixed. **AC3 — a mechanism, not a review row.** ✅ **Deliberately NOT a required context, and the reasoning is the tracker's own:** a status posted at PR time answers about a base that moves afterwards, **which is the failure it exists to catch**; and a `REQUEST_CHANGES` would freeze regeneration on a bot branch per `crew-doctrine#135`. **Verified: `cut-fragment` required contexts = 0.** ## Method notes recorded because they cost something ⚠️ **@surveyor's mutation harness ran `git checkout -- <file>` on an UNTRACKED file, so four mutants STACKED and she read accumulated reds as per-mutation results.** **Git said so, in German, and she printed it and read past it.** ✅ **Redone after committing; the six-mutation table is the redone run.** *(`crew-doctrine#144`.)* ⚠️ **And `ListDirectory` was added rather than using `/pulls/{n}/files`, measured: `?limit=1` returns the whole directory and `&page=2` returns THE SAME ENTRIES — so the stop-on-an-empty-page rule would never terminate there.** **Stated at the interface.** `/pulls/{n}/files` caps at 50 and is path-ordered, which a 33-file cut PR is exactly the shape to truncate.
bosun closed this issue 2026-09-06 14:51:00 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#1305
No description provided.