Context
The Terraform layer (infra/public-platform/, infra/bootstrap/) currently has two deterministic
checks, and both leave a gap between them:
terraform fmt+terraform validate(ADR 010) enforce formatting and catch what the language itself considers an error.validateis deliberately permissive: it accepts deprecated syntax, unused variable/local/output declarations, missingrequired_versionconstraints, and provider argument values that only fail atapplytime.- Trivy's IaC config scan (ADR 047) looks for security misconfigurations, exposed resources, excessive permissions, not correctness or hygiene.
Mapping this onto the determinism axis from ADR 048 (SonarQube), the way ADR 049 (zizmor) did for workflows:
| Terraform security | Terraform correctness/hygiene | |
|---|---|---|
| Deterministic | Trivy | validate only (permissive), gap |
| Non-deterministic | AI reviewers | AI reviewers |
The practical consequence of the gap: dead declarations accumulate silently as
infrastructure evolves (the exact class of drift that erodes iteration speed),
and a mistyped provider argument or deprecated interpolation is only caught by
a human, an AI reviewer that may or may not flag it, or a failed apply.
Decision
Adopt TFLint with its bundled terraform ruleset on the recommended
preset, for both Terraform roots. Like the other linters it is managed through
mise so CI and laptop run the same
pinned version:
- A
.tflint.hclper Terraform root enables the bundled ruleset. No plugin downloads, so the lint needs no network and notflint --initin CI. mise run //infra/public-platform:lint:tflint///infra/bootstrap:lint:tflinttasks, wired into the rootlintaggregate, each root'scheckaggregate, and lint-staged so a finding blocks the commit locally before it blocks the PR.- A dedicated TFLint CI workflow, triggered by any
*.tfor.tflint.hclchange across the monorepo. It runs as a blocking check (the actionlint role from ADR 049, not the Security-tab role), because lint findings here are correctness issues to fix, not risk findings to triage. A separate workflow rather than a job in Infrastructure CI keeps two properties: the lint needs no Terraform Cloud token and noproduction-infraenvironment, andinfra/bootstrap/-only changes don't trigger a no-op plan ofinfra/public-platform/behind that environment's gate.
This gives infra/bootstrap/** its first pull-request check of any kind (its
own workflow is workflow_dispatch-only).
Why It Adds Something New
TFLint is the specialist for the Terraform-correctness quadrant, and its ruleset
is disjoint from what already runs: validate accepts what TFLint flags
(unused declarations, deprecated syntax, missing version constraints), and
Trivy's rules are security-shaped rather than hygiene-shaped. The overlap with
either existing tool is effectively zero, mirroring the
zizmor-vs-generalists reasoning in ADR 049: generalists each carry a rule or two
about Terraform hygiene; TFLint carries the whole ruleset.
Alternatives Considered
Weighted by the same test as ADR 047 and ADR 049: deterministic, free, no new platform, mise-manageable.
| Tool | Role | Pros | Cons | Verdict |
|---|---|---|---|---|
| TFLint (accepted) | Terraform linter | De-facto standard linter; bundled ruleset needs no network; mise/CI/pre-commit friendly; provider rulesets available if ever needed. | Cloudflare/Neon have no provider-specific ruleset, so coverage is language-level only. | Accepted. |
| checkov / tfsec | IaC security scanners | Broad policy libraries. | Security-shaped: overlaps Trivy's config scan, not the hygiene gap. tfsec is folded into Trivy upstream. | Rejected: duplicates ADR 047. |
| OPA / conftest | Policy engine | Arbitrary custom policies over plans. | A policy language to author and maintain, not a curated ruleset; heavier than the gap warrants. | Rejected: Less Is More. |
| HCP Terraform policies (Sentinel) | Managed policy | Runs where the plan runs. | Paid tier; platform lock-in for a linting concern. | Rejected: free tooling covers the gap. |
Status quo (validate + Trivy) | n/a | No new tool. | Leaves the deterministic-hygiene quadrant empty. | Rejected: closing that gap is the point. |
Where It Sits On The Adoption Curve
Late majority. TFLint has been the default Terraform linter for most of a decade, with a stable CLI and a deep documentation/LLM corpus, squarely in the maturity band the philosophy favours for infrastructure tooling (LLM-Optimized), and a counterweight to the early-majority picks (zizmor) elsewhere in the stack.
Relation To Building Philosophy
- Less Is More. One binary, one config file per root, one CI job; no platform, no account, no dashboard.
- Short Feedback Loops. Findings surface at commit time via lint-staged, and the CI job is the fastest check on an infra PR because it skips Terraform init entirely.
- LLM-Optimized. Agents author most Terraform changes here; a deterministic lint floor means the same hygiene regression fails the same way every time, rather than depending on which AI reviewer notices.
- Build Flywheels. The same config shape drops into any future Terraform root in the monorepo.
Gaps & Risks
- No Cloudflare/Neon provider ruleset exists, so provider-specific argument
validation stays with
terraform planand review. If either provider ships a ruleset, enabling it is a two-line.tflint.hclchange. - Rule preset drift.
recommendedevolves across TFLint versions; a mise version bump can introduce new findings. Renovate makes that an explicit PR rather than a silent change (ADR 024).
Consequences
Positive
- Fills the deterministic Terraform-hygiene quadrant with a blocking, secrets-free CI check and a pre-commit hook.
- Gives
infra/bootstrap/its first pull-request check of any kind. - Free, offline-capable, mise-pinned for local/CI parity.
Negative
- One more tool version to keep updated (delegated to Renovate).
- Language-level rules only. Provider-specific mistakes still reach
plan.