docir — design documents Graph

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.

arch-ad342aae8293architectureactive#architecture
View as Markdown◉ View in graph

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 status value (not in the type's enum)
  • Invalid status transition (--override forces one and warns, naming the rule it broke; it cannot set a status the type does not declare)
  • A related id that does not exist in the index
  • A relation kind not in the relation_types registry, or one the source type's allowed_relations whitelist forbids for that target type
  • A tags key not in the tag registry
  • A code glob that can never match — absolute, containing .., backslash separated, or empty. A pattern that matches nothing today is accepted: a decision is routinely written before the code it decides
  • 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: the corpus is broken. 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. It now reads the derived mention graph too (adr-e86c5040d626), which removes most of that noise; 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 nothing connects to it — no related edge either way, and no other document names its id in prose
cycle warning a loop in the graph
layering warning a higher-level type depends on a lower one
stale warning past the type's review_days, measured from verified else updated
code-changed warning the code a document governs differs from what it was when somebody last verified it
unmatched-code warning a code glob that no longer names anything (only when the store sits in a repository)
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
tag-key-format warning a registry key that is not a usable tag

The last group 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.

Layering is opt-in per relation kind. The check reads only edges the schema marks dependency: truedepends_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.

docir check --fix repairs what needs no guess: duplicate ids are re-issued (the oldest file keeps the id, because existing edges were written against it and an edge cannot say which document it meant) and dangling edges are dropped. 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-0007 and 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)

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 docir reindex is the only writer of both:

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.

reindex is the only writer.

It is already the "make derived state agree with the sources" verb. 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 updatecheck, in that order.

To amend: Re-verify: