Skip to content

ADR-033: ADR Enforcement Architecture

Accepted

2026-07-12

Thirty-two ADRs govern this repo, but until now their constraints were enforced unevenly: some had dedicated tests wired into task ci (ADR-028’s inline-script pin, ADR-029’s token scan), some were enforced as a side effect of the build (ADR-005’s named queries), and many stated rules no machine ever verified (ADR-026’s log.Printf ban, ADR-002’s migration pairing). An agent or contributor had no single place to learn which rules are checked, which are review territory, and which are aspirational.

Correction 2026-09-30: ADR-000 … ADR-032 is thirty-three ADRs (thirty-four including this one); the Enforcement section’s “33/33 at launch” and the CHANGELOG’s “all 34” both count this ADR in. TC-1’s “status: warn” below labels a check that is not wired — read it as “not wired (no status)”.

This ADR applies the Clownware ADR-enforcement pattern: every ADR declares its testable consequences, a deterministic check suite verifies them, and enforcement graduates from warning to blocking only after a check earns trust. The pattern was specified for the Clownware repo family alongside astro-performance-starter; at implementation time the Astro repo had not yet landed its enforcement ADR, so this repo is the pattern’s reference implementation, not a port.

An honest scale note: this repo needed less new machinery than the pattern budgets for. Roughly half of the machine-checkable surface was already blocking in task ci (ADR-021) before this ADR existed. The new value is the mapping, the gap-filling checks, and the graduation mechanism — not a new wall of gates.

1. Every ADR carries an Enforcement section

Section titled “1. Every ADR carries an Enforcement section”

Appended as an amendment (never rewriting existing prose), in a fixed schema:

## Enforcement
<!-- added YYYY-MM-DD, see ADR-033 (Enforcement Architecture) -->
- **Testable consequences:**
- TC-1: <verifiable assertion>
- **Checks:**
- TC-1 → <tool/rule or scripts/adrcheck check> (status: **warn** | **block**)
- **Not machine-checkable:** <named honestly>
- **Graduation log:** _(empty)_

Constraints are classified into three buckets: A — structural invariant (deterministic: imports, file layout, forbidden patterns, config), B — semantic constraint (intent-level; gets only the “Not machine-checkable” line), C — process rule (metadata-checkable). An ADR too vague to yield a testable assertion is reported as such, never creatively patched.

2. A warn-only check suite: scripts/adrcheck

Section titled “2. A warn-only check suite: scripts/adrcheck”

A single dependency-free Go program (matching the repo’s scripts/<name>/main.go convention), run as task check:adr inside task ci. Checks are driven by checks/enforcement.config.json:

{ "id": "adr026-slog-only", "adr": "ADR-026", "tc": "TC-1/TC-2",
"status": "warn", "added": "2026-07-12" }

Semantics: a failing warn check reports WARNING and never fails the run; a failing block check reports BLOCKER and exits non-zero; a config entry naming no wired check is always a BLOCKER (the ADR-030 “key with no wired check fails” rule). Failure messages name the ADR, the TC, and the remedy. Output is human-readable by default, --json for machines. The suite is deterministic and runs in under a second.

Checks that an existing tool already enforces are not re-implemented: their Enforcement sections cite the tool (go test, golangci-lint, agents:check, budget scripts) with status block, pre-existing. Restarting already-blocking enforcement at warn would have been a downgrade dressed as rigour.

3. Two blocking hooks — the only blocking layer at launch

Section titled “3. Two blocking hooks — the only blocking layer at launch”

Configured in .claude/settings.json (project-shared, checked in), verified against the Claude Code hooks documentation as of 2026-07-12:

  1. Stop-gate (.claude/hooks/stop-gate.sh): when an agent finishes a turn, run go test ./... and the check suite. A test failure or BLOCKER exits 2, which prevents completion and feeds the report back into the agent’s loop; warnings pass through as information. Respects stop_hook_active to avoid ping-pong. Kill-switch: STOP_GATE_OFF=1.
  2. PreToolUse guard (scripts/adrguard): denies agent Edit/Write on protected paths — existing ADRs (the record is append-only; legal moves are a graduation-log append or supersession), AGENTS.md (generated, ADR-022), and sqlc/templ-generated code (ADR-019). The denial message names the governing ADR and the legal move. Kill-switch: ADR_GUARD_OFF=1 for one operator-authorized amendment.

A check is promoted from warn to block after 7+ days with no false positives OR one real catch (it flagged a change that was genuinely wrong). Promotion is three lines: flip status in checks/enforcement.config.json and set graduated, append a dated entry to the owning ADR’s Graduation log, and note it in CHANGELOG.md. Demotion is always allowed — a check producing false positives goes back to warn immediately, with the same three-line trail. Nothing was promoted in the launch session; every new check starts at warn.

  • Go-native suite in scripts/, not shell in checks/ — a check written in the repo’s own language is a feature consumers of a starter template inherit; only the config lives in checks/.
  • Pre-existing enforcement mapped, not migrated — task ci’s existing gates keep their blocking status and are recorded as such, rather than being rebuilt inside adrcheck.
  • Import checks parse Go, not text — go/parser (ImportsOnly), after the text version flagged its own detection literal on first run.
  • Every ADR now answers “how would I know this rule was broken?” in a fixed place, including the honest answer “you wouldn’t — it’s review territory.”
  • Warn-only launch means the suite can be wrong without hurting anyone; trust is earned per-check through the graduation log, and the log is public evidence either way.
  • Two new scripts/ programs and a hooks config join the template; consumers who don’t want enforcement delete scripts/adrcheck, scripts/adrguard, checks/, .claude/hooks/, the check:adr task, and the hooks block in .claude/settings.json.
  • The Stop-gate adds a go test ./... run each time an agent finishes a turn — seconds with a warm build cache.
  • First run produced one genuine finding: internal/handler/health_handler.go imports pgxpool to ping the pool for /health, a gray zone against ADR-003’s letter. It stays a warning pending a triage decision (allowlist as infrastructure vs. refactor behind an interface) — the graduation decision for that check will force the call.
  • sqlc and templ regeneration-drift checks (task db:generate / templ generate output vs committed code) — ADR-003/ADR-017. Done 2026-08-19 (#90): task check:generated / scripts/checkgenerated, warn-only per §4.
  • A secret scanner for ADR-014/ADR-015’s “no hardcoded secrets” beyond structural patterns. Done 2026-08-19 (#91): adr015-no-hardcoded-secrets, a Go-native high-signal pattern set inside adrcheck, warn per §4.
  • Lighthouse-CI accessibility/performance gates (ADR-000, ADR-009) — deliberately unwired at this repo’s scale.
  • Encode the new checks as golangci-lint rules (forbidigo/depguard). Rejected — lint findings are already blocking, which contradicts the warn-only launch; and most of the suite (migration pairing, package.json, workflow structure, ADR metadata) isn’t lintable Go anyway. Splitting one policy across two engines with different severity semantics costs more than it saves.
  • Block everything from day one. Rejected — an unproven check that false-positives inside a blocking gate erodes trust in the whole gate (the ADR-021 lesson in reverse). Warn-first is how a check earns its place.
  • Agent-as-judge review of semantic (bucket B) constraints. Out of scope by decision — deterministic checks only; bucket B stays human.
  • Testable consequences:
    • TC-1: Every ADR file contains an ## Enforcement section.
    • TC-2: Every entry in checks/enforcement.config.json names a wired check.
  • Checks:
    • TC-1 → not wired — adr011-adr-metadata checks only the ADR-NNN-Title.md filename pattern and a Status marker, not Enforcement-section presence (acknowledged gap; entry corrected 2026-08-02, it originally overstated the check’s scope); verified 33/33 manually at launch (status: warn)
    • TC-2 → adrcheck itself — an unwired config entry is a BLOCKER (status: block, structural)
  • Not machine-checkable: Whether a TC honestly captures its ADR’s intent, and whether graduation decisions follow the 7-day/real-catch rule — operator judgment, evidenced by the public graduation logs.
  • Graduation log: (empty)
  • Amendment 2026-10-12 (#161): TC-3 — an ADR that exists on the default branch loses no text: lines are added, the Status line may change, the empty graduation-log placeholder may be filled, and a line may be extended or struck through → adr033-append-only in scripts/adrcheck (status: warn, added 2026-10-12), which diffs docs/adr/ADR-*.md against the merge base with origin/master and skips with a notice when there is none. This closes a gap in §3: the PreToolUse guard matches file-edit tools only (Edit|Write, widened to Edit|Write|MultiEdit on this date), so a shell write (sed, a heredoc, a script) or a human’s editor never reaches it. Shell writes to ADRs are therefore review territory backed by this check, not by the hook; a guard that pattern-matched shell commands was considered and rejected as both evadable and prone to blocking the operator’s own amendment path. The check would have flagged 5f6b662 (2026-08-02), which rewrote ADR-001 §1 in place; ADR-001 carries the correction note. Also 2026-10-12 (#159): the regex checks and the ADR-029 token scan ignore comments, since a comment that names a forbidden pattern is not a violation of it; the secret scan still reads comments on purpose. Statuses unchanged.
  • Amendment 2026-10-12 (#165): the Stop-gate in §3 no longer lets the second stop through unconditionally. It blocks the same session up to two times (STOP_GATE_MAX_BLOCKS, counted per session_id), and tells the agent to open its final message with GATE FAILING: if it cannot fix the failure. On the next stop it lets the turn end, so an unfixable failure cannot loop, and emits a systemMessage telling the operator the turn ended with the gate failing. Reason: in the 2026-10 test (#162) the gate blocked eight runs and all eight finished on their next stop with a tree that fails task ci. The hook informs and delays; task ci in CI remains the gate (ADR-021). scripts/stopgate tests the script with a stub toolchain. adr019-governed-layer (ADR-019 TC-2) is the first check declared never to graduate; §4’s rule applies to checks that can be wrong, not to review flags.
  • Amendment 2026-10-13 (#169): three follow-ups to §3. (1) The PreToolUse guard now returns ask for an existing ADR instead of deny: an interactive session puts the edit in front of the operator, and a session with nobody to approve is refused. Generated files stay deny. ADR_GUARD_OFF=1 silences the denials only; it no longer applies to ADRs, because approving the prompt is the way through. Reason: every ADR amendment in #149, #163, #164 and #166 was made by shell, around a guard that denied the legal move along with the illegal one; a guard that the sanctioned path has to bypass teaches bypassing. This is the design the Astro starter chose when it ported the guard (its ADR-068). Whether an approved edit was an append is reported by adr033-append-only (TC-3). (2) The Stop-gate’s operator warning was verified in a live headless session on 2026-10-13 against an always-failing gate: two blocks (exit 2), then exit 0 with the systemMessage, which the CLI surfaced as a notice (“Stop says: Stop-gate: this turn ended with the gate still FAILING…”); the agent’s final message opened with GATE FAILING:. Rendering in the desktop app was not observed. (3) The gate sweeps its own counter files older than a day from $TMPDIR, left by sessions that died between a block and the next stop.