ADR-0007: Configuration file — a single project-level TOML with layered precedence

StateAccepted
Architectural SignificanceMEDIUM
DomainDeveloper Tooling
Document version1.9

Reference

Introduces a persistent configuration file so per-project preferences (which models to use, what to ingest, inference thresholds, ignore paths, serving) are set once and shared, rather than re-passed as CLI flags every run. It gives a home to settings spread across ADR-0003 (model picks), ADR-0005 (image ingestion toggles), and ADR-0006 (the [serve] table). Governed by the offline/deterministic principles of ADR-0001.

Summary

Add an optional roteiro.toml at the repository root (committed — so a team shares the same, reproducible settings), plus an optional user-level ~/.roteiro/config.toml, with a clear precedence:

CLI flag > project roteiro.toml > user ~/.roteiro/config.toml > built-in default.

One rule inverts this order (v1.4). For a capability key — one whose effect is to turn something on whose cost or risk falls on whoever runs the command — the project file may deny but never grant.

This began as a single documented exception for the remote tier in v1.2. It is now the standard, because an exception list grows one entry at a time until it is the rule and nobody has noticed. A rule with a stated scope can be applied to a new key by whoever adds it; an exception list can only be extended by whoever remembers it exists.

The reason is in this ADR's own words: roteiro.toml is "committed — so a team shares the same, reproducible settings". That is exactly right for a setting and exactly wrong for a permission. A merged line that starts sending source elsewhere, or running builds, on every teammate's machine is consent by pull request — granted by someone else, noticed by nobody. The project layer may switch such a capability off for everyone, and may never switch it on for anyone.

Which keys this covers

A key is a capability key if setting it causes something to happen that otherwise would not, and that thing does at least one of:

  1. sends repository content off the machine;
  2. executes code the repository supplies, rather than code Roteiro ships;
  3. writes outside the repository and Roteiro's own caches, that would not otherwise occur — Roteiro already writes to ~/.roteiro by default, so a key that changes where rather than whether does not qualify;
  4. spends materially more of the machine than an ordinary command — loading a multi-gigabyte model, or running a build;
  5. removes a guard against any of the above. A guard is disabled by setting something to false, so this clause exists because such a key is a grant wearing a denial's grammar and would otherwise have to be caught by noticing that one of 1–4 applies transitively.

A capability key's built-in default is denied. If a key defaults to granted, then setting it in a project file causes nothing that would not otherwise happen — it fails clause 1 of the test before any of 1–5 is reached — and it is a value. Whatever does gate the behaviour is the real capability, and it is that which the rule governs. This is not a caveat; it is how three of the keys below are classified.

Everything else is a value, and values follow the ordinary order above. Most keys are values, and for them the inversion is not merely unnecessary but inexpressible: there is no "deny" for [models] generative = "qwen3-8b", only a different value.

KeyClassWhy
[remote] enabledcapabilitytest 1 — ADR-0019 §3
[serve] max_client_tool_bytescapabilitytest 4. The built-in denies everything above 32 KiB, so a project raising it spends materially more of every teammate's machine — and the mirror image of [serve] max_context_tokens one row below, which is a value only because its built-in already grants. See ADR-0006 §1.11
[serve] prefix_cache_mbcapabilitytest 4. The built-in is 0 — off — so a project raising it claims hundreds of MiB per entry on every teammate's machine that would not otherwise be claimed. See ADR-0006 §1.12
host execution for builderscapabilitytest 2 — ADR-0020 §6
[media] gatecapabilitytest 5, and test 4 beneath it. gate = false "sends every blob to the model", so a project setting it spends every teammate's machine on model runs that would not otherwise happen, and admits the confabulation ADR-0015 exists to prevent
[ingest] ocr/vision/audiovaluecorrected on inspection. Each defaults to true, so a project setting true grants nothing that is not already granted. What actually gates them is the build feature — image-ocr, image-vision, audio-transcribe, none of them default — which is a stronger gate than a config default, being a decision taken at install. Should any default ever flip to false, these become capability keys and this row is wrong
[ingest] prose/pdfvalueno model and no repository-supplied code: parsing, which is what the rest of extraction already does
[paths] model_store, [telemetry] filevaluetest 3 as sharpened above. These change where Roteiro writes, not whether — it writes to ~/.roteiro by default regardless
[paths] exclude, [paths] opaquevalueno clause of the test can fire on a key that only subtracts. Setting either causes strictly less to happen — fewer nodes, fewer bytes read — so it fails clause 1 of the capability test ("causes something to happen that otherwise would not") before any of 1–5 is reached. The built-in default is nothing declared, which is the permissive end, so the capability rule's own corollary applies in reverse: the key can only deny. It is nonetheless the second key whose every setting is a denial, after [mcp] tools, and it takes the same additive layering for the same reason — see below (v1.9)
[models], [debt], [serve], [infer], [duplicates], [workspace], [[links]], [pins], [telemetry] rotation/format, [media] silence_rms/image_variancevaluesettings, not permissions
[mcp] toolsvalue — but see belowtest 1. The default already advertises every tool, so a project file setting it causes nothing that would not otherwise happen. It is nonetheless the first key whose every setting is a denial, and its layers therefore intersect rather than override (v1.5)

Two of those rows are worth reading as method rather than as answers. [ingest] was expected to be a capability and is not, because its default already grants — which is what produced the default rule above, and is a reminder that a key's class cannot be read off its subject matter. [media] gate is the reverse: an unremarkable-looking boolean that turns out to be the only key here whose false is the dangerous value.

A third case the classification did not have a name for (v1.5)

[mcp] tools restricts what an MCP server advertises. It is a value by this ADR's own default rule — the built-in surface is every tool, so naming a subset in a committed roteiro.toml grants nothing that was not already granted, and the capability test fails at its first clause. But applying the ordinary precedence to it would have been wrong, and the reason is worth recording because the abstract rule did not reach it.

Every setting of the key is a denial. Naming a tool does not ask for that tool; it declines every tool not named. So "the nearer layer wins" lets a nearer layer un-deny: a committed project file could restore sandbox_clear after the machine's owner removed it in ~/.roteiro/config.toml. That is exactly the outcome v1.2's inversion exists to prevent, reached from the other direction — not by a project file granting a capability, but by a project file cancelling a denial.

The rule is therefore: a key whose every setting is a denial layers by intersection. Every layer may narrow, none may widen, the invocation included. The flag narrows rather than winning because a flag has nothing else to express here — there is no grant available to it, exactly as this ADR says of a value key that "there is no deny for [models] generative, only a different value" — and because an MCP invocation is argv in a client's configuration file — written once by whoever wired that client up, then committed and shared — so the invocation is not reliably a person at a prompt, while the user layer is unambiguously that person's standing intent.

Intersection is a lattice meet: order-independent, idempotent and monotone downwards, so "may deny, may not grant" holds by construction rather than by a rule each call site remembers — which is what the section below requires. An empty intersection is a startup error, never an unrestricted server: a restriction that quietly restricts nothing is the defect the key was added for (issue #584, and omnigent#5178 on the client side of the same wire).

The key sits in its own [mcp] table rather than in [serve] because [serve] tools is taken and means something else — a boolean deciding whether the served-chat model is offered the graph tools at all. Two keys called tools in one table, over two different surfaces, would be worse than a second table.

The mechanism is structural, not remembered

RemoteConfig implements the inversion with a bespoke overlaid_with. A second bespoke implementation is how a rule decays into a convention, and a third is how a convention decays into folklore. A capability key's layering must therefore be carried by its type, so that declaring a key a capability and getting its precedence right are the same act rather than two things a future author has to remember to do together — the same reasoning that put debt exclusions behind one function and truncation behind one window.

roteiro config reports a key's class beside its layer, because a reader who sees one key inherit and its neighbour refuse to cannot otherwise tell whether that is the rule or a bug.

Three states, because two of the requests are not the same one (v1.9)

[paths] exclude and [paths] opaque are the only keys here that decide what is in the graph rather than how it is reported, and that is the whole reason they exist. Before them there was no mechanism to exclude a path from extraction at all (issue #840): the one exclusion list in this file is [debt] ignore, which is scoped to markers and filters the report. The node stays in the store, and search, explain, list_kind, path and export all still see it — so a repository could suppress a false finding from the report it reads and still publish it to a consumer.

The two keys are two of three states, and the third is the default:

classproducesthe decision that needs it
(nothing declared)everything, as beforeevery path not named
opaquea file node and nothing else — path, name, blob id, byte and line counts, and meta.scan = "opaque"a corpus manifest must be committed in every storage mode, so that a missing source is detectable rather than silent (ADR-0026 Resolved question 2). It must therefore be in the graph, and must equally not be mined: as data it is otherwise shredded into one config_key node per JSON leaf and its English prose scanned for intent-debt markers
excludeno node, and the bytes are never reada raw/ source root reached by an explicit ingest path is graphed once, as its summary, not twice (ADR-0026 Implementation step 1)

"Do not mine this as configuration" and "do not put this in the graph" are different requests and two live decisions need one each, which is why two states would not do. A fourth — in the graph, mined, but muted from the reports — already exists and is [debt] ignore; it is deliberately not reproduced here.

The rule is consulted by every reader, and this is the part that is easy to under-build. There are two independent readers of committed blobs: derived extraction, and the authored layer, which walks every path on its own and classifies by content, not by location. A document that declares type: adr is parsed as one of this project's decisions wherever it sits — the rule that lets a repository keep its decisions outside docs/adr/, and exactly what makes an ingested third-party document dangerous. An exclusion applied at extraction alone would therefore remove a manifest's config_key nodes while still parsing a stranger's paper as one of ours. The policy is carried in the resolved ingestion configuration, which already reaches every entry point that reads repository bytes, so a reader that has one has the other.

Nothing is excluded by a built-in default — not raw/, not vendor/. A built-in would silently drop a directory out of the graph of every repository that happens to have one, on upgrade, with nothing said, and the only way to notice would be to know the default existed. Declaring is visible in review; un-declaring a default is not.

Both lists merge across layers, and neither has a reset. They merge for the reason [debt] ignore does (v1.1). They have no reset because a reset on an exclusion would let a committed project file widen what is read over a user's own declaration — re-admitting, on their machine, a corpus they excluded machine-wide, by a line somebody else merged. Narrowing is the safe direction for an exclusion, so only narrowing is expressible. That is the same asymmetry v1.4's capability rule draws, reached from the other side: there the project layer may deny and not grant; here it may only subtract, so there is nothing to invert.

A path matching both lists is excluded: between two declarations about the same bytes, the narrower one wins.

Format: TOML, and only TOML. Not YAML.

The file is entirely optional — every key has a working default, so Roteiro runs with no config at all; the file only overrides defaults. Unknown keys are ignored (forward-compatible), and a malformed file is a hard error (never a silent partial parse).

Context

As Roteiro's surface has grown — a curated low/mid/high model matrix (ADR-0003), ingestion toggles (prose/PDF/OCR/vision, ADR-0005), inference/dedup thresholds, intent-debt ignore directives, and now serving (ADR-0006) — more behaviour is worth pinning per project rather than retyping as flags. A committed config also makes runs reproducible and shareable, which fits the dogfooded/deterministic ethos.

Forces to reconcile:

  1. Optional & defaulted (ADR-0001). Roteiro must work with zero configuration; the file only ever overrides defaults. No config must never mean "broken."
  2. Reproducible & shareable. A committed project file means a team gets identical behaviour; the user-level file is for personal, cross-project preferences (e.g. a default model tier for a beefy machine).
  3. One well-maintained format. Rust's ecosystem standard is TOML; serde_yaml is unmaintained. One format, cleanly parsed, beats two.
  4. Flags still win. A one-off --min-confidence 0.6 must override the file, so precedence is CLI > project > user > default.

Decision makers

Option 2 — a single optional roteiro.toml (+ user override), TOML-only, layered precedence (recommended).

Initial schema (all keys optional; every section defaulted). Start with the high-value "sticky" ones ([models], [ingest], [infer]) and grow:

[models]                       # per-project overrides of the tier defaults (ADR-0003)
embedding  = "bge-base-en-v1.5"     # `infer`
generative = "qwen3-8b"             # `spec draft` and the Ask panel
vision     = "smolvlm-500m-gguf"    # `media build`, image description   (v1.3)
audio      = "voxtral-mini-3b"      # `media build`, speech transcription (v1.3)
ocr        = "ocrs-text"            # `sync`, literal text in images      (v1.3)

[ingest]                       # which content types feed meta.content, + caps (ADR-0005)
pdf = true
image_ocr = false
image_vision = false
max_content_chars = 1500

[infer]
min_confidence = 0.4
top_k = 5

[duplicates]
min_similarity = 0.9

[debt]
ignore = ["vendor/**", "**/generated/*"]   # paths excluded from intent-debt

[serve]                        # ADR-0006
models = false
addr = "127.0.0.1:8080"

[paths]
model_store = "~/.roteiro/models"   # today only the ROTEIRO_HOME env var
exclude = ["raw/**"]                # not in the graph at all          (v1.9)
opaque  = ["manifest/**"]           # a file node, nothing mined       (v1.9)

Options considered + consequences

Option 1: No config file — CLI flags + env only (status quo)

Option 3: Support both TOML and YAML

Consequences

Advice Received

Project direction incorporated: add a config file, but keep it optional and fully defaulted (no config must never break Roteiro), make it committed and reproducible at the project level with a personal user-level override, and use one well-maintained format — TOML, not YAML (since serde_yaml is unmaintained) — with CLI flags always winning.

Document version history

VersionDateNotes
1.02026-08-09Accepted. Optional roteiro.toml (project, committed) + ~/.roteiro/config.toml (user), TOML-only (YAML rejected — serde_yaml unmaintained), precedence CLI > project > user > default. Initial schema: [models]/[ingest]/[infer] first, then [duplicates]/[debt]/[serve]/[paths]. Fully defaulted (zero-config works); unknown keys ignored; malformed = hard error; missing-feature keys warn.
1.12026-08-16Amended (issue #321). Two refinements to layering, neither changing the CLI > project > user > default order: (a) list-valued exclusion keys merge — [debt] ignore unions the layers instead of the project layer discarding the user layer, with a new ignore_reset key as the explicit way to inherit nothing, and per-pattern provenance in roteiro config; discovery/selection lists deliberately still replace. (b) Per-repo resolution: in a multi-repo process each repository is scanned under its own config, extending ADR-0009's per-repo [[links]] rule. Motivation: the graph API applied no exclusions at all, so the explorer UI and the CLI reported different intent debt for the same repository.
1.22026-08-17Amended by ADR-0019. One key — the remote-model-tier enable — inverts the precedence: the committed project file may deny but never grant egress, and granting needs the user layer plus the invocation. Recorded here as well as in 0019 because a reader of this ADR would otherwise apply the general rule and be wrong. No other key is affected. Also corrects the header table, which read 1.0 while the frontmatter read 1.1.
1.32026-08-17Amended (Stage 33). [models] grows from two keys to five: vision, audio and ocr join embedding and generative, one key per model kind rather than per command (generative governs both spec draft and Ask). Until now those three models were compiled-in constants, so a project could not pin its ASR model at all — the setting did not exist. Two rules are recorded here because a reader would otherwise apply the general ones and be wrong: (a) a key whose value is wrong — unknown model, wrong modality — is a named error quoting the key, not the warning that a missing-feature key gets, and never a silent fall-back to the default; (b) roteiro config reports such a key instead of refusing, being the command an operator runs when a pin is misbehaving. roteiro config also gains a per-surface resolution table (model, rule, layer, installed), on the same reasoning as the per-pattern [debt] ignore provenance added in v1.1. Precedence is unchanged; unset resolves to exactly the models each surface used before.
1.42026-08-19Amended on the owner's ruling that v1.2's inversion should be the standard rather than an exception, and that every key be classified under it. The project file may deny but never grant any capability key — one that turns on something whose cost or risk falls on whoever runs the command — with a four-part test (sends content off the machine; executes repository-supplied code; writes outside the repository; spends materially more of the machine) so a new key can be classified by whoever adds it rather than by whoever remembers the exception list. Records that most keys are values, for which the inversion is not merely unneeded but inexpressible. Classifying the whole surface produced two additions the abstract rule had missed: a fifth clause for a key that removes a guard (a grant wearing a denial's grammar — [media] gate = false, the only key here whose dangerous value is false), and the rule that a capability key's built-in default is denied, without which a key that already defaults to granted looks like a capability while being unable to grant anything. That rule reclassified [ingest] ocr/vision/audio from capability to value: their real gate is the non-default build feature, not the config key. Test 3 sharpened to writes that would not otherwise occur, since Roteiro writes to ~/.roteiro regardless and [paths]/[telemetry] change where rather than whether. Also requires the mechanism be structural — carried by the key's type rather than by a bespoke overlaid_with per capability — because a second hand-written inversion is how a rule decays into a convention.
1.52026-08-21Amended (issue #584). Adds [mcp] tools, the advertised MCP tool surface, and with it a case v1.4's classification had no name for: a key that is a value by the default rule — its default already advertises everything, so a project file grants nothing new — and whose every setting is nonetheless a denial. Ordinary precedence would let a nearer layer un-deny, restoring a tool the machine's owner removed, which is v1.2's failure reached from the other direction. Such a key therefore layers by intersection: every layer may narrow the surface and none may widen it, the invocation included, and the flag narrows rather than winning because it has no grant to express and is not reliably written by a person (issue #579). Intersection is a lattice meet, so the property holds by construction rather than by a remembered rule, satisfying v1.4's requirement that the mechanism be structural. An empty intersection is a startup error and never an unrestricted server. roteiro config labels the key project ∩ user rather than naming a winning layer, on the same reasoning as v1.1's per-pattern [debt] ignore provenance.
1.62026-09-07Amended (no issue; implemented directly at the owner's request). Adds [serve] max_client_tool_bytes to the table above as a capability, and — more to the point — §111's type finally exists. That section required a capability key's layering to be carried by its type "so that declaring a key a capability and getting its precedence right are the same act", and warned that a second bespoke implementation is how the rule decays into a convention and a third into folklore. On inspection there were already three hand-written copies of "a project may deny but never grant": rto_remote::ConfigGrant, rto_exec::LintConfigGrant, and the reasoning that would have been written a fourth time here. They are now one rto_graph::layering::Grant<T>, which all three delegate to. The generalisation is smaller than the prose suggests: "deny but never grant" and "lower but never raise" are one comparison under Ord, since false < true and a tighter bound is a smaller number. Two things did not generalise and are documented where they live — project_denied and project_grant_ignored report what a file said, which is a bool-shaped question rather than a layering one, so each grant type keeps its own; and the comparison is <=, not <, so a project restating the baseline applies rather than being misreported as an overruled grant.
1.72026-09-07Amended (issue #578). Adds [serve] prefix_cache_mb to the table above as a capability, by the same clause 4 as max_client_tool_bytes and with the same Grant carrying it. Recorded because it is the first key added after v1.6 built the shared type, and so the first evidence that §111's requirement — that declaring a key a capability and getting its precedence right be one act — actually holds in practice rather than only in principle: the key's whole layering is Grant::from_layers(project, user, 0).as_effective(), one line, with no new rule written and none available to get wrong. It is also the first key at a different width to reach the type, u64 where the others are bool and usize, which is what the generalisation was for.
1.82026-09-08Amended (no issue). Re-grounds v1.5's reason for --tools narrowing rather than winning, which cited issue #579 — now closed not planned. The conclusion is unchanged and the argument is stronger without it. v1.5 argued the invocation "is not reliably written by a person" because Roteiro itself might one day write a roteiro mcp … line into a third-party agent's config (#579). That will not happen, and it was always the weaker form of the point: it rested on a hypothetical feature rather than on what an MCP invocation already is. It is argv in a client's configuration file — written once by whoever wired the client up, then committed and shared with a team. Observed rather than supposed: a bundle in use against this server carried args: [mcp, --tools, query] in a checked-in config.yaml, which is precisely the "consent by pull request, granted by someone else, noticed by nobody" shape ADR-0019 §3 names — reached here through a narrowing flag rather than a granting one, which is why intersection is the right regime whoever writes it. No key changes class and no precedence moves; this replaces a citation that no longer points at anything with the reason that was underneath it.
1.92026-09-14Amended (issue #840, ADR-0026 step 1). Two new keys — [paths] exclude and [paths] opaque — the first in this file that decide what is in the graph rather than how it is reported. Until now the only exclusion list here was [debt] ignore, which is scoped to markers and filters the report: the node stays in the store and search, explain, list_kind, path and export all still see it, so a repository could mute a false finding from the report it reads and still publish it to a consumer. The new keys remove the node instead, which needs no per-surface honouring — a node that was never stored cannot be exported. Three states, not two, because "do not mine this as configuration" and "do not put this in the graph" are different requests that two live decisions need one each: opaque keeps a file node with identity and no derived content, so a committed corpus manifest stays detectable rather than silent while not being mined; exclude produces no node and never reads the bytes, so a document reached by an explicit ingest path is graphed once rather than twice. A fourth state — mined but muted — already exists and is [debt] ignore, deliberately not reproduced. Both classify as values under v1.4's test, which no clause of can fire on a key that only subtracts. Both lists merge across layers as [debt] ignore does, and neither takes a reset: a reset on an exclusion would let a committed project file widen what is read over a user's own declaration, and narrowing is the safe direction. Nothing is excluded by a built-in default, because a built-in would drop a directory out of every existing repository's graph on upgrade with nothing said. Records the requirement that decides whether the mechanism works at all: the policy is consulted by every reader of repository bytes, not by extraction alone — the authored layer walks every path independently and classifies by content, so a type: adr declaration is honoured wherever the file sits, and an exclusion reaching one reader and not the other would remove a manifest's config_key nodes while still parsing a stranger's paper as one of this project's decisions.