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, or an edge from a document to itself
  • 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, 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_chars that the write makes longer, unless the type sets max_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-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)
  • Oversized sections, ambiguous headings and unqualified section references — how well a body's ## sections can be retrieved and addressed
  • Broken --expr examples — an --expr a 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.

To amend: Re-verify: