ADR-0176: Security scans are tiered by cadence, and the SAST tier trades timeliness for cost
Date: 2026-08-03 Status: Accepted Deciders: Adrian (project owner), Claude Opus 5, Codex (review, PR #1033) Refines: ADR-0157 (CodeQL SARIF upload fail-open)
Context
A cost review measured, rather than estimated, what CI costs per push:
| workflow | billable min/push |
|---|---|
| CI | ~41 |
| Security | ~38 |
| Process Gates | ~5 |
| total | ~84 ≈ $0.67 |
Over seven days: 103 CI + 105 Security + 82 Process Gates runs ≈ 8,600 minutes ≈ $69. Roughly a third of that bought no additional information per push.
ADR-0157 already recorded that code scanning is not enabled on this private
repository — GET /code-scanning/alerts returns 403, verified — so
upload-sarif 403s and CodeQL "is NOT an active control ... must not be counted
as one in any compliance/conformity artifact". What ADR-0157 did not decide is
how often a control in that state should run.
Three jobs were running on every push:
gitleaks (full tree)— its two findings are in git history (the #288/#990 backlog). Every run since the job was written produced byte-identical output. ~260 min/week.- CodeQL ×2 — ~1,470 min/week.
- Semgrep — findings tracked in #290,
--errordeliberately not passed, so it has never blocked. ~315 min/week.
Separately, the same container image was being built three times per push: two jobs each spun up their own runner to inspect an image a third job already had in its local daemon.
Decision
1. Gates are tiered by cadence, and the tier is chosen by asking whether the gate's INPUT can change between runs.
| cadence | input | jobs |
|---|---|---|
| every push | code or dependencies a PR can edit | both test suites, Lint, pip-audit, npm audit, both Trivy image scans, Trivy IaC/Dockerfile misconfig, gitleaks on the pushed range |
weekly + workflow_dispatch | history a branch cannot alter, or an upstream clock | full-history gitleaks, CodeQL ×2, Semgrep |
2. Only checks OUTSIDE the required set are eligible to move. All three are. Merge protection is unchanged, and that is the boundary this ADR must not cross: a cadence change that drops a blocking control is a coverage cut wearing a cost-saving label.
3. The SAST tier trades TIMELINESS, and the record says so. An earlier rationale claimed CodeQL and Semgrep produce output that "cannot change" and is "unreadable by construction". Both claims are false and are withdrawn here. They analyse the checked-out source, so findings move with the code, and the workflow retains their SARIF as a build artifact — the 403 blocks Security-tab ingestion, not retrieval.
The honest trade is: a finding introduced on Tuesday is not seen until the Monday sweep, and seeing it means downloading a SARIF instead of reading the Security tab. That is a real reduction in timeliness, accepted for ~1,470 minutes a week, and reversed the moment GitHub Advanced Security is enabled — at which point these belong back on every push and ADR-0157's fail-open flag comes out with them.
4. Secret scanning is WIDENED, not narrowed. Because the full-tree scan is now
weekly and the documented admin bypass makes direct pushes to master possible, the
per-commit gitleaks job scans a push's before..sha in addition to a PR's
base..head. This also corrects a premise in the original change: each new commit
extends the history a full scan covers, so "the result cannot change" was true
of the two known findings, not of the scan.
5. Scheduled runs get their own concurrency group. With one group keyed on
github.ref, a push to master during the ~14-minute sweep cancelled it, and the
replacement push run skipped those jobs — silently eliminating that week's only
sweep. Superseding a push scan with a newer push scan is correct; superseding the
sweep with a run that does not perform it is not.
6. Image-dependent checks share one build, and stay independent of the tests.
The two legal-file jobs fold into the job that already builds both images. That
job carries no needs: — with needs: [tests…] a failing lint produced no
legal-artifact result at all, since a job needing a failed job is skipped. The
deleted jobs had no needs: either, so three unconditional builds become one with
the independence preserved. Inside the step, both images are inspected and both
statuses collected: set -e would let a backend failure suppress the frontend
result, which is exactly the property separate jobs provided.
Consequences
Positive
- ~84 → ~50 billable minutes per push, roughly 40% less, with the same blocking set.
- The weekly sweep can no longer be cancelled by ordinary development traffic.
- A secret pushed directly to master is now scanned immediately; before this it was covered by neither the PR-diff job (PR-only) nor the full scan (weekly).
Negative
- SAST findings are up to 7 days late, and triaging them means downloading a SARIF artifact. This is the cost of the decision, not a side effect of it.
- The per-commit gitleaks job's name still says "PR diff" while it now also scans pushes. It is a required status check matched by exact name, so renaming it and the ruleset cannot be done atomically in one PR. The name understates coverage, which is the tolerable direction; the rename is tracked.
- Weekly runs cost the same minutes; the saving is per-push only, so a week with few pushes saves proportionally less.
Neutral
- No application code changes. Workflows,
quality-gates.md, and this record. - ADR-0157 stays Accepted. Its narrow fail-open on the SARIF upload is unchanged; this ADR decides the cadence it left open.
Alternatives Considered
Alternative 1: enable GitHub Advanced Security and keep everything per-push
Makes CodeQL an active control, restores the Security tab, removes the ADR-0157 flag, and makes this whole decision unnecessary.
Why rejected for now: it is a licensing decision for the repository owner, not an engineering one. Recorded as the exit condition rather than dismissed — when GHAS is enabled, this ADR's decision 3 is reversed.
Alternative 2: delete the three jobs
The cheapest option, and honest about CodeQL not being an active control.
Why rejected: it destroys the artifact as well as the schedule. A weekly SARIF is a retained record an auditor can ask for; nothing is a record of nothing. Deleting a control because it is currently inconvenient to read is how a gate set decays.
Alternative 3: paths-ignore instead of a schedule
Skip the scans when only docs change.
Why rejected: it is a smaller saving with a worse failure mode — the exclusion list is a claim about which paths cannot affect the analysis, and it goes stale silently. A cadence is legible; a path filter is a standing assertion nobody re-checks.
Decision context:
- Latency: CI wall-clock per push drops; SAST finding latency rises to ≤7 days. Both directions stated because only one of them is the saving.
- Dependency surface: none. No actions added or removed.
- Debuggability:
gh workflow run Securityreproduces the full tier on demand, so "run everything before a release" remains one command. - Reversibility: minutes — delete four
if:conditions. - Blast radius: which jobs run per push. The required-check set is untouched, and that invariant is the one this ADR must be read against.
- Alternative considered: enabling GHAS — not rejected on merit, deferred as an owner decision, and recorded as the condition that reverses decision 3.