ASK KNOX
beta
LESSON 603

Freshness SLOs & CI Gates That Block

A doc verified six months ago is expired even if the file was never touched — verification is the SLO, and CI must block on it.

8 min read·Self-Maintaining Documentation

The SLO That Counts

There is a distinction between a doc that was last modified and a doc that was last verified. A reformatter can touch every line of a runbook without reading a single one. A generator can regenerate a reference table without checking whether the source it reads is still accurate. File modification timestamps are not freshness signals. They are timestamps.

The freshness signal that matters is verified_at: the date a human or agent last read the doc and confirmed its content matches the current state of the system. The DocRecord shape carries this field precisely because modification timestamps are insufficient. A doc that carries a verified_at of six months ago is expired by the SLO even if it was linter-reformatted yesterday.

This matters most for the docs that agents read most. A project's context file is loaded before every agent session. A stale hard rule in it — "the staging API is at URL X" when URL X was decommissioned three months ago — corrupts every session that loads it. Not loudly. The agent reads the rule, proceeds based on it, and produces output that is confidently grounded in an outdated constraint.

The matrix above is the policy layer. Each doc type carries a different max_age_days because the failure modes are different. A runbook's 90-day SLO reflects that operational procedures change with infrastructure. A reference doc's 14-day SLO reflects that API schemas and config options change fast. A context file's 30-day SLO reflects that operating rules must stay current to be usable.

The warn tier gives the owner advance notice. The block tier enforces the consequence. The escalate tier adds accountability when blocks are ignored for multiple days. The tiers are not bureaucracy — they are a consequence ladder calibrated to the stakes.

Why Advisory Warnings Fail

The temptation when adding a freshness gate is to start advisory: log a warning, continue, exit 0. This feels reasonable. Less disruptive. Gives teams time to adjust.

What actually happens: within two weeks, everyone learns that the warning line in CI does not affect whether the PR merges. The warning becomes noise. Three months later, every doc in the registry is expired, and the team has a backlog problem instead of a process problem.

The research on safety systems is consistent on this point: a warning that has no attached consequence degrades faster than no warning at all, because the warning's presence signals that someone checked and found it acceptable. This is the exact failure mode that produces the 3am runbook incident described in the runbooks lesson. The runbook had a comment saying "verify this step before production use." Nobody did, because the comment had no consequence.

Exit codes are the consequence. Exit 1 blocks the merge. Exit 0 does not.

The Gate Ladder in CI

The blocking gate flow is a three-stage filter: load the registry, check freshness against each DocRecord, block on any expired doc.

The implementation is a TypeScript script that CI runs as a step. The script loads the doc registry, computes daysSince(verified_at) for each record, compares against max_age_days, and exits 1 if any doc is in the block tier. The warn tier produces a console line. The block tier produces a console error and sets a flag. After processing all records, if the flag is set, process.exit(1).

One architectural choice matters: the script returns the exit code rather than calling process.exit directly inside the check function. A function that calls process.exit cannot be unit tested without spawning a subprocess — the function terminates the test runner along with itself. Returning the code lets the CI entry point call process.exit exactly once, and lets test suites call the gate function with a known registry and assert the return value.

// The gate function returns a code — the CI entry point exits on it
const exitCode = runFreshnessGate(registry)
process.exit(exitCode)

The CI step looks like:

- name: Freshness gate
  run: npx ts-node scripts/freshness-gate.ts

If the script exits 1, GitHub Actions marks the step failed and blocks the merge. Required status checks set in the repository enforce that this step must pass before any PR can merge.

Required-Doc Rules

The freshness gate answers: "are the docs that exist current?" A related but distinct question is: "do the docs that should exist, exist?"

Required-doc rules extend the gate to cover the second question. The pattern: the CI script inspects the PR diff, identifies files matching a pattern (e.g., any file in routes/), and asserts that the registry contains a DocRecord for the corresponding documentation. If the PR adds routes/webhook.ts without a DocRecord for docs/api/webhook.md, the gate exits 1.

This closes the gap that produces undocumented routes, undocumented runbook procedures, and undocumented configuration options. The code that ships without its doc is a future reverse-engineering problem. Required-doc rules make the doc part of the feature's definition of done — enforced by CI rather than by convention.

Deleting Dead Docs

The freshness gate creates pressure to maintain doc accuracy. There is a second lever that teams frequently underuse: deleting docs that cannot be maintained.

A doc that has been expired for 18 months and has no owner who can re-verify it is not a resource. It is a liability. Every reader who approaches it does so with the assumption that it is accurate. Agents do not apply the skepticism that a senior engineer might. An agent reading a confidently wrong doc will act on it.

The correct policy, from the prerequisite track: if you cannot maintain a doc, delete it. A missing doc forces a question. A wrong doc provides a wrong answer. The wrong answer is always worse.

The freshness gate exposes which docs are candidates for deletion: any doc that has been in the expired tier for more than N days with no re-verification activity. Adding an automated ticket for these — rather than silently blocking PRs indefinitely — converts the gate from a frustration source into a curation tool.

SLO Calibration Over Time

The right max_age_days for each doc type is not a single correct number — it is a calibration that evolves with the team's update cadence and the system's rate of change. Start generous. A 180-day SLO on a runbook is better than no SLO at all. Track how often the gate fires. If it never fires, the SLO is probably too loose and docs are drifting undetected. If it fires constantly on docs that owners are actively maintaining, the SLO may be tighter than the cadence supports.

The calibration goal is a gate that fires when docs are genuinely at risk of misleading a reader — not one that fires so often it becomes noise, and not one that is loose enough to let an agent run a 3am runbook against commands that were removed six months ago.

The warn tier is the calibration tool. A doc that is frequently in the warn tier but rarely in the block tier suggests the max_age_days is close to right — owners are catching it in time. A doc that frequently hits the block tier suggests either the SLO is too tight or the owner is not reading the warnings. Both scenarios are signals. Act on them.