One policy for the Go toolchain version surface across the LarsArtmann fleet — detected and auto-fixed everywhere, wired into BuildFlow like every other auto-configurer.

Why?
A Go version number lives in many places: every go.mod and go.work directive, flake.nix nixpkgs pins, CI go-version: pins. Each location is individually correct; together they drift — the exact "three-way split brain" documented in the go-ecosystem-upgrade protocol. A fleet-wide audit (2026-09-18) found 284 of 383 modules carrying patch versions in the go directive (go 1.26.7), Nix flakes pinning go_1_26 while their modules declare go 1.27, and CI pins scattered across 1.22–1.27.1.
Worse: the drift propagates. go mod tidy copies a dependency's go floor verbatim, so one published library with go 1.26.7 re-poisons every consumer that tidies — per-repo fixes can never stick while supply-side roots carry patch floors. This tool exists to make that visible everywhere and mechanically fix the part that is safe to fix.
The policy
go directives (go.mod, go.work) are major.minor only (go 1.26, never go 1.26.7). A patch component raises the minimum toolchain to one exact patch and breaks trailing environments (Nix, CI runners).
go.work must cover the workspace floor: its normalized target is never below the highest module floor it lists.
- Nix and CI pins must not trail the module floor; CI patch pins are flagged because they silently diverge from the nixpkgs minor the flakes track.
Auto-fix applies only to unambiguous form violations — via go mod edit / go work edit, never text rewriting, and every fix is verified by re-parsing the file. Alignment issues (which side moves: pin or floor?) carry suggestions only; downgrades are never auto-applied.
Rule × pin-semantics matrix
Which rules fire regardless of the repo's pins, which honor them, and which can rewrite files:
| Rule |
Honors the repo's pins? |
Auto-fix? |
Notes |
go-directive-patch-form |
YES via respect_patch_floor (WithRespectPatchFloor; BuildFlow tool_options.go-version-auto-configure.respect_patch_floor: true, empty value resolves go env GOVERSION): a patch-form go directive at or below the installed toolchain is a deliberate floor and is left alone. Above it → flagged (breaks this host). |
YES (strip the patch via go mod edit), tidy-gated; dep-forced findings are advisory, never error |
The old fleet war (rewrite fights the flake's pin) is dead in this configuration. |
go-work-patch-form |
Partially: the strip is offered only when the stripped minor still covers the full module floor — a dep-forced patch floor keeps the patch REQUIRED. |
YES (strip) |
|
go-work-below-floor |
Floor-driven, direction-safe: raising a floor to cover modules never invalidates anything. |
YES (raise go.work to the floor) |
Fixes the "module X requires go >= N, but go.work lists go M" failure class. |
nix-pin-below-floor |
Detect-only by design: raising a flake pin is a flake + lockfile decision. |
NO (suggestion) |
|
ci-pin-below-floor |
Detect-only. |
NO (suggestion) |
|
ci-pin-patch-form |
Detect-only. |
NO (suggestion) |
|
toolchain-below-directive |
Detect-only (dead-weight line). |
NO (suggestion) |
|
toolchain-non-version |
Informational (local/default opt out of resolution). |
NO |
|
minor-exceeds-expectation |
Honors the fleet minor (WithExpectedMinor, ADR-0001: 1.27); surfaces above it. |
NO (downgrades never auto-applied) |
|
release-authority-drift |
Detect-only (VERSION vs CHANGELOG). |
NO (suggestion) |
|
go-mod-unparseable, go-work-unparseable |
Discovery failures. |
NO |
|
The honor-the-pin contract: with respect_patch_floor set, gvac never rewrites a go directive the installed toolchain already satisfies — the pin is treated as repo policy (gotcha #232's taxonomy: pin vs track-latest is a preference, and preferences never converge under repair).
Install
go get github.com/larsartmann/go-version-auto-configure@latest
Or build from source:
git clone https://github.com/larsartmann/go-version-auto-configure
cd go-version-auto-configure
GOEXPERIMENT=jsonv2 go build -o go-version-auto-configure ./cmd/go-version-auto-configure
Usage
CLI
go-version-auto-configure check ~/projects/go-finding # detect drift, exit 1 when found
go-version-auto-configure fix ~/projects/go-finding # auto-fix directive form
go-version-auto-configure fix --dry-run . # report without touching files
go-version-auto-configure who-forces . # name the deps forcing each go directive
Built for fleet sweeps: every command accepts multiple roots (analyzed in parallel, reported sorted), --parallel N caps the worker pool (default: CPU count), --quiet runs exit-code-only on all three analysis commands, check --expect-minor 1.27 encodes the fleet policy (any surface naming a higher minor is a policy finding), fix skips clean repos entirely, and --json emits a stable machine-readable report for CI:
go-version-auto-configure check --json ~/projects/*/ | jq '.repos[] | select(.clean == false)'
go-version-auto-configure check --quiet --expect-minor 1.27 ~/projects/*/ ; echo $? # 0 clean, 1 findings, 2 errors
Scheduled enforcement as a cron job (nightly sweep, non-zero exit alerts through your usual failure channel):
17 4 * * * $HOME/go/bin/go-version-auto-configure check --quiet --expect-minor 1.27 $HOME/projects/*/ >> $HOME/.cache/gvac-sweep.log 2>&1
When a form fix cannot stick, the tool says so truthfully and names the dependencies that force the floor — the supply-side re-tag targets:
$ go-version-auto-configure fix .
applied 0, dep-forced 1, held back 0, failed 0
dep-forced: rewrite go.mod directive in go.mod: go 1.26.7 → go 1.26
floor go 1.26.7 is forced by: github.com/larsartmann/go-finding, github.com/larsartmann/go-finding/toolsdk, github.com/larsartmann/linter-autoconfigure-sdk
fix supply-side: re-tag those modules with a major.minor-only go directive, then bump consumers
Directive rewrites are byte-preserving text surgery, not go mod edit reflows: only the go line changes, so unrelated lines never churn. Every rewrite must survive a non-mutating go mod tidy -diff dependency-floor gate before it sticks — otherwise it is classified dep-forced (above) and the file is restored atomically. The CanonicalizeGoMod entry point (what BuildFlow's go-mod-normalize step calls) derives the same changes itself, adds an installed-toolchain guard, and optionally strips toolchain directives outright (StripToolchain) for consumers whose fleet policy bans them; the CLI keeps the conservative default (model, never strip).
who-forces reports the same matrix for every module up front — directive, highest dependency floor, and who carries it (every forcing dependency with its own floor, not just the max carriers) — without touching anything. At parity (directive == max dependency floor) the carriers holding that exact floor are named in parityFloors — one-command answer to "who holds me here". The additive source field marks whether floors resolved from go list -m ("list") or from vendor/modules.txt annotations ("vendor"). --allow-partial downgrades per-module go list failures from exit 2 to exit 1 for fleets where some modules cannot resolve.
Real run (2026-10-03, a scratch module pinned to the pre-remediation event/v4 v4.12.0 — the patch-form floor propagates transitively through schema, snapshot, and storage/memory):
$ go-version-auto-configure who-forces .
go.mod verify.example/readme-who
directive: go 1.27.1 max dep floor: go 1.27.1
clean: no dependency forces a higher floor
at parity: github.com/larsartmann/go-cqrs-lite/event/v4@v4.12.0 holds the directive at go 1.27.1
at parity: github.com/larsartmann/go-cqrs-lite/schema/v4@v4.4.1 holds the directive at go 1.27.1
at parity: github.com/larsartmann/go-cqrs-lite/snapshot/v4@v4.5.1 holds the directive at go 1.27.1
at parity: github.com/larsartmann/go-cqrs-lite/storage/memory/v4@v4.5.2 holds the directive at go 1.27.1
Four carriers, one re-tag train: this exact matrix drove the 2026-10-03 cqrs-lite 92-tag remediation.
JSON contract
The --json documents are a stable machine contract, versioned by the top-level "schema": 2 field: field names and presence (omitempty) are guaranteed, and list fields are empty, never null. Schema 2 dropped the redundant poisoners strings — forcing dependencies are named by poisonerFloors (module, version, own floor).
| Command |
Per-repo fields |
check |
root, error?, clean, counts (total, mechanical, suggested, discovery), findings (rule, message, file, line, suggestion?, fix?) |
fix |
root, error?, applied, heldBack, depForced (fix, floor, cause?), failures, suggested, discovery |
who-forces |
root, error?, modules (path, kind, module, directive?, maxDepFloor?, poisonerFloors?, parityFloors?, source?, poisoned, error?) |
BuildFlow provider
BuildFlow discovers the provider when a consumer blank-imports it:
import _ "github.com/larsartmann/go-version-auto-configure/pkg/provider"
The provider self-registers via toolsdk.Register (go-finding), built on linter-autoconfigure-sdk's ProviderSpec → ProviderFromSpec bridge — the same integration path as golangci-lint-auto-configure, oxlint-auto-configure, and dependabot-auto-configure.
The mini SDK
pkg/surface is the reusable half: it discovers and models the version surface (module directives, workspace, Nix pins, CI pins) and evaluates the policy rules, writing nothing. Consumers that want fleet versioning intelligence without the fixer import it directly:
s, discoveryIssues, err := surface.Discover(repoRoot)
// discoveryIssues: unparseable files; reported as findings, never auto-fixed
issues := surface.Analyze(s) // policy violations; mechanical ones carry issue.Fix
Relationship to the rest of the fleet
| Concern |
Owner |
| Toolchain surface drift (this repo) |
detect + mechanical fix + suggestions |
| go.mod hygiene (tidy, replaces, floors) |
BuildFlow gomod-check |
| Publishing clean library versions |
go-release protocol (supply-side root cause of floor poisoning) |
| Propagating releases to consumers |
go-ecosystem-upgrade protocol + BuildFlow update |
| Fleet graph, conflicts, release planning |
project-dependency-graph |
Development
GOEXPERIMENT=jsonv2 go build ./...
GOEXPERIMENT=jsonv2 go test ./...
Requires Go 1.27 or newer with GOEXPERIMENT=jsonv2 (inherited from go-finding; the fleet minor is pinned by ADR-0001).