bug(decide): rt decide reads tags and prepare commits as v-prefixed, so a release-{version} adopter's merged prepare is never detected as a cut #1533

Open
opened 2026-10-03 14:51:41 +02:00 by claude · 1 comment
Member

rt decide loads tag_format (cmd/rt/decide.go:160) but uses it only to render the next tag (internal/decide/decide.go:1351-1358). Every place it reads a tag or a prepare commit assumes a v prefix.

PR #1531's body names the --match 'v*' describe at internal/decide/git.go:228. On the manifest path that site is unreachable. Three other sites are reachable, and one of them is the cut detector.

Measured

rt was built from origin/main 8086700 and run with --dry-run against a forge URL that does not answer. The scratch adopter had tag_format: 'release-{version}', tag release-0.1.0 on the first commit, and one added fragment. The control is the same repo with tag_format: 'v{version}' and tag v0.1.0.

Path release-{version} control v{version}
No manifest (bootstrap) virtual-anchor bootstrap (#368) ... anchor v0.0.0; next_version=0.1.0, next_tag=release-0.1.0, a tag that already exists bootstrapped: last_released_tag=v0.1.0; next_tag=v0.2.0
Manifest with last_released_tag: release-0.1.0 next_tag=release-0.2.0 (correct) correct
After chore(release): prepare release-0.2.0 lands mode=blocked, reason=pending_cut, pending_prep_sha=unknown Layer 1 (subject-regex) MATCH: version=0.2.0
Same, with tag release-0.2.0 present pending_tag_exists=false, and the message says to run git tag --list v0.2.0 n/a

The control's Layer 2 could not grade against the unreachable forge and fell through to update. That is expected offline. The point is that Layer 1 matched for v and did not match for release-.

The four sites, read at origin/main:

  1. Bootstrap tag discovery.
    • internal/decide/git.go:50 defines stableTagRE = ^v?[0-9]+\.[0-9]+\.[0-9]+$, and latestStableTag (:85-97) uses it. No release-* tag matches.
    • So loadOrBootstrap (decide.go:297-306) takes the #368 virtual v0.0.0 anchor and walks from the root commit, as if the repo had never released.
    • :310 also strips a literal v to get the version.
  2. Cut detection.
    • internal/decide/prepsubject.go:26-28 admits only chore(release): [prepare ]v?X.Y.Z.
    • rt prep writes the commit chore(release): prepare <newTag> (cmd/rt/prep.go:736) and the PR title chore(release): <newTag> (:789). For release-0.2.0, neither the commit nor a squash title matches.
    • So Layer 1 (decide.go:569-588) never fires. The #417 orphan walk (decide.go:459-460) cannot find the prepare either, and a merged prepare reads as a cancelled cut.
    • IsPrepMergeSubject has two more callers: cmd/rt/check_self_bootstrap.go:124 and cmd/rt/prep_order_check.go:131.
  3. Pending-cut evidence.
    • pendingCutEvidence (decide.go:1541-1546) looks up tag := "v" + version instead of d.renderTag(version), for both the git tag and the forge release. For a non-v adopter, both reads answer "absent" whatever exists.
    • rt recover-pending-cut takes those two fields as given (cmd/rt/recover_pending_cut.go:204-218).
    • Its positive control renders the manifest's tag through tag_format (:237), so the control finds its release and passes.
    • Today the verb still stops for a non-v adopter, but only by accident. Site 2 leaves the prepare SHA unknown, and the restore step refuses on that (recover_pending_cut.go:351-353, "pending prepare SHA is unavailable").
    • A change that corrects site 2 without site 3 removes that accident. Read through, not measured against a forge: suppose a cut published release-0.2.0 but its post-cut bookkeeping never landed. It would grade tag_exists=false and release_published=absent, with the control present. That is the combination in which recovery (B) folds a published section back under [Unreleased], which is exactly what #885 and #1457 exist to prevent.
  4. --match 'v*' at git.go:228. resolveCCRange (decide.go:1277-1288) calls it only when the anchor tag is empty.

Does the manifest path hide it? It hides site 4 only.

  • autoBump walks from the anchor's tag (decide.go:1240). On the manifest path that is last_released_tag.
  • docs/architecture/contracts/manifest.schema.json makes that field required, and rt post-cut writes the rendered tag into it (cmd/rt/post_cut.go:585).
  • So git.go:228 is reached only by a manifest that omits last_released_tag. loadManifest (decide.go:381-386) requires only the SHA and the version, so such a manifest is schema-invalid but accepted.
  • The manifest cannot protect the other three sites. Site 1 is the no-manifest path. Site 2 matches commit subjects. Site 3 renders the tag from the version.

PR #1531 (head 6979efc) adds TagGlobsFor (internal/prep/git.go:108 on that head), which derives describe globs from tag_format. It uses them only for the opt-in commit-note walk. internal/decide is untouched.

Census, 2026-10-03: storey, ember, tmux-tell, cellblock and purser all use tag_format: 'v{version}'. Nobody on this instance is affected today, which is why this is priority/low. It becomes reachable as soon as rt prep's doc-refs refusal (the sibling issue) is lifted.

Why it matters

  • Lifting the prep refusal is not enough to cut. Once that refusal is gone, a non-v adopter's first merged prepare still lands in mode=blocked.

  • The adopter is stuck, with no working route offered. With a reachable forge, the release lookup for v0.2.0 returns 404, so release_published=absent. The blocked message's default arm (internal/decide/pending.go:144) then offers two routes:

    • completing the release by hand;
    • rt recover-pending-cut, which refuses because it has no prepare SHA.

    (Read, not run.)

  • Site 3 is dangerous once site 2 is corrected. It hands the recovery verb evidence that is wrong in the destructive direction, and the verb's control cannot catch it, because the control renders the tag correctly. Sites 2 and 3 have to change together.

  • A manifest-free adoption repeats an existing version. With release-* tags already present, it proposes a version that exists (site 1) and walks the whole history for the bump. That is the state #1483 tracks, here on a repo that has in fact released.

Decision tree

  • Support non-v formats in decide.
    • Render or parse every tag through tag_format:
      • pendingCutEvidence uses renderTag;
      • the prepare-subject match accepts the rendered tag, and IsPrepMergeSubject's other two callers move with it;
      • bootstrap discovery strips the format's prefix and suffix instead of ^v?;
      • the describe uses a glob derived from the format (TagGlobsFor, if #1531 lands with it).
    • Right when the prep-side sibling takes its option (a) or (b).
  • Refuse non-v{version} formats at config load. This is the prep sibling's option (c), shared.
    • Right if full support is deferred.
    • It has to be enforced in rt prep and rt manifest-check, which load the config strictly. rt decide loads it best-effort and falls back to defaults on a load failure (cmd/rt/decide.go:144-148), so a refusal at load would not stop decide on its own.

Acceptance criteria

  • A decision between the two branches above is recorded here, together with the prep-side sibling's decision.
  • If supported, each of sites 1-4 has a test using tag_format: 'release-{version}' that is red on origin/main 8086700 and green after (mutation-verified, AGENTS.md section 8 Axis B).
  • Site 3's test asserts both evidence fields against an existing release-X.Y.Z tag and a published release, in the style of internal/decide/pending_evidence_test.go.
  • Sites 2 and 3 land in the same PR, so no merged state matches the prepare while still reading the evidence through v.
  • IsPrepMergeSubject's callers (check_self_bootstrap.go:124, prep_order_check.go:131) agree with decide's new matching, or this issue records why they keep v.
  • The pending-cut message's git tag --list hint names the rendered tag (internal/decide/pending.go:125).
  • A changelog fragment.

Out of scope

  • rt prep's doc-refs refusal. That is the sibling issue.
  • The same v* describes outside decide. The same change should convert them, or each should get its own issue:
    • rt prep's bump and commit walk: internal/prep/git.go:70,80 and internal/prep/gitsource.go:71.
    • rt manifest-check's tag-vs-manifest check (cmd/rt/manifest_check.go:185-188), which passes vacuously for a non-v adopter. On the scratch repo it printed OK: no prior v* tag (first release; nothing to compare against) while release-0.1.0 existed.
  • cmd/rt/prune_rc_tags.go:82 ("v" + version + "-rc."). It is toolkit-self rc bookkeeping, dormant per AGENTS.md section 2.4.

Anchor

Found while reviewing PR #1531 / issue #1529 on 2026-10-03. #1531's body lists "rt decide and v*" under "Not in this PR (each needs its own issue)". Probing it showed that git.go:228 is latent behind the manifest, and that the cut detector and the pending-cut evidence are the live defects. Related: #885, #1457, #1483, #417, #368.

`rt decide` loads `tag_format` (`cmd/rt/decide.go:160`) but uses it only to render the next tag (`internal/decide/decide.go:1351-1358`). Every place it reads a tag or a prepare commit assumes a `v` prefix. PR #1531's body names the `--match 'v*'` describe at `internal/decide/git.go:228`. On the manifest path that site is unreachable. Three other sites are reachable, and one of them is the cut detector. ## Measured `rt` was built from origin/main `8086700` and run with `--dry-run` against a forge URL that does not answer. The scratch adopter had `tag_format: 'release-{version}'`, tag `release-0.1.0` on the first commit, and one `added` fragment. The control is the same repo with `tag_format: 'v{version}'` and tag `v0.1.0`. | Path | `release-{version}` | control `v{version}` | |---|---|---| | No manifest (bootstrap) | `virtual-anchor bootstrap (#368) ... anchor v0.0.0`; `next_version=0.1.0`, `next_tag=release-0.1.0`, a tag that already exists | `bootstrapped: last_released_tag=v0.1.0`; `next_tag=v0.2.0` | | Manifest with `last_released_tag: release-0.1.0` | `next_tag=release-0.2.0` (correct) | correct | | After `chore(release): prepare release-0.2.0` lands | `mode=blocked`, `reason=pending_cut`, `pending_prep_sha=unknown` | `Layer 1 (subject-regex) MATCH: version=0.2.0` | | Same, with tag `release-0.2.0` present | `pending_tag_exists=false`, and the message says to run `git tag --list v0.2.0` | n/a | The control's Layer 2 could not grade against the unreachable forge and fell through to update. That is expected offline. The point is that Layer 1 matched for `v` and did not match for `release-`. The four sites, read at origin/main: 1. **Bootstrap tag discovery.** - `internal/decide/git.go:50` defines `stableTagRE = ^v?[0-9]+\.[0-9]+\.[0-9]+$`, and `latestStableTag` (`:85-97`) uses it. No `release-*` tag matches. - So `loadOrBootstrap` (`decide.go:297-306`) takes the #368 virtual v0.0.0 anchor and walks from the root commit, as if the repo had never released. - `:310` also strips a literal `v` to get the version. 2. **Cut detection.** - `internal/decide/prepsubject.go:26-28` admits only `chore(release): [prepare ]v?X.Y.Z`. - `rt prep` writes the commit `chore(release): prepare <newTag>` (`cmd/rt/prep.go:736`) and the PR title `chore(release): <newTag>` (`:789`). For `release-0.2.0`, neither the commit nor a squash title matches. - So Layer 1 (`decide.go:569-588`) never fires. The #417 orphan walk (`decide.go:459-460`) cannot find the prepare either, and a merged prepare reads as a cancelled cut. - `IsPrepMergeSubject` has two more callers: `cmd/rt/check_self_bootstrap.go:124` and `cmd/rt/prep_order_check.go:131`. 3. **Pending-cut evidence.** - `pendingCutEvidence` (`decide.go:1541-1546`) looks up `tag := "v" + version` instead of `d.renderTag(version)`, for both the git tag and the forge release. For a non-`v` adopter, both reads answer "absent" whatever exists. - `rt recover-pending-cut` takes those two fields as given (`cmd/rt/recover_pending_cut.go:204-218`). - Its positive control renders the manifest's tag through `tag_format` (`:237`), so the control finds its release and passes. - Today the verb still stops for a non-`v` adopter, but only by accident. Site 2 leaves the prepare SHA unknown, and the restore step refuses on that (`recover_pending_cut.go:351-353`, "pending prepare SHA is unavailable"). - A change that corrects site 2 without site 3 removes that accident. Read through, not measured against a forge: suppose a cut published `release-0.2.0` but its post-cut bookkeeping never landed. It would grade `tag_exists=false` and `release_published=absent`, with the control present. That is the combination in which recovery (B) folds a published section back under `[Unreleased]`, which is exactly what #885 and #1457 exist to prevent. 4. **`--match 'v*'` at `git.go:228`.** `resolveCCRange` (`decide.go:1277-1288`) calls it only when the anchor tag is empty. **Does the manifest path hide it?** It hides site 4 only. - `autoBump` walks from the anchor's tag (`decide.go:1240`). On the manifest path that is `last_released_tag`. - `docs/architecture/contracts/manifest.schema.json` makes that field required, and `rt post-cut` writes the rendered tag into it (`cmd/rt/post_cut.go:585`). - So `git.go:228` is reached only by a manifest that omits `last_released_tag`. `loadManifest` (`decide.go:381-386`) requires only the SHA and the version, so such a manifest is schema-invalid but accepted. - The manifest cannot protect the other three sites. Site 1 is the no-manifest path. Site 2 matches commit subjects. Site 3 renders the tag from the version. PR #1531 (head `6979efc`) adds `TagGlobsFor` (`internal/prep/git.go:108` on that head), which derives `describe` globs from `tag_format`. It uses them only for the opt-in commit-note walk. `internal/decide` is untouched. Census, 2026-10-03: storey, ember, tmux-tell, cellblock and purser all use `tag_format: 'v{version}'`. Nobody on this instance is affected today, which is why this is priority/low. It becomes reachable as soon as `rt prep`'s doc-refs refusal (the sibling issue) is lifted. ## Why it matters - **Lifting the prep refusal is not enough to cut.** Once that refusal is gone, a non-`v` adopter's first merged prepare still lands in `mode=blocked`. - **The adopter is stuck, with no working route offered.** With a reachable forge, the release lookup for `v0.2.0` returns 404, so `release_published=absent`. The blocked message's default arm (`internal/decide/pending.go:144`) then offers two routes: - completing the release by hand; - `rt recover-pending-cut`, which refuses because it has no prepare SHA. (Read, not run.) - **Site 3 is dangerous once site 2 is corrected.** It hands the recovery verb evidence that is wrong in the destructive direction, and the verb's control cannot catch it, because the control renders the tag correctly. Sites 2 and 3 have to change together. - **A manifest-free adoption repeats an existing version.** With `release-*` tags already present, it proposes a version that exists (site 1) and walks the whole history for the bump. That is the state #1483 tracks, here on a repo that has in fact released. ## Decision tree - **Support non-`v` formats in decide.** - Render or parse every tag through `tag_format`: - `pendingCutEvidence` uses `renderTag`; - the prepare-subject match accepts the rendered tag, and `IsPrepMergeSubject`'s other two callers move with it; - bootstrap discovery strips the format's prefix and suffix instead of `^v?`; - the describe uses a glob derived from the format (`TagGlobsFor`, if #1531 lands with it). - Right when the prep-side sibling takes its option (a) or (b). - **Refuse non-`v{version}` formats at config load.** This is the prep sibling's option (c), shared. - Right if full support is deferred. - It has to be enforced in `rt prep` and `rt manifest-check`, which load the config strictly. `rt decide` loads it best-effort and falls back to defaults on a load failure (`cmd/rt/decide.go:144-148`), so a refusal at load would not stop decide on its own. ## Acceptance criteria - [ ] A decision between the two branches above is recorded here, together with the prep-side sibling's decision. - [ ] If supported, each of sites 1-4 has a test using `tag_format: 'release-{version}'` that is red on origin/main `8086700` and green after (mutation-verified, AGENTS.md section 8 Axis B). - [ ] Site 3's test asserts both evidence fields against an existing `release-X.Y.Z` tag and a published release, in the style of `internal/decide/pending_evidence_test.go`. - [ ] Sites 2 and 3 land in the same PR, so no merged state matches the prepare while still reading the evidence through `v`. - [ ] `IsPrepMergeSubject`'s callers (`check_self_bootstrap.go:124`, `prep_order_check.go:131`) agree with decide's new matching, or this issue records why they keep `v`. - [ ] The pending-cut message's `git tag --list` hint names the rendered tag (`internal/decide/pending.go:125`). - [ ] A changelog fragment. ## Out of scope - `rt prep`'s doc-refs refusal. That is the sibling issue. - The same `v*` describes outside decide. The same change should convert them, or each should get its own issue: - `rt prep`'s bump and commit walk: `internal/prep/git.go:70,80` and `internal/prep/gitsource.go:71`. - `rt manifest-check`'s tag-vs-manifest check (`cmd/rt/manifest_check.go:185-188`), which passes vacuously for a non-`v` adopter. On the scratch repo it printed `OK: no prior v* tag (first release; nothing to compare against)` while `release-0.1.0` existed. - `cmd/rt/prune_rc_tags.go:82` (`"v" + version + "-rc."`). It is toolkit-self rc bookkeeping, dormant per AGENTS.md section 2.4. ## Anchor Found while reviewing PR #1531 / issue #1529 on 2026-10-03. #1531's body lists "`rt decide` and `v*`" under "Not in this PR (each needs its own issue)". Probing it showed that `git.go:228` is latent behind the manifest, and that the cut detector and the pending-cut evidence are the live defects. Related: #885, #1457, #1483, #417, #368.
Author
Member

The sibling issue this body refers to is #1532 (a stable rt prep refuses every tag that is not vX.Y.Z). Both were found while reviewing PR #1531.

The sibling issue this body refers to is #1532 (a stable `rt prep` refuses every tag that is not `vX.Y.Z`). Both were found while reviewing PR #1531.
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#1533
No description provided.