ADR-0001: Build Roteiro — a unified, provenance-tagged codebase knowledge graph (spec-store v2)

StateAccepted
Architectural SignificanceHIGH
DomainDeveloper Tooling
Document version1.7

Reference

Successor to the spec-store CLI (April 2026). Standalone project; Thalweg will be its first consumer but Roteiro carries no twg- dependency and must serve any codebase.

Repository: github.com/OffeneDatenmodellierung/Roteiro · Docs site: roteiro.dev · crates.io: roteiro, rto-graph, rto-spec, rto-render (all confirmed available 2026-08-07). Open source, dual-licensed MIT OR Apache-2.0 (matching Thalweg and Rust ecosystem convention).

Summary

Build Roteiro: a standalone Rust tool (1.96, 2024 edition) that replaces the current three-tool stack (lat.md, codegraph, Graphify) with a single knowledge graph in which every edge carries provenance — derived (deterministic tree-sitter AST extraction), authored (house-style ADRs/blueprints linked into code symbols), or inferred (fuzzy doc/image/embedding extraction with confidence scores). One SQLite store, one query surface, three renderers (docs site, Obsidian vault, optional MCP), with a content-addressed cache keyed by git tree hash so branches and worktrees share extraction work and CI-published artifacts remain the single source of truth. Roteiro inherits spec-store's intent-confirmation interview as the front door for authoring new specs/ADRs, and ships one-shot importers for existing lat.md, Graphify, and codegraph content.

Context

We currently run three overlapping tools to give agents and humans context about a codebase: lat.md (authored markdown knowledge graph capturing intent, with wiki links and drift checking), codegraph (deterministic tree-sitter AST symbol/call graph in SQLite, exposed over MCP), and Graphify (broad tree-sitter + doc/PDF/image ingestion into a fuzzy relationship graph). Each answers a different question — why is it shaped this way, what is the structure, what exists across all artifacts — but running three tools per change is operationally heavy, and agents cannot reliably choose which tool answers a given question, degrading answer quality.

Separately, our house ADR/blueprint discipline already contains most of the intent content lat.md would hold (57 ADRs on Thalweg alone), but lacks symbol-level linkage into code and automated drift checking. The spec-store CLI proved two things worth carrying forward: the pre-coding interview flow that confirms user intent before specs are written, and registry-backed duplication prevention for AI agents.

The decision: consolidate into one purpose-built tool, or continue composing existing tools.

Decision makers

Option 4 — build Roteiro. The three tools answer different questions with incompatible production models (deterministic derivation, human authoring, heuristic inference); no existing tool unifies them, and a naive union would be a worse version of each. Modelling the difference as edge provenance within one graph eliminates the agent's tool-selection problem (one query surface returns mixed, labelled results), lets precise matching win wherever it exists with fuzzy links clearly marked as suggestions, and makes the docs website, Obsidian vault, and agent context all build outputs of the same graph — humans review the same data agents query. Build effort is bounded because the structural layer reuses proven tree-sitter patterns (validated externally at Linux-kernel scale) rather than reinventing parsing; our effort concentrates on the parts nobody ships: the ADR/blueprint parser, provenance schema, drift check, interview flow, and git-native caching.

Options considered + consequences

Evaluation dimensions: answer quality for agents, human review surface, ops burden per change, offline capability, build/maintenance effort, migration cost.

Option 1: Keep the three-tool stack (lat.md + codegraph + Graphify)

Description: Continue running all three, each maintained upstream, integrated via their own MCP servers/CLIs.

Consequences:

Option 2: Adopt lat.md + codegraph only, drop Graphify

Description: Use lat.md for intent and codegraph for structure, unmodified; accept loss of doc/PDF/image ingestion.

Consequences:

Option 3: Extend spec-store in place (v1.x)

Description: Bolt AST extraction, ADR parsing, and renderers onto the existing spec-store codebase (Qdrant/SQLite hybrid).

Consequences:

Description: New standalone Rust 1.96 / 2024-edition workspace: three crates plus one umbrella CLI —

Operational model: git hooks (post-checkout/post-merge) compare local store against HEAD tree hash → no-op, fetch CI-published content-addressed artifact, or incremental rebuild proportional to the diff. CI on PR merge runs extract → check → assemble → publish artifact → render docs + vault, making the merged graph the central source of truth; local runs are deterministic previews. Fully offline by default (compiled-in grammars, bundled local embedding model); network only for optional artifact fetch, with rebuild as fallback. Inference (fuzzy layer) is a separate, optional CI stage so it never gates offline local rebuilds. One-shot importers (roteiro import --from lat|graphify|codegraph) map lat.md → authored, Graphify doc/media nodes → inferred (code-structure edges dropped in favour of re-derivation), codegraph → bootstrap/validation oracle only; each import emits a migration report listing imported edges per provenance class, validation failures, and promotion candidates.

Consequences:

Cost is engineering time only in all options (all tools open source, local-first); no vendor rack rates apply.

Implementation

This ADR's decisions are linked into the code they govern, so roteiro check validates the design against the implementation (the ADR is dog-fooded like any other authored intent):

The three classes are closed, permanently

derived | authored | inferred is not a list awaiting a fourth entry. It is the decision this ADR makes, and the evidence that it is closed is that the project has since declined to extend it four times running:

The first three were candidates for a fourth variant, and each time a separate store was the better answer — because what did not fit was never a fourth way of producing a graph fact. It was not a graph fact. The fourth extends the rule rather than repeating it: a thing may be a graph fact and still not need a class, when what it wants to say is who rather than how. That is also the distinction v1.5's external-* widening turns on — externality entered the tier only because it changes what we can check, which a locally reviewed file does not.

So Provenance is deliberately exhaustive in Rust and closed on the wire, and that is a decision rather than an oversight. Marking it #[non_exhaustive] would be weaker documentation than the current silence: it would tell a reader that a fourth variant is anticipated, when four consecutive ADRs establish that it is not. The precedent for stating this at the definition is crates/rto-remote/src/escalation.rs#Trigger, whose doc comment records the same reasoning — exhaustiveness is the right default where a set is closed by a decision rather than by today's implementation.

The cost is stated rather than hidden. Provenance rides every edge of every roteiro.query/v1 document, so a fourth class would be simultaneously a Rust break and a wire break on the most-consumed document the project emits. That is the point. The break is the signal, and anything that would require one should first be asked whether it is a graph fact at all — which, in three of the four cases since, it was not. The fourth, ADR-0026's research note, is a graph fact and still needs no class, because the distinction it wanted to draw is about who produced the fact rather than how. So the question to ask has two steps now: is it a graph fact, and if it is, is what you want to say about it a production mode or an actor?

The MSRV rule

Stay on the current MSRV until a dependency forces a move, and treat any bump as an ADR-worthy decision.

This rule was written in docs/BUILD_PLAN.md and lived there until that document was archived (2026-09-02). It is restated here rather than retired because it is still in force and because v1.4 below already cites it as ADR-0001's rule — a citation that would otherwise point into a document marked "no longer maintained".

Two consequences the wording carries and that are easy to lose:

Pinning a dependency to hold the floor is the ordinary counter-move and needs no ADR — rusqlite = 0.39 is the standing example. What that pin does not do is survive its own reason: when the blocker lifts, the manifest says so rather than continuing to imply an MSRV constraint that has gone (see v1.4).

Advice Received

Accepted by the project team without external advisory review — single-team open-source project; future significant changes will be superseding ADRs. Naming secured: roteiro.dev registered, crates.io names available, repo created at OffeneDatenmodellierung/Roteiro. Planned: circulate v0.x for review before implementation beyond crate-name reservation.

DateAdvisorDecision versionAdvice

Document version history

VersionDateNotes
0.12026-08-07Initial draft: consolidation rationale, four options, Roteiro architecture (provenance model, three crates + CLI, content-addressed cache, CI-canonical artifacts, importers, MCP-optional).
1.02026-08-07Accepted. Naming secured (roteiro.dev, crates.io, OffeneDatenmodellierung/Roteiro); MIT OR Apache-2.0; attribution to The Roteiro Project Team. Stage 1 bootstrap started.
1.12026-08-10Added an Implementation section linking the ADR's decisions into the code ([[path#Symbol]]), so roteiro check validates this ADR against the implementation (Stage 14 self-check).
1.22026-08-16Corrected the quality bar (issue #319): the 85% per-file coverage ratchet named here was an aspiration that was never wired into CI — .github/workflows/ci.yml contained no coverage tooling at all, so every stage DoD citing it was unverifiable. Coverage is now measured non-blocking; the decision to enforce a floor is deferred until the real numbers are in hand. No architectural decision in this ADR changes. (Frontmatter version also brought up to date — it still read 1.0 after the 1.1 amendment.)
1.32026-08-19Answers a question the code had left to silence (issue #448): is derived | authored | inferred closed on purpose, permanently? Yes, and the evidence is that the project has since declined to extend it three times running — ADR-0012 gave analyzer findings their own artifact store, ADR-0013 gave agent memory one, ADR-0015 gave generated media one. Each was a candidate for a fourth variant; each time what did not fit was not a fourth way of producing a graph fact but something that was not a graph fact. Records the consequence for anyone reaching for #[non_exhaustive] on this enum: it would be weaker documentation than the current silence, because it would imply a fourth variant is anticipated when three consecutive ADRs establish that it is not. The precedent for saying so at the definition is rto_remote::escalation::Trigger. The cost is stated rather than hidden — Provenance rides every edge of every roteiro.query/v1 document, so a fourth class is simultaneously a Rust break and a wire break on the most-consumed document the project emits, and that break is the signal rather than the obstacle. No architectural decision changes; a standing one is written down.
1.42026-09-01MSRV raised 1.94 → 1.96. The driver is the OKF conformance stack (ADR-0021): okf-validator's tree pulls twelve oxc_* crates that declare rust-version = 1.96, and the project accepted that tree into the default build rather than gating it — so the MSRV, not a feature flag, is what has to give. This is the project's first MSRV move, and it is made under BUILD_PLAN's standing rule that a bump is ADR-worthy and happens only when a dependency forces one, which is what happened. A second consequence is recorded rather than left implicit: the raise retires the rusqlite = 0.39 pin's stated reason. That pin exists because libsqlite3-sys 0.38+ uses cfg_select!, stable since 1.95, and 1.95 was newer than the old MSRV; at 1.96 that blocker is gone. It is not thereby unblocked, and the manifest now says so: boxlite 0.10.0 requires rusqlite ^0.39 and libsqlite3-sys declares links = "sqlite3", so the graph admits one version and --all-features cannot move to 0.40 until boxlite does. Lifting the pin is a dependency-upgrade task gated upstream, not an MSRV one — recorded so the next MSRV bump does not inherit it as unfinished business. On CI only three of the ten pinned toolchain: values move — ci.yml's msrv job and both website.yml jobs; the other seven already ran 1.98 and are untouched. No architectural decision in this ADR changes.
1.52026-09-01The anticipated break happened (issue #706; recorded as a decision in ADR-0021 v1.1). v1.3 above closed derived | authored | inferred on purpose and named the price of ever opening it: a fourth class would be "simultaneously a Rust break and a wire break on the most-consumed document the project emits, and that break is the signal rather than the obstacle". Reading a peer's OKF bundle is that case, and it resolved the way v1.3 said it should. The enum was not given a fourth way of producing a graph fact; the three that exist were qualified by who produced them — external-derived / external-authored / external-inferred, six tokens, with externality flattened to exactly one level and the import layer's src_ref naming the source. v1.3's reasoning is why a flat External was rejected rather than an oversight: collapsing a peer's tier would force one arm of okf::origin_for and then re-emit the flattened tier outward, laundering by round-trip. So v1.3 should be read as having priced this correctly, not as having forbidden it, and #[non_exhaustive] is still declined for the reason it gives. The consequence v1.3 attached is narrower than it reads. Provenance is a pub enum without #[non_exhaustive], and rto-graph-v5.0.0 shipped three variants, so six is technically breaking for a downstream exhaustive match. AGENTS.md carves the rto-* crates out of that escalation deliberately: they publish only so that cargo install roteiro resolves, roteiro is their sole reverse dependency, and a technically-breaking change to their surface — a field's type, an enum variant, a signature — ships as a minor and does not take a !. 7a98938 invoked exactly that in its body ("No !, deliberately"), and release-plz cut 5.1.0 accordingly. So v1.3's "the break is the signal" is discharged by the record rather than by a major version, and the wire cost it named is borne by migration 14, which makes an older build report a widened store as written by a newer Roteiro rather than as corrupt. The escalation v1.3 imagined belongs to roteiro itself — its CLI contracts, config keys and on-disk formats, the surfaces AGENTS.md explicitly does not carve out — not to rto-graph's Rust API. Recorded because the conclusion is counter-intuitive: this is a real break that correctly ships as a minor, and the reasoning lives in two places (AGENTS.md and that commit body) rather than here.
1.62026-09-02Adopts the MSRV rule from BUILD_PLAN.md, which has been archived to docs/history/ along with BUILD_PLAN_V2.md. Both plans were delivered and their work moved to tracked issues, so they are marked status: deprecated — OKF §5.4's value, whose gloss is exactly this case: "kept for links and history; no longer current." Archiving rather than deleting follows the same principle this project already applies to ADR-0005's go/no-go spike table and BUILD_PLAN_V2's own baseline snapshot: a record of what was decided or measured at the time is not rewritten to match the present. One rule did not retire with them. v1.4 above cites "BUILD_PLAN's standing rule that a bump is ADR-worthy and happens only when a dependency forces one", which would have left a live constraint sourced from a dead document; it now has a section of its own here. No architectural decision changes — this moves a rule and a pair of documents, and adds nothing.
1.72026-09-13A fourth consecutive decline, on new grounds (ADR-0026). v1.3 counted three refusals to extend derived | authored | inferred and read them all the same way: what did not fit was not a graph fact. A model-written research note breaks that pattern — it would have a source blob, be committed and reviewed, and be a graph fact by every test this ADR applies. It still needs no class, and the reason is one level up: authored has always read "a human or agent" at the definition, and ADR-0013 states the discriminator as "deliberately wrote this in a reviewed file", so the human/model question is about who authored, an actor, which OKF carries in generated.by / verified.by independently of the trust tier Provenance decides (issue #799 is the render-path fix that stops collapsing them; it needs a breaking rto-render change, since Origin holds a single Actor, which ships as a minor under the rto-* carve-out this document's v1.5 records). The closing test therefore gains a second step. "Is it a graph fact?" disposes of ADR-0012, ADR-0013 and ADR-0015 but not of ADR-0026, so the question is now: is it a graph fact — and if it is, is what you want to say about it a production mode or an actor? Only a fourth production mode earns a variant. v1.5's external-* is consistent rather than a counter-example: externality entered the tier only because an imported fact has no local source blob and no locally checkable tier, so origin_for would otherwise be forced onto one arm and launder by round-trip; a reviewed file in this repository has both. The second candidate for the same slot resolved identically and independently — issue #801's citation records are closed with "no new provenance class". ADR-0026 is For Review and unbuilt, and crates/rto-spec/src/layer.rs has no knowledge arm, so nothing here describes current behaviour. What this row records is the class decision, not ADR-0026's acceptance — the two are separable, since a research note takes authored for reasons that hold whatever else that ADR settles. Should ADR-0026 be rejected or change the shape of knowledge/, revisit this row rather than assuming it survives: an Accepted ADR citing a proposal is a dependency, and naming it is cheaper than discovering it. No architectural decision changes; the enum, its six tokens and the #[non_exhaustive] refusal are all untouched.