Documents / Decisions / adr-bea42e359960
docir's own prose is validated against the CLI, all four sources
Why the command-resolution guard covers CLAUDE.md and the project store as well as the shipped guide, why a retired binary name needs a separate check, why the unreal-command exemption list may only shrink, and why type/status values are checked against the shipped vocabulary.
Context¶
issue-87a27629f6a6 closed a narrow version of this: the packaged agent guide told
agents to run a reindex flag that has never existed, at the single most important
recovery step, and nothing checked the guide against the CLI it documents. The fix was
a test that introspects the Typer tree from cli.app and resolves every docir ...
code span in modules/agents/infra/templates/skill.md, later extended to README.md.
That covered what an adopter reads. It did not cover what an agent working in this
repository reads, which is CLAUDE.md and the project store — and CLAUDE.md's first
instruction is to read the store. A review of the corpus on 2026-08-15 found that half
had rotted exactly the way the guide had:
arch-1cfb1b212237, the document CLAUDE.md sends readers to first, carried 96 invocations of the binary's previous name, plus--set-field(never shipped),reindex --all(the very flagissue-87a27629f6a6is about) and a command table listing 11 of the 22 commands that exist.- Two active documents asserted
docir check --fixdid not exist, years of--fixafter it shipped, and one asserted there was no similarity floor after--min-scorelanded.
None of it was found by reading the suite. All of it was found by running the commands.
Decision¶
One oracle, six sources. tests/entry_points/test_agent_guide_matches_cli.py
resolves every docir ... code literal in the packaged guide, README.md,
CLAUDE.md, every file under .docir/docs/**, every docstring under src/, and
every markdown file shipped inside the package — the six CONTRACT.md files —
against the same introspected command tree. A document in the store, a docstring on
the method that implements the command, and the guide shipped to other repositories
are judged by identical rules, because they fail identically.
Each source was added believing the previous pass had finished the job, and each
one moved the count: the store held 96 stale invocations, src/ docstrings held 37
more after the markdown side was clean, and the contracts held none — which is the
result the rule is for. A source is not covered because someone looked at it; it
is covered because the oracle is pointed at it and keeps being pointed at it.
The CONTRACT.md files earn their place for a structural reason rather than a
historical one. §8.6 already makes each change in the same commit as the api.py
it documents, so they are the one document class guaranteed to be edited exactly
when a module's public operations move — which is precisely when a command name in
one goes stale. They were clean today and are the most likely to rot tomorrow.
Three parts are load-bearing and are the reason this is a decision rather than a patch.
A retired binary name needs its own check, in every source¶
The extractor is anchored on docir . A code span opening with the binary's previous
name therefore never reaches it — the line reads as "nothing to validate", which is
indistinguishable from "valid". That is precisely how one document accumulated 96 of
them while the guard beside it was green, and how src/ kept 37 after the markdown
side was clean. _RETIRED_BINARIES matches an old name
followed by a word that is a live subcommand; requiring the second word to resolve is
what keeps the docs directory and docs-schema.yaml out, since neither is followed
by a space and a command.
Generalising: a guard anchored on the correct spelling cannot see the incorrect one. Whenever a name changes, the retired name needs an explicit check or the rename is unenforced in exactly the places nobody re-reads.
Prose that names an unreal command is exempted by list, and the list may only shrink¶
A corpus of decisions necessarily writes commands that do not exist: docir import was
proposed and rejected, docir repair is the name of the gap check --fix closed, and
the probe logs quote reindex --all as the defect they record. Rewriting those would
destroy the argument. _DELIBERATELY_UNREAL maps the leading words to why, and
test_every_exemption_is_still_needed fails when an entry stops matching anything.
That inverse test is the point. An exemption that outlives its prose becomes a blind
spot pointed at the exact command most likely to be typed wrong later — if docir import ever ships, a stale entry would swallow every future typo in it silently.
Dropping the entry is part of shipping the command, and this is what says so.
An exemption is never the fix for a document that tells a reader to run something.
issue-8f6576cd7bc9 instructed the reader to supply a missing steward with an update
call naming a bare --owner; the flag is --set-owner, and that took the correction,
not an entry. Writing the wrong invocation here as a code span would have failed this
document too — as an earlier draft of it did.
Type and status values are checked; other values are not¶
Resolving a command proves the shape of a line and nothing about its meaning. A
filter reading --type decision --status open parses, runs, exits zero and matches
nothing — forever — because decision goes proposed -> accepted and never holds an
open. That is worse than a broken command, which at least announces itself.
So --type and --status values are checked against the core merged with every
bundled profile, not against the schema this store resolved. Which profiles a store
enables is a local choice; whether decision declares an open status is not. An
example may therefore reach for a test_plan from the qa profile that this repository
does not turn on, and still fail if it pairs that type with a status the type never
declares. Without a --type to scope it, a status only has to be one some shipped type
declares, because update <id> --status resolved names no type and cannot.
Other values stay unchecked, and the line is deliberate: a tag, an id, a heading or a path is a fact about one corpus, while a type and its statuses are facts about docir. Checking the first class would bind this test to the contents of a store, and the guard would then fail for anyone who wrote a different document.
The corpus-level instrument remains and is not replaced by this: Tier 1's
unknown-status and unknown-type findings judge documents, which is where a wrong
status actually costs something. This guard judges prose, which is where the wrong
status is learned.
Consequences¶
- Extraction had to get stricter before it could get broader. Global flags precede the
command (
docir --pretty get <id>), slash alternatives occur below the top level (docir agent install/update), and a$(...)substitution carries another program's flags. All three produced false failures on correct prose and are now handled in the shared helpers, so the guide and README benefit too. - The suite grew from 1154 to 1779 tests. They are parametrized per invocation, so a failure names the line rather than a count.
- Writing about docir in docir is now constrained: a command named in backticks must be real or exempted. Prose stays prose. This document's own first draft tripped the retired-binary check by quoting an example, which is the rule working.
- All fourteen failure modes were verified by injection — a wrong flag, an unknown
subcommand, an unknown command, a misplaced global, a retired binary, an unreal
subcommand, a stale exemption, a moved store path, and six shapes of bad
--type/--statusvalue — because a guard that has never failed has not been shown to work, and because two earlier versions of this same test silently checked nothing. The value check was run against five legitimate lines as well, since a guard that rejects correct prose gets deleted rather than obeyed. - Extending the checker turned up its own bug: the placeholder rule knew
...and not the typographic…that prose actually uses, so an ellipsis read as a type name. A checker that understands only one spelling of a convention is the same failure as one anchored on only the current binary name.