Context
Surfaced during the pre-PR audit of #130, the toolchain-pin refresh that moved the Go pin from 1.26.5 to 1.27.0. That change is correct; what the audit found is that the invariant it relies on is undefended.
Problem
.claude/rules/toolchain-ci-parity.md states the coupling as unconditional:
CI installs Go via actions/setup-go with go-version-file: go.mod, so the go directive in go.mod — not mise.toml — chooses the CI toolchain.
It is conditional. actions/setup-go reading go-version-file prefers a toolchain directive over the go directive when both are present. The repository has no toolchain line today, so the rule holds — but only by an absence that nothing records, asserts, or checks.
That absence is load-bearing well beyond version hygiene. go tool govulncheck reports standard-library advisories against the Go on PATH, which in CI is whatever setup-go resolved. So the go.mod field CI resolves decides which standard library the vulnerability scan is reporting on, and SECURITY.md rests a stated guarantee on it: "Dependencies and the standard library are scanned for known vulnerabilities."
If a toolchain directive ever lands — go get and go mod tidy both write one when run under a newer toolchain, and Dependabot's gomod ecosystem updates go.mod — CI silently begins building and scanning against that Go instead of the pinned one.
The failure is invisible to every gate the repository currently has:
just tidy-check passes — a toolchain line is tidy.
- The Toolchain currency workflow passes — it reads
mise.toml, where the Go pin still agrees with itself.
- The Vulnerability scan passes — it reports cleanly, against the wrong standard library.
just vuln passes locally — it resolves Go from mise, so a developer sees the pinned toolchain's result and never observes the divergence.
The outcome is a stated security posture that becomes false with nothing red. Same class as #115 (CI executing code the repository does not pin), reached through a different mechanism.
Evidence
actions/setup-go README, "Breaking changes in V6": "If the toolchain directive is present, its version is used; otherwise, the action falls back to the go directive."
.github/workflows/vuln.yml and both Go jobs in .github/workflows/ci.yml resolve Go via go-version-file: go.mod. No toolchain directive and no GOTOOLCHAIN appears anywhere in the repository.
.claude/rules/toolchain-ci-parity.md § "go.mod go directive tracks the mise Go pin" states the coupling without naming the precondition it rests on.
Suggested approaches
Options, not a prescription:
- Assert the absence in CI — a step that fails when
go.mod carries a toolchain directive, or when it carries one disagreeing with the go directive.
- Make the value explicit instead — write a
toolchain line matching the go directive, so setup-go and mise agree by construction and any later change to it is a visible diff rather than a silent takeover.
- Record the precondition in
.claude/rules/toolchain-ci-parity.md so a maintainer editing go.mod knows the invariant is conditional. Weakest alone — it addresses the human path but not the automated one, which is where the directive is most likely to arrive.
Context
Surfaced during the pre-PR audit of #130, the toolchain-pin refresh that moved the Go pin from 1.26.5 to 1.27.0. That change is correct; what the audit found is that the invariant it relies on is undefended.
Problem
.claude/rules/toolchain-ci-parity.mdstates the coupling as unconditional:It is conditional.
actions/setup-goreadinggo-version-fileprefers atoolchaindirective over thegodirective when both are present. The repository has notoolchainline today, so the rule holds — but only by an absence that nothing records, asserts, or checks.That absence is load-bearing well beyond version hygiene.
go tool govulncheckreports standard-library advisories against the Go onPATH, which in CI is whateversetup-goresolved. So thego.modfield CI resolves decides which standard library the vulnerability scan is reporting on, andSECURITY.mdrests a stated guarantee on it: "Dependencies and the standard library are scanned for known vulnerabilities."If a
toolchaindirective ever lands —go getandgo mod tidyboth write one when run under a newer toolchain, and Dependabot'sgomodecosystem updatesgo.mod— CI silently begins building and scanning against that Go instead of the pinned one.The failure is invisible to every gate the repository currently has:
just tidy-checkpasses — atoolchainline is tidy.mise.toml, where the Go pin still agrees with itself.just vulnpasses locally — it resolves Go from mise, so a developer sees the pinned toolchain's result and never observes the divergence.The outcome is a stated security posture that becomes false with nothing red. Same class as #115 (CI executing code the repository does not pin), reached through a different mechanism.
Evidence
actions/setup-goREADME, "Breaking changes in V6": "If thetoolchaindirective is present, its version is used; otherwise, the action falls back to thegodirective.".github/workflows/vuln.ymland both Go jobs in.github/workflows/ci.ymlresolve Go viago-version-file: go.mod. Notoolchaindirective and noGOTOOLCHAINappears anywhere in the repository..claude/rules/toolchain-ci-parity.md§ "go.modgodirective tracks the mise Go pin" states the coupling without naming the precondition it rests on.Suggested approaches
Options, not a prescription:
go.modcarries atoolchaindirective, or when it carries one disagreeing with thegodirective.toolchainline matching thegodirective, sosetup-goand mise agree by construction and any later change to it is a visible diff rather than a silent takeover..claude/rules/toolchain-ci-parity.mdso a maintainer editinggo.modknows the invariant is conditional. Weakest alone — it addresses the human path but not the automated one, which is where the directive is most likely to arrive.