Documents / Architecture / arch-ad342aae8293
Doc-Index CLI — validation strictness tiers
The three tiers deciding what blocks a write, what a check reports and what stays advisory, plus how a schema change is detected against the index build.
Validation strictness tiers¶
The system borrows the "programming language" metaphor (schema = grammar, typed
related links = imports, validation = compiler, graph checks = linter) but only
where it holds: document metadata and the relation graph are formally checkable,
document body text is natural language and is not — so checks are split into
three tiers, not one uniform gate. Mixing these levels (hard-failing on a
text-similarity heuristic, say) is the main overengineering risk and is
deliberately avoided.
Tier 0 — hard errors (synchronous, blocks the write)¶
Runs inline inside every docir add / docir update call, like a compiler. Only
checks that are cheap and essentially free of false positives:
- Missing required frontmatter field for the document's type
- Invalid
statusvalue (not in the type's enum) - Invalid status transition (
--overrideforces one and warns, naming the rule it broke; it cannot set a status the type does not declare) - A
relatedid that does not exist in the index, or an edge from a document to itself - A relation
kindnot in therelation_typesregistry, or one the source type'sallowed_relationswhitelist forbids for that target type - A
tagskey not in the tag registry - A
codeglob that can never match — absolute, containing.., backslash separated, empty, or opening with!(a literal here, not an exclusion). A pattern that matches nothing today is accepted: a decision is routinely written before the code it decides - A body over its type's
max_body_charsthat the write makes longer, unless the type setsmax_body_chars_enforce: false(One max_body_chars per type, and a flag that picks the tier it acts at). No type ships a ceiling - Malformed frontmatter (not valid YAML, wrong types)
Tier 1 — structural findings (non-blocking, via docir check)¶
Graph-level issues, run on demand or in CI, never inline in an agent's write call — an agent mid-task should not be blocked by a "possible problem".
Findings carry a severity, and this is the load-bearing part. ERROR_KINDS
is duplicate-id / dangling / malformed, where the corpus is broken, plus
empty-index, where check could not look, and the two findings only
--against <ref> produces. Everything else is a warning about shape or age.
docir check --strict exits 1 on errors only and is the pre-merge gate;
--strict-all makes every finding fatal for anyone who wants that.
The distinction is not cosmetic. orphan used to fire for every document with
no related: edges — the default state of a new one, and of any document linked
only by someone writing its id in a sentence — so a fail-on-any-finding gate went
red on a healthy corpus, and the only way to keep CI green was to drop the gate,
which also dropped the duplicate-id detection that was its actual purpose. For a
while it read the derived mention graph too, and that was reverted
(The orphan queue empties itself when somebody triages it): the body most likely to name a queue of orphan ids is
the triage diagnosing them, so writing "these four still need wiring" emptied the
queue tracking those four. The exemption is isolated: instead — a reviewed
answer somebody wrote on purpose. The severity split stays, because the finding
is still about shape.
CheckIssue derives severity from kind, so a new check classifies itself by
being added to ERROR_KINDS or not.
| kind | severity | means |
|---|---|---|
duplicate-id |
error | two files claim one id; the index dedupes, so one document is invisible. Found by scanning the files, not the index |
dangling |
error | a related edge points at nothing |
malformed |
error | a file the loader cannot parse — absent from every read path |
orphan |
warning | no related edge connects it, in either direction. Prose does not count; isolated: is the exemption |
cycle |
warning | a loop in the graph |
unblocked |
warning | everything it waits on has closed — the one finding that reports readiness rather than damage |
code-drifted |
warning | the files a code: glob matched when the document declared it have moved, and nobody has verified it since |
verification-outdated |
warning | the title/description/body a --verified covered are not the ones on disk |
empty-index |
error | the index holds nothing while docs/ holds files — every read answers nothing |
branch-id-collision |
error | only with --against <ref>: an id this branch allocated that the ref already uses |
unreadable-ref |
error | only with --against <ref>: the ref could not be read, so nothing was compared |
store-format-undeclared |
warning | the file's contents need a higher store_format: floor than it records |
layering |
warning | a higher-level type depends on a lower one |
stale |
warning | past the type's review_days, measured from verified, else revoked, else created — never updated, which an edit moves |
code-changed |
warning | the code a document governs differs from what it was when somebody last verified it |
code-unwatched |
warning | a code glob that matches files and carries neither digest, so no edit to them is reported |
unmatched-code |
warning | a code glob that no longer names anything (only when the store sits in a repository) |
code-unchecked |
warning | no repository encloses the store, so the four code findings above could not look; raised only when a document declares code:, with no ids |
unknown-type / unknown-status / unknown-tag / unknown-relation-kind |
warning | the file was written outside the CLI, or a profile was disabled under it |
missing-required |
warning | the rule moved under a document that was valid when written |
schema-drift |
warning | the resolved schema differs from the one the index was built against |
stale-index-build |
warning | a different docir built this index |
unresolved-link |
warning | a [[...]] in a body naming no document — the prose half of dangling |
repeated-entry |
warning | a file lists one tag, glob or edge twice; every read already holds each once, so --fix drops the repeat |
tag-key-format |
warning | a registry key that is not a usable tag |
The unknown-*, missing-required and schema-drift rows must not be promoted
to errors: the schema they measure against
ships in the package, so a corpus that passed yesterday can fail today with no
commit to point at, and nothing about the documents changed.
stale and code-changed are the two halves of one question and neither
replaces the other. stale is the calendar — how long since a human read this —
and fires on documents nothing has touched. code-changed is the evidence — the
code moved since they read it — and fires the day it moves. code-changed must
stay a warning for a reason of its own: editing code before its documentation is
the ordinary shape of a change, so an error kind would fail the CI of every
correct commit. Clearing it is a judgement — read the document against the code
and stamp --verified — and the one thing the rule forbids is making that
judgement inside the task that moved the code, which certifies its own change.
See adr-d9e6d5ccd0b4.
unresolved-link is Tier 1 where unresolved-mention is Tier 2, and the two
look like the same check. An id named in a sentence is a citation and needs no
target — writing adr-0007 while explaining the id format is correct usage, which
is why that one stays advisory. [[...]] is link syntax with no second reading, so
one that points at nothing is a defect by construction. The single false positive
available, a body demonstrating the syntax, is excluded because the scan skips code
spans and fences — free here, and not free for mentions, where 56 resolved edges
live only inside code. Resolution reads every document, inactive and archived
included, and it feeds no graph: orphan still reads related: alone
(adr-ae631a356639).
Layering is opt-in per relation kind. The check reads only edges the schema
marks dependency: true — depends_on and refines among the core six. It is
not a list of exempt kinds: relates_to, implements and supersedes are
simply not dependencies, so linking a decision to the issue that motivated it is
normal and silent. Treating every edge as a dependency made the most natural
pairing in the quickstart a permanent warning.
Store-defined checks¶
A store states its own Tier 1 rules under checks: in docs-schema.yaml: a
JMESPath expr each document is tested against, and the message a match
reports (A store declares its own checks; docir ships none). docir ships none. check reports each as a
warning, so --strict stays green and --strict-all makes them fatal. A rule
may not take a name docir's own findings use — the loader refuses it, because a
store's check called dangling would make --strict depend on whose schema is
loaded.
What docir check --fix repairs¶
Only what needs no guess. Duplicate ids are re-issued, and the established
file keeps the id — decided by git history first (Which of two colliding files keeps the id is decided by git, then created, then the alphabet) —
because existing edges were written against it and an edge cannot say which
document it meant. Dangling edges are dropped, and a tag, glob or edge a file
lists twice is dropped from the file. Two more actions file evidence rather than
repair damage: a code_baseline for each glob code-unwatched names, and the
store_format: line store-format-undeclared asks for.
It reindexes first, and does not advance updated — a mechanical repair is
not a re-verification. malformed, unknown-type, unmatched-code and
code-changed are deliberately left alone and returned unrepaired: each needs
somebody to read something and decide what the file, the schema, the pattern or
the document should say, and a repair has nothing to read with.
Tier 2 — advisory/style (opt-in only, via docir lint --deep)¶
Heuristic, never CI-blocking, run only when a human chooses to:
- Content similarity across documents (DRY at the idea level), using the
document vectors already computed for
docir context— surfaced as a suggestion, never an error. Chunk vectors are deliberately not used here: they would answer "do these share a section", not "are these the same document" - Document size / scope creep (one document covering several unrelated decisions)
- Unresolved mentions — ids a body names that no document carries, one finding per
document. It lives here and cannot be promoted: measured on this corpus, all 47 were
documentation examples (
adr-0007and the rest, in the documents explaining the id format), so a Tier 1 warning would fire only on correct usage. Ignoring code spans does not rescue it — 20 of the 47 sit outside code anyway, and 56 resolved mentions live only inside code spans (adr-e86c5040d626) - Oversized sections, ambiguous headings and unqualified section references — how
well a body's
##sections can be retrieved and addressed - Broken
--exprexamples — an--expra body documents that will not compile
Why this split¶
Tier 0 can be as strict as a real compiler because it is fully deterministic and cheap to verify. Tiers 1–2 deal with things that are inherently uncertain (graph shape, natural-language meaning), so they are surfaced as information, not failures — keeping the CLI usable in an agent's task flow while still giving humans and CI a way to keep the graph healthy. Never promote a heuristic to a hard error.
Schema drift and the index build stamp¶
The schema is not only docs-schema.yaml. The frozen core and the bundled
profiles are compiled into the package and re-merged on every command, so
upgrading docir can add a type, make a field required:, or change a prefix in
a store whose schema file nobody touched — with nothing in git diff to review.
The index therefore records two facts about how it was last built, each in its
own one-row table, and a rebuild is the only writer of both — docir reindex,
or the bootstrap that builds the index of a store that has none:
| Table | Records | Reported by check as |
|---|---|---|
schema_baseline |
the resolved schema the index was built against | schema-drift, one finding per change (+type test_plan, type decision: required [] -> ['owner']) |
index_build |
the docir version that built the index | stale-index-build |
They are separate on purpose. The baseline payload is diffed line by line and printed, so a version key inside it would render every upgrade as a schema change — and the baseline cannot answer the version question anyway, since it compares schemas and stays silent for a release that changes how documents are read rather than what they may say.
Three rules hold this up:
Absent means unknown, not unchanged.¶
A store with no baseline reports
nothing, rather than reporting its entire schema as new; an unparseable one
reads the same way, since reindex overwrites it. stale-index-build
likewise fires on inequality, not "older than" — a downgrade needs the
same rebuild.
A rebuild is the only writer.¶
reindex is already the "make derived state agree
with the sources" verb, and the bootstrap is the same rebuild with the vectors
deferred. A separate accept command would be a ritual whose
only effect is silencing a report.
One renderer.¶
The drift check lives in application, which may not import
infra, so both sides go through domain/services/schema_shape.describe. A
second renderer would mean a baseline written in one shape and compared in
another.
DOCIR_SCHEMA_NOTICE=1 prints the drift on stderr after every command. It is
emitted client-side, through the same request boundary, because with the
daemon running the process that first loads a changed schema is the daemon —
whose stderr is a log nobody reads. docir self upgrade is the command that
acts on stale-index-build: reindex → agent update → check, in that order.