release-bump derives the target version from planning docs nothing keeps current, so an off-roadmap release can silently downgrade the manifest #87

Open
opened 2026-07-28 20:43:53 +00:00 by crenshawdev · 0 comments
crenshawdev commented 2026-07-28 20:43:53 +00:00 (Migrated from github.com)

What happened

Opening v2.0.0 turned up that .planning/PROJECT.md, REQUIREMENTS.md and ROADMAP.md all still named v1.4.0 as the current release, two releases after it shipped. v1.4.1 and v1.5.0 both went out as issue-driven /cad-task work with no cycle open, and nothing on that path writes the version back: /cad-milestone is what evolves PROJECT.md, and it only runs when a cycle closes.

On its own that is cosmetic. What makes it a bug is that release-bump.mjs reads those files.

The actual failure

deriveTargetVersion takes the first version-shaped token in PROJECT.md's ### Active body, falling back to the ROADMAP title. Between cycles that body opens with the version that just closed:

No milestone is open. v1.4.0 — Stated grammars closed 2026-07-28 with all five requirements shipped and verified

So at the v1.5.0 cut, with the manifest sitting at 1.4.1, the derivation returned a version that had already shipped. Reproduced against the exact tree at a1453c3:

derived target: "1.4.0"
vs manifest 1.4.1: {"action":"bump","bumped":true,"from":"1.4.1","to":"1.4.0","reason":"bump 1.4.1 -> 1.4.0"}

A downgrade, ok: true, nothing said. It did not happen only because that cut passed --version 1.5.0 explicitly, and an explicit version wins the precedence. Nothing about the drift was load-bearing on the decision to pass it.

Two separable defects

1. Nothing keeps the planning docs current when a release ships outside a cycle. /cad-health checks presence, cursor schema, phase numbering and traceability consistency, and never reads .claude-plugin/plugin.json. /cad-audit traces requirements to phases, not versions. self-verify touches plugin.json only to decide whether it is looking at a full tree. The drift is invisible to all three, which is why it survived two releases and was caught by a human reading the file.

2. decideManifestBump has no monotonicity check. Handed a target below the manifest's current version it returns bump like any other change. An already-published version is the one input that can never be a valid target, and a stale ### Active produces exactly that.

Defect 2 is the cheap guard and it closes the sharp edge by itself. Defect 1 is the one worth thinking about, because the same stale prose is also what branch-decision.mjs reads.

What you want Cadence to do

  • Refuse a non-forward target. decideManifestBump returns an error rather than bump when the target is at or below the current manifest version by semver comparison, naming both versions in the reason. The existing noop on an equal version already half-states this rule; it just stops short of the case that matters.
  • Make the drift visible. A /cad-health rule comparing plugin.json's version against the version the planning docs name, reported and never auto-fixed, is the smallest thing that would have caught this. It belongs to rule 5's consistency family.
  • Decide what the between-cycles ### Active should name, if anything. The derivation reads that token as "the version we are shipping"; the prose there means "the version that just shipped". Those are opposite readings of the same bytes, and reconciling them is a design call rather than a patch. Either the closed-milestone prose names the next version, or it puts the shipped one somewhere the derivation does not scan.

Reproduction

import { deriveTargetVersion, decideManifestBump } from './cadence-core/bin/lib/release-decision.mjs';
import { readFileSync } from 'node:fs';

// PROJECT.md/ROADMAP.md as of a1453c3, manifest at 1.4.1
const t = deriveTargetVersion({
  projectText: readFileSync('.planning/PROJECT.md', 'utf8'),
  roadmapText: readFileSync('.planning/ROADMAP.md', 'utf8'),
});
console.log(t, decideManifestBump('1.4.1', t));
// => 1.4.0 { action: 'bump', bumped: true, from: '1.4.1', to: '1.4.0', ... }

More generally: ship any release without an open cycle, then run bump with no --version before the next cycle opens.

Files this touches

cadence-core/bin/lib/release-decision.mjs, cadence-core/bin/lib/branch-decision.mjs (activeVersion is shared with branch derivation), cadence-core/bin/release-bump.test.mjs, skills/cad-health/SKILL.md.

Notes

Fixed for this tree by hand at the v2.0.0 open (c57568c): the planning docs now record v1.4.1 and v1.5.0, and the derivation returns 2.0.0, which is correct for the next cut. The failure mode returns on the next release that ships outside a cycle.

Related: #86, the other release-bump defect from the same v1.5.0 cut. Both were caught by reading the output rather than by any check, which is the pattern worth noticing.

## What happened Opening v2.0.0 turned up that `.planning/PROJECT.md`, `REQUIREMENTS.md` and `ROADMAP.md` all still named `v1.4.0` as the current release, two releases after it shipped. `v1.4.1` and `v1.5.0` both went out as issue-driven `/cad-task` work with no cycle open, and nothing on that path writes the version back: `/cad-milestone` is what evolves PROJECT.md, and it only runs when a cycle closes. On its own that is cosmetic. What makes it a bug is that `release-bump.mjs` **reads** those files. ## The actual failure `deriveTargetVersion` takes the first version-shaped token in PROJECT.md's `### Active` body, falling back to the ROADMAP title. Between cycles that body opens with the version that just *closed*: > No milestone is open. **v1.4.0 — Stated grammars** closed 2026-07-28 with all five requirements shipped and verified So at the v1.5.0 cut, with the manifest sitting at `1.4.1`, the derivation returned a version that had already shipped. Reproduced against the exact tree at `a1453c3`: ``` derived target: "1.4.0" vs manifest 1.4.1: {"action":"bump","bumped":true,"from":"1.4.1","to":"1.4.0","reason":"bump 1.4.1 -> 1.4.0"} ``` A **downgrade**, `ok: true`, nothing said. It did not happen only because that cut passed `--version 1.5.0` explicitly, and an explicit version wins the precedence. Nothing about the drift was load-bearing on the decision to pass it. ## Two separable defects **1. Nothing keeps the planning docs current when a release ships outside a cycle.** `/cad-health` checks presence, cursor schema, phase numbering and traceability consistency, and never reads `.claude-plugin/plugin.json`. `/cad-audit` traces requirements to phases, not versions. self-verify touches plugin.json only to decide whether it is looking at a full tree. The drift is invisible to all three, which is why it survived two releases and was caught by a human reading the file. **2. `decideManifestBump` has no monotonicity check.** Handed a target below the manifest's current version it returns `bump` like any other change. An already-published version is the one input that can never be a valid target, and a stale `### Active` produces exactly that. Defect 2 is the cheap guard and it closes the sharp edge by itself. Defect 1 is the one worth thinking about, because the same stale prose is also what `branch-decision.mjs` reads. ## What you want Cadence to do - **Refuse a non-forward target.** `decideManifestBump` returns an error rather than `bump` when the target is at or below the current manifest version by semver comparison, naming both versions in the reason. The existing `noop` on an equal version already half-states this rule; it just stops short of the case that matters. - **Make the drift visible.** A `/cad-health` rule comparing plugin.json's `version` against the version the planning docs name, reported and never auto-fixed, is the smallest thing that would have caught this. It belongs to rule 5's consistency family. - **Decide what the between-cycles `### Active` should name, if anything.** The derivation reads that token as "the version we are shipping"; the prose there means "the version that just shipped". Those are opposite readings of the same bytes, and reconciling them is a design call rather than a patch. Either the closed-milestone prose names the *next* version, or it puts the shipped one somewhere the derivation does not scan. ## Reproduction ```js import { deriveTargetVersion, decideManifestBump } from './cadence-core/bin/lib/release-decision.mjs'; import { readFileSync } from 'node:fs'; // PROJECT.md/ROADMAP.md as of a1453c3, manifest at 1.4.1 const t = deriveTargetVersion({ projectText: readFileSync('.planning/PROJECT.md', 'utf8'), roadmapText: readFileSync('.planning/ROADMAP.md', 'utf8'), }); console.log(t, decideManifestBump('1.4.1', t)); // => 1.4.0 { action: 'bump', bumped: true, from: '1.4.1', to: '1.4.0', ... } ``` More generally: ship any release without an open cycle, then run `bump` with no `--version` before the next cycle opens. ## Files this touches `cadence-core/bin/lib/release-decision.mjs`, `cadence-core/bin/lib/branch-decision.mjs` (`activeVersion` is shared with branch derivation), `cadence-core/bin/release-bump.test.mjs`, `skills/cad-health/SKILL.md`. ## Notes Fixed for this tree by hand at the v2.0.0 open (`c57568c`): the planning docs now record v1.4.1 and v1.5.0, and the derivation returns `2.0.0`, which is correct for the next cut. The failure mode returns on the next release that ships outside a cycle. Related: #86, the other `release-bump` defect from the same v1.5.0 cut. Both were caught by reading the output rather than by any check, which is the pattern worth noticing.
Sign in to join this conversation.
No description provided.