Skip to content

fix(upgrade-guard): parse the version witness as semver, and stop reading "unreadable" as "legacy" - #5650

Merged
julianknutsen merged 2 commits into
mainfrom
fix/witness-pseudoversion
Aug 25, 2026
Merged

fix(upgrade-guard): parse the version witness as semver, and stop reading "unreadable" as "legacy"#5650
julianknutsen merged 2 commits into
mainfrom
fix/witness-pseudoversion

Conversation

@julianknutsen

Copy link
Copy Markdown
Collaborator

The outage

The cross-era upgrade guard (#4907) refused five production workspaces outright:

Error: legacy Dolt server workspace detected; explicit migration is required before this
bd version can open or modify the workspace. Preserve .beads unchanged and follow
docs/getting-started/upgrading.md#cross-era-upgrades

All five were current-era. Their .beads/.local_version held a Go pseudo-version,
v1.1.1-0.20260805093327-bf97b73749ac, and currentVersionWitness split it on .
expecting exactly three fields. It got four.

Because the guard runs in PersistentPreRunE before any store opens, this failed
every bd invocation — including the read-only ones an operator would reach for to
diagnose it. Measured blast radius before the witness files were hand-rewritten to
1.1.0: one gate sweep logged 644 failures in 6 hours, which froze PR review gates
entirely (no bead ever left needs-review), stalled triage, and left demand-driven
pools at zero — one city ran 1 session against 8 declared agents. Rewriting nine
.local_version files took the sweep to 0 failures/6h immediately. That workaround
is a band-aid: any tooling that writes a pseudo-version back re-breaks the fleet.

Which side is wrong

The writer. And it isn't.

.local_version holds main.Version verbatim, and release tooling injects that by
ldflags — goreleaser passes {{.Version}}, .github/workflows/release.yml passes
steps.version.outputs.version, other build paths pass whatever they resolved. So the
writer can legitimately emit a plain release, a release candidate (1.2.0-rc.1 — the
repo shipped v1.1.0-rc.1), a build carrying metadata, or a Go pseudo-version. Every
one of those is a valid semantic version; none is three bare numeric fields. bd could
not read what bd wrote.

The writer is correct and stays as it is: recording anything but the true version would
destroy the ordering CompareVersions and upgrade detection depend on (a pseudo-version
sorts before the release it names). The reader was counting dots. This PR fixes the
reader and pins the round trip so it cannot drift again.

Two fixes

1. Parse. classifyVersionWitness parses with golang.org/x/mod/semver (already an
indirect dependency; promoted to direct) and reports an era instead of a boolean.
legacyVersionMinor shares the parse, so a pre-1.0 pseudo-version or release candidate
is now correctly classified as legacy where it previously fell through unrecognized.
Shapes strict semver rejects but a distributor could still stamp onto a legacy build —
zero-padded or four-component versions — are still read as legacy from their major
component alone, so the pre-1.0 guard only tightens.

2. Policy — the part that matters. Before this change an unparseable witness meant
"legacy workspace, refuse everything". That is what turned a trivial parse bug into a
fleet outage, and it is wrong on its own terms:

  • Every pre-1.0 bd wrote a plain X.Y.Z through this same writer. A string that fails a
    real semver parse cannot have come from one. The guard was inferring "legacy" from
    data that positively excludes legacy.
  • The refusal is a claim about the workspace. The evidence is "I could not parse a
    38-byte advisory text file."
  • bd treats that file as advisory everywhere else: .local_version is gitignored,
    clone-local, and written best-effort (_ = writeLocalVersion(...)). bd doctor
    downgrades a bad value to a warning whose suggested fix is "run any bd command to
    reset version tracking" — which the guard made impossible.
  • The consequences are wildly asymmetric. Refusing is total and fleet-wide. The
    population it protects against is empty, because a genuine 0.55–0.62 workspace has a
    parseable witness and is already caught one branch earlier by legacyServerVersion.

So a witness that is present but unreadable is now its own era, witnessEraUnknown:
bd warns and opens the workspace. A missing witness is unchanged and still refused —
a workspace that never announced itself is genuinely ambiguous, and that is the dominant
real-world legacy shape since the file is gitignored. Any witness that reads as pre-1.0
is still refused.

The warning is self-healing rather than permanent noise. The guard runs immediately
before trackBdVersion, which rewrites the witness whenever it differs from Version,
so the admitted command leaves a readable witness behind and the next command is silent
(TestLegacyUpgradeGuardWarningIsSelfHealing).

Relationship to #5603 / #5625

#5603 reports the same defect from a different writer: Homebrew stamps HEAD-<shortsha>
into main.Version for --HEAD installs. Its analysis reaches the same conclusion this
PR argues — no legacy-era channel could have produced that shape — and it independently
confirms the 1.1.0-rc.1 case.

The unknown era admits those workspaces too, so this fixes the brew---HEAD outage as
well (TestLegacyUpgradeGuardAdmitsBrewHeadStamp). It does not silence their
warning: a HEAD stamp is rewritten identically on every run, so it never heals. That
wants the shape recognizer in #5625 (@anisoptera), which this PR deliberately does not
duplicate, along with that PR's doctor-side fixes which are orthogonal to the guard.
The two compose and both are worth landing. If #5625 lands first this reduces to the
policy commit; the parse here is a superset of versionCore (a real semver parse rather
than cutting at the first -/+), so the merge is mechanical either way. Not opened to
supersede — see the review comment on #5625.

Red before

15 cases across 5 tests fail against the unfixed parser, verified by expressing the
original dot-counting predicates in the new era vocabulary. Representative:

classifyVersionWitness("v1.1.1-0.20260805093327-bf97b73749ac") = Unknown, want Current  <- production
classifyVersionWitness("1.1.0-rc.1")                          = Unknown, want Current
classifyVersionWitness("1.2.0+build.5")                       = Unknown, want Current
classifyVersionWitness("v0.62.0-0.20250101000000-abcdefabcdef") = Unknown, want Legacy
classifyVersionWitness("0.62.0.1")                            = Unknown, want Legacy
guardLegacyUpgradeWorkspace() = legacy Dolt server workspace detected; ... , want nil

Coverage

  • TestClassifyVersionWitness — the production pseudo-version, plain 1.1.0,
    v-prefixed release, release candidates, build metadata, prerelease+metadata,
    whitespace, the pre-1.0 side of each, empty, whitespace-only, garbage, binary noise.
  • TestVersionWitnessRoundTrip — everything bd can write, including Version itself,
    bd reads back and recognizes as current. This also holds the writer inside the witness
    reader's bounded 64-byte size.
  • TestLegacyUpgradeGuardStillRefusesPreOneWorkspaces (0.9.1, v0.49.6, 0.55.0,
    0.62.21, a 0.x pseudo-version, 0.62.0.1) and
    TestLegacyUpgradeGuardRefusesWorkspaceWithoutAnyWitness prove the cross-era guard
    did not relax in either direction.
  • Two existing subtests that asserted "malformed ⇒ refuse" now assert
    "malformed ⇒ admit with a warning". That flip is the policy change, called out here
    rather than buried.

Gates

  • go build ./..., go vet ./... — clean
  • make ci-pr-lint (gofmt + golangci-lint v2.10.1, native and cross-linted
    windows/amd64 nocgo) — 0 issues both lanes
  • scripts/test.sh ./cmd/bd ./cmd/bd/doctor ./internal/configfile ./internal/beads
    all ok (cmd/bd 274s, the full package suite)
  • scripts/check-doc-freshness.sh, scripts/check-doc-flags.sh — PASS

Not run: the rest of make test. cmd/bd/doctor/fix fails on this box before and after
the change — it needs a Dolt test container (dial tcp 127.0.0.1: connection refused) —
and no other package touches the guard, the witness, or version tracking.

🤖 Generated with Claude Code

@steveyegge

Copy link
Copy Markdown
Contributor

Verdict: CLOSE-SUPERSEDED — in favor of #5675, which is your own next-day rewrite of the same fix and is stronger where it counts. Requesting that three pieces unique to this PR be ported there before it merges (list below).

Both PRs make the same two changes — parse the witness with golang.org/x/mod/semver (identical go.mod promotion) and reclassify present-but-unparseable as warn-and-proceed — so only one should land. Reasons #5675 is the better base:

  1. This PR leaves the 64-byte read bound in place (legacyUpgradeVersionWitness, unchanged here), and an oversized marker still reads as absent → refused. That's the same "bd cannot read what bd wrote" class one layer down: a pseudo-version of a prerelease tag plus build metadata (v1.2.1-rc.1.0.20260805093327-bf97b73749ac+build.20260805.linux.amd64, 68 bytes, valid semver) trips it. The round-trip test's comment ("holds the writer inside the … 64-byte size") documents the trap; fix(upgrade-guard): parse .local_version as semver; unreadable witness is unknown, not legacy #5675 removes it (bound → 256, oversized = present-but-unreadable).
  2. No warning dedup. guardLegacyUpgradeWorkspace runs from several sites per invocation (main.go PersistentPreRunE, bootstrap, doctor, the ancestor walk), so one command can print the warning repeatedly. Also the self-healing property is conditional: trackBdVersion only runs under policy.runMaintenance and not in preview mode, so read-only commands re-warn every run. fix(upgrade-guard): parse .local_version as semver; unreadable witness is unknown, not legacy #5675 dedups per workspace.
  3. No CHANGELOG entry; fix(upgrade-guard): parse .local_version as semver; unreadable witness is unknown, not legacy #5675 has one.

Three things here are better than #5675 and should be ported before closing:

Coordination note: #5625 (anisoptera) is still open and rewrites the same functions plus the same docs table; whichever lands first forces a manual rebase of the survivors. Your composition analysis in this PR body is the right one — carry it over to #5675, which currently references neither PR.

@julianknutsen

Copy link
Copy Markdown
Collaborator Author

This PR has been added to the review queue.

Track progress: https://factory.gascity.com/city/maintainer-city/runs/gcg-5436965277228230

@julianknutsen julianknutsen added status/reviewing PR review workflow is running and removed status/needs-review-auto Request automated PR review workflow labels Aug 24, 2026
Julian Knutsen and others added 2 commits August 25, 2026 06:50
…ding "unreadable" as "legacy"

The cross-era upgrade guard (#4907) refused five production workspaces
outright:

    legacy Dolt server workspace detected; explicit migration is required
    before this bd version can open or modify the workspace.

Every one was current-era. The witness was a Go pseudo-version,
`v1.1.1-0.20260805093327-bf97b73749ac`, and `currentVersionWitness` split
it on "." expecting exactly three fields. It got four. Measured blast
radius before the operator hand-rewrote nine `.local_version` files to
`1.1.0`: every `bd` invocation against five cities failed, taking every
order that shells out to bd with it — one gate sweep alone logged 644
failures in 6 hours, which froze PR review gates entirely (no bead ever
left `needs-review`), stalled triage, and left demand-driven pools at
zero (one city ran 1 session against 8 declared agents). Rewriting the
witness files took the sweep to 0 failures/6h immediately.

Two defects, one in the parse and one in the policy.

## The parse

`.local_version` holds `main.Version` verbatim, and release tooling
injects that by ldflags: goreleaser passes `{{.Version}}`, the release
workflow passes `steps.version.outputs.version`, and other build paths
pass whatever they resolved. So the writer can emit a plain release, a
release candidate, a build carrying metadata, or a Go pseudo-version —
all valid semantic versions, none of them three bare numeric fields. bd
could not read what bd wrote. The writer is right; recording anything
but the true version would destroy the ordering `CompareVersions` needs.
The reader was counting dots.

`classifyVersionWitness` now parses with `golang.org/x/mod/semver`
(already an indirect dependency) and reports an era. `legacyVersionMinor`
shares the parse, so a pre-1.0 pseudo-version or release candidate is now
*correctly* classified as legacy where it used to fall through
unrecognized. Shapes strict semver rejects but a distributor could still
stamp on a legacy build — zero-padded or four-component versions — are
still read as legacy from their major component alone, so the pre-1.0
guard only tightens.

## The policy

Before this change an unparseable witness meant "legacy workspace, refuse
every command". That is the part that turned a trivial parse bug into a
fleet outage, and it is wrong on its own terms: every pre-1.0 bd wrote a
plain X.Y.Z through this same writer, so a string that fails a real semver
parse is affirmative evidence *against* a legacy workspace. The guard was
inferring "legacy" from data that positively excludes it, using a file the
codebase treats as advisory everywhere else — `.local_version` is
gitignored, clone-local, written best-effort, and `bd doctor` downgrades a
bad value to a warning whose suggested fix is "run any bd command", which
the guard made impossible.

So a witness that is *present but unreadable* is now its own era,
unknown: bd warns and opens the workspace. A *missing* witness is
unchanged and still refused — a workspace that never announced itself is
genuinely ambiguous — as is any witness that reads as pre-1.0.

The warning is self-healing rather than permanent noise: the guard runs
in PersistentPreRunE immediately before `trackBdVersion`, which rewrites
the witness whenever it differs from `Version`, so the admitted command
leaves a readable witness behind and the next command is silent.

## Coverage

`TestClassifyVersionWitness` pins the production string, plain and
v-prefixed releases, release candidates, build metadata, the pre-1.0 side
of each, empty, and garbage. `TestVersionWitnessRoundTrip` pins the
contract the guard depends on — everything bd can write, including
`Version` itself, bd reads back and recognizes as current, which also
holds the writer inside the witness reader's bounded size.
`TestLegacyUpgradeGuardStillRefusesPreOneWorkspaces` and the missing-
witness test prove the cross-era guard did not relax. Red before the
parse fix on 15 cases across 5 tests.

Refs GH#5603. That report reaches the same conclusion from a different
writer — Homebrew stamps `HEAD-<shortsha>` into `main.Version` for
`--HEAD` installs — and the unknown era admits those workspaces too
(`TestLegacyUpgradeGuardAdmitsBrewHeadStamp`). It does not silence their
warning: a HEAD stamp is rewritten identically every run, so it never
heals. Silencing it wants the shape recognizer in GH#5625 (anisoptera),
which this deliberately does not duplicate; the two changes compose.

Co-Authored-By: Claude <noreply@anthropic.com>
The legacy-upgrade guard's witness reader collapsed a present-but-blank
`.local_version` (0-byte or whitespace-only, e.g. from an interrupted or
disk-full best-effort write) into the same ("", false) result as a
genuinely missing witness. In server mode with a local Dolt root the
`if ok` reader-gate then routed present-blank onto the missing->refuse
path, hard-refusing a possibly-current workspace as "legacy Dolt server
workspace" with no self-heal -- the exact false-refusal class this PR
removes, left open for the blank-witness shape.

Make legacyUpgradeVersionWitness report presence independently of blank
contents: a present, bounded, regular witness returns ("", true) so the
guard classifies it witnessEraUnknown and warns-and-opens (matching the
present-but-unparseable case and the documented upgrading.md contract),
while a missing, non-regular, or oversized witness stays ("", false) and
still refuses, preserving the pre-1.0 guard safety invariant.

Adds guard-level tests for present-blank (0-byte / newline-only /
spaces+tabs) -> warn+open and missing -> refuse, plus a reader-contract
test pinning present-blank->present and missing/oversized->absent.

Addresses the maintainer review's one major finding (Codex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@julianknutsen
julianknutsen force-pushed the fix/witness-pseudoversion branch from 7d4624f to 80d5910 Compare August 25, 2026 06:53
@julianknutsen

Copy link
Copy Markdown
Collaborator Author

Maintainer Adoption Review

Thanks for the contribution, @julianknutsen! This PR fixes a production-outage-class bug in the legacy-Dolt upgrade guard: the version-witness parser was counting dots instead of parsing semver, so valid pseudo-version witnesses like v1.1.1-0.20260805093327-bf97b73749ac were misread and current server workspaces were falsely refused as "legacy". It also stops treating a present-but-unreadable witness as "legacy -> refuse everything". This matters because it removes a false-refusal that could lock users out of their own current workspaces, while keeping the safety-critical refusal of genuinely pre-1.0 layouts fully intact.

This PR was reviewed and adopted with maintainer fixes pushed directly to the PR branch.

Original PR Review

Decision: request_changes resolved to approve.

Specific gaps fixed:

  • The outage itself: the dot-counting parser rejected valid semver witnesses. The final patch delegates classification to golang.org/x/mod/semver, so the production pseudo-version v1.1.1-0.20260805093327-bf97b73749ac classifies as current and the guard opens.
  • "Present but unreadable" was read as "legacy -> refuse everything". The final patch routes a present, non-empty, unparseable witness to warn-and-open, and it self-heals on the next run.
  • Present-but-blank witness was still hard-refused with no self-heal (the review's one major finding). The reader now reports witness presence independently of trimmed content, so a present-but-blank .local_version warns-and-opens while a genuinely missing, non-regular, or oversized witness still refuses. The legacy-refusal safety invariant is preserved and pinned by new tests.

Review findings addressed:

  • major / Error Handling & Resilience (source: Codex, confidence: high): a present-but-blank version witness was hard-refused with a misleading "legacy" message and no self-heal. Fixed by maintainer commit 80d59103 ("fix(upgrade-guard): warn+open on a present-but-blank version witness"), which also adds guard-level coverage in cmd/bd/legacy_upgrade_guard_witness_test.go asserting present-blank -> warn+open and missing -> refuse.

Non-blocking follow-ups (shared for visibility -- NOT completed in this PR):

  • nit / Debuggability & Operability (source: Claude, confidence: high): a syntactically-current but non-canonical witness that strict semver rejects (zero-padded / leading-zero, e.g. 1.2.03, 01.2.3) now emits the "unreadable version witness ... assuming a current workspace" warning on every run, where the old dot-counter accepted these shapes silently. It is strictly cosmetic -- the workspace still opens -- and no real release path emits these shapes, so it is deliberately not addressed here. Tracked as mc-7mtq: absorb these shapes into the version-witness recognizer referenced for GH#5625 if field warning-noise is ever observed.

Maintainer Changes

One maintainer commit was pushed to the PR branch: 80d59103 "fix(upgrade-guard): warn+open on a present-but-blank version witness" -- cmd/bd/legacy_upgrade_guard.go and a new cmd/bd/legacy_upgrade_guard_witness_test.go, 2 files changed, 115 insertions(+), 14 deletions(-). Your original commit is preserved with authorship intact.

Final Review Status

Ready for the merge queue: final head 80d59103a has passing required GitHub checks, and the merge-ready metadata is in place. I am marking this PR status/merge-ready so the merge queue can pick it up.

CI: https://github.com/gastownhall/beads/actions/runs/32819001787

Review Iterations

2 review passes performed. Iteration 1 returned request_changes on the present-but-blank major finding. After the maintainer fix, iteration 2 re-verified both original defect fixes and the legacy-guard safety property, confirmed the major was resolved, and approved -- an additional reviewer test-hygiene finding was verified against the reviewed code and refuted.


Adopted via /adopt-pr workflow. Original contributor commits preserved.

@julianknutsen julianknutsen added status/merge-ready PR is ready for merge workflow status/merge-queued Queued for deterministic PR-review merge and removed status/merge-ready PR is ready for merge workflow status/reviewing PR review workflow is running labels Aug 25, 2026
@julianknutsen
julianknutsen merged commit 62d2119 into main Aug 25, 2026
124 checks passed
@julianknutsen julianknutsen removed the status/merge-queued Queued for deterministic PR-review merge label Aug 25, 2026
marcodelpin pushed a commit to marcodelpin/beads that referenced this pull request Aug 25, 2026
…mits)

Conflict: cmd/bd/legacy_upgrade_guard.go - upstream gastownhall#5650 rewrote the
version-witness parse as a three-way era classifier (semver-based,
unreadable != legacy), superseding our bda-hcs5 suffix-tolerant parser
with a richer design covering the same defect family. Resolved keeping
upstream's classifier; our hcs5 pinning test re-pointed at
classifyVersionWitness (clause 219) - the suffix-tolerance property it
pins is preserved, with one deliberate semantics change recorded in the
test ('1.2' is v1-era under x/mod semver, no longer unparseable).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants