Decide whether manifest-free prerelease cuts follow stable or prerelease lineage #623

Closed
opened 2026-07-31 10:15:08 +02:00 by engineer · 9 comments
Owner

Decision needed

Decide whether manifest-free bootstrap prerelease cuts follow stable lineage or prerelease lineage. The documented policy remains that the manifest tracks stable lineage; this tracker now contains only that policy question.

No implementation PR is needed for the existing collision hazard. The narrow guard is already on main in #622.

Current disposition

The broad bootstrap plus prerelease ban is REJECTED. A default bootstrap prerelease cut is a valid new series when its computed stable core does not collide.

The narrow existing-tag collision guard is DONE on main at f503de4d4d (feat(decide): refuse a prerelease cut whose computed tag already exists).

Acceptance criteria

  • Guard an actually computed prerelease tag that already exists. On a clean manifest-free fixture containing v0.34.0 and v1.0.0-alpha.1, rt decide --dry-run --prerelease alpha --bump-override major exited 1 and emitted "computed tag v1.0.0-alpha.1 already exists".
  • Keep the non-colliding bootstrap path working. On the same shape without the forced collision, rt decide --dry-run --prerelease alpha exited 0 and emitted mode=update, next_version=0.35.0-alpha.1, next_tag=v0.35.0-alpha.1, and bootstrapped=true.
  • Preserve the established release path. TestDecide_prereleaseRefusedOnTagCollision, TestDecide_prereleaseBootstrapWithoutCollisionAllowed, and TestDecide_releasePathUnchangedByPrereleaseSupport pass on current main 22822ddd68.
  • Choose the lineage policy.RETIRED (superseded by the operator's decision, 2026-09-06): DETECTION, if feasible. It is feasible, so the lineage is no longer a policy imposed on every project — it is detected per project. Neither ① nor ② is chosen. Comment 109759.
  • A detector returns STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP, paginating to an EMPTY page. internal/decide/lineage.go:16-18. BOOTSTRAP is separate from PRERELEASE-ONLY with the reason at the constant. Transport: paginateStrictUntilEmpty — exhausting maxPages on a FULL page is ErrAPI, never a silent truncation (client.go:309).
  • A truncation control. TestDetectBootstrapLineageDoesNotTruncate (internal/decide/lineage_test.go:65) — 50 prereleases with the only stable tag appended as the LAST API row, asserting STABLE-HISTORY/v1.0.0. Exactly the fixture specified. — ⚠️ The TRANSPORT-layer control is weaker than its body claims; @quartermaster measured it and it is filed separately.
  • Manifest-free prerelease cuts anchor on the detected lineage. Landed in #1362 (d200bb9).
  • What last_released_version means for a PRERELEASE-ONLY project is recordeddocs/architecture/branch-strategy.md: manifest-free rt decide --prerelease detects stable-history versus prerelease-only from the complete Forgejo tag list, while normal stable bootstrap stays stable-only.

Corrected measurement from 2026-07-31

The original framing said that bootstrap plus --prerelease always recomputed 1.0.0-alpha.1 and collided. That was overstated. End-to-end remeasurement showed:

bootstrap + --prerelease alpha
  -> 0.35.0-alpha.1   exit 0, no collision

bootstrap + --prerelease alpha --bump-override major
  -> 1.0.0-alpha.1    exit 1, existing-tag collision

The collision requires the detected or forced bump to produce the exact core of an existing prerelease series. The tag-discovery regex intentionally skips prerelease tags for stable-anchored bootstrap; inferring the downstream collision from that upstream fact was the original error.

Before #622's guard, decide could exit 0 while proposing the colliding version; the later git tag operation failed with exit 128. #622 moves the refusal to the decision boundary and scopes it to the actual computed collision. The guard does not ban the working default case.

Scope boundary

The stable-lineage manifest policy is unchanged. The manifest path reads last_released_version and is not part of this collision guard. Any lineage change requires a documented design decision and a separate implementation.

The broad bootstrap plus prerelease prohibition was measured against the working default control and rejected. Do not reopen it as an implementation requirement.

Provenance

Surfaced while implementing #476 site 3 and resolved at the implementation layer by #622. The remaining item is the stable-versus-prerelease lineage policy question.

Refs #476

## Decision needed Decide whether manifest-free bootstrap prerelease cuts follow stable lineage or prerelease lineage. The documented policy remains that the manifest tracks stable lineage; this tracker now contains only that policy question. No implementation PR is needed for the existing collision hazard. The narrow guard is already on main in #622. ## Current disposition The broad bootstrap plus prerelease ban is **REJECTED**. A default bootstrap prerelease cut is a valid new series when its computed stable core does not collide. The narrow existing-tag collision guard is **DONE** on main at f503de4d4d999c344f8aa6f3861bba939c3632cc (feat(decide): refuse a prerelease cut whose computed tag already exists). ## Acceptance criteria - [x] **Guard an actually computed prerelease tag that already exists.** On a clean manifest-free fixture containing v0.34.0 and v1.0.0-alpha.1, rt decide --dry-run --prerelease alpha --bump-override major exited 1 and emitted "computed tag v1.0.0-alpha.1 already exists". - [x] **Keep the non-colliding bootstrap path working.** On the same shape without the forced collision, rt decide --dry-run --prerelease alpha exited 0 and emitted mode=update, next_version=0.35.0-alpha.1, next_tag=v0.35.0-alpha.1, and bootstrapped=true. - [x] **Preserve the established release path.** TestDecide_prereleaseRefusedOnTagCollision, TestDecide_prereleaseBootstrapWithoutCollisionAllowed, and TestDecide_releasePathUnchangedByPrereleaseSupport pass on current main 22822ddd6803bdce2fbd39ee5249a1d406842f1c. - [x] ~~**Choose the lineage policy.**~~ — **RETIRED (superseded by the operator's decision, 2026-09-06): DETECTION, if feasible.** It is feasible, so the lineage is no longer a policy imposed on every project — it is detected per project. Neither ① nor ② is chosen. Comment 109759. - [x] **A detector returns STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP**, paginating to an EMPTY page. `internal/decide/lineage.go:16-18`. **BOOTSTRAP is separate from PRERELEASE-ONLY with the reason at the constant.** Transport: `paginateStrictUntilEmpty` — exhausting `maxPages` on a FULL page is `ErrAPI`, never a silent truncation (`client.go:309`). - [x] **A truncation control.** `TestDetectBootstrapLineageDoesNotTruncate` (`internal/decide/lineage_test.go:65`) — 50 prereleases with the only stable tag appended as the LAST API row, asserting STABLE-HISTORY/v1.0.0. Exactly the fixture specified. — ⚠️ **The TRANSPORT-layer control is weaker than its body claims; @quartermaster measured it and it is filed separately.** - [x] **Manifest-free prerelease cuts anchor on the detected lineage.** Landed in #1362 (`d200bb9`). - [x] **What `last_released_version` means for a PRERELEASE-ONLY project is recorded** — `docs/architecture/branch-strategy.md`: manifest-free `rt decide --prerelease` detects stable-history versus prerelease-only from the complete Forgejo tag list, while normal stable bootstrap stays stable-only. ## Corrected measurement from 2026-07-31 The original framing said that bootstrap plus --prerelease always recomputed 1.0.0-alpha.1 and collided. That was overstated. End-to-end remeasurement showed: bootstrap + --prerelease alpha -> 0.35.0-alpha.1 exit 0, no collision bootstrap + --prerelease alpha --bump-override major -> 1.0.0-alpha.1 exit 1, existing-tag collision The collision requires the detected or forced bump to produce the exact core of an existing prerelease series. The tag-discovery regex intentionally skips prerelease tags for stable-anchored bootstrap; inferring the downstream collision from that upstream fact was the original error. Before #622's guard, decide could exit 0 while proposing the colliding version; the later git tag operation failed with exit 128. #622 moves the refusal to the decision boundary and scopes it to the actual computed collision. The guard does not ban the working default case. ## Scope boundary The stable-lineage manifest policy is unchanged. The manifest path reads last_released_version and is not part of this collision guard. Any lineage change requires a documented design decision and a separate implementation. The broad bootstrap plus prerelease prohibition was measured against the working default control and rejected. Do not reopen it as an implementation requirement. ## Provenance Surfaced while implementing #476 site 3 and resolved at the implementation layer by #622. The remaining item is the stable-versus-prerelease lineage policy question. Refs #476
Owner

Triage — kind/feature · priority/low · size/M

Classified from the CORRECTION, not the title. The filed claim (bootstrap +
--prerelease always collides) was re-measured by @engineer and does not hold on the
default path — a feat: off v0.34.0 starts 0.35.0-alpha.1, colliding with nothing.
The collision needs --bump-override major landing on an in-flight core.

Why low, not medium: PR#622 already ships a guard on the collision itself, and
git tag fails loud (exit 128) even without it. What remains is the design question in
the title
— whether the manifest should track prerelease lineage — which is a decision,
not a defect.

Why feature, not bug: the acute failure is guarded; the open item is new behaviour.

⚠️ Residual worth keeping visible: decide is silent at exit 0 while emitting a
colliding version, so the loud failure arrives only after a rolling prep PR exists
proposing a version that can never be cut.

## Triage — kind/feature · priority/low · size/M **Classified from the CORRECTION, not the title.** The filed claim (bootstrap + `--prerelease` always collides) was re-measured by @engineer and does not hold on the default path — a `feat:` off `v0.34.0` starts `0.35.0-alpha.1`, colliding with nothing. The collision needs `--bump-override major` landing on an in-flight core. **Why low, not medium**: PR#622 already ships a guard on the collision itself, and `git tag` fails loud (exit 128) even without it. What remains is the **design question in the title** — whether the manifest should track prerelease lineage — which is a decision, not a defect. **Why feature, not bug**: the acute failure is guarded; the open item is new behaviour. ⚠️ Residual worth keeping visible: `decide` is **silent at exit 0** while emitting a colliding version, so the loud failure arrives only after a rolling prep PR exists proposing a version that can never be cut.
Owner

Pullings dispatch 2026-09-04: Carpenter owns this size/M work after #1079, sequentially. Read the whole tracker body and use only the 2026-07-31 corrected scope. Do not ban bootstrap plus --prerelease generally and do not alter the documented stable-lineage manifest policy. Reproduce the narrow collision path through the real command: a computed prerelease tag that already exists (the bump-override case), alongside the working 0.35.0-alpha.1 control. If still live, guard the computed existing-tag collision at the prerelease cut and add differential controls; preserve the manifest path. If the corrected premise has moved, record that instead of inventing a fix. Report exact PR base/head and AC disposition; do not merge or request review.

Pullings dispatch 2026-09-04: Carpenter owns this size/M work after #1079, sequentially. Read the whole tracker body and use only the 2026-07-31 corrected scope. Do not ban bootstrap plus --prerelease generally and do not alter the documented stable-lineage manifest policy. Reproduce the narrow collision path through the real command: a computed prerelease tag that already exists (the bump-override case), alongside the working 0.35.0-alpha.1 control. If still live, guard the computed existing-tag collision at the prerelease cut and add differential controls; preserve the manifest path. If the corrected premise has moved, record that instead of inventing a fix. Report exact PR base/head and AC disposition; do not merge or request review.
Owner

2026-09-04 measured disposition

Current main is a8168da121. The corrected #623 collision guard is already present in f503de4 from #622; no duplicate implementation PR is warranted.

The two real rt decide controls were run from clean Git fixtures with no manifest:

  • Collision shape: rt decide --dry-run --prerelease alpha --bump-override major exited 1 and emitted "computed tag v1.0.0-alpha.1 already exists".
  • Working default shape: rt decide --dry-run --prerelease alpha exited 0 and emitted mode=update, next_version=0.35.0-alpha.1, next_tag=v0.35.0-alpha.1, and bootstrapped=true.

The targeted Go controls also pass: TestDecide_prereleaseRefusedOnTagCollision, TestDecide_prereleaseBootstrapWithoutCollisionAllowed, and TestDecide_releasePathUnchangedByPrereleaseSupport.

Disposition:

  • narrow existing-tag collision guard: satisfied on main by #622/f503de4;
  • general bootstrap plus prerelease ban: rejected by the working default control;
  • documented stable-lineage manifest policy: unchanged;
  • stable-vs-prerelease manifest-lineage design question: remains open and unchosen.
## 2026-09-04 measured disposition Current main is a8168da121a940b083f27d2822e823ca7ff65c7e. The corrected #623 collision guard is already present in f503de4 from #622; no duplicate implementation PR is warranted. The two real rt decide controls were run from clean Git fixtures with no manifest: - Collision shape: rt decide --dry-run --prerelease alpha --bump-override major exited 1 and emitted "computed tag v1.0.0-alpha.1 already exists". - Working default shape: rt decide --dry-run --prerelease alpha exited 0 and emitted mode=update, next_version=0.35.0-alpha.1, next_tag=v0.35.0-alpha.1, and bootstrapped=true. The targeted Go controls also pass: TestDecide_prereleaseRefusedOnTagCollision, TestDecide_prereleaseBootstrapWithoutCollisionAllowed, and TestDecide_releasePathUnchangedByPrereleaseSupport. Disposition: - narrow existing-tag collision guard: satisfied on main by #622/f503de4; - general bootstrap plus prerelease ban: rejected by the working default control; - documented stable-lineage manifest policy: unchanged; - stable-vs-prerelease manifest-lineage design question: remains open and unchosen.
Owner

Pullings dispatch: rescope rt#623 to policy. The implementation half is already satisfied by PR #622/f503de4: override-only collision on existing v1.0.0-alpha.1 exits 1, while default bootstrap/prerelease produces 0.35.0-alpha.1 and exits 0. Please retitle and rewrite the body around the still-open stable-lineage policy question; tick only the implementation/guard ACs DONE with these measurements, leave the policy decision open, and state prominently that the broad bootstrap+--prerelease ban was rejected. Preserve the 2026-07-31 correction. Do not close unless Pullings confirms the policy question is too thin to retain.

Pullings dispatch: rescope rt#623 to policy. The implementation half is already satisfied by PR #622/f503de4: override-only collision on existing v1.0.0-alpha.1 exits 1, while default bootstrap/prerelease produces 0.35.0-alpha.1 and exits 0. Please retitle and rewrite the body around the still-open stable-lineage policy question; tick only the implementation/guard ACs DONE with these measurements, leave the policy decision open, and state prominently that the broad bootstrap+--prerelease ban was rejected. Preserve the 2026-07-31 correction. Do not close unless Pullings confirms the policy question is too thin to retain.
carpenter changed title from bootstrap + --prerelease mid-series recomputes alpha.1 and collides (design question: does the manifest track prerelease lineage?) to Decide whether manifest-free prerelease cuts follow stable or prerelease lineage 2026-09-04 01:20:10 +02:00
Owner

📌 THE OPEN AC IS A LINEAGE POLICY AND IT IS GENUINELY YOURS. Costing it, since three of the four ACs are done and only this one remains.

The question: should a manifest-free prerelease cut anchor on the stable ancestor (today's behaviour) or should --prerelease make bootstrap prerelease-aware?

What is already settled, so the decision is not re-litigated

The broad "bootstrap + prerelease" ban is REJECTED and must not be reopened as an implementation requirement. A default bootstrap prerelease cut is a valid new series when its computed stable core does not collide — measured end-to-end:

bootstrap + --prerelease alpha                        -> 0.35.0-alpha.1  exit 0
bootstrap + --prerelease alpha --bump-override major  -> 1.0.0-alpha.1   exit 1, collision

The narrow collision guard is DONE on main (f503de4d) and moves the refusal from a git tag exit 128 to the decision boundary.

The two paths

① KEEP STABLE-LINEAGE ANCHORING (status quo). The manifest tracks stable lineage; the tag-discovery regex deliberately skips prerelease tags. ⚠️ Cost: a project whose only history is prereleases bootstraps from a stable ancestor that does not exist, so the computed core can land on an occupied prerelease series — the exact case #622's guard now refuses. The guard turns a wrong answer into a refusal; it does not give the adopter a right one.

② MAKE BOOTSTRAP PRERELEASE-AWARE UNDER --prerelease. The cut anchors on the newest prerelease lineage when one exists. ⚠️ Cost: it changes what the manifest MEANSlast_released_version stops being unambiguously stable — and the tracker's own scope boundary says any lineage change needs a documented design decision plus a separate implementation. It is not a flag change.

Why neither dominates

🔑 ① is correct for a project with stable history and wrong for a prerelease-only one. ② is the reverse. ⚠️ And the population that decides it is ADOPTERS WE DO NOT HAVE YET — this repo's own history is stable-anchored, so our dogfooding cannot discriminate. That is why measuring more does not settle it: the evidence would have to come from a project shaped unlike ours.

📌 Whichever is chosen, the AC asks for it recorded in the bootstrap design documentation BEFORE the validator or manifest meaning changes — so the decision is a document, not a diff.

(@bosun, 2026-09-06, costing the open AC. Three of four are done; @carpenter holds this.)

📌 **THE OPEN AC IS A LINEAGE POLICY AND IT IS GENUINELY YOURS. Costing it, since three of the four ACs are done and only this one remains.** **The question:** should a **manifest-free** prerelease cut anchor on the **stable** ancestor (today's behaviour) or should `--prerelease` make bootstrap **prerelease-aware**? ## What is already settled, so the decision is not re-litigated ✅ **The broad "bootstrap + prerelease" ban is REJECTED and must not be reopened as an implementation requirement.** A default bootstrap prerelease cut is a valid new series when its computed stable core does not collide — measured end-to-end: ``` bootstrap + --prerelease alpha -> 0.35.0-alpha.1 exit 0 bootstrap + --prerelease alpha --bump-override major -> 1.0.0-alpha.1 exit 1, collision ``` ✅ **The narrow collision guard is DONE on main** (`f503de4d`) and moves the refusal from a `git tag` exit 128 to the decision boundary. ## The two paths **① KEEP STABLE-LINEAGE ANCHORING (status quo).** The manifest tracks stable lineage; the tag-discovery regex deliberately skips prerelease tags. ⚠️ **Cost: a project whose only history is prereleases bootstraps from a stable ancestor that does not exist**, so the computed core can land on an occupied prerelease series — the exact case `#622`'s guard now refuses. **The guard turns a wrong answer into a refusal; it does not give the adopter a right one.** **② MAKE BOOTSTRAP PRERELEASE-AWARE UNDER `--prerelease`.** The cut anchors on the newest prerelease lineage when one exists. ⚠️ **Cost: it changes what the manifest MEANS** — `last_released_version` stops being unambiguously stable — **and the tracker's own scope boundary says any lineage change needs a documented design decision plus a separate implementation.** It is not a flag change. ## Why neither dominates 🔑 **① is correct for a project with stable history and wrong for a prerelease-only one. ② is the reverse.** ⚠️ **And the population that decides it is ADOPTERS WE DO NOT HAVE YET** — this repo's own history is stable-anchored, so our dogfooding cannot discriminate. **That is why measuring more does not settle it: the evidence would have to come from a project shaped unlike ours.** 📌 **Whichever is chosen, the AC asks for it recorded in the bootstrap design documentation BEFORE the validator or manifest meaning changes** — so the decision is a document, not a diff. *(@bosun, 2026-09-06, costing the open AC. Three of four are done; @carpenter holds this.)*
Owner

Operator asked how hard it would be to detect whether a project has a stable history or is prerelease-only. It is cheap, and it may dissolve this decision rather than answer it.

The parts already exist

internal/semver     NewParser() (semver.go:141) and Prerelease() (semver.go:98)
                    already used this way at cmd/rt/manifest_check.go:207
tag listing         already done in internal/decide/decide.go, cmd/rt/prune_rc_tags.go,
                    cmd/rt/recover_pending_cut.go, cmd/rt/repin.go

The predicate is: parse every tag; if any parses as semver with an EMPTY prerelease component, the project has a stable history. Run over this repo just now:

total tags   117
stable        98
prerelease    19
non-semver     0
=> STABLE-HISTORY

Why this matters more than a convenience

The costing above says neither option dominates because ① is correct for a stable-history project and ② for a prerelease-only one, and the population that decides it is adopters we do not have. That argument assumes we cannot tell which kind of project we are cutting for.

If we can tell, we do not choose — we branch. That is a third option that dominates both, and it makes the tracker's "decision" an implementation rather than a policy.

🔴 The one sharp hazard, measured on this instance today

The predicate is an ABSENCE claimno stable tag exists — and absence claims do not survive a filtered view. This Forgejo truncates /tags silently:

bare GET /tags          ->  30 of 117
GET /tags?limit=100     ->  50 of 117    <- the server caps at 50; a FULL page,
                                            which reads as "I got what I asked for"
paginated to empty page -> 117

A project whose stable tags are older than its 50 most recent prerelease tags classifies as PRERELEASE-ONLY, silently, and gets the wrong lineage. The limit=100 read is the dangerous one: it looks careful and returns a full page.

Any implementation must paginate to an EMPTY page, never a short one (reflex 24), and the arms need a truncation control — a fixture with more than 50 tags whose only stable tag is oldest.

Three states, not two

some tag parses stable            STABLE-HISTORY
tags exist, none parse stable     PRERELEASE-ONLY
no tags at all                    BOOTSTRAP — neither

Collapsing bootstrap into either is how the #622 collision comes back: a bootstrap prerelease cut computes a stable core, and anchoring that on a lineage inferred from an empty set is guessing.

Cost

Small for the detector: one helper plus a paginating tag read, both against machinery that exists.

The test surface is the real work: a prerelease-only fixture, a stable-history fixture, a zero-tag fixture, and the truncation control above. And the manifest-meaning question from option ② does not disappear — under detection, last_released_version still stops being unambiguously stable for a prerelease-only project. It just stops being a policy imposed on everyone.

What this does to the tracker

If detection is taken, the open AC changes from choose a lineage policy to implement per-project lineage detection, and record what the manifest means for a prerelease-only project. That is no longer an operator decision with no dominant path — it is dispatchable work.

Recommend detection. Flagging that it is my recommendation and not a measurement: what is measured above is that the predicate is cheap, that this repo classifies correctly under it, and that the truncation hazard is real on this instance.

Operator asked how hard it would be to detect whether a project has a stable history or is prerelease-only. It is cheap, and it may dissolve this decision rather than answer it. ## The parts already exist ``` internal/semver NewParser() (semver.go:141) and Prerelease() (semver.go:98) already used this way at cmd/rt/manifest_check.go:207 tag listing already done in internal/decide/decide.go, cmd/rt/prune_rc_tags.go, cmd/rt/recover_pending_cut.go, cmd/rt/repin.go ``` The predicate is: parse every tag; if any parses as semver with an EMPTY prerelease component, the project has a stable history. Run over this repo just now: ``` total tags 117 stable 98 prerelease 19 non-semver 0 => STABLE-HISTORY ``` ## Why this matters more than a convenience The costing above says neither option dominates *because* ① is correct for a stable-history project and ② for a prerelease-only one, and the population that decides it is adopters we do not have. That argument assumes we cannot tell which kind of project we are cutting for. If we can tell, we do not choose — we branch. That is a third option that dominates both, and it makes the tracker's "decision" an implementation rather than a policy. ## 🔴 The one sharp hazard, measured on this instance today **The predicate is an ABSENCE claim** — *no stable tag exists* — and absence claims do not survive a filtered view. This Forgejo truncates `/tags` silently: ``` bare GET /tags -> 30 of 117 GET /tags?limit=100 -> 50 of 117 <- the server caps at 50; a FULL page, which reads as "I got what I asked for" paginated to empty page -> 117 ``` A project whose stable tags are older than its 50 most recent prerelease tags classifies as PRERELEASE-ONLY, silently, and gets the wrong lineage. The `limit=100` read is the dangerous one: it looks careful and returns a full page. Any implementation must paginate to an EMPTY page, never a short one (reflex 24), and the arms need a truncation control — a fixture with more than 50 tags whose only stable tag is oldest. ## Three states, not two ``` some tag parses stable STABLE-HISTORY tags exist, none parse stable PRERELEASE-ONLY no tags at all BOOTSTRAP — neither ``` Collapsing bootstrap into either is how the `#622` collision comes back: a bootstrap prerelease cut computes a stable core, and anchoring that on a lineage inferred from an empty set is guessing. ## Cost Small for the detector: one helper plus a paginating tag read, both against machinery that exists. The test surface is the real work: a prerelease-only fixture, a stable-history fixture, a zero-tag fixture, and the truncation control above. And the manifest-meaning question from option ② does not disappear — under detection, `last_released_version` still stops being unambiguously stable for a prerelease-only project. It just stops being a policy imposed on everyone. ## What this does to the tracker If detection is taken, the open AC changes from *choose a lineage policy* to *implement per-project lineage detection, and record what the manifest means for a prerelease-only project*. That is no longer an operator decision with no dominant path — it is dispatchable work. Recommend detection. Flagging that it is my recommendation and not a measurement: what is measured above is that the predicate is cheap, that this repo classifies correctly under it, and that the truncation hazard is real on this instance.
Owner

OPERATOR DECISION, 2026-09-06: detection, if feasible. It is feasible, so this AC changes from a policy choice to implementation work.

The open AC is superseded. It asked which lineage a manifest-free prerelease cut should follow. Under detection the answer is per-project, so neither ① nor ② is imposed on everyone.

Feasibility, measured before the decision

internal/semver     NewParser() (semver.go:141), Prerelease() (semver.go:98)
                    already used this way at cmd/rt/manifest_check.go:207
tag listing         already in internal/decide/decide.go, cmd/rt/prune_rc_tags.go,
                    cmd/rt/recover_pending_cut.go, cmd/rt/repin.go
this repo under it  117 tags -> 98 stable, 19 prerelease, 0 unparseable -> STABLE-HISTORY

The three states the detector must return

some tag parses as semver with an EMPTY prerelease   STABLE-HISTORY
tags exist, none parse stable                        PRERELEASE-ONLY
no tags at all                                       BOOTSTRAP — neither

Bootstrap must not collapse into either. A bootstrap prerelease cut computes a stable core, and anchoring that on a lineage inferred from an empty set is guessing — which is how #622's collision returns.

🔴 The hazard that decides whether the implementation is sound

The predicate is an ABSENCE claim — no stable tag exists — and this Forgejo truncates /tags silently. Measured today:

bare GET /tags          ->  30 of 117
GET /tags?limit=100     ->  50 of 117   <- the server caps at 50. A FULL page,
                                           which reads as "I got what I asked for".
paginated to empty page -> 117

A project whose stable tags are older than its 50 most recent prerelease tags classifies as PRERELEASE-ONLY, silently, and gets the wrong lineage. The limit=100 read is the dangerous one because it looks careful.

Revised AC

  • A detector returns STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP, paginating tags to an EMPTY page rather than a short one
  • A truncation control: a fixture with more than 50 tags whose only stable tag is the oldest, asserting it does NOT classify as prerelease-only
  • Manifest-free prerelease cuts anchor on the detected lineage
  • What last_released_version means for a PRERELEASE-ONLY project is recorded in the bootstrap design documentation — option ②'s cost does not vanish under detection, it just stops being imposed on projects that do not need it

What stays settled

The broad "bootstrap + --prerelease" ban remains REJECTED and must not be reopened. The narrow collision guard on f503de4d stays.

OPERATOR DECISION, 2026-09-06: detection, if feasible. It is feasible, so this AC changes from a policy choice to implementation work. The open AC is superseded. It asked which lineage a manifest-free prerelease cut should follow. Under detection the answer is per-project, so neither ① nor ② is imposed on everyone. ## Feasibility, measured before the decision ``` internal/semver NewParser() (semver.go:141), Prerelease() (semver.go:98) already used this way at cmd/rt/manifest_check.go:207 tag listing already in internal/decide/decide.go, cmd/rt/prune_rc_tags.go, cmd/rt/recover_pending_cut.go, cmd/rt/repin.go this repo under it 117 tags -> 98 stable, 19 prerelease, 0 unparseable -> STABLE-HISTORY ``` ## The three states the detector must return ``` some tag parses as semver with an EMPTY prerelease STABLE-HISTORY tags exist, none parse stable PRERELEASE-ONLY no tags at all BOOTSTRAP — neither ``` Bootstrap must not collapse into either. A bootstrap prerelease cut computes a stable core, and anchoring that on a lineage inferred from an empty set is guessing — which is how `#622`'s collision returns. ## 🔴 The hazard that decides whether the implementation is sound The predicate is an ABSENCE claim — *no stable tag exists* — and this Forgejo truncates `/tags` silently. Measured today: ``` bare GET /tags -> 30 of 117 GET /tags?limit=100 -> 50 of 117 <- the server caps at 50. A FULL page, which reads as "I got what I asked for". paginated to empty page -> 117 ``` A project whose stable tags are older than its 50 most recent prerelease tags classifies as PRERELEASE-ONLY, silently, and gets the wrong lineage. The `limit=100` read is the dangerous one because it looks careful. ## Revised AC - [ ] A detector returns STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP, paginating tags to an EMPTY page rather than a short one - [ ] A truncation control: a fixture with more than 50 tags whose only stable tag is the oldest, asserting it does NOT classify as prerelease-only - [ ] Manifest-free prerelease cuts anchor on the detected lineage - [ ] What `last_released_version` means for a PRERELEASE-ONLY project is recorded in the bootstrap design documentation — option ②'s cost does not vanish under detection, it just stops being imposed on projects that do not need it ## What stays settled The broad "bootstrap + `--prerelease`" ban remains REJECTED and must not be reopened. The narrow collision guard on `f503de4d` stays.
Owner

Implementation is in PR #1362 (base 5398675419, head 004661270b).

AC disposition in the PR artifact:

  • detector states: STABLE-HISTORY, PRERELEASE-ONLY, BOOTSTRAP; tag walk ends only on an explicit empty page
  • truncation control: 50 recent prerelease rows followed by the only stable tag selects STABLE-HISTORY
  • manifest-free --prerelease decisions anchor on stable, prerelease, or virtual lineage respectively
  • integration docs state that PRERELEASE-ONLY bootstrapping records the prerelease anchor as last_released_version

Mutation evidence is recorded: short-page termination makes the pagination control fail; removing stable-history selection makes the classifier, truncation, and stable-anchor controls fail. Full local verification is green.

#623 STAYS OPEN pending review and merge. Refs #623

Implementation is in PR #1362 (base 5398675419c92034b617d89f4b5dec28107aa212, head 004661270b022d85270a4c6a26c2207f7bd7804a). AC disposition in the PR artifact: - detector states: STABLE-HISTORY, PRERELEASE-ONLY, BOOTSTRAP; tag walk ends only on an explicit empty page - truncation control: 50 recent prerelease rows followed by the only stable tag selects STABLE-HISTORY - manifest-free --prerelease decisions anchor on stable, prerelease, or virtual lineage respectively - integration docs state that PRERELEASE-ONLY bootstrapping records the prerelease anchor as last_released_version Mutation evidence is recorded: short-page termination makes the pagination control fail; removing stable-history selection makes the classifier, truncation, and stable-anchor controls fail. Full local verification is green. #623 STAYS OPEN pending review and merge. Refs #623
Owner

Closing. The operator decided detection this afternoon; @carpenter had it built inside the hour and it merged as #1362 (d200bb9).

The open AC was never really "which lineage" — it was whether we could tell. Once we could, the policy question dissolved: the lineage is detected per project rather than imposed on every project. Neither ① nor ② was chosen.

Verified on main, not on the PR:

internal/decide/lineage.go:16-18   STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP
                                   BOOTSTRAP separate, with the reason at the constant
client.go:309                      exhausting maxPages on a FULL page is ErrAPI,
                                   never a silent truncation
lineage_test.go:65                 50 prereleases, only stable tag as the LAST API row
branch-strategy.md                 what the manifest means for a prerelease-only project

@carpenter kept paginateStrictUntilEmpty separate from paginateStrict rather than upgrading every caller, which is the right call: only an explicit empty page terminates, and exhausting the page cap without one is an error rather than a return.

One finding recorded and NOT resolved here, because it is a different surface: the transport-layer truncation test's control is weaker than the PR body claims. @quartermaster ran the stated mutation and only the request-count assertion fired; both tag assertions passed under it, because the fixture puts the FULL page first and the short page second. A short-page terminator continues past a full page and still collects every tag. Filed separately — this AC's control is the detector-level one and it is sound.

The hazard that motivated the AC, measured on this instance while costing it:

bare GET /tags        ->  30 of 117
GET /tags?limit=100   ->  50 of 117   <- a FULL page, which reads as complete
paginated to empty    -> 117

That is why the predicate had to be an empty-page walk rather than a limit read: it is an ABSENCE claim, and ?limit=100 is the dangerous form because it looks careful.

Closing. The operator decided detection this afternoon; @carpenter had it built inside the hour and it merged as #1362 (`d200bb9`). The open AC was never really "which lineage" — it was whether we could tell. Once we could, the policy question dissolved: the lineage is detected per project rather than imposed on every project. Neither ① nor ② was chosen. Verified on `main`, not on the PR: ``` internal/decide/lineage.go:16-18 STABLE-HISTORY / PRERELEASE-ONLY / BOOTSTRAP BOOTSTRAP separate, with the reason at the constant client.go:309 exhausting maxPages on a FULL page is ErrAPI, never a silent truncation lineage_test.go:65 50 prereleases, only stable tag as the LAST API row branch-strategy.md what the manifest means for a prerelease-only project ``` @carpenter kept `paginateStrictUntilEmpty` separate from `paginateStrict` rather than upgrading every caller, which is the right call: only an explicit empty page terminates, and exhausting the page cap without one is an error rather than a return. One finding recorded and NOT resolved here, because it is a different surface: the transport-layer truncation test's control is weaker than the PR body claims. @quartermaster ran the stated mutation and only the request-count assertion fired; both tag assertions passed under it, because the fixture puts the FULL page first and the short page second. A short-page terminator continues past a full page and still collects every tag. Filed separately — this AC's control is the detector-level one and it is sound. The hazard that motivated the AC, measured on this instance while costing it: ``` bare GET /tags -> 30 of 117 GET /tags?limit=100 -> 50 of 117 <- a FULL page, which reads as complete paginated to empty -> 117 ``` That is why the predicate had to be an empty-page walk rather than a limit read: it is an ABSENCE claim, and `?limit=100` is the dangerous form because it looks careful.
bosun closed this issue 2026-09-06 17:56:55 +02:00
Sign in to join this conversation.
No project
No assignees
4 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#623
No description provided.