carlos / platform Public

docs: release versioning design spec

Tag model, counter iterations under a per-app version target, notes at
ship compiled into the tag changelog, auto-tag at stable promote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 72ba65ba732bb53afd6ed34168dac216aa7e142e parent dc6a6c3
1 file changed, +130 −0
  • docs/superpowers/specs/2026-08-08-release-versioning-design.md +130 −0
diff --git a/docs/superpowers/specs/2026-08-08-release-versioning-design.md b/docs/superpowers/specs/2026-08-08-release-versioning-design.md
new file mode 100644
index 000000000..8ebee032e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-08-release-versioning-design.md
@@ -0,0 +1,130 @@
+# Release versioning — design
+
+2026-08-08. Status: approved by Paul (brainstorming session), pending spec review.
+
+## Problem
+
+Every release surface — the Running column, `carlos releases`, pointers,
+promote output — identifies releases by whatever `-version` string was passed
+at ship time, which by convention is a git sha. A 40-character checksum is
+unique but meaningless: it carries no ordering, no intent, and no story. There
+is no human label, no notion of "what we're working toward," and no changelog
+anywhere in the system.
+
+## Decisions (made during brainstorming)
+
+1. **Tag model, not rename.** A build keeps its immutable version id forever.
+ "0.5.0" is a *tag* written at promote time that points at a build. Nothing
+ is ever renamed or re-signed.
+2. **Counter iterations.** Under a target of `0.5.0`, shipped builds are
+ `0.5.0-1`, `0.5.0-2`, … — ordered, scannable, and semver-prerelease-valid.
+ The git sha stays traceable via the manifest's existing `Commit` field, so
+ it does not need to pollute the version string.
+3. **Notes written at ship.** Changelog content is deliberate operator input
+ at ship time (`-notes` / `-notes-file`), not derived from git log. Tagging
+ compiles the notes of all iterations into the tag's changelog.
+4. **Auto-tag at stable promote.** Promoting an iteration to stable writes the
+ tag and advances the target to the next patch version. One command; the
+ ladder flow is unchanged; stable always ends up with a human name.
+
+## Concepts
+
+Three new nouns. Everything is additive; no existing object changes shape.
+
+### Target
+
+The per-app "next version": a small JSON object in the bucket.
+
+- Key: `apps/<app>/version-target.json`
+- Shape: `{schema: 1, target: "0.5.0", set_by, set_at}`
+- Set via `carlos version target 0.5.0` (console API backed).
+- **No target set → ship behaves exactly as today.** The feature is opt-in
+ per app; every existing app, script, and habit keeps working untouched.
+- A new target must compare strictly greater (semver) than any existing tag
+ for the app; lowering or reusing a released version is refused.
+- After a tag is cut the target auto-advances to the next patch version
+ (`0.5.0` → `0.5.1`). The operator can raise it (e.g. to `0.6.0`) at any
+ time.
+
+### Iteration
+
+With a target set, `carlos ship` mints the version itself.
+
+- Version: `<target>-<n>` (e.g. `0.5.0-3`). The counter is allocated
+ server-side inside the existing ship path (`handleAPIShip` already
+ serializes ships through a semaphore), so allocation is race-free. Counters
+ are per app + target and monotonically increasing.
+- New optional ship inputs, stored as new *optional* manifest fields:
+ - `label` — short human label for the iteration ("fixing modals").
+ - `notes` — changelog fragment, free text/markdown (`-notes` inline or
+ `-notes-file`).
+- Old manifests without these fields still parse (additive wire-shape rule).
+- An explicit `-version` still wins — escape hatch and back-compat for
+ direct-bucket shippers.
+
+### Tag
+
+Written automatically when an iteration is promoted to stable.
+
+- Key: `apps/<app>/tags/<version>.json` (e.g. `apps/keymail/tags/0.5.0.json`)
+- Shape: `{schema: 1, version: "0.5.0", release: "0.5.0-3", commit,
+ tagged_at, actor, changelog}`
+- `changelog` is compiled at tag time: the `notes` of every iteration of that
+ target (`0.5.0-1` … `0.5.0-3`), oldest first, each section headed by the
+ iteration's version and label. Iterations without notes contribute a
+ heading only.
+- Tags are immutable once written; a tag key that already exists refuses
+ overwrite (same posture as release immutability).
+
+## What does not change
+
+- Pointers, adopt, bake, grants, laddercheck, channel names: untouched.
+- The Running column mechanics are unchanged — it displays the release id as
+ today; the id is simply `0.5.0-3` now instead of a sha.
+- Promote/adopt still operate on release ids. Tags are read-side sugar plus
+ an audit record; no control-plane flow keys off a tag.
+- Release immutability (`shipImmutableConflict`) already refuses re-shipping
+ a taken version with different artifacts — this is the collision backstop
+ for counter allocation too.
+
+## Edge cases
+
+- **Promoting a non-semver build to stable** (old sha release, or explicit
+ `-version` that isn't `<semver>-<n>`): promote succeeds, no tag is written,
+ and the output warns that no tag was cut. History is never invented.
+- **Target vs. existing iterations:** raising the target mid-cycle (e.g.
+ `0.5.0` → `0.6.0` with `0.5.0-3` un-promoted) is allowed; the abandoned
+ iterations simply never get a tag. Counters restart at 1 per target.
+- **Hotfix flow:** target is already `0.5.1` once `0.5.0` is tagged; ship
+ `0.5.1-1`, fast-track through the ladder as today, promote tags `0.5.1`.
+- **Direct-bucket shippers** (pinfra `scripts/ship-app.sh`): keep sha
+ versions or pass explicit `-version`. The console API is the paved road;
+ flagship apps opt in by setting a target and shipping via `carlos ship`.
+- **Concurrent ships under one target:** serialized by the existing ship
+ semaphore; allocation reads the release list for the target and takes
+ max+1.
+
+## Surfaces
+
+- **CLI:** `carlos ship -label "..." -notes/-notes-file`; `carlos version
+ target [get|set]`; `carlos releases` shows version, label, and tag where
+ one exists; promote output names the tag it cut.
+- **Console:** releases page shows label and notes per iteration; app page
+ shows "stable · 0.5.0" when the stable build is tagged; tag detail view
+ renders the compiled changelog.
+- **API:** additive fields on the existing ship/release endpoints; new
+ endpoints for target get/set and tag list/read. Everything the console
+ shows is API-reachable (operate-via-API rule — no box-local-only).
+
+## Testing
+
+- `internal/release` unit tests: counter allocation (including gap/restart
+ behavior per target), target set/advance/refusal (semver comparison),
+ changelog compilation (with and without notes), tag immutability,
+ no-tag-for-non-semver promote.
+- Console API tests: ship with label/notes, target endpoints, tag endpoints,
+ owner/auth gates matching existing API posture.
+- Wire-shape back-compat: a pre-change manifest and pointer (no new fields)
+ must still parse; golden tests extended for the new objects.
+- Production channel names in `laddercheck_test.go` are untouched — this
+ design adds no channel or ladder rules.