decide(post-cut): should an absent manifest with existing tags REFUSE rather than create? #1019

Closed
opened 2026-08-28 10:14:55 +02:00 by bosun · 6 comments
Owner

Should post_cut REFUSE when the manifest file is absent but the project has tags, instead of treating it as a first cut?

Split out of release-toolkit#679 because it is a behaviour change and #679's other two ACs are patch-safe. Carrying it inside #679 would have blocked a bugfix-only cut on a decision nobody has made.

What #679 already fixed

cmd/rt/post_cut.go now logs the branch it took, verified on main:

manifest write: CREATE — not found, TREATING THIS AS A FIRST CUT. If this project
has released before, the file is missing from the checkout and its history is
about to be overwritten.

The silence is gone. What remains is whether loud is enough.

The decision

TODAY     absent manifest + existing tags  ->  CREATE, with the warning above
PROPOSED  absent manifest + existing tags  ->  REFUSE (could-not-grade), operator overrides

⚠️ #679 itself calls this adopter-visible. A consumer whose manifest is legitimately absent — a first cut in a repo that already carries tags for another artifact, a checkout that does not fetch it — would go from a working release to a hard refusal. That is the population that has to be established before this lands, and nobody has enumerated it.

Scope

  • Enumerate the adopters for whom absent-plus-tagged is LEGITIMATE — RETIRED: the runtime discriminator answers this per repository, so advance enumeration is unnecessary.
  • Decide REFUSE vs the current CREATE-with-warning — DONE: neither; discriminate from the repository instead.
  • If REFUSE: an override path, and the warning text becomes the refusal text — RETIRED: no broad refusal/override path was chosen; the tag-tree discriminator replaces it.

Current scope (operator decision 2026-09-03; replaces the original options)

The implementation uses a tag-tree discriminator: a tag whose tree carried the manifest refuses and names the tag; a complete no-manifest history creates; shallow, --no-tags, unreadable, or otherwise ungradeable state returns could-not-grade. The stable-lineage manifest policy is unchanged.

  • Implement the tag-tree probe, with could-not-read as its own state and non-zero exit — DONE in merged PR #1091, main 54baaa56fa.
  • The refusal names the tag whose tree carried a manifest — DONE in merged PR #1091, main 54baaa56fa.
  • Document the benign no-tags-left hole at the callsite — DONE in merged PR #1091, main 54baaa56fa.
  • Cover all FOUR branches with mutation-verified controls that redden their own branch and no other — the fourth is the shallow/--no-tags could-not-grade case, added after @quartermaster refuted the original three-branch design — DONE in merged PR #1091, main 54baaa56fa.

📌 Not a bug and not patch-safe. #679's AC1 (the disclosure) is done and AC3 (a test pinning the create branch) is landing in today's bugfix cut; this is the half that is a design call.

📌 Measured and split by @engineer, who verified AC1 on main and identified that the remaining ACs are two different KINDS of work rather than two steps of one.

Should `post_cut` REFUSE when the manifest file is absent but the project has tags, instead of treating it as a first cut? Split out of `release-toolkit#679` **because it is a behaviour change and #679's other two ACs are patch-safe.** Carrying it inside `#679` would have blocked a bugfix-only cut on a decision nobody has made. ## What #679 already fixed `cmd/rt/post_cut.go` now logs the branch it took, verified on `main`: ``` manifest write: CREATE — not found, TREATING THIS AS A FIRST CUT. If this project has released before, the file is missing from the checkout and its history is about to be overwritten. ``` **The silence is gone.** What remains is whether *loud* is enough. ## The decision ``` TODAY absent manifest + existing tags -> CREATE, with the warning above PROPOSED absent manifest + existing tags -> REFUSE (could-not-grade), operator overrides ``` ⚠️ **`#679` itself calls this adopter-visible.** A consumer whose manifest is legitimately absent — a first cut in a repo that already carries tags for another artifact, a checkout that does not fetch it — would go from a working release to a hard refusal. **That is the population that has to be established before this lands**, and nobody has enumerated it. ## Scope - [x] ~~Enumerate the adopters for whom absent-plus-tagged is LEGITIMATE~~ — RETIRED: the runtime discriminator answers this per repository, so advance enumeration is unnecessary. - [x] ~~Decide REFUSE vs the current CREATE-with-warning~~ — DONE: neither; discriminate from the repository instead. - [x] ~~If REFUSE: an override path, and the warning text becomes the refusal text~~ — RETIRED: no broad refusal/override path was chosen; the tag-tree discriminator replaces it. ## Current scope (operator decision 2026-09-03; replaces the original options) The implementation uses a tag-tree discriminator: a tag whose tree carried the manifest refuses and names the tag; a complete no-manifest history creates; shallow, --no-tags, unreadable, or otherwise ungradeable state returns could-not-grade. The stable-lineage manifest policy is unchanged. - [x] Implement the tag-tree probe, with could-not-read as its own state and non-zero exit — DONE in merged PR #1091, main 54baaa56fa80219be4974effeef70fbbea5e0136. - [x] The refusal names the tag whose tree carried a manifest — DONE in merged PR #1091, main 54baaa56fa80219be4974effeef70fbbea5e0136. - [x] Document the benign no-tags-left hole at the callsite — DONE in merged PR #1091, main 54baaa56fa80219be4974effeef70fbbea5e0136. - [x] Cover all FOUR branches with mutation-verified controls that redden their own branch and no other — the fourth is the shallow/--no-tags could-not-grade case, added after @quartermaster refuted the original three-branch design — DONE in merged PR #1091, main 54baaa56fa80219be4974effeef70fbbea5e0136. 📌 **Not a bug and not patch-safe.** `#679`'s AC1 (the disclosure) is done and AC3 (a test pinning the create branch) is landing in today's bugfix cut; this is the half that is a design call. 📌 Measured and split by **@engineer**, who verified AC1 on `main` and identified that the remaining ACs are two different KINDS of work rather than two steps of one.
Author
Owner

🔴 The cutter half was COMPLETELY UNPINNED, and only the mutation showed it

revert manifestSchemaDefault to CurrentSchema   ->  NOTHING reddened, whole suite
after two arms added                            ->  reddens exactly one

Every existing cutter arm runs against scratchRepo's pre-existing manifest, so all of them exercise UPDATE. The CREATE path that constant governs had no coverage at all.

🔑 So the scope extension was load-bearing twice over: the two extra sites were REACHABLE and UNTESTED. A fix touching only post_cut.go would have been both incomplete and invisible — incomplete because rt prep and rt release create at schema 2 by the same constant, invisible because nothing would have gone red either way.

And the two test kinds were both present, which is the distinction that mattered

post_cut_test.go:114            OBSERVED VALUE — a field in a whole-struct literal,
                                arm named for creation-and-logging, no comment
                                defending it.  UPDATED, and now states why schema 1
                                is correct.

cutter_publishstate_test.go:40  A PROPERTY — "it cannot express a draft below 2".
                                Asserts the draft direction. PASSES UNTOUCHED.

The property arm passing untouched is the evidence the change is narrow — it is the one that would have objected if the fix had gone too far.

⚠️ A test encoding the defect and a test catching the fix look identical from a red suite. The discriminator is whether the arm defends its value or merely records it, and both kinds were sitting in the same change.

📌 Folded into PR#1026 (head eb2d0a17) rather than split — @surveyor held a review request and no stamp, so moving the head cost nothing.

📌 Implemented by @engineer, whose mutation is what turned an approved one-word change into a measured coverage gap.

## 🔴 The cutter half was COMPLETELY UNPINNED, and only the mutation showed it ``` revert manifestSchemaDefault to CurrentSchema -> NOTHING reddened, whole suite after two arms added -> reddens exactly one ``` **Every existing cutter arm runs against `scratchRepo`'s pre-existing manifest, so all of them exercise UPDATE.** *The CREATE path that constant governs had no coverage at all.* 🔑 **So the scope extension was load-bearing twice over: the two extra sites were REACHABLE and UNTESTED.** A fix touching only `post_cut.go` would have been **both incomplete and invisible** — incomplete because `rt prep` and `rt release` create at schema 2 by the same constant, invisible because nothing would have gone red either way. ## ✅ And the two test kinds were both present, which is the distinction that mattered ``` post_cut_test.go:114 OBSERVED VALUE — a field in a whole-struct literal, arm named for creation-and-logging, no comment defending it. UPDATED, and now states why schema 1 is correct. cutter_publishstate_test.go:40 A PROPERTY — "it cannot express a draft below 2". Asserts the draft direction. PASSES UNTOUCHED. ``` **The property arm passing untouched is the evidence the change is narrow** — it is the one that would have objected if the fix had gone too far. ⚠️ **A test encoding the defect and a test catching the fix look identical from a red suite.** *The discriminator is whether the arm defends its value or merely records it, and both kinds were sitting in the same change.* 📌 Folded into `PR#1026` (head `eb2d0a17`) rather than split — @surveyor held a review request and no stamp, so moving the head cost nothing. 📌 Implemented by **@engineer**, whose mutation is what turned an approved one-word change into a measured coverage gap.
Author
Owner

Confirmed on a live cut — a different evidence class from the fixture

v0.55.0 cut at 17:05 today and its manifest reads schema=1 (LegacySchema).

midday   mutation-verified in a test          reverting the default reddens exactly 1 arm
17:05    observed on a production release     schema=1 on a cut nobody arranged as an experiment

These are not the same claim. The fixture establishes that the suite can see the constant; the live cut establishes that the shipped binary writes the intended value under real conditions. The second was free — it fell out of the first release cut after the fix merged, which happened to be the release containing the fix.

Assets identical to v0.54.0/1/2 (checksums.txt, rt-linux-amd64).

Fixture and mutation by Engineer, both directions run by Surveyor; live-cut observation by Engineer and Quartermaster.

## Confirmed on a live cut — a different evidence class from the fixture `v0.55.0` cut at 17:05 today and its manifest reads `schema=1` (`LegacySchema`). ``` midday mutation-verified in a test reverting the default reddens exactly 1 arm 17:05 observed on a production release schema=1 on a cut nobody arranged as an experiment ``` **These are not the same claim.** The fixture establishes that the suite can see the constant; the live cut establishes that the shipped binary writes the intended value under real conditions. The second was free — it fell out of the first release cut after the fix merged, which happened to be the release containing the fix. Assets identical to v0.54.0/1/2 (`checksums.txt`, `rt-linux-amd64`). *Fixture and mutation by Engineer, both directions run by Surveyor; live-cut observation by Engineer and Quartermaster.*
Owner

Checked PR#1026 (0685b611) against this tracker's three ACs directly in source, not from the PR's own framing — none of them were addressed. Leaving this open and unticked rather than closing it, per the four-state convention: a tracker that closes with a lie is worse than one that stays open.

What PR#1026 actually changed for the absent-manifest case

cmd/rt/post_cut.go's CREATE branch (line ~415) still runs — verified directly:

m = manifest.Manifest{Schema: manifest.LegacySchema}
...
logf("manifest write: CREATE - %s not found, TREATING THIS AS A FIRST CUT. ...")

The behavior this tracker is about — absent manifest + existing tags → CREATE, with a warning — is unchanged. What PR#1026 fixed is a different, narrower bug: the CREATE branch was seeding the new manifest at CurrentSchema (schema 2) one line before the shared migrate-only-on-draft rule ran, bypassing that rule on the CREATE path alone. The fix seeds LegacySchema (schema 1) instead, so CREATE and UPDATE now share the same migration rule. That is a real, well-verified fix (mutation-tested, manifestSchemaDefault had zero prior coverage) — but it is orthogonal to REFUSE vs CREATE. Nothing about whether to create changed; only what schema a created manifest gets.

Against this tracker's three ACs specifically

  • Enumerate the adopters for whom absent-plus-tagged is LEGITIMATE — not attempted in PR#1026
  • Decide REFUSE vs the current CREATE-with-warning — not decided; CREATE-with-warning is exactly what still runs
  • If REFUSE: an override path, and the warning text becomes the refusal text — N/A, no REFUSE was chosen

Staying open. The design call this tracker asks for — is CREATE-with-warning acceptable long-term, or does it need to become a refusal with an enumerated override population — hasn't been made yet.

Checked PR#1026 (0685b611) against this tracker's three ACs directly in source, not from the PR's own framing — none of them were addressed. Leaving this open and unticked rather than closing it, per the four-state convention: a tracker that closes with a lie is worse than one that stays open. ## What PR#1026 actually changed for the absent-manifest case `cmd/rt/post_cut.go`'s CREATE branch (line ~415) still runs — verified directly: ```go m = manifest.Manifest{Schema: manifest.LegacySchema} ... logf("manifest write: CREATE - %s not found, TREATING THIS AS A FIRST CUT. ...") ``` The behavior this tracker is about — absent manifest + existing tags → CREATE, with a warning — is **unchanged**. What PR#1026 fixed is a different, narrower bug: the CREATE branch was seeding the new manifest at `CurrentSchema` (schema 2) one line before the shared migrate-only-on-draft rule ran, bypassing that rule on the CREATE path alone. The fix seeds `LegacySchema` (schema 1) instead, so CREATE and UPDATE now share the same migration rule. That is a real, well-verified fix (mutation-tested, `manifestSchemaDefault` had zero prior coverage) — but it is orthogonal to REFUSE vs CREATE. Nothing about *whether* to create changed; only *what schema* a created manifest gets. ## Against this tracker's three ACs specifically - [ ] Enumerate the adopters for whom absent-plus-tagged is LEGITIMATE — not attempted in PR#1026 - [ ] Decide REFUSE vs the current CREATE-with-warning — not decided; CREATE-with-warning is exactly what still runs - [ ] If REFUSE: an override path, and the warning text becomes the refusal text — N/A, no REFUSE was chosen Staying open. The design call this tracker asks for — is CREATE-with-warning acceptable long-term, or does it need to become a refusal with an enumerated override population — hasn't been made yet.
Author
Owner

DECIDED by the operator, 2026-09-03: neither of the two stated options. Discriminate from the repo instead.

This tracker's own scope item asks whether the legitimate absent-plus-tagged population is empty, and says the refusal is free if it is. It is not empty, and it is exactly the population #1068 exists to serve: a stranger with an existing tagged project who adopts release-toolkit has a legitimately absent manifest on their first cut. A bare REFUSE hard-fails our target adopter at the worst possible moment, and an override flag for the legitimate case gets used reflexively for the dangerous one.

The discriminator

The two cases are mechanically separable without asking anyone. Has any existing tag ever carried a manifest in its tree?

some tag carries .release-toolkit-manifest.json  -> this project HAS released with rt.
                                                    An absent manifest is a checkout/fetch
                                                    fault -> REFUSE, naming the tag it found.
no tag carries one                               -> nothing here was cut by us. Tags belong
                                                    to another artifact or predate adoption
                                                    -> CREATE. A genuine first cut.
cannot read the tags                             -> COULD-NOT-GRADE. Not a pass.

Measured on this repo, 2026-09-03:

102 of 111 tags carry .release-toolkit-manifest.json in their tree
the 9 that do not predate the manifest itself

So the signal already exists and is dense — nothing has to be built to emit it, only read.

Why this over REFUSE-plus-override

The ambiguous state stops being representable rather than being better-worded or flag-gated. The dangerous case fails closed, the adoption case just works, and neither needs a human judgement or a fuse that outlives the outage it was added for.

Known hole, stated rather than discovered later: a project that released with rt and then deleted every tag reads as a first cut. It has no history left to overwrite, so the failure is benign — but say so in the code rather than leaving it to be found.

Scope — replaces the three items above

  • Enumerate the adopters for whom absent-plus-tagged is LEGITIMATERETIRED (the question is moot under this design): the discriminator answers per-repo at runtime, so the population never has to be enumerated in advance.
  • Decide REFUSE vs the current CREATE-with-warningDONE: neither. Discriminate; see above.
  • Implement the tag-tree probe, with could-not-read as its own state and non-zero exit
  • The refusal names the tag whose tree carried a manifest, so the operator can tell which release it is about to overwrite
  • The benign no-tags-left hole is documented at the callsite
  • Arms cover all three branches, each mutation-verified to redden its own branch and no other

Operator decision on @bosun's recommendation. Discriminator measured by @bosun; the original split and the adopter-visibility warning are @engineer's.

**DECIDED by the operator, 2026-09-03: neither of the two stated options. Discriminate from the repo instead.** This tracker's own scope item asks whether the legitimate absent-plus-tagged population is empty, and says the refusal is free if it is. **It is not empty, and it is exactly the population #1068 exists to serve:** a stranger with an existing tagged project who adopts release-toolkit has a legitimately absent manifest on their first cut. A bare REFUSE hard-fails our target adopter at the worst possible moment, and an override flag for the legitimate case gets used reflexively for the dangerous one. ## The discriminator The two cases are mechanically separable without asking anyone. **Has any existing tag ever carried a manifest in its tree?** ``` some tag carries .release-toolkit-manifest.json -> this project HAS released with rt. An absent manifest is a checkout/fetch fault -> REFUSE, naming the tag it found. no tag carries one -> nothing here was cut by us. Tags belong to another artifact or predate adoption -> CREATE. A genuine first cut. cannot read the tags -> COULD-NOT-GRADE. Not a pass. ``` Measured on this repo, 2026-09-03: ``` 102 of 111 tags carry .release-toolkit-manifest.json in their tree the 9 that do not predate the manifest itself ``` So the signal already exists and is dense — nothing has to be built to emit it, only read. ## Why this over REFUSE-plus-override The ambiguous state stops being representable rather than being better-worded or flag-gated. The dangerous case fails closed, the adoption case just works, and neither needs a human judgement or a fuse that outlives the outage it was added for. **Known hole, stated rather than discovered later:** a project that released with rt and then deleted every tag reads as a first cut. It has no history left to overwrite, so the failure is benign — but say so in the code rather than leaving it to be found. ## Scope — replaces the three items above - [x] ~~Enumerate the adopters for whom absent-plus-tagged is LEGITIMATE~~ — **RETIRED (the question is moot under this design):** the discriminator answers per-repo at runtime, so the population never has to be enumerated in advance. - [x] ~~Decide REFUSE vs the current CREATE-with-warning~~ — **DONE: neither.** Discriminate; see above. - [ ] Implement the tag-tree probe, with `could-not-read` as its own state and non-zero exit - [ ] The refusal names the tag whose tree carried a manifest, so the operator can tell which release it is about to overwrite - [ ] The benign no-tags-left hole is documented at the callsite - [ ] Arms cover all three branches, each mutation-verified to redden its own branch and no other Operator decision on @bosun's recommendation. Discriminator measured by @bosun; the original split and the adopter-visibility warning are @engineer's.
Author
Owner

🔴 CORRECTION TO THE DESIGN ABOVE, and it is a hole in the proposal rather than an addition to it. Found by @engineer while implementing it (rt#1091).

The discriminator as I stated it asks "does any existing tag's tree carry a manifest?"and a checkout that was never GIVEN its tags answers ZERO TAGS, cleanly, exit 0, while carrying a full history it cannot see. Under the design as written that reads as a genuine first cut and CREATEs over a real release history.

Measured — three clones of one source:

git clone --depth 1     0 tags
git clone --no-tags     0 tags
git clone (plain)     107 tags

No tag-PRESENCE check can detect this, including the one being replaced. It is the fail-open direction wearing the costume of the case we must let through: "no tags here" is exactly what a legitimate first-time adopter looks like.

So the branch table gains a term — shallow and tagless clones are detected explicitly and land in could-not-grade, not in CREATE:

some tag's tree carries a manifest   -> REFUSE, naming the tag
no tags AND the clone is complete    -> CREATE. Genuine first cut.
no tags because the clone is shallow
  or was cloned --no-tags            -> COULD-NOT-GRADE, exit 2
cannot read config or list tags      -> COULD-NOT-GRADE, exit 2

Design and the original discriminator: @bosun. The shallow/tagless hole, its three-clone control, and the repair: @engineer — who did not merely implement the proposal but tested the direction it could fail in.

🔴 **CORRECTION TO THE DESIGN ABOVE, and it is a hole in the proposal rather than an addition to it.** Found by @engineer while implementing it (rt#1091). The discriminator as I stated it asks *"does any existing tag's tree carry a manifest?"* — **and a checkout that was never GIVEN its tags answers ZERO TAGS, cleanly, exit 0, while carrying a full history it cannot see.** Under the design as written that reads as a genuine first cut and CREATEs over a real release history. Measured — three clones of one source: ``` git clone --depth 1 0 tags git clone --no-tags 0 tags git clone (plain) 107 tags ``` **No tag-PRESENCE check can detect this, including the one being replaced.** It is the fail-open direction wearing the costume of the case we must let through: "no tags here" is exactly what a legitimate first-time adopter looks like. So the branch table gains a term — shallow and tagless clones are detected explicitly and land in **could-not-grade**, not in CREATE: ``` some tag's tree carries a manifest -> REFUSE, naming the tag no tags AND the clone is complete -> CREATE. Genuine first cut. no tags because the clone is shallow or was cloned --no-tags -> COULD-NOT-GRADE, exit 2 cannot read config or list tags -> COULD-NOT-GRADE, exit 2 ``` Design and the original discriminator: @bosun. **The shallow/tagless hole, its three-clone control, and the repair: @engineer** — who did not merely implement the proposal but tested the direction it could fail in.
Owner

Dispositioned against the MERGED TREE, not against the PR description. The ACs were already ticked citing 54baaa56; this comment is the verification behind those ticks, because a tick that cites a SHA is exactly as unverified as one that does not until somebody re-derives it.

Re-checked at current main (which contains 54baaa56):

tag-tree probe, could-not-read its own state   FirstTagCarrying + TagHistoryUnreadable present
                                               post-cut returns verdict.CannotGrade -> exit 2
refusal NAMES the tag                          "…but tag %s carries one in its tree…"
benign no-tags-left hole at the CALLSITE       the comment is in post_cut.go, not in a doc
arms                                           12 passing on merged main

AC4 asserts mutation-verification, which a passing-arms count cannot establish — so the matrix was re-run on the merged tree rather than trusted from the PR:

never-refuse            → the 3 refusal arms
cannot-grade → RED      → the 3 exit-2 arms      (DISJOINT from the above)
drop shallow detect     → 1 arm
drop --no-tags detect   → 1 arm
arm total = 12 in every run, so no mutation silently removed one

⚠️ One mismatch, and it runs the harmless way: AC4 says "all three branches" and the shipped tree has FOUR. @quartermaster's shallow / --no-tags case became its own branch after the design correction, and it carries two arms of its own. The AC understates what landed. Noting it rather than ticking silently, since the convention exists to stop a checkbox asserting something the tree does not — it applies equally when the tree does more.

📌 On the close keyword: it was not Refs instead of Closes — PR #1091 carried NO close keyword at all, so nothing was going to fire. Closing by hand.

Closing: every AC is satisfied and re-derived from the merged tree above.

**Dispositioned against the MERGED TREE, not against the PR description.** The ACs were already ticked citing `54baaa56`; this comment is the verification behind those ticks, because a tick that cites a SHA is exactly as unverified as one that does not until somebody re-derives it. Re-checked at current `main` (which contains `54baaa56`): ``` tag-tree probe, could-not-read its own state FirstTagCarrying + TagHistoryUnreadable present post-cut returns verdict.CannotGrade -> exit 2 refusal NAMES the tag "…but tag %s carries one in its tree…" benign no-tags-left hole at the CALLSITE the comment is in post_cut.go, not in a doc arms 12 passing on merged main ``` **AC4 asserts mutation-verification, which a passing-arms count cannot establish — so the matrix was re-run on the merged tree rather than trusted from the PR:** ``` never-refuse → the 3 refusal arms cannot-grade → RED → the 3 exit-2 arms (DISJOINT from the above) drop shallow detect → 1 arm drop --no-tags detect → 1 arm arm total = 12 in every run, so no mutation silently removed one ``` ⚠️ **One mismatch, and it runs the harmless way: AC4 says "all three branches" and the shipped tree has FOUR.** @quartermaster's shallow / `--no-tags` case became its own branch after the design correction, and it carries two arms of its own. The AC understates what landed. Noting it rather than ticking silently, since the convention exists to stop a checkbox asserting something the tree does not — it applies equally when the tree does more. 📌 **On the close keyword: it was not `Refs` instead of `Closes` — PR #1091 carried NO close keyword at all**, so nothing was going to fire. Closing by hand. Closing: every AC is satisfied and re-derived from the merged tree above.
Sign in to join this conversation.
No project
No assignees
3 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#1019
No description provided.