0025 — Static Repository Checkers Are Written in Go, Acceptance Rigs Stay Bash¶
Status: Accepted
Supersedes in part: 0020 — Retain Bash Acceptance Rigs Until a Measured Pilot Trigger
Context¶
ADR 0020 answered a narrow question well and a broad question by accident.
The question it was asked, in #491, was whether the large acceptance rigs should move from Bash to Go. Its evidence table measures six files. Its options weigh a test/acceptance/ Go module against Bats. Its revisit triggers are all per-rig. Its ranked pilot order names those same six files. On that question its answer is correct and this ADR does not disturb it: the rigs own disposable cluster lifecycles, teardown ownership, atomic receipts, and crash recovery, and porting them would combine a language migration with an acceptance-boundary rewrite at exactly the gate used to detect regressions.
But its Decision section says "Retain Bash" without a scope qualifier, and the repository has read that as a general answer. The result is measurable.
The growth is not where ADR 0020 was looking¶
scripts/ at three points:
| Date | Marker | Shell files | Shell lines |
|---|---|---|---|
| — | #491 filed | 73 | 21,137 |
| 2026-07-18 | ADR 0020 evidence snapshot (b90c1bd) |
81 | 27,383 |
| 2026-07-30 | main at 4b2ffb9 |
115 | 35,812 |
That is +8,429 lines and +34 files in the twelve days since the ADR was accepted, a 30.8% increase. The six rigs ADR 0020 measured account for 355 of those lines — 4.2%. The other 95.8% landed outside the ADR's entire field of view, overwhelmingly as new files.
Those new files are not rigs. They are static checkers and document renderers: check-github-actions.sh (77 → 534 lines), check-fed-registry.sh (388), check-activitypub.sh (406), check-identity.sh (128), check-bom.sh (111), check-fed-agreement.sh (107), check-release-versioning.sh (62), plus fed-registry-render.sh (154), fed-trust-alert-render.sh (136), and fed-agreement-render.sh (107). The check-*.sh and *-render.sh surface is now 2,950 lines, and 2,400 of those lines contain no kubectl, helm, k3d, flux, or docker reference at all. They read tracked YAML, JSON, and Markdown and assert invariants over it. They own no cluster, no receipt, no teardown, and none of the semantics ADR 0020 was protecting.
The repository already has a second, better dialect for exactly this class¶
apps/matrix-a2a-bridge/cmd/ already holds static checkers in Go: check-model-catalog (178 lines + 54 test), check-model-residency (272 + 77), pin-mcp (292 + 111), validate-agents, agent-contract. They decode once into typed structs, wrap errors with %w, and carry positive and negative fixtures under t.TempDir(). mise.toml invokes them exactly as it invokes the shell checkers:
[tasks."check:model-catalog"]
run = "go run ./cmd/check-model-catalog --repo-root ../.."
[tasks."check:github-actions"]
run = "bash scripts/check-github-actions.sh" # before this ADR
Two dialects, one job. Which one a new checker lands in was decided by which file the author happened to open, not by a rule.
The cost is on the critical path and compounds¶
ADR 0020 recorded check:shell as the longest observed task in the check aggregate at 150.69s and correctly concluded that porting one 1,100-line rig would remove about 4% of that lint input — no demonstrated return. The inverse case was never examined: every new Bash line is added to the repository's slowest gate, on every pull request, linearly and permanently. The shell surface has grown 30.8% since that measurement.
The gate has grown with it. ADR 0020 measured complete hosted CI jobs — install, format, check, and test — at 311–338s on 2026-07-18. The same job on 2026-07-29 (run 30490105004, the most recent run in which it executed rather than resolving a gate receipt) took 507s. That is not attributable to the shell surface alone, but the shell surface is the one input that grew 30.8% in the window, and it sits on the slowest task. A Go checker adds nothing to check:shell, compiles in 0.53s, and is linted by gates that already run.
The failure-granularity weakness ADR 0020 conceded as "a valid weakness" is also concentrated in this class. scripts/check-github-actions.sh enforces 15 distinct named contracts in 534 lines. Each is a separate yq -o=json … | jq -r … | @tsv pipeline feeding a while IFS=$'\t' read loop — 19 yq and 19 jq call sites, roughly 152 subprocess YAML parses of 8 workflow files per run. It emits one flat success line naming all 15 contracts. An operator cannot run one contract, and a reviewer cannot see which contract a diff changed. The file has taken 21 commits.
Decision¶
-
Narrow ADR 0020 to the class it measured. It governs the cluster-owning acceptance rigs — the six named files and any future script that owns a disposable environment, teardown ownership, receipts, or crash recovery. Those stay Bash under its unchanged triggers and pilot order. This ADR does not authorize porting any of them.
-
Static repository checkers and document renderers are written in Go. A script belongs to this class when it reads tracked repository files and asserts or renders, and it neither creates nor mutates a cluster, container, or credential. New checkers in this class land in Go from acceptance of this ADR. There is no deadline for converting existing ones.
-
The home is
apps/matrix-a2a-bridge/cmd/, not a newtools/module, reusing the established idiom —run(args []string) error,flag.NewFlagSet, a--repo-rootflag,%w-wrapped errors, and per-contract tests with positive and negative fixtures. No new Go module, nogo.work, and no shared acceptance-helper package until two ports demonstrate the same duplication, per ADR 0020's shared-helper boundary, which this ADR preserves. -
Public
misetask names do not change.check:github-actionsstayscheck:github-actions. Task consumers, CI, and hooks see no difference. -
A converted checker aggregates violations rather than failing on the first one, preserving the current Bash behaviour of reporting every violation in one run, and names the failing contract in the message.
-
scripts/check-github-actions.shis the first conversion, on the evidence above: the largest static checker, the fastest-growing file in the repository, 15 separately-named contracts, zero cluster references, and no shared-library coupling. -
What this ADR does not do. It does not port a rig. It does not create a
fgctl. It does not mandate a mass rewrite of the existing 2,950 static-checker lines. It does not touchscripts/lib/, which remains the only helper dialect for unported shell paths.
Consequences¶
-
The repository gains a stated rule for a decision it was making implicitly, and the fastest-growing part of the shell surface stops growing.
-
The half-migrated world is explicit and indefinite:
check-*.shandcmd/check-*will coexist for a long time. This is accepted. ADR 0020's prohibition on leaving two selectable implementations of the same checker still binds — a conversion removes its Bash original atomically in the same change. -
Line count got worse, and the first conversion measured exactly how much.
scripts/check-github-actions.shwas 534 lines;cmd/check-github-actions/main.gois 1,192 — +123%, plus 1,113 lines of test the Bash had no equivalent for.jqis a domain-specific language for exactly this problem and Go is not; the cost isyaml.Nodetraversal to preserve the missing / wrong-type / wrong-value distinctions the Bash got fromtojson. Line-count reduction is therefore explicitly not a justification or a gate for this class, and ADR 0020's 30%-reduction bar stays with the rigs it was written for. The wins that were measured and did hold: runtime 1.047s → 0.125s (8.4×,misesubprocess included), 152 subprocess YAML parses → 8 typed decodes, 18 contracts individually named in failure output and individually testable, and 186 passing subtests over negative fixtures where the Bash had no tests at all. A reviewer who weighs lines above those four should reject this ADR rather than accept it quietly — the trade is real and it is not free. Some of the 1,192 lines are one-time scaffolding (ordered-map decoding, duplicate-key rejection, node-to-JSON rendering) that the next conversion reuses, but that is a prediction, not evidence, and it must not be counted until a second conversion demonstrates it. -
The conversion found three defects the Bash gate had. Differential testing over 222 cases showed the shell accepted a workflow with a duplicate mapping key, a trailing YAML document, or a non-scalar mapping key. The first is security-relevant: GitHub honours the last duplicate, so
persist-credentials: falsefollowed bypersist-credentials: truepassed the old gate. All three are now rejected at the parse boundary. This is the strongest single argument for the class change, and it was not predicted — it emerged from having to state the document model explicitly. -
The module home was contested and the objection is recorded. The alternative was a new top-level
tools/Go module exposing onefgctlbinary, on the argument that the bridge module is a production image with a licensing andgovulnchecksurface that repository tooling should not enter. Two measurements decided it the other way:apps/matrix-a2a-bridge/Dockerfile:16-18builds only./cmd/bridge,./cmd/usage-receipt, and./cmd/offboarding-reconciler, so a checkercmdships in no image; andgopkg.in/yaml.v3is already a direct dependency, so the first conversion adds no dependency and no vulnerability surface. Five checkers already live there. A fifth Go module would also cut against the finding that the four existing module paths (github.com/fmind-ai/matrix-a2a-bridgeand its siblings) all 404 — the current module split is not a real boundary and should not be extended casually. Revisit this if a conversion ever requires a dependency the bridge binary does not already carry; that is the trigger to extracttools/, and it should be taken immediately rather than by adding the dependency here. -
Operator
bash -xfluency is lost for converted checkers. This is a real cost for the rigs, where it is preserved, and a small one for static checkers, where the failure is a message about a tracked file rather than a live-environment state. -
Bash competence remains required. This ADR reduces the shell surface's growth rate, not its existence.
-
If the checker class stops growing on its own, or if two conversions fail to produce better failure messages than the Bash they replace, this ADR should be superseded rather than extended.