Maintain
Keep the repository correct as the world underneath it moves, on both of its
axes: the dbt project and the semantic layer. Maintenance is the recurring half
of the loop: warehouses drift, loads half-fail, models go stale, keys stop being
unique, and business definitions change. This skill compares a known-good
baseline against current reality, classifies what drifted, and proposes the
reconciling edit. It is manual and on-demand here; continuous drift detection and
automated PRs are the commercial product.
The model: baseline, detect, reconcile
Drift is measured against a baseline (the .dex/snapshot.json fingerprint of
the warehouse map and the repository's per-layer definitions). Detection is
read-only; only reconcile proposes edits.
The two project layers are fingerprinted independently. The transform layer
comes from the dbt project; the semantic layer comes from whichever vendor
semantic.vendor names, which may be dbt's own or a native format such as
Apache Ossie. A repository with a semantic layer and no dbt project at all still
gets a baseline and still runs every free axis: transform_layer comes back
null, and the warning that names why no project was fingerprinted is reserved for
the case where neither layer answered, since that is the one you could otherwise
mistake for a clean read.
Snapshot discipline matters. A snapshot is only as trustworthy as the moment
it froze. Take one right after a known-good build (maintain snapshot), and
commit .dex/snapshot.json like a lockfile so the whole team diffs against
the same reference. Snapshot a state that is already drifted and check will
mask the very drift you care about. When you accept a change as the new normal
(re-run explore map first, then maintain snapshot); check warns when the
baseline looks stale.
On a warehouse past the rank cutoff, use explore map --full before
snapshotting. Past 50 objects explore map profiles the top 25 by rank and
enters the rest as metadata alone, and the baseline can only compare columns for
objects it has columns for. Snapshotting a partial map is still valid, and the
envelope reports column_detail_count against dataset_count plus a warning
naming what it could not cover, so the gap is visible rather than silently
mistaken for a clean bill.
How to drive it
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]
dex runs its engine through uv, which is a prerequisite and is not installed by
Claude Code. If the shell reports uv: command not found, stop and tell the user
to install it (curl -LsSf https://astral.sh/uv/install.sh | sh, or
brew install uv, or pipx install uv), then re-run. Never fall back to diffing
the warehouse against the project by hand instead: the drift axes and the baseline
comparison live in the engine, so any other path is guesswork.
The first command in a fresh environment installs the engine, so it can take tens
of seconds where later ones take well under a second. --warm pays that install up
front and exits without running anything:
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm
Offer it once at setup. It is not something to run before an ordinary command.
maintain snapshot captures or refreshes the baseline. Run it after a clean
explore or transform session so later runs have a known-good reference. It pins
the current .dex/cache.json (so the grain baseline is the exact-distinct
verdicts explore map already computed) plus per-layer fingerprints of the dbt
project and of the semantic layer. A native semantic layer contributes its
definitions per dataset and per metric, each with a content hash, the relation
behind it, the column each field resolves to, its declared keys in the arity
they were written, and its relationships with every ordered column pair;
whether that side was captured is itself recorded, so a baseline written before
it reports the relationship axis as unchecked rather than clean.
Without a cache it captures a metadata-only baseline and says so. It
also warns when the cache it pinned is thin (objects without column detail) or
older than the profile freshness window, because either makes an "accept
current state" only partly true.
maintain snapshot --project-only is for a project-only refactor, such as
moved model files or a dbt project rename. It refreshes transform and semantic
fingerprints without opening the warehouse, carrying the previous warehouse
evidence and original capture time forward instead. It requires an existing
snapshot and refuses connection-target flags.
maintain check is the everyday entry point: it sweeps every axis and returns
a report ranked by blast radius. Read-only.
maintain schema [<objects>] detects structural drift: source columns and
tables added, dropped, retyped, or renamed; nullability changes; declared
sources the warehouse no longer honors.
maintain volume [<objects>] detects freshness drift: row counts that
collapsed, spiked, or went to zero. This is the "is the data still flowing
correctly?" axis, distinct from "did the shape change?".
maintain grain [<objects>] detects grain drift: a key that now has
duplicates, a changed row-per-entity cardinality, or an increased join fanout.
It also re-verifies the grains the repository declares, which measurement on
its own can miss: a dbt model-level unique_combination_of_columns, and a
semantic layer's own key declarations. A multi-column declaration is measured
as one complete composite and never one column at a time. Uses aggregates,
never raw rows.
A native semantic layer's keys reach this axis and nothing else reaches it for
them, since such a layer is never the transformation project. They go through
the identical billed handshake on a metered warehouse: nothing here is cheaper
or less gated because the declaration came from a document rather than from
dbt.
Two findings come out of the uniqueness checks and the difference is the
baseline. key_lost_uniqueness is a key that was proven unique and is not any
more: something changed in the data. declared_grain_not_unique is a declared
combination that does not hold, and nothing changed at all: the project asserts
a grain the data never had, so the fix is to the declaration (widen it, dedup
upstream, or drop the claim) rather than to the data.
maintain semantic [<objects>] detects definition drift: definitions that
changed, were added, or were removed against the baseline; a source relation
that is gone; a dimension, entity, measure, or declared key naming a column
that is gone; a relationship whose endpoint or column pairs no longer resolve,
which is high because a join nothing can resolve is a broken layer rather
than a stale one; and categorical dimensions whose set of values widened or
narrowed underneath their metrics.
Read unavailable on the layer before hunting for an element kind. A native
Ossie layer has no measures and no entities at all, so their absence is the
format rather than drift. Its cardinality half also never fires, because that
check needs a semantic model naming a transformation model and Ossie names
none: on such a layer this command is free and offers no scan.
maintain verify [<selector>] answers a different question from every command
above it: not "what changed since the baseline" but "is this project right
now", and it needs no baseline at all, so it works on a project that was
never correct and on one somebody else just built. Two classes of finding.
Build status: nodes that failed, nodes skipped because a parent failed (naming
the one that actually failed), nodes that warned rather than failed, and models
the project declares that built no relation. A warning ranks low deliberately:
a project that runs relationship tests at severity: warn over documented gaps
has warnings by design, so this is a list to compare against last run's rather
than a defect on its own. What it must not be is missing, which is what leaves
a caller counting statuses in a run's raw node list to find out which tests
warned. Row population: row_loss where a model holds materially fewer rows
than its driving parent (the relation in its FROM clause, followed through
the CTE chain, as distinct from anything it joins) and nothing in its SQL
accounts for the shortfall, and row_fanout where it holds materially more,
each naming the join and its key and stating both counts.
Row population is conservative on purpose. A model with a WHERE, GROUP BY,
DISTINCT, QUALIFY, LIMIT, a semi or anti join, or a set operation was
written to hold a different number of rows and is never reported for loss; an
incremental model is skipped outright. So a quiet answer here is weaker
evidence than a finding, and the warnings say which models could not be
lined up at all.
A project that does not compile is reported first and suppresses everything
else, since a manifest a broken project could not have produced is not
evidence. Read data.suppressed before reading an empty data.findings as a
clean bill of health.
The same sweep runs from the other side of the loop, as
transform build --verify, scoped to the nodes one build touched. Use that
when the question is whether a change you just made is right; use this one
when the question is the whole project, or when the build was somebody
else's.
maintain reconcile [<class>] proposes the dbt edits that bring the project
back in sync, as reviewable diffs. Optionally scope it to one class (schema,
volume, grain, or semantic). It composes every layer's declarations
first, so a grain the semantic layer already declares is not proposed as though
nothing declared it. Where there is no editable dbt project it has nothing to
author: every proposal is advisory and no plan is stored. Authoring into a
native semantic layer is semantic ossie in the transform skill, never this
command.
The usual flow: check to triage, a focused detector to understand one axis in
depth, then reconcile to get the proposed fix. With no baseline, or on a
project whose numbers were never right, start at verify instead: it is the one
command here that does not need a snapshot, and it answers "is this right"
rather than "what moved".
Per-axis cost: what is free and what scans
Detection is read-only, but read-only is not the same as free on a metered
connector (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse).
The axes split:
- Schema, volume, and the reference/definition half of semantic are free
everywhere: they read metadata and the snapshot, and run immediately.
- Grain and the dimension-cardinality half of semantic scan the warehouse, so
on a metered connector they run the two-step handshake. Asked for directly,
maintain grain returns needs_confirmation with an estimate in
cost.estimate (and a per-table breakdown). Surface it to the user in human
units, get an explicit budget, and re-issue the same command with
--confirm --budget <magnitude> in the paradigm's unit (bytes on BigQuery,
warehouse-seconds on Snowflake and Databricks, compute-seconds on Redshift,
database-seconds on Postgres and
ClickHouse). Never invent a budget the user did not agree
to, and never retry with a raised budget on an over-ceiling refusal without
asking. An over-ceiling
refusal carries a calibration line from .dex/spend.jsonl (what this
connector's recent commands billed as a fraction of estimate, or a sentence
saying there is too little history to say): relay it, and note that the
ceiling binds on the estimate, so a budget set at that fraction of the
estimate is refused again.
verify is free except for the counts a warehouse does not keep. Its
build-status findings read artifacts on disk, and its row counts come from
object metadata. A view has no stored row count anywhere, and a view is dbt's
default materialization, so on a metered connector those counts are batched
into one aggregate-only statement, priced, and returned in data.offer beside
findings that are already final. On DuckDB there is no gate, so every count is
measured rather than estimated and the findings come back exact.
check, semantic and verify answer first and offer second. Their free axes
complete on every call, so the envelope is ok and the findings in it are
final. The price of the scanning axes sits in data.offer, with axes naming
what it would add; data.axes_run names what already ran. Confirming is a
choice, not a required next step: quote the estimate, say which axes are still
dark, and let the user decide. A triage pass that stops at the free axes is a
complete piece of work, not an abandoned one.
- Read
warnings on these responses, always. They carry the reasons the
baseline may no longer describe the warehouse (a cache newer than the
snapshot, a baseline pinned from a stale cache), which bound every finding
above them. A stale baseline is often the most important line in the response
and it is never in findings.
On DuckDB everything is free and local, so nothing prompts.
A needs_confirmation envelope carrying suggested_session_ceiling is the
project's one-time ask for a cumulative daily cap, separate from the
per-command --budget. Surface it, get the user's answer, and add
--session-ceiling <value> or --no-session-ceiling to the same re-issue; it is
written to .dex/config.yml once and never asked again. Never answer it for
them.
Reconcile proposals are mechanical or advisory
Reconcile tags every proposal by kind, because the fix differs sharply by axis:
mechanical: schema drift reconciles in one of two shapes. On a
dex-scaffolded staging model it re-scaffolds the model from the drifted source;
on a project format that places a declaration but authors no staging model, it
edits the drifted columns into that declaration and says so. High-confidence, but
still a reviewable diff: read it for hand-written logic the scaffold cannot know
about.
advisory: grain, volume, and semantic drift are decisions, not auto-fixes
(dex cannot dedup your warehouse or decide whether a new 'refunded' status
belongs in a metric). The proposal is the decision surfaced, at most backed by a
test edit that makes the break visible in builds. It declines that test where
the test would be wrong: if your model declares a composite grain covering the
column, no column-level unique is proposed on it, and the warning names the
combination so you can tell "re-baseline, this is still the grain" from
"something relied on that column alone".
A type change is advisory on every format. Nothing dex writes declares a type,
and the type it holds is the connector's own spelling rather than a canonical one
(Snowflake reports NUMBER(38,0) and NUMBER(10,2) both as FIXED), so the
proposal names both spellings and the edit is yours. One consequence to know: on
ClickHouse nullability is part of the type, so a column that starts accepting nulls
is reported as a retype and gets advice where other connectors get an edit.
When reconcile produces edits it stores them as a plan and prints a plan_id.
Apply them with transform apply <plan-id> (the one apply door): a human edit made
since detection surfaces as a conflict, never a silent overwrite.
Guardrails (enforced in the engine, not here)
- Read-only against data. Schema, volume, and semantic references are computed from
metadata and the snapshot; grain and dimension-cardinality use aggregates only.
Raw rows and dimension values never cross the envelope.
- Propose, don't impose. Reconciliation is always a reviewable diff, applied
through
transform apply. Human edits to the project and to the semantic layer
are authoritative; on conflict the engine surfaces the divergence and asks
rather than overwriting.
- The repository is the source of truth, on both axes; the
.dex/ snapshot is a
non-canonical fingerprint used only to detect change.
1---2name: maintain3description: Use this to keep a dbt project and its semantic layer correct as the warehouse and the business change, including a semantic layer that is native Apache Ossie documents rather than dbt. It detects drift on four axes and proposes the fix: schema drift (source columns and tables added, dropped, retyped, or renamed), volume drift (a row count that collapsed, a table that emptied, a load that half-failed), grain drift (a key that lost uniqueness, a changed row-per-entity cardinality, an increased join fanout), and semantic drift (a metric, measure, dimension, or entity definition that no longer matches, new categorical values, dangling semantic references). Reach for this when something that used to work has started failing or producing different numbers and the cause is more likely upstream than in the code you just wrote: a test that began failing with no code change, a dashboard whose numbers moved, a model that is suddenly empty or duplicated. Trigger it for requests like "what changed in the warehouse", "did4---56# Maintain78Keep the repository correct as the world underneath it moves, on both of its9axes: the dbt project and the semantic layer. Maintenance is the recurring half10of the loop: warehouses drift, loads half-fail, models go stale, keys stop being11unique, and business definitions change. This skill compares a known-good12baseline against current reality, classifies what drifted, and proposes the13reconciling edit. It is manual and on-demand here; continuous drift detection and14automated PRs are the commercial product.1516## The model: baseline, detect, reconcile1718Drift is measured against a **baseline** (the `.dex/snapshot.json` fingerprint of19the warehouse map and the repository's per-layer definitions). Detection is20read-only; only reconcile proposes edits.2122**The two project layers are fingerprinted independently.** The transform layer23comes from the dbt project; the semantic layer comes from whichever vendor24`semantic.vendor` names, which may be dbt's own or a native format such as25Apache Ossie. A repository with a semantic layer and no dbt project at all still26gets a baseline and still runs every free axis: `transform_layer` comes back27null, and the warning that names why no project was fingerprinted is reserved for28the case where neither layer answered, since that is the one you could otherwise29mistake for a clean read.3031**Snapshot discipline matters.** A snapshot is only as trustworthy as the moment32it froze. Take one right after a known-good build (`maintain snapshot`), and33**commit `.dex/snapshot.json` like a lockfile** so the whole team diffs against34the same reference. Snapshot a state that is already drifted and `check` will35mask the very drift you care about. When you accept a change as the new normal36(re-run `explore map` first, then `maintain snapshot`); `check` warns when the37baseline looks stale.3839**On a warehouse past the rank cutoff, use `explore map --full` before40snapshotting.** Past 50 objects `explore map` profiles the top 25 by rank and41enters the rest as metadata alone, and the baseline can only compare columns for42objects it has columns for. Snapshotting a partial map is still valid, and the43envelope reports `column_detail_count` against `dataset_count` plus a warning44naming what it could not cover, so the gap is visible rather than silently45mistaken for a clean bill.4647## How to drive it4849```bash50uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]51```5253dex runs its engine through `uv`, which is a prerequisite and is not installed by54Claude Code. If the shell reports `uv: command not found`, stop and tell the user55to install it (`curl -LsSf https://astral.sh/uv/install.sh | sh`, or56`brew install uv`, or `pipx install uv`), then re-run. Never fall back to diffing57the warehouse against the project by hand instead: the drift axes and the baseline58comparison live in the engine, so any other path is guesswork.5960The first command in a fresh environment installs the engine, so it can take tens61of seconds where later ones take well under a second. `--warm` pays that install up62front and exits without running anything:6364```bash65uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm66```6768Offer it once at setup. It is not something to run before an ordinary command.6970- `maintain snapshot` captures or refreshes the baseline. Run it after a clean71 explore or transform session so later runs have a known-good reference. It pins72 the current `.dex/cache.json` (so the grain baseline is the exact-distinct73 verdicts `explore map` already computed) plus per-layer fingerprints of the dbt74 project and of the semantic layer. A native semantic layer contributes its75 definitions per dataset and per metric, each with a content hash, the relation76 behind it, the column each field resolves to, its declared keys in the arity77 they were written, and its relationships with every ordered column pair;78 whether that side was captured is itself recorded, so a baseline written before79 it reports the relationship axis as unchecked rather than clean.80 Without a cache it captures a metadata-only baseline and says so. It81 also warns when the cache it pinned is thin (objects without column detail) or82 older than the profile freshness window, because either makes an "accept83 current state" only partly true.84- `maintain snapshot --project-only` is for a project-only refactor, such as85 moved model files or a dbt project rename. It refreshes transform and semantic86 fingerprints without opening the warehouse, carrying the previous warehouse87 evidence and original capture time forward instead. It requires an existing88 snapshot and refuses connection-target flags.89- `maintain check` is the everyday entry point: it sweeps every axis and returns90 a report ranked by blast radius. Read-only.91- `maintain schema [<objects>]` detects **structural drift**: source columns and92 tables added, dropped, retyped, or renamed; nullability changes; declared93 sources the warehouse no longer honors.94- `maintain volume [<objects>]` detects **freshness drift**: row counts that95 collapsed, spiked, or went to zero. This is the "is the data still flowing96 correctly?" axis, distinct from "did the shape change?".97- `maintain grain [<objects>]` detects **grain drift**: a key that now has98 duplicates, a changed row-per-entity cardinality, or an increased join fanout.99 It also re-verifies the grains the repository *declares*, which measurement on100 its own can miss: a dbt model-level `unique_combination_of_columns`, and a101 semantic layer's own key declarations. A multi-column declaration is measured102 as one complete composite and never one column at a time. Uses aggregates,103 never raw rows.104105 A native semantic layer's keys reach this axis and nothing else reaches it for106 them, since such a layer is never the transformation project. They go through107 the identical billed handshake on a metered warehouse: nothing here is cheaper108 or less gated because the declaration came from a document rather than from109 dbt.110111 Two findings come out of the uniqueness checks and the difference is the112 baseline. `key_lost_uniqueness` is a key that was proven unique and is not any113 more: something changed in the data. `declared_grain_not_unique` is a declared114 combination that does not hold, and nothing changed at all: the project asserts115 a grain the data never had, so the fix is to the declaration (widen it, dedup116 upstream, or drop the claim) rather than to the data.117- `maintain semantic [<objects>]` detects **definition drift**: definitions that118 changed, were added, or were removed against the baseline; a source relation119 that is gone; a dimension, entity, measure, or declared key naming a column120 that is gone; a relationship whose endpoint or column pairs no longer resolve,121 which is `high` because a join nothing can resolve is a broken layer rather122 than a stale one; and categorical dimensions whose set of values widened or123 narrowed underneath their metrics.124125 Read `unavailable` on the layer before hunting for an element kind. A native126 Ossie layer has no measures and no entities at all, so their absence is the127 format rather than drift. Its cardinality half also never fires, because that128 check needs a semantic model naming a transformation model and Ossie names129 none: on such a layer this command is free and offers no scan.130- `maintain verify [<selector>]` answers a different question from every command131 above it: not "what changed since the baseline" but **"is this project right132 now"**, and it needs no baseline at all, so it works on a project that was133 never correct and on one somebody else just built. Two classes of finding.134 Build status: nodes that failed, nodes skipped because a parent failed (naming135 the one that actually failed), nodes that warned rather than failed, and models136 the project declares that built no relation. A warning ranks low deliberately:137 a project that runs relationship tests at `severity: warn` over documented gaps138 has warnings by design, so this is a list to compare against last run's rather139 than a defect on its own. What it must not be is missing, which is what leaves140 a caller counting statuses in a run's raw node list to find out which tests141 warned. Row population: `row_loss` where a model holds materially fewer rows142 than its **driving parent** (the relation in its FROM clause, followed through143 the CTE chain, as distinct from anything it joins) and nothing in its SQL144 accounts for the shortfall, and `row_fanout` where it holds materially more,145 each naming the join and its key and stating both counts.146147 Row population is conservative on purpose. A model with a `WHERE`, `GROUP BY`,148 `DISTINCT`, `QUALIFY`, `LIMIT`, a semi or anti join, or a set operation was149 written to hold a different number of rows and is never reported for loss; an150 incremental model is skipped outright. So a quiet answer here is weaker151 evidence than a finding, and the `warnings` say which models could not be152 lined up at all.153154 A project that does not compile is reported first and suppresses everything155 else, since a manifest a broken project could not have produced is not156 evidence. Read `data.suppressed` before reading an empty `data.findings` as a157 clean bill of health.158159 The same sweep runs from the other side of the loop, as160 `transform build --verify`, scoped to the nodes one build touched. Use that161 when the question is whether a change you just made is right; use this one162 when the question is the whole project, or when the build was somebody163 else's.164- `maintain reconcile [<class>]` proposes the dbt edits that bring the project165 back in sync, as reviewable diffs. Optionally scope it to one class (`schema`,166 `volume`, `grain`, or `semantic`). It composes every layer's declarations167 first, so a grain the semantic layer already declares is not proposed as though168 nothing declared it. Where there is no editable dbt project it has nothing to169 author: every proposal is advisory and no plan is stored. Authoring into a170 native semantic layer is `semantic ossie` in the transform skill, never this171 command.172173The usual flow: `check` to triage, a focused detector to understand one axis in174depth, then `reconcile` to get the proposed fix. With no baseline, or on a175project whose numbers were never right, start at `verify` instead: it is the one176command here that does not need a snapshot, and it answers "is this right"177rather than "what moved".178179## Per-axis cost: what is free and what scans180181Detection is read-only, but read-only is not the same as free on a metered182connector (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse).183The axes split:184185- **Schema, volume, and the reference/definition half of semantic are free**186 everywhere: they read metadata and the snapshot, and run immediately.187- **Grain and the dimension-cardinality half of semantic scan the warehouse**, so188 on a metered connector they run the two-step handshake. Asked for directly,189 `maintain grain` returns `needs_confirmation` with an estimate in190 `cost.estimate` (and a per-table breakdown). Surface it to the user in human191 units, get an explicit budget, and re-issue the same command with192 `--confirm --budget <magnitude>` in the paradigm's unit (bytes on BigQuery,193 warehouse-seconds on Snowflake and Databricks, compute-seconds on Redshift,194 database-seconds on Postgres and195 ClickHouse). Never invent a budget the user did not agree196 to, and never retry with a raised budget on an over-ceiling refusal without197 asking. An over-ceiling198 refusal carries a calibration line from `.dex/spend.jsonl` (what this199 connector's recent commands billed as a fraction of estimate, or a sentence200 saying there is too little history to say): relay it, and note that the201 ceiling binds on the estimate, so a budget set at that fraction of the202 estimate is refused again.203- **`verify` is free except for the counts a warehouse does not keep.** Its204 build-status findings read artifacts on disk, and its row counts come from205 object metadata. A view has no stored row count anywhere, and a view is dbt's206 default materialization, so on a metered connector those counts are batched207 into one aggregate-only statement, priced, and returned in `data.offer` beside208 findings that are already final. On DuckDB there is no gate, so every count is209 measured rather than estimated and the findings come back `exact`.210- **`check`, `semantic` and `verify` answer first and offer second.** Their free axes211 complete on every call, so the envelope is `ok` and the findings in it are212 final. The price of the scanning axes sits in `data.offer`, with `axes` naming213 what it would add; `data.axes_run` names what already ran. Confirming is a214 choice, not a required next step: quote the estimate, say which axes are still215 dark, and let the user decide. A triage pass that stops at the free axes is a216 complete piece of work, not an abandoned one.217- **Read `warnings` on these responses, always.** They carry the reasons the218 baseline may no longer describe the warehouse (a cache newer than the219 snapshot, a baseline pinned from a stale cache), which bound every finding220 above them. A stale baseline is often the most important line in the response221 and it is never in `findings`.222223On DuckDB everything is free and local, so nothing prompts.224225A `needs_confirmation` envelope carrying `suggested_session_ceiling` is the226project's one-time ask for a *cumulative* daily cap, separate from the227per-command `--budget`. Surface it, get the user's answer, and add228`--session-ceiling <value>` or `--no-session-ceiling` to the same re-issue; it is229written to `.dex/config.yml` once and never asked again. Never answer it for230them.231232## Reconcile proposals are mechanical or advisory233234Reconcile tags every proposal by `kind`, because the fix differs sharply by axis:235236- **`mechanical`**: schema drift reconciles in one of two shapes. On a237 dex-scaffolded staging model it re-scaffolds the model from the drifted source;238 on a project format that places a declaration but authors no staging model, it239 edits the drifted columns into that declaration and says so. High-confidence, but240 still a reviewable diff: read it for hand-written logic the scaffold cannot know241 about.242- **`advisory`**: grain, volume, and semantic drift are decisions, not auto-fixes243 (dex cannot dedup your warehouse or decide whether a new `'refunded'` status244 belongs in a metric). The proposal is the decision surfaced, at most backed by a245 test edit that makes the break visible in builds. It declines that test where246 the test would be wrong: if your model declares a composite grain covering the247 column, no column-level `unique` is proposed on it, and the warning names the248 combination so you can tell "re-baseline, this is still the grain" from249 "something relied on that column alone".250251**A type change is advisory on every format.** Nothing dex writes declares a type,252and the type it holds is the connector's own spelling rather than a canonical one253(Snowflake reports `NUMBER(38,0)` and `NUMBER(10,2)` both as `FIXED`), so the254proposal names both spellings and the edit is yours. One consequence to know: on255ClickHouse nullability is part of the type, so a column that starts accepting nulls256is reported as a retype and gets advice where other connectors get an edit.257258When reconcile produces edits it stores them as a plan and prints a `plan_id`.259Apply them with `transform apply <plan-id>` (the one apply door): a human edit made260since detection surfaces as a conflict, never a silent overwrite.261262## Guardrails (enforced in the engine, not here)263264- Read-only against data. Schema, volume, and semantic references are computed from265 metadata and the snapshot; grain and dimension-cardinality use aggregates only.266 Raw rows and dimension values never cross the envelope.267- Propose, don't impose. Reconciliation is always a reviewable diff, applied268 through `transform apply`. Human edits to the project and to the semantic layer269 are authoritative; on conflict the engine surfaces the divergence and asks270 rather than overwriting.271- The repository is the source of truth, on both axes; the `.dex/` snapshot is a272 non-canonical fingerprint used only to detect change.