Detecting Doc Drift
Overview
A doc is a set of claims about the repo; drift is a claim the repo no longer backs.
This skill declares the shape of drift detection so it runs the same way every time and
can be invoked programmatically to trigger updates — not a one-off prose review.
Two non-negotiables make the output usable by automation:
- A verdict requires evidence. Never mark a claim VERIFIED because doc and code "seem
consistent." Verified means you ran the command, opened the line, or matched the grep.
- The result is structured, not prose. Answer in the verdicts shape below. A human
summary on top is fine; the artifact is what the engine reads and downstream tooling parses.
REQUIRED SUB-SKILL: Use writing-docs for any fix you propose — every fix must meet
its bar (real output, no aspirational claims, marked+anchored rationale). This skill finds
and classifies drift; writing-docs governs how the correction reads. fixing-docs
consumes the engine report this skill's audit writes — record digests are the handoff, and
mint-approval takes them from the person who selected them. The scheduling-doc-sync skill
wires detect→fix to cron/PR; where a standing policy is committed there, the selection is
derived by policy-mint instead, which is the only door that mints a policy brand.
The audit (run these steps, in order)
The engine owns scope, segmentation, and validation; this skill owns the judgment in the
middle. Every command below is python3 -m doclifecycle … with the plugin's engine/
directory on PYTHONPATH (plugins/doc-lifecycle/engine/README.md covers both spellings).
Every artifact this audit writes — the plan, the verdicts, the report — goes to
${TMPDIR:-/tmp}/, never the work tree: fixing-docs' applier confines a run to its
approval set's paths, so an audit artifact sitting in the tree reads as an unaccounted
change and the apply refuses (apply-working-tree-not-confined). The steps below reuse
that same destination; only the filename changes per artifact.
Exception — the scheduled headless audit lane (doc-audit.yml). That job's checkout is
throwaway (persist-credentials: false, no write-capable credential reaches it at all) and
fixing-docs' applier never runs against it — the job ends and the checkout is discarded
before any apply could see it, so a stray artifact there can never trip
apply-working-tree-not-confined. Its model step's prompt is this lane's own restated copy of
the output contract (a headless run states its own contract rather than depending on a plugin
version resolved at run time — see that prompt's comment), and it names verdicts.json in the
repository root. When this skill is invoked from doc-audit.yml, follow that prompt's file
destination, not the ${TMPDIR:-/tmp}/ rule above: the workflow's own instructions are
authoritative for that lane, and its deterministic steps read verdicts.json from the root.
verdicts.json is the only file that lane permits in the work tree — every other artifact it
generates (the plan, the report, cost data) lives under ${RUNNER_TEMP}/doc-audit/, and a
repository-integrity gate refuses the whole run over any other work-tree change before a report
is assembled (scheduling-doc-sync/scripts/check-repo-integrity.py). So scratch files in that
lane go under ${RUNNER_TEMP}/doc-audit/, never the checkout. Every other invocation —
interactive, local, or a future lane whose checkout an apply might run against — keeps the
out-of-tree rule above; it exists to protect exactly the checkout an apply could later touch,
which this one structurally is not.
- Plan the scope.
python3 -m doclifecycle drift-plan --repo . --mode full > "${TMPDIR:-/tmp}/drift-plan.json" (diff-scoped: --mode incremental --since <commit>). Deterministic — no
model — so the scope is re-derivable rather than trusted. Each documents[] entry carries a
path and an obligation: assertions is a living document whose claims you judge;
anchor is a narrative document whose As of line the engine checks itself, and for which
it refuses claim verdicts outright (drift-verdict-on-narrative-document). Write none.
- Segment each
assertions document. python3 -m doclifecycle segment --repo . --path <path> returns its assertion units — one per sentence, list item, or table row — each with
an ordinal, its source line, and assertion_capable. The ordinal is how you answer for
a unit. The parser is fixed and model-free, so the same bytes always yield the same units.
- Classify every assertion-capable unit —
factual, normative, rationale, or
non-assertive. Leaving one out is refused (classification-missing): an unclassified unit
is indistinguishable from one nobody found a claim in. Classification never waives a living
truth obligation: factual, normative, and rationale units must all be judged; only
non-assertive prose takes no verdict.
- Judge each assertion at the appropriate tier (below), tagged by
kind — one of
exactly: command, path, symbol, behavior, structure, value (use these strings
verbatim; automation switches on them) — and record the obligation discharged:
evidence for factual assertions, governing-source or owner-judgment for normative
assertions, and coherence for rationale. The governing source, owner judgment, or current
evidence that settles coherence is cited through the same evidence contract as a factual
judgment. Pure prose is not a claim — except lines that
sound factual but name no checkable thing ("robust", "production-ready", "reasonably
fast", "handles most workloads"). Extract those too, kind value: they become
UNVERIFIABLE. Do not skip them — an unbacked quality claim is the most common drift a
human eye waves through. And they stay UNVERIFIABLE even when you can build a code case
that the boast overreaches ("handles arbitrarily large inputs" vs. a whole-buffer read):
put that argument in evidence, not in the verdict. STALE is reserved for claims with a
checkable true value to restore — puffery has none, so any replacement text you'd draft is
new authorship, and cutting or rewording it is a human decision, not a sync. In a
scheduled install the human's third option is a durable waiver
(.doc-lifecycle/drift-waivers.json, owned by scheduling-doc-sync): an accepted claim
stops resurfacing on run surfaces, while this skill keeps emitting it — detection stays
pure; disposition is the pipeline's job. Lines
already marked > UNVERIFIED: <claim> (the marker
llm-doc-writer writes) are extracted like any claim and default to UNVERIFIABLE unless
the repo now makes them checkable.
Each judgment is VERIFIED / STALE / UNVERIFIABLE, and carries its kind, its tier,
and its evidence.
- Write
"${TMPDIR:-/tmp}/verdicts.json" in the contract below, then validate it
mechanically before the audit: pipe it through
${CLAUDE_PLUGIN_ROOT}/skills/detecting-doc-drift/scripts/validate-drift-output.py
(reads the JSON on stdin or as a file arg). It enforces the enum, field-set, evidence, and
fix rules, and that no unit is answered twice within a document, and exits nonzero on any
violation. Run it — neither thing the engine does with a
shape violation is a loud failure. A violation inside a document's verdicts drops that
whole document to an unexamined coverage gap (the verdicts returned for this document did not validate: <code>), and the run still exits with a report; a violation in the entry — a
bad status, an unexpected field, a duplicate path, a document the plan never declared —
refuses the whole run instead. The validator checks shape, not whether a verdict is right; that judgment
is still yours, and drift-audit is the authority on everything the shape check cannot see
(whether an ordinal names a real unit, whether a multi-line fix owns its span).
- Run the audit. The run's evidence boundary is empty unless you declare it: a
verdict citing
evidence.command is refused (drift-evidence-outside-boundary) — which
discards that whole document's verdicts, STALE and UNVERIFIABLE records included —
unless the tool it names was passed to --evidence-command. Before running, check for
.doc-lifecycle/evidence-tools.json (scheduling-doc-sync owns the file; a repo without
it, or with {"tools": []}, declares none): pass one --evidence-command <tool> per
name it lists. No declared tools means the run is tool-free — cite source only, never
command; a claim only a command could settle becomes UNVERIFIABLE, with what you tried
in evidence.observed, not a command citation nothing declared permits.
python3 -m doclifecycle drift-audit --repo . --mode full --verdicts "${TMPDIR:-/tmp}/verdicts.json" [--evidence-command <tool> ...] > "${TMPDIR:-/tmp}/drift-report.json" writes the validated report: your STALE and
UNVERIFIABLE judgments as records with digests, your VERIFIED ones as coverage, and the
narrative documents' anchors checked engine-side. Exit 0 is a complete report, 4 partial
(something was not examined), 1 refused (e.g. a document the plan never declared).
Pass the same --mode (and --since) you planned with in step 1 — the audit
re-derives the scope from these flags, so a diff-scoped plan audited --mode full is
measured against every living document instead of the planned ones: the documents you
were never given become unexamined scope, and a correct incremental run exits 4 partial
rather than producing the report that was asked for.
Verification tiers + escalation rule
| Tier |
Cost |
Does |
Catches |
| 1 STATIC |
seconds |
grep/glob: path/symbol exists, command exists in Makefile/package.json, link resolves |
renames, moves, deletions |
| 2 SHALLOW |
moderate |
read the cited line; run safe --help/--version/dry-run |
changed flags, values, signatures |
| 3 DEEP |
expensive |
read implementing code; run the documented workflow where safe |
behavior drift |
Running a Tier 2 --help/--version/dry-run only earns a command citation when that
tool is declared per step 6 above; against an undeclared or tool-free run it is still a
legitimate read, but the verdict it settles cites source (or, if nothing repository-side
settles it either, stays UNVERIFIABLE) rather than a command the audit would refuse.
Every claim starts at Tier 1. Escalate a claim only when (a) Tier 1 flags suspicion,
(b) the claim's subject is in the diff (diff-scoped mode), or (c) a deep audit was
requested. This concentrates cost where drift is likely.
Anchors: open the line, but judge the claim, not the line number. A file:line anchor
is not evidence — open it. But the anchor is metadata on a claim, never its own claim: do
not extract "the exit is at line 14" as a separate verdict and grade its precision. Classify
the underlying claim on whether the referenced construct is there and the stated value is
right. An anchor that lands a few lines off the exact statement (points at the guard instead
of the exit() it guards) but still locates the right code is VERIFIED. Never emit a
STALE verdict whose fix only changes a line number — a line-number-only correction is not
drift. Mark STALE only when the value/behavior/symbol is wrong or the anchor points to a
construct that moved or no longer exists.
A fix that names a file is settled by opening that file. Every property the replacement
asserts of another document — that it exists, that it carries a section, that it is now the
live one — is read there first. A Supersedes: header, a filename, or a commit message says a
file replaced another, never what the replacement contains. Assert only what you read: if the
target does not carry it yet, the fix says what the target actually shows, and if nothing in
the repository settles it, the verdict is UNVERIFIABLE with the pointer in evidence rather
than a STALE with a drafted replacement. This is the rule that makes a repointing fix safe to
land, and the auto-apply policy refuses one anyway (policy-fix-names-other-document) — a fix
that changes which files the line names is a person's to approve.
The output contract (this is the "shape")
verdicts.json is the engine's verdicts artifact — one entry per document the plan declared,
each carrying one verdict per assertion unit segment printed:
{"schema_version": 1, "documents": [{"path": "CLAUDE.md", "status": "ok", "verdicts": []}]}
schema_version is optional and must be 1 when present. An entry's fields are exactly
path, status, verdicts, reason, chunk — no extras. status is ok or failed: an
ok entry carries verdicts and no reason; a failed entry carries a one-line reason and
no verdicts, which is how you declare a document you did not examine — a gap with no reason is
indistinguishable from a document nobody thought about. Narrative (anchor) documents get no
entry at all; the engine checks their As of anchors itself.
A verdict's fields are exactly unit, assertion_class, obligation, verdict, kind, tier,
evidence, fix. Two are always required: unit is the ordinal segment printed for that
unit, and assertion_class is one of factual / normative / rationale / non-assertive.
obligation, verdict, kind, tier, and evidence travel together — all five, or none.
Every factual, normative, and rationale unit owes all five; a non-assertive unit takes none of
them, because it asserts nothing the code could contradict. obligation is evidence for a
factual unit, governing-source or owner-judgment for a normative unit, and coherence for a
rationale unit; any other value or class/obligation pairing is refused. verdict is one of
VERIFIED / STALE / UNVERIFIABLE; kind is one of
command / path / symbol / behavior / structure / value; tier is the integer 1,
2, or 3 — literal enum values, no invented ones.
fix is present only, and always, for STALE, and it is the complete replacement text for
that unit — never an instruction like "change X to Y" — and must meet the
writing-docs bar. Preserve the target document's physical-line convention: when the source unit
is soft-wrapped, draft fix already wrapped, with LF embedded in the JSON string and the exact
list marker and continuation indentation the replacement needs. Do not leave reflow to
fixing-docs or the applier; both place the approved string byte-verbatim. The engine accepts
embedded LF only for a unit that already spans multiple source lines and owns every line in that
span (no neighboring assertion unit shares either boundary line), and every physical line must be
non-empty (no CR or NUL). The replacement may take a different number of physical lines when its
corrected content wraps differently, but it remains one logical assertion unit.
evidence is an object, mandatory for every judged unit, VERIFIED included: {"observed": <the fact you read>, "source": <repository-relative path>, "line": <int>, "command": <the one command line that settled it>}. observed is always required and is one line — the fact
the citation shows. No history (prior PRs, how the drift arose), no restated command output, no
reasoning narrative: the verdict carries the conclusion; evidence carries only what proves it.
VERIFIED and STALE each rest on exactly one citation — source or command, never both,
because a verdict rests on one place a reader goes. line points into a file, so it never
accompanies command. A cited command is one read-only command line, not a shell program: it
may not carry ;, &, |, <, >, (, ), $, a backtick, or a backslash. UNVERIFIABLE
needs no citation — it records that nothing in the repository settles the claim — but it still
carries observed.
On success the validator prints a summary: line as JSON, recomputed from your verdicts
(verified / stale / unverifiable, counted across every document), that automation can
gate on.
See output-contract.md for a worked example covering all four assertion classes and a
failed document entry.
Modes
- Full audit (manual / nightly sweep): extract every claim across the target docs,
Tier 1 by default, escalate per the rule. Emit the full drift report, severity-ordered
(wrong command/behavior before stale prose).
- Diff-scoped (PR check / what automation calls): input is a diff or commit range. For
each changed file/symbol/value, grep every doc for passages referencing it — including
command output blocks and examples, not just the obvious gotcha line — then verify each
referencing claim, escalating past Tier 1 to the tier that settles it. A changed subject
in the diff is exactly the
(b) escalation trigger, so do not stop at a Tier-1 grep that
only confirms the name still appears: read the cited line (Tier 2), and read implementing
code (Tier 3) for any behavior/value claim, where the rename-grep gives false comfort. A
rename/move/deletion is settled cheaper — don't pay Tier 3 for a claim Tier 1–2 already
resolves. Judge only the units carrying touched claims. Completeness is the metric: a changed
symbol referenced in five docs must produce five verdicts.
Red flags — STOP
- Writing "looks consistent" / "should be fine" without opening the file → not a verdict.
- Trusting a
file:line anchor instead of reading that line → anchors drift a few lines;
read the line and judge the claim (the anchor rule above).
- Emitting a prose report instead of
verdicts.json → the engine has nothing to audit.
- A VERIFIED verdict with no
evidence.observed → unverified; go get the evidence.
- Diff-scoped run that checked the one obvious doc → grep ALL docs for the changed subject.
- Eyeballing a command table instead of grepping the Makefile/package.json for each target.
- Skipping a "robust"/"fast"/"production-ready" line because it's "just prose" → extract it
as UNVERIFIABLE; that is the finding.
- Marking a quality boast STALE because the code argues against it, and drafting a
replacement → still UNVERIFIABLE; the contradiction goes in
evidence. A fix must
restore a checkable true value, not reword puffery.
- A verdict with an invented
kind (e.g. schema_mismatch) → use the six enum values only.
- Marking an anchor STALE for being off by a line, or emitting a
fix that only changes a
line number → not drift. The anchor is metadata, not its own claim.
- Repointing a claim at a superseding document you did not open → the header says it supersedes,
not what it contains. Open the target; assert only what it shows.
- Evidence that tells a story — prior fixes, what re-staled the line, pasted command output →
one line, pointer + fact. History lives in git; the evidence proves, it doesn't narrate.
- Citing
evidence.command without first declaring it to drift-audit — the boundary is
empty by default, so this discards the whole document's verdicts, not just the one citation.
Check .doc-lifecycle/evidence-tools.json and pass --evidence-command per step 6 before
writing a single command citation, or use source instead.
1---2name: detecting-doc-drift3description: Use when auditing documentation against the code it describes, checking whether a README/CLAUDE.md/runbook is still accurate, or finding which doc passages a code change invalidates — and whenever drift detection is invoked programmatically (by a PR check or nightly sync) and must emit a structured, parseable result.4---56# Detecting Doc Drift78## Overview910**A doc is a set of claims about the repo; drift is a claim the repo no longer backs.**11This skill declares the *shape* of drift detection so it runs the same way every time and12can be **invoked programmatically to trigger updates** — not a one-off prose review.1314Two non-negotiables make the output usable by automation:15161. **A verdict requires evidence.** Never mark a claim VERIFIED because doc and code "seem17 consistent." Verified means you ran the command, opened the line, or matched the grep.182. **The result is structured, not prose.** Answer in the verdicts shape below. A human19 summary on top is fine; the artifact is what the engine reads and downstream tooling parses.2021**REQUIRED SUB-SKILL:** Use **writing-docs** for any fix you propose — every `fix` must meet22its bar (real output, no aspirational claims, marked+anchored rationale). This skill finds23and classifies drift; writing-docs governs how the correction reads. **`fixing-docs`**24consumes the engine report this skill's audit writes — record digests are the handoff, and25`mint-approval` takes them from the person who selected them. The `scheduling-doc-sync` skill26wires detect→fix to cron/PR; where a standing policy is committed there, the selection is27derived by `policy-mint` instead, which is the only door that mints a `policy` brand.2829## The audit (run these steps, in order)3031The engine owns scope, segmentation, and validation; this skill owns the judgment in the32middle. Every command below is `python3 -m doclifecycle …` with the plugin's `engine/`33directory on `PYTHONPATH` (`plugins/doc-lifecycle/engine/README.md` covers both spellings).3435Every artifact this audit writes — the plan, the verdicts, the report — goes to36`${TMPDIR:-/tmp}/`, never the work tree: `fixing-docs`' applier confines a run to its37approval set's paths, so an audit artifact sitting in the tree reads as an unaccounted38change and the apply refuses (`apply-working-tree-not-confined`). The steps below reuse39that same destination; only the filename changes per artifact.4041**Exception — the scheduled headless audit lane (`doc-audit.yml`).** That job's checkout is42throwaway (`persist-credentials: false`, no write-capable credential reaches it at all) and43`fixing-docs`' applier never runs against it — the job ends and the checkout is discarded44before any apply could see it, so a stray artifact there can never trip45`apply-working-tree-not-confined`. Its model step's prompt is this lane's own restated copy of46the output contract (a headless run states its own contract rather than depending on a plugin47version resolved at run time — see that prompt's comment), and it names `verdicts.json` in the48repository root. When this skill is invoked from `doc-audit.yml`, follow *that prompt's* file49destination, not the `${TMPDIR:-/tmp}/` rule above: the workflow's own instructions are50authoritative for that lane, and its deterministic steps read `verdicts.json` from the root.51`verdicts.json` is the *only* file that lane permits in the work tree — every other artifact it52generates (the plan, the report, cost data) lives under `${RUNNER_TEMP}/doc-audit/`, and a53repository-integrity gate refuses the whole run over any other work-tree change before a report54is assembled (`scheduling-doc-sync/scripts/check-repo-integrity.py`). So scratch files in that55lane go under `${RUNNER_TEMP}/doc-audit/`, never the checkout. Every other invocation —56interactive, local, or a future lane whose checkout an apply might run against — keeps the57out-of-tree rule above; it exists to protect exactly the checkout an apply could later touch,58which this one structurally is not.59601. **Plan the scope.** `python3 -m doclifecycle drift-plan --repo . --mode full >61 "${TMPDIR:-/tmp}/drift-plan.json"` (diff-scoped: `--mode incremental --since <commit>`). Deterministic — no62 model — so the scope is re-derivable rather than trusted. Each `documents[]` entry carries a63 `path` and an `obligation`: `assertions` is a living document whose claims you judge;64 `anchor` is a narrative document whose `As of` line the engine checks itself, and for which65 it refuses claim verdicts outright (`drift-verdict-on-narrative-document`). Write none.662. **Segment each `assertions` document.** `python3 -m doclifecycle segment --repo . --path67 <path>` returns its assertion units — one per sentence, list item, or table row — each with68 an `ordinal`, its source `line`, and `assertion_capable`. The `ordinal` is how you answer for69 a unit. The parser is fixed and model-free, so the same bytes always yield the same units.703. **Classify every assertion-capable unit** — `factual`, `normative`, `rationale`, or71 `non-assertive`. Leaving one out is refused (`classification-missing`): an unclassified unit72 is indistinguishable from one nobody found a claim in. Classification never waives a living73 truth obligation: factual, normative, and rationale units must all be judged; only74 non-assertive prose takes no verdict.754. **Judge each assertion** at the appropriate tier (below), tagged by `kind` — one of76 exactly: `command`, `path`, `symbol`, `behavior`, `structure`, `value` (use these strings77 verbatim; automation switches on them) — and record the obligation discharged:78 `evidence` for factual assertions, `governing-source` or `owner-judgment` for normative79 assertions, and `coherence` for rationale. The governing source, owner judgment, or current80 evidence that settles coherence is cited through the same evidence contract as a factual81 judgment. Pure prose is not a claim — **except** lines that82 *sound* factual but name no checkable thing ("robust", "production-ready", "reasonably83 fast", "handles most workloads"). Extract those too, kind `value`: they become84 `UNVERIFIABLE`. Do not skip them — an unbacked quality claim is the most common drift a85 human eye waves through. And they stay `UNVERIFIABLE` even when you can build a code case86 that the boast overreaches ("handles arbitrarily large inputs" vs. a whole-buffer read):87 put that argument in `evidence`, not in the verdict. `STALE` is reserved for claims with a88 checkable true value to restore — puffery has none, so any replacement text you'd draft is89 new authorship, and cutting or rewording it is a human decision, not a sync. In a90 scheduled install the human's third option is a durable waiver91 (`.doc-lifecycle/drift-waivers.json`, owned by scheduling-doc-sync): an accepted claim92 stops resurfacing on run surfaces, while this skill keeps emitting it — detection stays93 pure; disposition is the pipeline's job. Lines94 already marked `> UNVERIFIED: <claim>` (the marker95 llm-doc-writer writes) are extracted like any claim and default to `UNVERIFIABLE` unless96 the repo now makes them checkable.97 Each judgment is `VERIFIED` / `STALE` / `UNVERIFIABLE`, and carries its `kind`, its `tier`,98 and its `evidence`.995. **Write `"${TMPDIR:-/tmp}/verdicts.json"`** in the contract below, then **validate it100 mechanically** before the audit: pipe it through101 `${CLAUDE_PLUGIN_ROOT}/skills/detecting-doc-drift/scripts/validate-drift-output.py`102 (reads the JSON on stdin or as a file arg). It enforces the enum, field-set, `evidence`, and103 `fix` rules, and that no unit is answered twice within a document, and exits nonzero on any104 violation. Run it — neither thing the engine does with a105 shape violation is a loud failure. A violation inside a document's `verdicts` drops that106 whole document to an unexamined coverage gap (`the verdicts returned for this document did107 not validate: <code>`), and the run still exits with a report; a violation in the *entry* — a108 bad `status`, an unexpected field, a duplicate `path`, a document the plan never declared —109 refuses the whole run instead. The validator checks *shape*, not whether a verdict is *right*; that judgment110 is still yours, and `drift-audit` is the authority on everything the shape check cannot see111 (whether an ordinal names a real unit, whether a multi-line `fix` owns its span).1126. **Run the audit.** The run's evidence boundary is **empty unless you declare it**: a113 verdict citing `evidence.command` is refused (`drift-evidence-outside-boundary`) — which114 discards that *whole document's* verdicts, STALE and UNVERIFIABLE records included —115 unless the tool it names was passed to `--evidence-command`. Before running, check for116 `.doc-lifecycle/evidence-tools.json` (`scheduling-doc-sync` owns the file; a repo without117 it, or with `{"tools": []}`, declares none): pass one `--evidence-command <tool>` per118 name it lists. No declared tools means the run is tool-free — cite `source` only, never119 `command`; a claim only a command could settle becomes `UNVERIFIABLE`, with what you tried120 in `evidence.observed`, not a `command` citation nothing declared permits.121 `python3 -m doclifecycle drift-audit --repo . --mode full --verdicts122 "${TMPDIR:-/tmp}/verdicts.json" [--evidence-command <tool> ...] >123 "${TMPDIR:-/tmp}/drift-report.json"` writes the validated report: your STALE and124 UNVERIFIABLE judgments as records with digests, your VERIFIED ones as coverage, and the125 narrative documents' anchors checked engine-side. Exit 0 is a complete report, 4 partial126 (something was not examined), 1 refused (e.g. a document the plan never declared).127 **Pass the same `--mode` (and `--since`) you planned with in step 1** — the audit128 re-derives the scope from these flags, so a diff-scoped plan audited `--mode full` is129 measured against every living document instead of the planned ones: the documents you130 were never given become unexamined scope, and a correct incremental run exits 4 partial131 rather than producing the report that was asked for.132133### Verification tiers + escalation rule134135| Tier | Cost | Does | Catches |136|------|------|------|---------|137| 1 STATIC | seconds | grep/glob: path/symbol exists, command exists in Makefile/package.json, link resolves | renames, moves, deletions |138| 2 SHALLOW | moderate | read the cited line; run safe `--help`/`--version`/dry-run | changed flags, values, signatures |139| 3 DEEP | expensive | read implementing code; run the documented workflow where safe | behavior drift |140141Running a Tier 2 `--help`/`--version`/dry-run only earns a `command` citation when that142tool is declared per step 6 above; against an undeclared or tool-free run it is still a143legitimate read, but the verdict it settles cites `source` (or, if nothing repository-side144settles it either, stays `UNVERIFIABLE`) rather than a `command` the audit would refuse.145146**Every claim starts at Tier 1.** Escalate a claim only when (a) Tier 1 flags suspicion,147(b) the claim's subject is in the diff (diff-scoped mode), or (c) a deep audit was148requested. This concentrates cost where drift is likely.149150**Anchors: open the line, but judge the claim, not the line number.** A `file:line` anchor151is not evidence — open it. But the anchor is *metadata on a claim, never its own claim*: do152not extract "the exit is at line 14" as a separate verdict and grade its precision. Classify153the underlying claim on whether the *referenced construct* is there and the *stated value* is154right. An anchor that lands a few lines off the exact statement (points at the guard instead155of the `exit()` it guards) but still locates the right code is `VERIFIED`. **Never emit a156STALE verdict whose `fix` only changes a line number** — a line-number-only correction is not157drift. Mark `STALE` only when the value/behavior/symbol is wrong or the anchor points to a158construct that moved or no longer exists.159160**A `fix` that names a file is settled by opening that file.** Every property the replacement161asserts of another document — that it exists, that it carries a section, that it is now the162live one — is read there first. A `Supersedes:` header, a filename, or a commit message says a163file replaced another, never what the replacement contains. Assert only what you read: if the164target does not carry it yet, the fix says what the target actually shows, and if nothing in165the repository settles it, the verdict is `UNVERIFIABLE` with the pointer in `evidence` rather166than a `STALE` with a drafted replacement. This is the rule that makes a repointing fix safe to167land, and the auto-apply policy refuses one anyway (`policy-fix-names-other-document`) — a fix168that changes which files the line names is a person's to approve.169170## The output contract (this is the "shape")171172`verdicts.json` is the engine's verdicts artifact — one entry per document the plan declared,173each carrying one verdict per assertion unit `segment` printed:174175```json176{"schema_version": 1, "documents": [{"path": "CLAUDE.md", "status": "ok", "verdicts": []}]}177```178179`schema_version` is optional and must be `1` when present. An entry's fields are exactly180`path`, `status`, `verdicts`, `reason`, `chunk` — no extras. `status` is `ok` or `failed`: an181`ok` entry carries `verdicts` and no `reason`; a `failed` entry carries a one-line `reason` and182no `verdicts`, which is how you declare a document you did not examine — a gap with no reason is183indistinguishable from a document nobody thought about. Narrative (`anchor`) documents get no184entry at all; the engine checks their `As of` anchors itself.185186A verdict's fields are exactly `unit`, `assertion_class`, `obligation`, `verdict`, `kind`, `tier`,187`evidence`, `fix`. Two are always required: `unit` is the `ordinal` `segment` printed for that188unit, and `assertion_class` is one of `factual` / `normative` / `rationale` / `non-assertive`.189190`obligation`, `verdict`, `kind`, `tier`, and `evidence` travel together — all five, or none.191Every factual, normative, and rationale unit owes all five; a `non-assertive` unit takes none of192them, because it asserts nothing the code could contradict. `obligation` is `evidence` for a193factual unit, `governing-source` or `owner-judgment` for a normative unit, and `coherence` for a194rationale unit; any other value or class/obligation pairing is refused. `verdict` is one of195`VERIFIED` / `STALE` / `UNVERIFIABLE`; `kind` is one of196`command` / `path` / `symbol` / `behavior` / `structure` / `value`; `tier` is the integer `1`,197`2`, or `3` — literal enum values, no invented ones.198199`fix` is present only, and always, for `STALE`, and it is the **complete replacement text** for200that unit — never an instruction like "change X to Y" — and must meet the201writing-docs bar. Preserve the target document's physical-line convention: when the source unit202is soft-wrapped, draft `fix` already wrapped, with LF embedded in the JSON string and the exact203list marker and continuation indentation the replacement needs. Do not leave reflow to204`fixing-docs` or the applier; both place the approved string byte-verbatim. The engine accepts205embedded LF only for a unit that already spans multiple source lines and owns every line in that206span (no neighboring assertion unit shares either boundary line), and every physical line must be207non-empty (no CR or NUL). The replacement may take a different number of physical lines when its208corrected content wraps differently, but it remains one logical assertion unit.209210`evidence` is an object, mandatory for **every** judged unit, VERIFIED included: `{"observed":211<the fact you read>, "source": <repository-relative path>, "line": <int>, "command": <the one212command line that settled it>}`. `observed` is always required and is **one line** — the fact213the citation shows. No history (prior PRs, how the drift arose), no restated command output, no214reasoning narrative: the verdict carries the conclusion; evidence carries only what proves it.215VERIFIED and STALE each rest on **exactly one** citation — `source` or `command`, never both,216because a verdict rests on one place a reader goes. `line` points into a file, so it never217accompanies `command`. A cited `command` is one read-only command line, not a shell program: it218may not carry `;`, `&`, `|`, `<`, `>`, `(`, `)`, `$`, a backtick, or a backslash. UNVERIFIABLE219needs no citation — it records that nothing in the repository settles the claim — but it still220carries `observed`.221222On success the validator prints a `summary:` line as JSON, recomputed from your verdicts223(`verified` / `stale` / `unverifiable`, counted across every document), that automation can224gate on.225226See **output-contract.md** for a worked example covering all four assertion classes and a227`failed` document entry.228229## Modes230231- **Full audit** (manual / nightly sweep): extract every claim across the target docs,232 Tier 1 by default, escalate per the rule. Emit the full drift report, severity-ordered233 (wrong command/behavior before stale prose).234- **Diff-scoped** (PR check / what automation calls): input is a diff or commit range. For235 each changed file/symbol/value, **grep every doc for passages referencing it** — including236 command output blocks and examples, not just the obvious gotcha line — then verify each237 referencing claim, **escalating past Tier 1 to the tier that settles it**. A changed subject238 in the diff is exactly the `(b)` escalation trigger, so do not stop at a Tier-1 grep that239 only confirms the name still appears: read the cited line (Tier 2), and read implementing240 code (Tier 3) for any `behavior`/`value` claim, where the rename-grep gives false comfort. A241 rename/move/deletion is settled cheaper — don't pay Tier 3 for a claim Tier 1–2 already242 resolves. Judge only the units carrying touched claims. Completeness is the metric: a changed243 symbol referenced in five docs must produce five verdicts.244245## Red flags — STOP246247- Writing "looks consistent" / "should be fine" without opening the file → not a verdict.248- Trusting a `file:line` anchor instead of reading that line → anchors drift a few lines;249 read the line and judge the claim (the anchor rule above).250- Emitting a prose report instead of `verdicts.json` → the engine has nothing to audit.251- A VERIFIED verdict with no `evidence.observed` → unverified; go get the evidence.252- Diff-scoped run that checked the one obvious doc → grep ALL docs for the changed subject.253- Eyeballing a command table instead of grepping the Makefile/package.json for each target.254- Skipping a "robust"/"fast"/"production-ready" line because it's "just prose" → extract it255 as UNVERIFIABLE; that is the finding.256- Marking a quality boast STALE because the code argues against it, and drafting a257 replacement → still UNVERIFIABLE; the contradiction goes in `evidence`. A `fix` must258 restore a checkable true value, not reword puffery.259- A verdict with an invented `kind` (e.g. `schema_mismatch`) → use the six enum values only.260- Marking an anchor STALE for being off by a line, or emitting a `fix` that only changes a261 line number → not drift. The anchor is metadata, not its own claim.262- Repointing a claim at a superseding document you did not open → the header says it supersedes,263 not what it contains. Open the target; assert only what it shows.264- Evidence that tells a story — prior fixes, what re-staled the line, pasted command output →265 one line, pointer + fact. History lives in git; the evidence proves, it doesn't narrate.266- Citing `evidence.command` without first declaring it to `drift-audit` — the boundary is267 empty by default, so this discards the whole document's verdicts, not just the one citation.268 Check `.doc-lifecycle/evidence-tools.json` and pass `--evidence-command` per step 6 before269 writing a single `command` citation, or use `source` instead.