ADR-0023: ods doctor: typed health checks, stable codes and exit semantics¶
- Status: Accepted (2026-10-04)
- Date: 2026-09-29
- Issues: #181 (builds on #6, #7, #188, #227, #230, #17)
- Deciders: @n1ckyb
Context¶
Issue #181 asks for a first-class ods doctor that says whether ODS can work in the
current project and environment: project discovery and dbt artifacts, configuration and
where each value came from, dbt and its version, the target, the state store,
provider capabilities, and live connectivity. Its constraints:
- diagnostics are typed data, not formatted strings;
- human output uses the ODS presentation layer (ADR-0003), with plain and JSON too;
- unknown or inconclusive checks are reported as such, never as success;
- exit statuses are deterministic for the healthy, warning and failure cases.
Much of what it checks already exists, and must not be implemented twice:
ods state doctor(#188, ADR-0018) checks the state database: integrity, schema version, migrations and snapshot chains;- configuration loading (ADR-0005) already reports each value's source and refuses plaintext credentials;
- the dbt executor already finds dbt (#214), renders the target identity (ADR-0017), checks relations (ADR-0016) and probes table versions (ADR-0022).
The M1 audit on #181 limits it to project, configuration, store and tools checks: live provider connectivity (a Databricks SQL connection, #15) depends on M2. The live checks that already exist in M1 run through dbt's own connection, so they need no credential in ODS (AGENTS.md rule 9).
AGENTS.md rules that apply: 1 (the model in core is provider-neutral), 3 (missing
evidence is never success), 4 (findings carry evidence and render as text and JSON), 7
(presentation at the CLI edge, CliError and ADR-0004 exit codes) and 9 (never print a
resolved secret).
Options considered¶
Where the model lives¶
Option A: in ods-cli, next to the command¶
- ✅ Nothing to share yet.
- ❌ The LSP (#67), the MCP server and
ods-webwill show the same findings; they would each re-model them.
Option B: in ods-core (chosen)¶
- ✅ Presentation-free and provider-neutral, at the bottom of the graph, so every consumer (CLI, server, editor) can use it (ADR-0001).
- ❌
ods-coregrows a small module unrelated to State.
ods-sdk was also possible, but nothing in the model is a provider contract: providers
don't report health themselves in M1.
How codes are allocated¶
Option A: a new ODS-D#### namespace for every doctor finding¶
- ✅ Obviously distinct.
- ❌ A missing manifest would be
ODS-E0201fromods state planandODS-D…fromods doctor: two codes for one problem.
Option B: extend ADR-0004's ODS-<letter><NNNN> scheme (chosen)¶
- ✅ One code per problem across commands; the number blocks already group areas.
- ❌ Letters other than
Eare new, and must be documented.
What a failed verdict exits with¶
Option A: the exit status of the worst finding's area (e.g. 4 for configuration)¶
- ❌ Several failures give several candidate statuses; the order of checks would decide.
Option B: 1 (failure)¶
- ❌ Scripts can't tell "the doctor couldn't run" from "the doctor found problems".
Option C: 5, "check failed" (chosen)¶
- ✅ ADR-0004 defines 5 as "the command ran correctly and its verdict is negative", which is exactly a doctor finding problems. One status, whatever failed; the findings' codes carry the detail.
Decision¶
1. Model (ods_core::diagnostic)¶
Each check yields a CheckResult:
| Field | Meaning |
|---|---|
id |
stable id, <category>.<name>, e.g. project.manifest |
category |
config, project, tools, target, state_store, capabilities, connectivity |
provider |
the provider the check concerns (dbt, databricks, sqlite), if any |
status |
ok, warning, error, unknown or skipped |
required |
whether ODS can't work without it |
code |
the finding's stable code; absent for ok and skipped |
message |
what was found, for people |
evidence |
ordered {key, value, source?} facts it relied on; source says where a value came from (flag, variable, file, default) |
hint |
what to do |
A HealthReport holds the checks (grouped by category, in a fixed order), a summary
of counts per status, strict, and the verdict: healthy, warnings or failed.
All types are Serialize and Deserialize (so a consumer can read a report back), snake_case and #[non_exhaustive]. Nothing in them names a
provider; the CLI supplies ids and provider names as data.
unknownmeans the check couldn't conclude: a check it depends on failed (codeODS-U0001, evidencedepends_on), or the evidence was inconclusive. It is never shown asok.skippedmeans not run by choice (a live check without--connect, or an adapter with nothing to probe).
2. Codes¶
Codes are ODS-<L><NNNN>: E error, W warning, U unknown. The number blocks are
ADR-0004's (00xx general, 01xx configuration, 02xx project and artifacts, 04xx
state), plus 05xx tools, target and the doctor itself, and 06xx provider
capabilities and live checks. A number is used with one letter only, so a code alone
identifies the finding.
- A finding that means what an existing command error means reuses its code:
ODS-E0101–E0104(configuration),ODS-E0201(manifest missing, unreadable or unsupported),ODS-E0401andODS-E0405(state store). - New:
ODS-U0001(depends on a failed check),ODS-E0204,W0205,W0206,U0207(project),ODS-E0501(the doctor's verdict),E0502,E0503,W0504,U0505,E0506,U0507,U0508(dbt and its adapter),E0509(target),W0601,W0602,E0603,E0604,U0605,W0606,U0607(capabilities, live checks).
docs/cli.md lists every code with its hint; a test fails if one is missing there.
3. Exit semantics¶
The verdict decides, the output mode never does:
| Outcome | Exit |
|---|---|
every check ok or skipped |
0 |
warnings, or unknown on optional checks |
0; with --strict, 5 |
any error, or unknown on a required check |
5 |
- Required checks are those ODS can't work without:
config.load,config.resolution,project.dbt_project,project.manifest,tools.dbt,target.identity,state_store.database. An unknown required check fails: ODS can't say it will work, so it doesn't (AGENTS.md rule 3). - A failed verdict is
ODS-E0501with exit status 5. In JSON the envelope carries the whole report inresultandODS-E0501indiagnostics; in human and plain modes the report goes to stdout and the error to stderr (ADR-0004 §4). - Invalid configuration is a finding. Other commands stop with status 4 before
they run.
ods doctorruns on configuration from the flags alone, reports the error asconfig.load(with itsODS-E010xcode) and exits 5. Checks that read settings areunknown(depends_on: config.load), since a configuredproject_dirmay not have been read. AModuleopts into this withdiagnoses_config(). - Usage errors (e.g.
--provider nope) are still status 2, and a failure to write output is still 1.
4. Checks, offline by default¶
Offline means ODS runs no warehouse query: it runs dbt --version, and dbt renders
the profile without connecting. dbt itself may use the network for its own version
check and usage statistics. ODS writes nothing; dbt writes the target check's artifacts
and its log. An integration test asserts which dbt commands a default run makes. The
default set:
| Check | What | Provider |
|---|---|---|
config.load |
files considered, profile; a load error is its code | |
config.values |
every effective value and its source; credentials only as references, and connection strings without user, query, fragment or options | |
config.resolution |
where ods state finds dbt, the project, target dir, state db and environment, and from what (flag, DBT_* variable, config file, profile, default) |
|
project.dbt_project |
dbt_project.yml in the project directory |
dbt |
project.manifest |
the manifest (or dbt's Information Schema) reads, at a supported schema | dbt |
project.name |
the manifest names its project | dbt |
project.freshness |
the manifest is newer than every project file (.sql, .yml, .yaml, .csv, .py, outside target/, packages and hidden directories) |
dbt |
tools.dbt |
dbt --version runs; 1.7+ supported (manifest v11), 2.x untested |
dbt |
tools.adapter |
the manifest's adapter_type is among dbt's plugins |
dbt |
target.identity |
dbt renders the target (ADR-0017) | dbt |
state_store.database |
ods state doctor's check, shared, not re-implemented |
sqlite |
capabilities.relation_existence |
a provider can check relations before reuse (#230) | dbt |
capabilities.relation_versions |
sources get data versions: table versions for the adapter, else loaded_at_field; what's missing and its consequence |
by adapter* |
target.identityis in the default set. It needs dbt to render the profile (dbt compile --inlinewith--no-populate-cache --no-introspect), which readsprofiles.ymlbut opens no warehouse connection. A profile dbt can't render stops every recording run (ADR-0017 §4), so it is the first thing a user needs to know. If dbt can't be run, the target isunknown, which fails the run, since it is required. dbt writes its artifacts for this check undertarget/ods-target-check, asods state rundoes; the doctor writes nothing else.- * The source-version checks concern
databrickswhen the manifest's adapter is Databricks (whose table versions ODS reads) anddbtotherwise, so--providerselects them by the project's adapter. If the manifest can't be read, they are shown under the provider asked for, so the failure isn't hidden. dbt --versionmay look up dbt's latest release itself; ODS doesn't, and dbt reads no profile to answer.- Only dbt Core's
- installed:line is read (installed version:before dbt 1.0); anything else, including other programs that call themselves dbt, is an unknown version (ODS-U0505), never a number picked from elsewhere in the output. dbt's versions are PEP 440 (1.9.0b1), which semantic-version crates reject. They are parsed withpep440_rs(Apache-2.0 OR BSD-2-Clause, about 3,500 lines, used by uv); without default features it adds onlyunicode-widthandunscanny(both MIT OR Apache-2.0), plusonce_cellandserde, already in the tree. Only major and minor decide support. - A freshness scan that can't read a directory or file, or a modification time that
isn't a date, makes
project.freshnessunknown(ODS-U0207), neverok.
--connect adds the live checks that already exist in M1, through dbt's own
connection (no credential in ODS):
| Check | What | Provider |
|---|---|---|
connectivity.relations |
the relation check (dbt show, ADR-0016) over every model, seed and snapshot |
dbt |
connectivity.table_versions |
the table-version probe (ADR-0022) over every source; skipped for adapters without table versions, unknown (ODS-U0507) when the manifest names no adapter |
by adapter* |
A failure there is an error (ODS-E0603, ODS-E0604) with a hint. A check that ran
but couldn't conclude isn't a pass: a relation check that couldn't tell about some
relations is unknown (ODS-U0605); a probe that found no table version for some
sources warns (ODS-W0606), and for none is unknown (ODS-U0607). Without
--connect both are skipped, so it is visible that they didn't run.
Filters. --project keeps the project checks; --provider dbt|databricks|sqlite
keeps the checks that concern that provider. Both together keep their intersection.
A check whose precondition was filtered out still depends on it (e.g.
--provider databricks still reads the manifest) and says so if it failed.
5. Presentation¶
ods doctor is a Module whose result model is the HealthReport plus the scope
(project_only, provider, connect). Human output renders one tree per category,
each check with its status, code, evidence and hint; plain is the same line by line;
JSON is the ADR-0003 envelope, command: "doctor".
Consequences¶
- Positive:
- One command answers "why doesn't ODS work here?", with a code per finding that docs, issues and scripts can refer to.
- Invalid configuration, the most common first failure, is reported with the rest instead of stopping everything.
- The model is reusable by the LSP, MCP server and
ods-web. ods state doctorandods doctorcan't disagree about the store: one code path.- Negative / trade-offs:
- The default run starts dbt twice (
--version, the target check), which takes a few seconds on a large project.--projectand--provider sqliteavoid it. ODS-E0101–E0104exit 4 from other commands and 5 from the doctor.- Two new direct dependencies:
pep440_rs(see §4) andwalkdir(Unlicense OR MIT, already in the tree through other crates), for the freshness check's directory walk. - Follow-up work:
- M2 (#15, #126): a Databricks SQL connectivity check and secret resolution checks
(a reference that doesn't resolve), under
connectivityandconfig. - Provider-reported health (a
health()on providers, ADR-0006) once third-party providers exist. ods doctor --fixfor safe remedies (e.g.dbt parsefor stale artifacts).
References¶
-
181; AGENTS.md rules 1, 3, 4, 7 and 9¶
- ADR-0003 (presentation and JSON envelope), ADR-0004 (exit and error codes), ADR-0005
(configuration, secrets as references), ADR-0006 (capabilities), ADR-0016 (relation
check), ADR-0017 (target identity), ADR-0018 (
ods state doctor), ADR-0022 (table versions) docs/cli.md#ods-doctor