DVT CONFIG-FIX

dvt config-fix repairs your model configs — the exact issues dvt config-checkup reports, acted on. Provable repairs land automatically, every edited file keeping a timestamped .bak beside it; anything ambiguous becomes a choice you make at the terminal, never a guess. No AI anywhere in it.

The minimal run — a project with one typo'd materialization:

$ dvt config-fix
✅ models/silver/customer_ltv.sql
    materialized='f_tabel' is not a DVT materialization
    `dvt config-fix` sets it to 'f_table' (the one legal neighbour)

1 fixed, 0 suggested, 0 informational (default target: pg_warehouse)

The file now says materialized='f_table', and the original sits beside it as customer_ltv.sql.20260803-102341.bak. On a healthy project there is nothing to do, and it says so:

$ dvt config-fix
Every config tells the truth — nothing to do.

ONE DIAGNOSIS, TWO COMMANDS

config-checkup and config-fix are a deliberate pair. Checkup tellsyou what's wrong — every issue, in enough detail to fix by hand — and never writes a thing: no file, no .bak, nothing. Fix repairs— and it knows nothing of its own, because it runs checkup's exact detection and acts on the answer. Fix can never see an issue checkup didn't, and checkup can never promise a repair fix won't make. Same findings, same wording; the only difference is whether anything was acted on.

You can live in either command. Prefer to understand and edit your own files? Run checkup, read, fix by hand. Want the provable ones just handled? Run fix. And dvt flip-target-to runs both for you, before and after every flip.

IN PLAIN DBT

There is no repair command. A typo'd materialization surfaces as a compilation error at run time; you grep for it, open the file, fix it by hand, run again. Config drift — a config that no longer matches what actually runs — has no detector at all, and a deleted project setting silently becomes whatever the default is. DVT names all of it in config-checkup and repairs it in config-fix, with a .bak of every file it touches.

THE CYCLE — CHECKUP, FIX, CHECKUP AGAIN

Here's the whole loop on a project that drifted: someone typo'd a materialization, a MySQL source got wired into a plain table model, a target= landed on a standard model, a source yml lost its meta.connection, and a merge ate the suite_port line in dvt_project.yml. First, the diagnosis:

$ dvt config-checkup
🔧 models/silver/customer_ltv.sql
    materialized='f_tabel' is not a DVT materialization
    `dvt config-fix` sets it to 'f_table' (the one legal neighbour)
🔧 models/silver/orders_enriched.sql
    materialized='table' but its dependencies are federated — the
    engine already coerces this at runtime
    `dvt config-fix` sets it to 'f_table' (the config then says
    what actually runs)
💡 models/staging/stg_payments.sql
    target='pg_analytics' on a standard materialization — dbt runs
    those on the default target only
    federate it (materialized='f_table') or drop the target=
💡 models/staging/stripe.yml
    source 'stripe' has no meta.connection
    name a profiles.yml output — one rule, no exceptions
🔧 dvt_project.yml
    suite_port is missing — deleted or never scaffolded; the suite
    would fall back to its silent default (46100) — the port is
    defined in dvt_project.yml and NOWHERE else (no sticky record,
    no dynamic allocation), so a missing key leaves no record of
    where this project serves
    `dvt config-fix` restores the scaffolded key (suite_port: 46100),
    keeping a .bak of dvt_project.yml

3 repairable, 2 suggested, 0 informational (default target: pg_warehouse)
config-checkup only REPORTS — nothing was written.
`dvt config-fix` repairs the 3 provable one(s), each file keeping a .bak.

Three findings carry the 🔧 spanner — provable, and repairable automatically. Two carry 💡 — real issues where more than one repair would be correct, so a human has to pick. Now the repair. Fix prints the same findings with the marks resolved: ✅ means written (with a .bak), 💡 still means yours to decide — and then it asks about those, one at a time:

$ dvt config-fix
✅ models/silver/customer_ltv.sql
    materialized='f_tabel' is not a DVT materialization
    `dvt config-fix` sets it to 'f_table' (the one legal neighbour)
✅ models/silver/orders_enriched.sql
    materialized='table' but its dependencies are federated — the
    engine already coerces this at runtime
    `dvt config-fix` sets it to 'f_table' (the config then says
    what actually runs)
💡 models/staging/stg_payments.sql
    target='pg_analytics' on a standard materialization — dbt runs
    those on the default target only
    federate it (materialized='f_table') or drop the target=
💡 models/staging/stripe.yml
    source 'stripe' has no meta.connection
    name a profiles.yml output — one rule, no exceptions
✅ dvt_project.yml
    suite_port is missing — deleted or never scaffolded; the suite
    would fall back to its silent default (46100) — the port is
    defined in dvt_project.yml and NOWHERE else (no sticky record,
    no dynamic allocation), so a missing key leaves no record of
    where this project serves
    `dvt config-fix` restores the scaffolded key (suite_port: 46100),
    keeping a .bak of dvt_project.yml

3 fixed, 2 suggested, 0 informational (default target: pg_warehouse)

⚠️  these changed ROLE, so their SQL is now in the wrong dialect —
    config-fix owns configs, flip owns transpilation (ruling 239):
      • models/silver/orders_enriched.sql

2 issue(s) need a decision — Enter skips:

💡 models/staging/stg_payments.sql
    target='pg_analytics' on a standard materialization — dbt runs
    those on the default target only
    [1] federate it: materialized='f_table', keep the target (data lands there)
    [2] drop the target= (runs on the default target)
    [Enter] skip — leave as is
    > 2
    ✅ target= dropped — runs on the default target

💡 models/staging/stripe.yml
    source 'stripe' has no meta.connection
    [1] connection: mysql_crm
    [2] connection: pg_warehouse
    [3] connection: stripe_replica
    [Enter] skip — leave as is
    > 3
    ✅ meta.connection: stripe_replica added

2 resolved interactively, 0 left for you

And the re-checkup closes the loop — the same detector that found five issues now finds none:

$ dvt config-checkup
Every config tells the truth — nothing to do.

That's the habit worth forming: config-checkup to see, config-fix to repair, config-checkup again to confirm. Because both commands run one detection, the second checkup is a real verification, not a formality.

F_TABLE CORRECTIONS — MAKING THE CONFIG SAY WHAT ACTUALLY RUNS

The big one. A model written as materialized='table' whose dependencies are federated in fact — it reads a source on a foreign connection, or refs a model that does — already runs federated: the engine coerces it at runtime. The file is lying about what happens. config-fix makes it tell the truth: table becomes f_table, and incremental becomes f_incremental, following the same ref cascade the engine follows.

-- models/silver/orders_enriched.sql — before
{{ config(materialized='table') }}

-- after `dvt config-fix` (original kept as
-- orders_enriched.sql.20260803-102341.bak)
{{ config(materialized='f_table') }}

Only the materialization's own text is rewritten — a target= or tags=[...] in the same config() call is never touched. Even a computed materialization is handled: in materialized=('view' if target.type == 'mysql5' else 'ephemeral'), each materialization literal is substituted where it stands, so both branches become 'f_table' — the conditional goes vacuous and the answer is still right, because a federated model is f_table whichever branch fires. An expression with no literal at all to substitute — materialized=var('mat'), a macro call — is refused by name, never rewritten.

Two kinds of model are exempt on purpose. A model that pins its own target= declared its role deliberately, and fix never coerces it on the authority of a default it opted out of. And an ephemeral whose dependencies are federated is never auto-fixed — that combination blocks dvt parse outright, and its repair is always offered as a choice (see below).

TYPO REPAIRS — PROVABLE ONES ONLY

A materialization string outside the legal set gets fixed automatically only when the intent is provable: either it's a known alias (ftable and f-table mean f_table, fincremental means f_incremental, federated_table means federation_table), or exactly one legal name sits within two edits of it — f_tabel has one nearest neighbour and it's f_table, so that's a repair. A string with no close neighbour, or two equally close ones, is not a repair — it becomes a choice, with the full legal set on the menu. The line between the two is the command's whole personality: provable gets fixed, ambiguous gets asked.

SCAFFOLD-KEY RESTORATION — THE DVT_PROJECT.YML HEALTH GUARD

Every key dvt init scaffolds into dvt_project.ymlmust exist in the file. DVT still runs with one missing — every reader has a default — but the engine's silent default must never be a project's only record of a decision. Checkup flags the missing key; fix restores it at its scaffolded default, appended with a comment saying it was restored and why, and keeps the previous file as a .bak at the project root:

# dvt_project.yml — appended by `dvt config-fix`

# restored by dvt config-fix (ruling 318): this scaffolded key was missing — every dvt init key must exist in this file; the engine's silent default is never the project's record.
suite_port: 46100

Your ordering, your comments and your commented-out experiments are never rewritten — the restore is an append, not a regeneration. The full story of the health doctrine lives on the dvt_project.yml page. One boundary worth knowing: dbt_project.ymlis the user's dbt file, and neither command ever edits it — a DVT key found in there is reported with the exact move to make, and the move is yours.

EVERY WRITE LEAVES A .BAK

No exceptions. Before config-fix rewrites any file — a model, a source yml, dvt_project.yml — it writes the original beside it as <file>.YYYYmmdd-HHMMSS.bak. That backup is your undo: mv it back and the repair never happened.

$ ls models/silver/
customer_ltv.sql
customer_ltv.sql.20260803-102341.bak
orders_enriched.sql
orders_enriched.sql.20260803-102341.bak

The naming is one shared convention across every DVT tool that edits files, and it's collision-proof: two writes to one file in the same second get two distinct backups — a second write never silently clobbers the first. When you trust the changes, dvt clean-bak-files sweeps them all, dry run first.

AMBIGUITIES BECOME CHOICES — NEVER GUESSES

Some issues have more than one correct repair, and picking for you would be a guess dressed as a fix. On a terminal, config-fix walks each one as a numbered prompt. Pressing Enter always means skip, leave as is — mashing through the prompts changes nothing. Four kinds of issue get a prompt:

An ephemeral with federated dependencies. dvt parse blocks this outright — ephemeral cannot carry federated dependencies — so it must be resolved, but to what is your call. The prompt offers the full legal set:

💡 models/staging/stg_orders_union.sql
    materialized='ephemeral' but its dependencies are federated —
    `dvt parse` blocks this (ephemeral cannot carry federated
    dependencies)
    [1] materialized='f_incremental'
    [2] materialized='f_table'
    [3] materialized='federation_incremental'
    [4] materialized='federation_python'
    [5] materialized='federation_table'
    [6] materialized='incremental'
    [7] materialized='table'
    [8] materialized='view'
    [Enter] skip — leave as is
    > 2
    ✅ materialized='f_table'

A target= on a standard materialization. dbt runs standard materializations on the default target only, so the pin does nothing — an illegal combination with two legal exits. Federate it (the model becomes f_table and keeps its target, so data lands there) or drop the target= (it runs on the default).

A source with no meta.connection.One rule, no exceptions — every source names a profiles.yml output. Which one? DVT can't guess, so the prompt lists your profile's actualconnections. The insert is a targeted text edit that preserves the yml's comments and layout.

Needless federation.A federated model whose every dependency provably lives on the default target computes there anyway — but the result still round-trips out through Sling and back in. The prompt offers to convert it to a standard materialization, transpiling its DuckDB SQL to the target's dialect first (the same deterministic machinery flip-target-to uses); the file is untouched unless the transpile fully succeeds. Or keep it federated — also a fine answer.

IN PLAIN DBT

None of these are questions dbt ever asks. A missing source detail fails at run time with a Jinja stack trace; a pointless config just quietly costs you; and resolving any of it means you already knowing every legal value by heart. Here each ambiguity arrives as a menu of the actual legal options — including your own profile's connection names — and skipping is always safe.

ROLE MOVES AND THE REFLIP BOUNDARY

One boundary is absolute: config-fix owns configs, flip owns transpilation. When a repair changes a model's role — table to f_table— that model's SQL is now in the wrong dialect: it was written for the default target, and federated models execute on DVT's federation engine. config-fix will not touch the SQL. It names each such model under the ⚠️ block you saw in the walkthrough, and leaves the body exactly as you wrote it — transpilation belongs to dvt flip-target-to, which runs checkup and fix itself, before and after every flip. Two commands with overlapping authority over the same bytes is how tools drift; DVT keeps exactly one owner per concern, and tells you which owner you need next.

--DRY-RUN, --SILENT, AND CI

--dry-run shows precisely what a real run would repair and writes nothing — the provable findings keep the 🔧 spanner instead of gaining a ✅, because a mark that claims an edit that never happened would be a lie:

$ dvt config-fix --dry-run
🔧 models/silver/customer_ltv.sql
    materialized='f_tabel' is not a DVT materialization
    `dvt config-fix` sets it to 'f_table' (the one legal neighbour)

1 fixed, 0 suggested, 0 informational (default target: pg_warehouse)
dry run — nothing written

--silent drops the interactive prompts and prints the plain report — and any run without a terminal (cron, CI, a server) behaves that way automatically, so an unattended config-fix still applies the provable repairs but never picks an answer to an ambiguous question. The exit code makes it gateable: 0 when nothing is left needing a human, 1 when suggestions remain — so a pipeline step can fail loudly the day a decision is waiting.

Scope either command with patterns when you want a subset: dvt config-fix -s 'silver_*' matches against model names and file paths alike, and -x excludes the same way.

REFERENCE — EVERY FLAG

FLAGDEFAULTWHAT IT DOES
-s, --select PATTERN ...all modelsOnly look at models matching the pattern(s) — matched case-insensitively against both the model name and its file path (fnmatch wildcards).
-x, --exclude PATTERN ...noneSkip models matching the pattern(s); same matching rules as --select.
--project-dir.The DVT project to repair.
--profiles-dirautoWhere profiles.yml lives. Unset, DVT resolves it the dbt way: $DBT_PROFILES_DIR, then the project directory, then ~/.dbt. Given explicitly, only that directory is consulted.
--dry-runoffShow what would be repaired, write nothing. Provable findings keep the 🔧 mark — nothing gains a ✅ it didn't earn.
--silentoffNo interactive prompts — the plain report only. Automatic whenever there's no terminal (cron, CI, server-side runs).

Exit code: 0 when no suggestions remain, 1 when issues are still waiting on a decision.