Skip to content

Drift Detection: How a Template Rots, and Who Notices

A project template is one of the rare programs whose most dangerous failures arrive without any code change. A pinned tool releases a new major, a dependency's license changes underneath you, upstream fixes a bug you never saw, a formatter release reflows a file you hand-wrote — and the template renders exactly as confidently as before. This page is the framework this repository uses to reason about that: every way a template can be wrong has a source, every way it can be noticed has a timing, and the two are independent axes. Keep the matrix honest and rot has nowhere to hide; leave a cell empty and it becomes the story of the bug you find six months late. The internal design notes live in notes/Strategy.md; this page is the transferable version.

(The runtime verification these mechanisms feed into — input space, render invariants, execution — is a different axis again; see Verification Architecture.)

The two axes

Sources — where wrongness comes from:

Source Examples that actually happened here
A Static quality inside the template a when expression referencing a question defined later; a jinja block that renders valid YAML only for one answer
B The render and the rendered output a generated project that fails its own ruff format under one line-length setting but not the other
C Aging of external packages and toolchains copier shipping a breaking major; a PyPI floor pin outliving the package; pip-audit finding a CVE in a locked dev dependency
D The upstream fork DiamondLightSource/python-copier-template merging fixes this repo never reviews
E The meta-accident: your own dev toolchain breaking the template a pre-commit end-of-file-fixer rewriting .jinja files, whose missing trailing newline is meaningful

Timings — when the wrongness can be noticed:

Timing Property
1 Before commit milliseconds; sees only what a local linter can see
2 Push / PR CI minutes; the workhorse, but fires only when someone changes code
3 Scheduled fires with zero code changes — the only timing that does; a red run names an external cause
4 Logical verification (Z3) milliseconds, no render at all; proves reachability of the input space instead of sampling it

The matrix is MECE on purpose: a fix belongs to exactly one source cell, a check to exactly one timing cell, and "which cell catches this?" is a question you can ask before writing the check. The failures that hurt are the ones whose cell was silently empty.

The matrix, filled in

1: pre-commit 2: push/PR CI 3: scheduled 4: logical (Z3)
A ruff / typos the jinja+YAML structural sweep — the satisfiability sweep (test_copier_structure.py: typo'd gates, self-contradictions, unreachable combinations are proven absent, and meta-tests break the detector on purpose to prove it still detects)
B — render + generated lint/format + type-check + the example executed end to end the weekly full check (same jobs as CI, publish: false) —
C — renovate PRs weekly pin drift report (MicroPython, CUDA, ROS 2 EOL, Python floor, Postgres, the copier ceiling); lock-file maintenance; Thursday pip-audit with a deduplicated issue on failure —
D — — weekly upstream commit check, URL-direct fetch, issue on drift —
E auto-fixers excluded from .jinja / copier.yml — — —

Nothing exotic lives in the cells — scheduled workflows, a weekly audit, a Z3 encode of the questionnaire's when expressions. The engineering is in refusing to leave a cell empty, and in the properties below.

What the matrix taught us

1. The scheduled cell is the only one that fires when nobody types. CI is change-triggered by construction, so source C (aging) is invisible to it: the template sits still while the world moves. The weekly full check exists to turn "the world changed" into a red run, and it carries its own deduplicated issue ("Scheduled full check failed — no repo changes involved") because a red schedule you don't subscribe to is a cell that only pretends to be filled. Schedules are staggered across weekdays so a failure names its workflow, and each workflow owns exactly one concern — a Friday audit finding should never share a run with a Tuesday link check.

2. Satisfiability is a detection timing, not a vibe. Encoding the questionnaire's when logic for Z3 proves, before any render, that every gate is reachable and no combination is contradictory — and it proves the absence of whole classes of bugs (a typo'd gate name goes unsat) rather than sampling for their presence. Two honesty rules keep it from being theater: the Z3 model is a projection of jinja's evaluation, so a per-expression test replays every when through copier's own machinery and compares verdicts (~10k comparisons, asserting both polarities occur per expression); and the enumerated leaves are pinned to a committed ledger whose freshness check fails with the exact regeneration command. A logical proof nobody can regenerate is a comment.

3. The detectors rot too — so guard the guards. Every mechanism in the matrix has a meta-test that breaks it on purpose: the satisfiability sweep has detectors fed typo'd literals and expected to catch them; the witness ledger has a test that renames a fixture and expects the freshness check to name the command that fixes it. The ⑤-style question — "does the Z3-enumerated leaf space actually get tested?" — was answered by a coverage ledger recording one verdict per leaf, written atomically by the test session itself.

4. Your dev toolchain is an error source (E), not a neutral actor. The worst template bug in this repo's history was inflicted by its own linting: a pre-commit autofixer rewrote .jinja files whose missing-trailing-newline was load-bearing. The general form: any tool that writes to the tree is a mutation source, and the template's meaningful oddities (no trailing newline, multi-document YAML, jinja blocks) are exactly what such tools normalize away. Exclusions are the fix, and "run the tool, then assert the tree didn't change" is the test — the regression test became moot here only because the local hooks were removed entirely and hygiene moved to CI.

5. The matrix is a budget for new features. The extension runbook's first questions (Extending the questionnaire) are "what does it sit on?" and "how many leaves does it add?" — the drift matrix adds the third: which cell notices when this feature rots? A feature whose answer is "none" ships with a named, dated review obligation instead — the ethics registry's review_by is the same matrix applied to non-code facts, where the "scheduled" cell is a literal calendar date that reddens CI when it passes.

Costs, honestly

The timings are cheap in this order: pre-commit milliseconds, Z3 milliseconds (no render), CI minutes, scheduled the same CI cost paid weekly for nothing changing. The expensive mistakes are all on the empty side of the matrix: the width-dependent formatter bug (source B) shipped because the lightweight render tier lacked one check and the heavyweight tier sampled too narrowly; the un-pinned copier dependency (source C) was one renovate merge away from breaking every test at once. The recurring pattern is not "add more checks" — it is name the source and the timing for every check you already have, then look at which cells your actual incidents came from and fill those first.