Skip to content
GitHub
Decisions

ADR-069: Trigger-driven `spectral.core` re-audit (supersedes ADR-067 D1 + D2)

Status: Accepted (2026-04-29) Supersedes: ADR-067 D1, ADR-067 D2

Context

ADR-067 established the killer-test re-audit cadence as quarterly calendar review (D1) with a 25%-growth trigger override (D2). The calendar cadence was the structural backstop against the cumulative-drift mode that ADR-024’s PR-statement governance failed to catch.

Two pieces of context shifted between ADR-067’s authoring (M1, this same session date) and now (M3a, same date but downstream of M2 + M3 lessons):

  1. The validator’s structural coverage is wider than ADR-067 anticipated. M2 landed validator rules 1–7 + ruff D100/D104. ADR-068 (companion to this ADR) extends rule 3 to inspect class-internal method signatures. Each layer the lint structurally enforces is one fewer thing the audit needs to catch via judgment. The audit’s residual scope is narrower than when ADR-067 was written.

  2. The audit-records location ADR-067 specified was wrong. ADR-067 D3 step 5 directed audit records to planning/audits/<YYYY-MM-DD>-...md. planning/ is gitignored — those records would never ship in the repo, making them unavailable as anchors for any subsequent automation that depends on knowing “what was audited last and when.” A tracked location is required for trigger-driven scheduling to work.

The user’s framing on M3a session: “I’d love to shift away from the calendar- based re-audit to something more automated and lint-scheduled so it doesn’t drift over time.” Calendar discipline drifts when a quarter’s audit lands late, lighter, or skipped because the calendar didn’t reflect actual kernel activity. A trigger-driven model fires when there is something to look at, which both eliminates the drift risk and removes the silent-quarter case where the calendar fired against a stable kernel.

Decision

D1 — Audit cadence is fully trigger-driven; no calendar floor

The kernel re-audit fires when one or more audit-candidate signals are present, not on a calendar. Audit candidates are surfaced by tools/quality/check_kernel_audit_candidates.py, which runs in CI and prints a candidate list to the workflow log on every push to main.

Audit-candidate signals (any one fires):

  • Module-count growth. Any core/<subdir>/ has grown by ≥25% in module file count since the last audit recorded in docs/audits/. This was ADR-067 D2’s threshold; carries over verbatim.
  • Module added or removed. Any core/<subdir>/ has had a module file added or removed since the last audit, regardless of percentage.
  • Substantial body change. Any core/<subdir>/ module has had ≥30 lines added or removed (cumulative git log --numstat since the last audit) on files other than __init__.py.
  • Stale admission rationale. Any core/<subdir>/ module’s body has changed since the last audit but its module docstring (admission rationale) has not. This catches the “code drifts but the rationale freezes” failure mode that ADR-024 governance also missed.
  • No prior audit on record. If docs/audits/ is empty, every core/<subdir>/ is a candidate. The first audit seeds the baseline.

When candidates surface, the responsible owner (founder under current operational reality, per ADR-067 D4) runs the audit per the ADR-067 D3 procedure and lands a new record.

D2 — Audit records live in docs/audits/ (tracked)

ADR-067 D3 step 5 directed records to planning/audits/<YYYY-MM-DD>-core-reaudit.md. planning/ is gitignored, which would defeat trigger-driven scheduling (the trigger tool needs to read the records to determine what changed since last audit). Records relocate to:

docs/audits/<YYYY-MM-DD>-core-reaudit.md

The directory is tracked. ADR-067 D3 steps 1–5’s procedure content is unchanged; only the file path changes.

A tracked docs/audits/index.md (created at first audit, not pre-created by this ADR) optionally lists past audits with one-line descriptions, for human browsability. The trigger tool does not depend on the index file — it discovers records by globbing docs/audits/*-core-reaudit.md.

D3 — The trigger tool is a reporter, not a CI gate

check_kernel_audit_candidates.py prints candidates to stdout and exits 0 regardless of how many candidates surface. It does not fail CI. The audit is a judgment-required activity; the tool surfaces the work, the owner does it.

This is an intentional design choice. Failing CI on candidate-presence would force every PR that touches core/<subdir>/ to wait on an audit, which would re-create exactly the calendar-discipline-becomes-paperwork failure mode the trigger-driven model is supposed to eliminate. The tool’s job is to make the audit-candidate state visible, not to gate work.

The CI workflow runs the tool in the existing architecture job and captures its output as a workflow-log annotation. Owners read the output when reviewing CI status; downstream tooling can scrape for notification purposes (out of scope for this ADR).

Consequences

  • No calendar reminder needed. ADR-067 D1’s quarterly cadence is retired. Owners check CI workflow logs for audit-candidate notices when the kernel has visibly changed (which is when the candidates fire).
  • Records move from gitignored planning/audits/ to tracked docs/audits/. The first audit creates the directory; ADR-067 D3 procedure is otherwise unchanged.
  • The 25%-growth trigger is preserved but recontextualized. It was an override to the calendar in ADR-067 D2; under D1 here it is one of the primary trigger signals (no longer an override since there is no calendar to override).
  • The cumulative-drift defense remains intact. Stale-admission-rationale detection is the new structural backstop against the failure mode ADR-024 governance + ADR-067 calendar audit were both designed to catch. When a module’s body changes but its admission docstring does not, the trigger fires and the audit re-examines whether the module still belongs in core.
  • Silent zero-audit periods are an explicit feature. If the kernel sits stable for six months, no candidates fire and no audit is needed. The prior calendar discipline would have audited a stable kernel anyway as re-examination posture; the trigger-driven model trusts that “nothing changed” means “nothing to re-examine.” If founder later wants the re-examination posture back, this ADR is the supersession point.
  • DX cost is bounded. The trigger tool runs in CI; pre-push is unchanged. Owners read CI log periodically. The audit itself is the only judgment cost, and that cost is unchanged from ADR-067.

Alternatives considered

  • Keep ADR-067’s quarterly calendar cadence. Drifts when a quarter’s audit lands late or is skipped because the kernel is stable. The user’s M3a framing flagged this drift risk explicitly. Rejected.
  • Halve the calendar cadence to biannual + keep triggers as overrides. Preserves a cadence floor. Rejected on the same grounds — the audit’s value is in re-examining when there’s something to re-examine, not in hitting a date. The trigger-driven model is strictly more aligned with the audit’s purpose.
  • Move the trigger tool into validate_architecture.py --show-audit-candidates. Couples the validator (CI gate that fails closed) with the audit reporter (informational; never gates). The two have different purposes; mixing them makes both harder to reason about. Standalone tool keeps separation of concerns clean. Rejected for now; revisitable if maintenance burden of two tools surfaces a real cost.
  • Make the trigger tool a hard CI gate (fail when candidates exist). Recreates the calendar-becomes-paperwork failure mode (every PR that touches core/ blocks on audit completion). Rejected.
  • Track audit records via git tags rather than a tracked directory. Cleaner from a tooling perspective (single source of truth for “when did audit X happen”), but adds tag-management overhead and means the audit content still needs a separate location. Rejected; tracked directory carries both the date and the content.