Shape of the boundary
- A surface (SKILL.md or AGENTS.md) tells the agent which subcommand to run.
- A thin PEP 723 wrapper (
skills/<skill>/scripts/run.py) runs it viauv run --no-projectagainst the pinned engine version, installing the connector extra it resolves at runtime (an explicit--connector, then theconnector:in the.dex/config.ymlfound by walking up from the run directory to the git root, then DuckDB), so the pin stays connector-neutral.uvis therefore a prerequisite, and the wrapper holds the envelope contract even there: with nouvonPATHit refuses with areason: prerequisiteerror envelope naming the install command, rather than failing the exec. It is the one refusal built by hand, because the engine that would otherwise build it is what is missing. Two commands need more than a warehouse client, and both are resolved the same way, from the command being run rather than installed always:explore clusteradds[cluster], andexplore semanticadds[semantic-api]plus, where a statement might be rendered locally (any mode butlist, without--api),[semantic]. A repo that runs neither resolves neither scikit-learn nor MetricFlow. - That environment is uv’s own, and
--no-projectis what keeps it so. Without it uv discovers whatever Python project the caller is standing in, builds it, leaves a.venv/and auv.lockin their repo, and puts their dependencies on the engine’s import path: unreviewed writes into a tree dex was asked only to read, and an engine no longer running against the closure it pinned. The invariant is asserted structurally on the commands the wrapper builds, in the safety spine. --warmis the wrapper’s own flag and the only one it answers itself, stripped before the argv reaches the engine. It resolves extras through the same path a real run does, installs them, prints one envelope naming what it installed, and exits without running a command. That is what lets a container build, a CI setup step, or a first-time install pay a cold resolution once instead of leaving it on an interactive caller’s clock, and resolving through the shared path is what keeps it from warming an environment the next command would contradict.- The engine prints exactly one sanitized JSON envelope to stdout and nothing else. Diagnostics go to stderr.
- Every envelope carries a constant
connectionblock. On success it names the resolved connector, its non-secret target coordinates, and the source of that resolution (flag,.dex/config.yml,environment variable,dbt profiles.yml, ordirectory-local inference). Commands that resolve no warehouse keep the same shape with null/empty fields. The block is assembled from facts already resolved by the command and never opens a connection or spends merely to describe one. DBT_PROFILES_DIRlocatesprofiles.ymlfor dbt operations and the last-resort credential fallback. It does not select dex’s connector or override--connector/--pathor.dex/config.yml; help and no-connector refusals say so explicitly.- The agent reads the envelope and decides the next step.
.dex/ cache, so
subcommands are stateless: the agent orchestrates multi-step flows by
re-reading them between calls. Credentials never cross this boundary, and nothing
reaches agent context except through the sanitized envelope: values cross only
from profiled, PII-cleared columns, bounded and capped by the query firewall.
The command surface
Capabilities, not final spelling. Implemented incrementally:demo, connect test,
the config group, the explore group, the authoring surface (transform,
semantic), and the maintain group are live; viz preview returns a valid
not_implemented envelope until the Viz integration lands.
How authored content reaches the engine
The engine has no model of its own; the agent authors the dbt file content and hands it over via--edits-file <path> (or - for stdin), a JSON payload:
kind is one of model_sql, schema_yml, semantic_yml (optional on
semantic define|update, which imply semantic_yml), packages_yml,
macro_sql, snapshot_sql, seed_csv, test_sql, analysis_sql,
project_yml, profiles_yml, or semantic_document (optional on semantic ossie define|update|plan). Each edit also has an op:
upsert (create or update, the default, carrying content) or delete (remove
the file, no content). A delete is a reviewable diff pinned to the file’s hash
like any other edit, and it is guarded: the plan is refused if any surviving file
still ref()s a deleted model (the offenders are named, with the line), so the
post-deletion project is proven free of dangling references before the plan is
stored, and, when dbt is available, the same post-deletion tree is confirmed by
dbt’s parser. The guard reads the reference index behind transform references,
which is why it sees the two-argument ref('package', 'model') form, why a seed’s
data rows no longer count as source, and why a reference dex could not resolve
statically warns rather than refusing: it may or may not name the deleted node,
and no edit the caller could make would settle it.
A rename is one plan: delete the old model, create the new, update the
referrers, validated together. transform rename generates exactly that plan, and
the rest of the rename’s edits with it, so hand-assembling one is now the fallback
rather than the route. Note the two guards read the same index and answer
differently on purpose: a reference dex cannot resolve warns on a delete and
refuses on a rename. A dangling dynamic ref left by a delete is unsatisfiable,
so refusing would block a legitimate delete forever; the same reference in a
rename’s path is satisfiable, because the caller can resolve it by hand and
re-run. The engine validates each edit
(model SQL must be a single read-only SELECT once jinja is stripped; YAML must
parse; semantic YAML must satisfy MetricFlow’s schemas; a packages_yml edit
must carry a packages: or dependencies: list and targets the project-root
packages.yml or dependencies.yml; a macro_sql edit must hold only macro
definitions and jinja comments and must target the project’s macro paths, where
no other kind may go; a snapshot_sql edit must hold exactly one
{% snapshot %} block whose config() names a unique_key and a strategy of
timestamp (with updated_at) or check (with check_cols), whose body is a
single read-only SELECT, and must target the project’s snapshot paths; a
seed_csv edit must parse as CSV with a named, duplicate-free header and one
field per column on every row, must stay under 5,000 data rows and 1 MiB, must
not name columns that look like personal data, and must target the project’s
seed paths; a test_sql edit must target the project’s test paths and must be
either a generic test definition (only {% test %} blocks and jinja comments,
balanced) or a singular test that is a single read-only SELECT once its jinja is
stripped; an analysis_sql edit must target the project’s analysis paths and be
a single read-only SELECT on the same terms; a project_yml edit targets the
project-root
dbt_project.yml and must keep a name; a profiles_yml edit targets the
project-root profiles.yml and must reference every secret via
{{ env_var('NAME') }}, never a literal), pins it to the sha256 of the file it
would change, computes the diffs, and stores the plan under
.dex/plans/<plan-id>.json. Nothing touches the dbt project until an apply.
packages_yml is the guarded way to declare dbt package dependencies: a
reviewable diff like any other edit, then transform deps (or the automatic
deps step in transform build) installs them. macro_sql is how a macro is
repaired or customized by hand; transform macro <name> is the scaffolding path
for the macros dex ships. snapshot_sql and seed_csv bring slowly-changing
dimension capture and small reference tables into the same flow, each confined to
its own path family (snapshot-paths and seed-paths in dbt_project.yml, read
with dbt’s own defaults) and each gated by dbt’s parser at plan time. A
schema_yml edit is accepted beside either one, which is where dbt expects a
seed’s column types and a snapshot’s tests and docs to be declared. dbt build
runs seeds and snapshots natively, so nothing new has to be run after an apply;
a snapshot writes a table and is priced in the cost handshake, while a seed
loads a local CSV, scans nothing, and is deliberately unpriced.
test_sql and analysis_sql do the same for the two remaining families
(test-paths and analysis-paths, again with dbt’s defaults). A singular test
is an arbitrary SELECT that must return no rows, which is where most
project-specific assertions live, and generic test definitions share the same
directory, so test_sql reads the file to tell them apart: a {% test %} block
is checked structurally like a macro, anything else is checked like a model. A
singular test naming no ref() or source() warns rather than refuses, since it
runs against nothing and passes unconditionally. An analysis is held to the same
read-only SELECT even though dbt only ever compiles it, because read-only against
data describes what dex writes rather than what dbt runs. schema_yml is
accepted beside each. Neither kind builds a relation and nothing can ref()
either, so neither is a node: neither enters the drift baseline, and deleting one
raises no dangling-reference guard. dbt build runs a singular test natively and
prices it like any other test; an analysis is compiled by dbt compile, never
built, and costs nothing.
Three different things here are called a test. Generic tests are declared
inside a schema.yml (data_tests: on a model or a column). Unit tests are
scaffolded by transform test --scaffold <model> into a unit_tests: block,
also schema_yml. Singular tests and generic test definitions are files under
test-paths, and those are what test_sql authors. transform test --mutate
measures all three together, because dbt runs all three and a defect only has to
get past every one of them to ship.
transform test --mutate <model>: what the tests are worth
A passing suite says the tests ran. It does not say they would notice if the
model were wrong, and no count of tests distinguishes the two. This plants one
defect at a time in the model’s compiled SQL and reports which ones nothing
caught.
The defect classes are the ones that recur in analytics code: a boundary
comparison flipped to include or exclude its edge, a WHERE predicate dropped or
negated, an inner join swapped for a left join or the reverse, a CASE branch
removed, a ratio inverted, a window frame bound shifted by one, and sum
swapped with max. Each finding is written as the defect rather than as a diff,
and carries suggested_test, because the reader’s next action is to write a
test.
Where a mutant lives. In a throwaway copy of the project, and nowhere else.
Each one is written with materialized='ephemeral', so dbt inlines it into each
test as a CTE and materializes nothing: no relation is created, replaced or
dropped, and there is nothing to clean up afterwards. The run uses dbt test
rather than dbt build, which is what keeps a failing unit test from skipping
the model and cascading that skip onto every data test attached to it. The
project’s on-run-start and on-run-end hooks are stripped from the copy,
since dbt is invoked once per mutant and a hook that grants or audits should not
fire N+1 times; a note says so when it happens.
What the verdicts mean. killed is a defect at least one test caught, named
in caught_by. survived is one nothing caught. rejected is one the warehouse
refused outright, kept separate because a build would have failed on it anyway
and counting it as caught would flatter the suite. not_run is a mutant the
budget stopped. Every verdict is relative to the tests that passed against the
unmutated model, and any test that did not is listed in baseline.excluded with
the reason, so a suite measured against its own broken tests cannot read as
clean. A run where nothing passes at baseline is an error, not a clean sweep.
Equivalent on the dev data. A survivor is not always a gap in the
assertions. Some mutants produce exactly the model’s rows on the data the dev
target holds (an inner join where every key resolves, a window frame no
partition is long enough to reach), and no test over that data could tell them
apart. So each survivor is compared with the unmutated model on the dev target,
and equivalence on the mutant carries the answer: status, then
only_in_mutant and only_in_baseline (rows one side has that the other does
not, counted as a multiset, so duplicated rows count), and mutant_rows and
baseline_rows. Counts only; no row value leaves the warehouse. equivalent
means both difference counts are zero, so only a unit test fixture that reaches
the case can catch it. distinguishable means the data already differs, so an
assertion over that data would catch it today. Distinguishable survivors are
listed first, then any that could not be compared, then equivalent ones, and
suggested_test follows the label: a fixture for an equivalent survivor, an
assertion for a distinguishable one. not_checked carries a reason. An
equivalent survivor is still survived and still counts in score, since a
fixture can catch it.
Before any survivor is compared, the unmutated model is compared with itself.
A model that does not reproduce its own output (a random(), a uuid, a
row_number() over ties) would make every mutant look different from it, so in
that case no survivor is labelled and data.equivalence.self_check says
not_reproducible. data.equivalence also reports checked, the
distinguishable, equivalent and not_checked counts, and a reason when
the comparison did not run. The comparison is free and automatic on DuckDB. On
BigQuery and Snowflake it is opt-in, with --check-equivalence, because it is
spend; --no-check-equivalence turns it off anywhere. Other connectors report
survivors as not_checked until dex has a verified whole-row comparison for
them.
Cost. Free on DuckDB. On a metered connector each mutant is priced as the
statements the warehouse will actually run, by splicing it into each test’s
compiled SQL, because a mutant that drops a partition predicate scans more than
the model it came from. The whole batch is one estimate and one confirmation:
per_table_bytes names (baseline) and each mutant, so the caller sees the
total and the breakdown before anything executes. With --check-equivalence,
two more lines join the same estimate: (equivalence self-check) and
(equivalence checks, if every mutant survives). The checks are priced as if
every mutant survives, because the suite’s strength is not known until the run,
so the settled spend is at most that line. If the confirmed budget runs out
partway, the run stops and the remaining mutants are reported not_run rather
than the budget being exceeded. Spend settles per run under
command: "transform test" in the ledger, each comparison included.
The cap. 20 mutants, and --max-mutants may only lower it. Mutants are
ordered round robin across the defect classes, so a capped run on a model with
forty comparisons and one join still tests the join; what the cap cut is reported
in data.cap.elided, per class.
seed_csv is the first kind that puts values, not logic, into a reviewable
diff, and a diff goes into git and stays there. So a seed’s header is checked
both against the PII detector explore profiles warehouse columns with and
against the flags already in the .dex/ cache. A column at or above the
blocking threshold is refused, and the refusal names the pii_overrides entry
that would clear it, never a value (�4). The
standing limit is worth knowing: dex detects PII from names and types and never
from values, everywhere, so a seed column named code full of email addresses
passes this gate.
project_yml and profiles_yml bring the two
project-root config files into the same plan/diff/apply flow; because they carry
project-wide settings and connection targets, they are gated by dbt’s own parser
at plan time, and a profiles_yml edit is refused if it (or the file it would
replace) inlines a literal credential, so no secret ever reaches the diff.
-
transform init "<name>" --connector <duckdb|snowflake|bigquery|databricks|postgres>bootstraps a dbt project when none exists:dbt_project.yml,models/staging/andmodels/marts/, and a project-localprofiles.ymlwith a singledevtarget wired to the warehouse dex already knows (--path, orduckdb.pathin.dex/config.yml). The profile is written owner-only (0600), as is anyprofiles.ymla later apply rewrites: dex renders onlyenv_var()references and key paths into it, but it is the file a password gets pasted into. It is strictly additive (any existing dbt project is a refusal, so nothing is ever overwritten), and everything created is reported ascreatediffs. Init never defaults the connector: the DuckDB on-ramp a read-only command may take (an explicit--path, or a committed config that omitsconnector:) is wrong here, because init bakes the connector into the generated profile.--connectorwins, aconnector:already committed in.dex/config.ymlis accepted (the envelope names which source was used), and bare init is an error listing the valid connectors. On success init writesconnector,dbt_project_dir, anddbt_target: devback to.dex/config.yml, so the choice is made once and is ambient for every later command. Every connector renders: DuckDB, BigQuery, Snowflake, Databricks, Postgres, and Redshift.--layered-schemasopts into per-layer schema routing: init additionally scaffoldsmodels/intermediate/, the shippedgenerate_schema_namemacro override, and amodels:block with+schema: staging|intermediate|marts. The override composes<custom schema>_<target name>(layer first, target last), so a dev build lands instaging_dev/intermediate_dev/marts_dev; a model with no custom schema falls back totarget.schema. On BigQuery the layer namespaces are sibling datasets in the profile’s project; on Snowflake and Databricks they are sibling schemas inside the dev database/catalog; on Postgres and Redshift sibling schemas in the database; on DuckDB schemas inside the target file. The same macro is available to existing projects viatransform macro generate_schema_name. Init also runs a content preflight over every namespace the new project would build into (the base dev namespace, plus each layer namespace when--layered-schemasis on): a free, metadata-only listing on every connector (never a query, never a warehouse wake-up). A namespace that already exists with tables or views produces a warning naming it, the object count, and up to five object names, because a later build would write alongside that content and replace same-named relations; init is the one point where the name is still trivial to change. It is advisory by design: init still succeeds, an empty or absent namespace stays silent, and when dex cannot open a connection the check degrades to a single note (init is credential-optional and stays that way). DuckDB’s base namespace is exempt, since the dev target is the same file as the source warehouse; only the layer schemas are probed there. -
transform planalso accepts--scaffold <table>(repeatable): a deterministic staging skeleton (stg_<table>.sqlplus per-model YAML with key tests underdata_tests:and PII flags in model and columnconfig.meta) generated from the.dex/cache. -
transform planreports the row-population consequence of an edit to a model that already exists, underdata.row_attribution. Validation proves an edit is well formed; this is the only plan-time check that asks whether it behaves the same. In scope is everything that can change which rows enter a model:WHERE,HAVINGandQUALIFYpredicates, a join added, removed or retyped between inner and left, a swapped driving relation, andDISTINCTorGROUP BYchanges. Out of scope, and silent by construction rather than by filtering, are column expressions, aliases, casts and ordering, none of which can move a row. Each change is measured by applying it, alone, to the prior model and counting, so a delta belongs to one change rather than to the edit as a whole. That matters because a ticket routinely requires a row-population change, and a net figure cannot tell a requested change from a silent side effect. The whole-model net is reported alongside, measured on the authored model rather than summed from the parts; when the two disagree the changes interact andinteractssays so, because isolated counterfactuals do not compose. This is a warning and never a refusal, and the plan is built and stored before any of it runs, so nothing here can stop a plan from existing. Naming a change is free and opens no connection, so it always happens. Measuring one is aCOUNTaggregate over the relations the model already reads, free on DuckDB and spend elsewhere, so counting runs unasked only on DuckDB; on a billed connector it needs--attribute-rowsand then goes through the ordinary estimate and--confirm --budgethandshake, with the priced ask returned beside the stored plan so a confirmed re-run measures without re-planning.--no-attribute-rowsturns counting off anywhere. Every counting statement is oneSELECT COUNT(*)over the model, cleared by the query firewall like any agent-authored SQL, so the model’s parents must be profiled and the PII policy applies (a count projects no column, so a filter over a flagged column is still attributable and no value crosses the envelope). Nothing is materialized and no relation is created. A change dex cannot isolate or measure reportsattributed: falseand names why: macro-generated SQL, a jinja statement block, a renamed or added CTE that cannot be paired with a prior scope, a relation absent from the cache, or a counterfactual that is not valid on its own because it depends on something else in the same edit. Authoring a model that does not exist yet produces no findings and opens nothing. -
transform planraises two advisory warnings about the shape of what was authored, both free, both static, and neither able to refuse a plan. The first compares the authored SELECT list against the columns the model’sschema.ymldeclares, in both directions: a declared column the SELECT does not produce, and a produced column the declaration does not name. A model with no declared columns has no contract and produces nothing; a SELECT list that cannot be resolved statically (aselect *, at.*, an unaliased macro standing in for a column) says so rather than guessing, and an aliased macro call is resolved by its alias. The declaration is read with this same plan’s ownschema.ymledits overlaid, so a model and its documentation edited together are compared against each other rather than against a stale file. The second reads a house convention out of the project’s own models: an authored model exposing a raw foreign key where its siblings all resolve the equivalent key to a descriptive attribute. Siblings are the models sharing the authored one’s folder and its layer prefix, widening to that prefix project-wide when the folder is too small to hold a precedent. At least three of them must resolve a key of the same id-suffix shape and none may pass one through, since one counter-example is enough to say the house has not settled the question (a fact table beside the dimensions is that counter-example, and a*_keyconvention says nothing about a*_id). A key naming the model’s own entity is its identity, not a foreign key. And the project must hold a parent to resolve against, because the fix is aref(). The warning names the siblings and the parent so the inference can be argued with. Both read the project as this plan will leave it, so models authored together inform each other, and both judge only what the plan authors: an existing violation elsewhere in the project is not this plan’s warning. The convention check is the only one dex raises on a style judgment rather than on a fact, which is why it is the only one a project can decline:conventions.resolved_keys: falsein.dex/config.yml, named in the warning itself. -
transform apply [plan-id]re-hashes every file first. A file edited by a human after the plan was made is a conflict: nothing is written, the divergence is returned as diffs withneeds_confirmation, and only an explicit--confirmoverrides it. An apply is all-or-nothing across the whole plan: one conflict refuses the set, so the clean edits beside it do not land either. The stored paths are re-checked against the project format’s editing surface at the same time, and a path outside it is refused outright rather than surfaced for confirmation, since nobody can accept a write into a region the format says it does not own. With no plan id it applies the latest unapplied plan of any kind (semantic plans included;emit dbtremains the semantic-scoped spelling).transform planslists what is stored, pending and applied. -
A plan can leave the process that made it.
transform exportreturns the stored plan as a document, andtransform apply --plan-fileapplies one in a checkout whose plan store has never seen it. That path needs a repo root and nothing else: no store, no connector, no cache, no dbt, no jinja, no SQL parser, no network. The only repository-controlled content it reads isdbt_project.yml, parsed with YAML’s non-constructing loader, so it can learn where files may live. The document carries a digest, and what that digest is worth is stated rather than implied. It proves the document is internally consistent: recompute every content hash from the content carried, recompute the digest from those, and a byte changed anywhere fails. It is not a signature and cannot be, because anything that can rewrite the content can rewrite the digest beside it. The caller closes that gap by carrying the digest across the boundary by a route it trusts and passing it as--expect-digest, which is the one check that catches a document re-signed after the fact. A digest presented as tamper-proof is worse than no digest, since it invites skipping the pinning that does the work. Containment and kind placement are re-checked against the applying checkout’s own declared surface rather than trusted from the document, for the same reason the preimage hashes are re-checked: the document is an artifact that crossed a boundary, and what it was validated against is not what it is being written into. Both are hard refusals, and--confirmdoes not reach them; confirmation is the handshake for a human edit somebody can look at and accept.transform groundandtransform classifyare the other two halves a caller needs before it decides: what the plan depends on and how complete that answer is, and what its edits actually contain. Both are repo-only and free. Seereferences/host-integration.mdfor the whole seam, including the conformance vectors shipped for a consumer to assert its own reader against. -
transform buildaccepts--target,--selectand--verify. The target must bedev(or thedbt_targetnamed in.dex/config.yml); production-looking targets are refused outright, before the cost gate, and--confirmcannot override the refusal. On a billed connector the cost gate is priced upfront: dex runs a freedbt compileand dry-runs each compiled node through the connector’s own estimator (the same oneexploreuses), summing the result into theneeds_confirmationestimate. It is a partial floor when a cold dev target has not built a node’s inputs yet, and degrades to no estimate (with a note) when dex cannot open its own connection; the ceiling and the server-side per-statement cap bind regardless. dbt runs with cwd pinned to the project dir (relativeprofiles.ymlpaths resolve there, never against the caller’s shell). Whenpackages.yml(or adependencies.ymlwith packages) is declared anddbt_packages/is missing,dbt depsruns automatically first;transform depsis the explicit install/refresh. A missing dev DuckDB database is an actionable refusal when the project reads from sources (seed it first), and a warning otherwise. On failure the envelope’serrors[0]carries the first real dbt message; the rest land inwarnings, per-entry capped, deduplicated, with a pointer to the full log when anything was trimmed. Each node indata.nodescarries dbt’sunique_idalongside a readablename. The two are distinct because a generic test’s id ends in a content hash, so a name taken from the last segment reads as3249b83c15; a green build reporting seventeen warning tests has to be able to say which seventeen. -
transform build’ssuccessis dbt’s exit code, anddata.outcomeis what the run established. The two are not the same question and the gap between them is not small: a build whose--selectmatched no nodes at all exits zero, and so does a build of a model the change never touched.successkeeps meaning exactly what it meant, because that is the right meaning for a command line and because consumers read it.outcomeisvalidatedonly when every node the change required ran and passed against artifacts that still describe the tree; otherwise it isempty_selection,unrelated,partial,skipped,stale,failed, ornot_run, each of which a caller acts on differently.data.evidenceis where the reasoning lives: the dbt invocation, per-node status and materialization and relation, what the selection asked for against what it matched, the manifest and run-results digests, the relations the run generated, and the principal errors.coverageneeds to be told which change the build is for, through--for-plan(a stored plan id) or--for-plan-file(an exported plan document, which is what a build sandbox holds instead of a store). It reports the nodes the change required, which of them ran, and how each requirement was derived: a model edit requires its own node, aschema.ymlthe nodes it documents, and a semantic YAML the dbt model its semantic model sits on. Without either flag there is no coverage and the key is absent rather than empty, because an empty coverage block reads as “nothing required was built”, which is the opposite of “nobody asked what was required”.selection.completeisnullfor any selector using dbt’s graph operators or method selectors. dex does not evaluate dbt’s selector language, and a second implementation of it would disagree with dbt’s own.staleis worth reading twice.stale_artifactsis the obvious version, a manifest older than the model sources, and it is close to unreachable from a build that just ran, because a successful dbt build rewrites its own manifest.plan_driftis the reachable one: a file the plan wrote no longer holds what the plan wrote, so the run validated a tree the plan does not describe. It fires on builds that otherwise look perfect. -
transform build --no-install-depsrefuses a missing package instead of installing it. The default is unchanged and still runsdbt depspost-gate, which is what an interactive user wants and what keeps a first build from failing on a step the agent has no verb for. In a sandbox with no network and a pinned dependency set the correct outcome is the opposite, and a dbt failure to reach a registry names nothing useful. The refusal (reason: prerequisite) names each declared-but-uninstalled package and its version, and it fires immediately after the dev-target check, before any subprocess and before the freedbt compilethat prices the run, on the same argument the dev-target check already makes: work that cannot succeed should not be priced first. A declaration dex cannot check by name (a git URL names a repository, and dbt installs under the package’s own name) is reported separately rather than named on a guess. -
transform build --verifyruns themaintain verifysweep over the nodes this build touched and reports it underdata.verification, in one envelope with the build result. Opt-in on every connector, free ones included.data.verification.ranis always present: a build that did not verify and a build that verified and found nothing are different answers, and only the second means the models are clean. When it ran, the payload addsscope(the models this build ran),findingsandfinding_countin the shapemaintain verifyreturns them, andsuppressed, naming every finding class that did not run and why. Model names. A model is named the way dbt selects it, here and inmaintain verify, in the build’snodes, and in the per-node estimate. A versioned model carries its version,dim_customers.v2, the latest one included, because two versions are two relations that share onenameand either can fail, lose rows, or go missing on its own. The sweep’s scope is the exact nodes the build ran, so a build ofdim_customers.v2never judgesdim_customers.v1. As in dbt’s--select,maintain verify dim_customersselects every version andmaintain verify dim_customers.v2selects one. Findings never fail the build. A build dbt completed is a build that completed; whether arow_lossshould gate a pipeline is a caller’s policy, so findings stay out oferrorsand never change the status. A pointer line inwarningsnames the count so a caller reading warnings alone still learns they are there. A build that failed partway still reports the build-status half, which is when “which node failed, and what did it take down with it” is worth most; row population is suppressed there, because a half-built dev target is a mix of this run’s output and the last one’s.no_relationis never reported from a build: dbt’s run results are authoritative for the nodes it just ran, and a node it reports assuccesshas a relation whether or not dex’s source scope covers it. Cost. The row counts a verdict needs are priced during the build’s own pricing pass, off the manifest the freedbt compilewrote, and folded into the same estimate under a(row counts)entry in the per-table breakdown, so one--budgetcovers both phases. Only relations the warehouse keeps no row count for cost anything, which is any view, and a view is dbt’s default materialization; a table’s count is free metadata. On a connector with no cost gate every count is made exact instead, because doing so bills nothing. Where the counts cannot be priced upfront (a cold dev target has nothing to dry-run against yet) a note says so, and they are priced again after the build as a phase of it, drawn against the reservation the build is already holding rather than as a second command. A phase that does not fit returnsokwith the counts priced indata.offer, neverneeds_confirmation: the build is finished and billed, and reporting otherwise would invite a caller to pay for it twice. Read scope. Every other command refuses the namespace dbt writes to as a source, so exploration can never mistake a built model for a source table.--verifyalso reads that namespace for the length of the command, because the relations dbt just wrote are its subject. It is held apart from the committed allowlist and bounded by its own rules:- It need not exist. dbt creates it on a first build, so before that build it reads as empty, and the build is priced on its sources as usual. A committed allowlist entry that names nothing is still refused.
- It is listed after dbt has run, so a first build sees the relations it created, and a rebuild is judged on the row counts dbt just wrote rather than on the ones listed while pricing.
--scopenarrows the sources and nothing else: it can neither name the dev namespace nor drop it from the build that is judging it.- An allowlist that already covers it (the whole dev database, or an empty allowlist) reads it once.
connection.targetwhile it is read, and nothing is written back to.dex/config.yml. Where the built relations still cannot be seen (a model built into a custom schema lands outside the configured dev namespace), row population is suppressed with a reason that names the gap rather than comparing nothing and reporting clean. -
semantic definerefuses names that already exist in the project (useupdate);updaterefuses names that do not (usedefine);semantic planaccepts a mix and classifies per name, reportingdefined,updated,unchanged, andremovedin the envelope.updatedmeans the definition’s parsed content actually differs from the project’s; a definition re-stated identically in the file that already holds it isunchanged. The distinction matters because a whole-file payload restates every definition in the file, so without it a two-metric change reports thirty objects as updated and the real blast radius is invisible in the one place a reviewer checks it. Key order and formatting are not changes; list order is, and identical content written to a different file is a move, so both read asupdated. -
Two payload units.
--edits-filecarries whole files.--definitions-filecarries individual definitions:{"definitions": [{"kind", "path", "content"}, ...]},kindbeingsemantic_modelormetric,contentthat definition’s YAML body (a mapping, written without the leading-). The name is read from the content, so the two cannot disagree.pathis required for a name the project does not have and optional for one it does, defaulting to the file that declares it; an explicit path that would relocate an existing definition is refused, because writing it to a second file duplicates the name. The engine writes each definition in place and preserves every other byte, comments included, then re-parses the result and compares it against what was sent. A file whose layout it cannot span safely (a flow-style sequence, anchors or aliases, multiple documents, tab indentation) is refused with--edits-filenamed as the way to edit it. Classification is scoped to the definitions named, so a spliced file’s other definitions are reported in no class at all. Names implicitly created bycreate_metric: truemeasures count as existing metrics everywhere. Beyond MetricFlow’s schemas, the engine resolves every metric input (ratio and derived inputs must reference metrics, not measures) and then runs the emitted YAML through dbt’s own parser against a throwaway copy of the project; a plan that fails parse is never stored. When dbt is unavailable (or the project has no time spine yet) the parse degrades to a warning;--no-parseskips it. A stored semantic plan is applied withtransform applylike any other plan (no id applies the latest unapplied one). -
Removing a definition is an entry with
"op": "delete", which carries thename(there is no body to read one from) and nocontent. It is always declared: a definitions payload never removes a definition for having gone unmentioned, because an unmentioned definition is what the unit promises to leave alone.semantic definerefuses a removal;updateandplanaccept one, and it is reported as a fourth class,removed. One payload names each definition once, so what it asks for cannot depend on the order it is read in. A removal the surviving project still reads is refused, naming the reader and its file: a metric whose input is a removed metric, or a measure of a removed semantic model (create_metricnames included). The check is over the state the payload leaves behind, so adding those readers’ own removals or updates to the same payload satisfies it, in any order; what a reference index cannot resolve statically (aMetric()call inside a filter string) is left to dbt’s parser. A removal that would leave a file declaring no semantic model or metric is refused as well, pointing attransform plan --edits-file: emptying or deleting a file is a file-level act, and the semantic verbs delete no files. The file comes back without that one definition, as an ordinary content edit, so the plan store, the diffs, andtransform applysee the unit they always have. -
semantic ossie define|update|planis the corresponding native-file route. Its namespace guard is the semantic-model namespace across every configured document:definerejects existing names,updaterejects missing names, andplanreports a mixed change underdefinedandupdated. Each edit is a complete configured.ossie.yaml,.ossie.yml, or.ossie.jsondocument; Dex overlays all edits in memory and validates the prospective configured set before storing anything. It writes the accepted content byte-for-byte, with no YAML/JSON reformatting, and never changes a configured document absent from the payload. File removal is not part of this command. -
semantic slayer define|update|planis the same route for SLayer model files. Each edit is one whole model file keyed assemantic.slayer.modelsnames it, relative tosemantic.slayer.base_dir, and nothing else under the storage root is writable. The prospective set is validated with the pinned release’s ownSlayerModel(current formatversion, unique names, path agreeing with name and data source, joins resolving within the set onto columns both sides have) and, for an edited model, adefault_time_dimensionthat names a column, no key the pinned release does not declare at its level (SLayer ignores one, so it would be written and do nothing), and saved-measure formulas that parse. Every refusal names the file, the element, and for an unknown key the pinned release. Apply writes the authored bytes, all files or none, and a file SLayer’s own storage rewrote after planning is a conflict that says so.
explore fronts connect/explore; transform
fronts transform, semantic, and viz; maintain fronts the whole
maintain group. Within maintain, snapshot manages the baseline, check
plus schema/volume/grain/semantic detect drift (read-only), and
reconcile is the only verb that emits diffs (applied through transform apply). Detection is read-only on every connector, but read-only is not free:
schema, volume, and the reference half of semantic read metadata and run
immediately, while grain and the dimension-cardinality half of semantic scan
the warehouse and take the --confirm --budget handshake on billed connectors;
check runs the free axes first and returns one combined estimate for the
scanning axes.
That estimate arrives as an offer on a complete answer
(�0), not as a pending charge. check and
semantic finish their free axes on every call and return ok, with the price
of the scanning axes under data.offer, which carries axes naming what the
estimate would add. data.axes_run names what completed, and reading both is
how a caller tells “grain found nothing” from “grain did not run”, which the
status used to imply.
A baseline reports its own coverage, and the axes only compare what it
covers. maintain snapshot pins the exploration cache, and a cache is thin
whenever explore map stopped at its rank cutoff: past 50 objects it profiles
the top 25 and enters the rest as metadata alone. An object the baseline holds no
columns for has an unknown column set, not an empty one, so the schema axis
compares no columns for it rather than reporting every live column as added, and
the grain axis has no keys to probe. Both snapshot and the detectors warn and
name the objects, and snapshot reports column_detail_count beside
dataset_count, so partial coverage is never mistaken for a clean bill. Run
explore map --full before snapshotting to cover everything. The detectors also
warn when the baseline was pinned from a cache older than
profile_freshness_hours, judged on the capture time recorded in the baseline
rather than on which file was written last, so re-pinning cannot silence it.
explore semantic queries the semantic layer; transform and the semantic
group author it, and maintain semantic detects drift in it. Three backends
answer the same three subcommands through one abstraction, chosen ambiently by
semantic.vendor and semantic.deployment in .dex/config.yml (the released
semantic.backend spelling of the two is still accepted) and overridable per
command with --local / --api. Those two flags name who executes, not which
vendor: every catalog and every result reports it as execution (dex or
vendor), and that is the axis the guards read. A vendor-executed backend owns the
warehouse connection, so dex never holds a statement it could price or cap, and
every hosted result carries a warning saying exactly that.
vendor: ossie reads native Apache Ossie documents from the repository, with
no dbt project and no MetricFlow in the path. It is catalog-first because the
format is: Ossie specifies interchange metadata and not a portable query runtime,
so list answers and query and values refuse by name rather than inventing
filter grammar, join planning, and execution semantics the document’s author never
stated. --for-dimension refuses too, off the backend’s own declared
unavailable block rather than off a vendor name: Ossie states no
metric-to-dimension relationship, and answering “no metric can be grouped that
way” would be a claim about the layer where the truth is that this backend was
never told. The documents are named in semantic.ossie.files, including for an
Ossie-only repository. They are confined to the repository, and selecting Ossie
without the [ossie] extra refuses and names it. dex pins the Ossie schema by
content hash rather than by the version string the document carries. See
references/semantic-layer.md, and references/ossie-compatibility.md for what
dex accepts, checks, links, and declines to claim under that pin.
vendor: slayer reads SLayer model files from the repository, the ones named
in semantic.slayer.models under semantic.slayer.base_dir, each validated by
SLayer’s own SlayerModel from the release the [slayer] extra pins. A file in
any format version but that release’s current one is refused before SLayer can
migrate it, and every refusal names the file and the element. list answers:
each model is a semantic model, each column a dimension keyed model.column, and
each saved measure a formula metric groupable by its own model’s columns, so
--for-dimension answers too. Measures and entities are declared unavailable,
because a saved measure declares no aggregation and SLayer has no entities; its
keys and joins reach explore through the declaration channel instead. query
and values refuse by name: SLayer would execute through its own datasource
connection, outside the cost guard and the query firewall, and under dex SLayer
never touches the warehouse. Values SLayer caches in a model file
(sampled_values) are never read. See references/semantic-layer.md.
maintain reads the same models on every axis without opening a SLayer
datasource or SLayer storage: the baseline records the SLayer release and model
format version, declared keys and join cardinality are measured on the grain
axis, and the schema axis runs SLayer’s own validate-models comparison on the
metadata dex captured, whose removals maintain reconcile plans through the
semantic slayer update route.
list costs no warehouse query on either backend, and neither does the reverse
lookup, which inverts the dimension list each metric already carries rather than
asking the layer a second question. values and query each execute one, and both
screen every dimension a request would touch before it is sent: on query a
flagged dimension is refused from the grouping or the filter, and on values it
refuses the command outright, since there is no aggregate to fall back to when the
whole output is values. Where only the name heuristic could screen a dimension, the
result says so, so weaker screening is never mistaken for evidence. The
field-by-field catalog contract, the two backends’ declared asymmetries, and what
dimension_scope and scoped_to mean are in
�7.
explore relationships and explore map accept --verify, which measures each
inferred join with one aggregate overlap probe (non-null foreign keys, orphan
count) and adjusts its confidence; the result carries verified and
orphan_fraction.
Exploration starts bare: by default the warehouse is observed as-is, and a dbt
project in the repo earns only a discovery note. explore profile,
explore relationships, and explore map accept --use-project, which folds
the project’s declared definitions into the result: relationships tests
become declared joins at confidence 1.0 (resolved against the connection’s
inventory; a declared join that matches nothing or more than one object is
reported in notes, never guessed, and a declared edge wins over the same
inferred one), a semantic model’s primary entity overrides the heuristic grain
(disagreements and contradicted unique tests land in data_quality), and
models reachable from metric definitions rank higher alongside the configured
ranking_hints. The compiled manifest resolves names exactly when present;
an uncompiled project falls back to name-based resolution and says so. A
stale manifest (older than the model sources) is noted, not trusted silently.
explore relationships and explore map also accept the independent
--use-hosted-semantic-layer opt-in. It permits a configured hosted layer read;
when that layer cannot expose physical relations (as dbt Cloud currently cannot),
dex reports the partial linkage and does not invent warehouse edges or exposure.
The project’s semantic layer folds in on the same flag, in both directions,
and neither direction costs a warehouse query.
Every entity two semantic models share is a join the layer states outright, with
the physical key named per model, so those arrive as declared joins at
confidence 1.0 beside the relationships tests, through the same endpoint
resolution and the same never-guess rule. declared_by on the edge names the
entity, which is the part a reader can look up with explore semantic list and
the only part the edge does not already carry; a relationships test leaves it
unset, because it declares exactly the two columns the edge already names. An edge
both channels declare is counted once. notes says how many came from the layer
and, separately, how many of those name-based inference did not find, which is the
case that matters: the layer routinely joins columns that share no name.
In the other direction, each object in data.objects carries semantic_models,
the models that sit on that relation. Empty is an answer: a relation nothing in the
layer reads is a different object from one several metrics are built on, and row
counts and PII flags cannot tell them apart. Every object in view is rewritten
whenever the layer was read, so a model dropped from the layer clears rather than
leaving a stale claim. A project with no compiled semantic layer contributes
neither direction and is not an error on this path; explore semantic list is the
command whose subject is the layer, and it is the one that refuses by name.
A key is ranked, and the reason travels with it. candidate_keys is ordered,
tightest proven key first, so the first entry is the one grain elects and the
rest are alternatives rather than an unordered set a caller has to guess through.
key_evidence carries one entry per combination the profile considered, each with
its columns, a status of reported or suppressed, and a reason in the
profile’s own words. The reported entries are in the same order as
candidate_keys, which is the invariant that keeps the two fields from drifting;
the suppressed ones follow.
Suppression exists because unique is not the same as identifying. Where one column
is unique on all but a handful of rows, every wider column in the table completes
it, and the resulting combinations are the same fact restated: the column has
duplicates. Reporting them as keys buries the real key among filler and puts a
test in scaffolded dbt on a tuple nobody meant. So they are suppressed from
candidate_keys, kept in key_evidence with the reason, and stated once in
data_quality alongside the counts. A caller learns from key_evidence that a
probe ran and found only artifacts; that a probe never ran, or was narrowed by the
budget, is what the probe’s own notes say, and the two are not the same answer.
key_evidence is a profile field. explore map reports the best-ranked key as
candidate_key and is budgeted per object, so the full ranking belongs to the
command whose subject is one relation in full.
explore map returns the map, not a receipt for it. Alongside the counts,
data.objects carries each top-ranked object’s row count, detected grain,
best-ranked candidate key (the full ranking and the reason behind each key are
explore profile’s key_evidence, which this payload deliberately does not
carry), notable columns (each with the role that earned it a place: grain,
key, join, or a PII flag) and data-quality findings, and data.edges
carries the join edges in exactly the shape explore relationships returns them.
It is budgeted the way explore diagram is budgeted: at most 25 objects kept by
rank, 12 columns per object, 40 edges, and 5 data-quality findings per object.
Every cap binds in every mode, elided_object_count / elided_column_count /
elided_edge_count report what each one cut, and notes names the count, the cap
and the way to read the rest, so a truncated answer never reads as a complete one.
notes is always present, so an empty list is the positive statement “nothing was
elided”.
Which objects are eligible differs from explore diagram on purpose, and the two
commands share only the column selection. Every profiled object is eligible here,
including one that joins to nothing, because an isolated lookup table carrying PII
flags and an empty-table warning is exactly a finding; the diagram drops those,
since a box with no edge draws nothing. So a map can report more objects than the
diagram of the same cache draws, and the counts in the envelope always reconcile
with the objects printed beside them.
--detail widens what is eligible: every column rather than the notable ones,
and objects that were inventoried but never profiled. It lifts none of the caps.
It is deliberately not spelled --full, which on this command decides how much
gets scanned and therefore what the run costs; --detail decides only how much of
what was found comes back, and spends nothing.
No column value crosses this envelope. The cache holds min_value,
max_value and a value domain for the columns that earned them, and this command
does not read them: explore profile is where a caller asks for a value domain,
deliberately and one object at a time. PII is category and confidence, as
everywhere.
explore map never caps silently: past 50 objects it profiles the top
profile_top_n (default 25) by rank and announces the cutoff in notes
alongside skipped_count (--full profiles everything). data.skipped names
what it skipped: count, by_namespace (records of namespace and count,
largest first, each namespace being everything before an identifier’s last dot),
and objects in rank order, highest first, each list capped at 25 with the rest
counted in elided_namespaces and elided. It is always present, so count: 0
on a --full run is the positive statement that nothing was skipped, and it
changes neither which objects are profiled nor the estimate. Objects skipped on a
re-map carry their prior profiles forward (carried_forward_count), each
stamped with its own profiled_at. A selected object whose cached profile is
still fresh (same connector, schema unchanged, profiled within
profile_freshness_hours, default 24; 0 disables reuse) is reused without a
re-scan and reported as cache_hit_count, so it never enters the cost preflight
or the billed handshake. --refresh forces a full re-profile of every selected
object even when the cache is fresh, for a source that changed in a way the free
metadata check cannot see. explore relationships and the standalone
explore profile <objects> reuse fresh profiles the same way
(cache_hit_count), so a profile on a table map just wrote is served free;
all three accept --refresh.
Global flags (shared resolution path): --connector, --path (DuckDB),
--scope, --project and --dataset (BigQuery only), --repo-root,
--confirm, --budget, --session-ceiling and --no-session-ceiling.
--session-ceiling <value> and --no-session-ceiling are the two answers to the
one-time cumulative-ceiling ask below. Either one writes the answer into
.dex/config.yml and reports the amendment as an update diff; they are answers
to a question about the project, not per-command overrides, which is why they are
durable and why --budget is unaffected by both. The amendment edits the budget
lines in place and nothing else, so the file’s comments, key order, and any key
this release does not read all survive it.
The config verbs take none of these. They read and write the committed file
before any engine is built, so a run override would be accepted and then do
nothing; each one is refused by name instead, pointing at dex config set.
.dex/config.yml is found by walking up from the --repo-root directory (default
the shell cwd) to the enclosing git root, the way git and dbt locate their project,
so a command run from a subdirectory resolves the project’s config rather than the
current directory’s. The walk anchors on the config file (a subdirectory holding
only a .dex/ cache never shadows the real config higher up) and stops at the git
root (a stray .dex/config.yml above the repo cannot capture the session). The
config verbs find the file the same way, so they read and change the config the
next command will load. With no
config found anywhere and no explicit --connector/--path, the engine refuses
and names the fix rather than defaulting to DuckDB. A committed relative
duckdb.path resolves against the project root the config lives in, so the same
target opens from any subdirectory; a live --path stays relative to the shell cwd.
dex demo’s positional path differs because it names a file to write rather than
one to read: a relative one resolves against the --repo-root directory itself
(not the walked-up project root), so the demo writes exactly what
cd <repo-root> && dex demo would.
--scope is repeatable and narrows the source allowlist for one command. Each
connector reads it in its own namespace vocabulary: a dataset on BigQuery, a
schema, database, or database.schema on Snowflake, a catalog or
catalog.schema on Databricks, a schema on Postgres. It is never written back
to config, so connect test --scope X works before a connector block exists.
Two rules make it a cost control rather than a hint, both in
�0: it narrows and never widens, and it is
honored or named in an error, never dropped. --project and --dataset are
BigQuery vocabulary and error on any other connector; DuckDB has no namespace to
scope and refuses all three (its target is --path).
The query firewall
explore query executes SQL the agent wrote; the engine generates nothing and
only refuses or bounds. A call may carry several statements, and the gate below
runs over each of them separately: batching buys call count and nothing else.
The gate, in order:
-
Parse, don’t trust. A single read-only SELECT per statement, structurally
checked. Writes, DDL, PRAGMA and DESCRIBE are refused (introspection goes
through
inventory/profile), and so is multi-statement input inside one string:"select 1; select 2"stays refused whether it arrives alone or alongside other arguments. Separate arguments are the supported way to ask several questions, because they keep the statement boundaries explicit instead of asking the parser to find them. -
Resolve against the cache, then against the connection. Every table and
column must exist in
.dex/cache.json, because profiling is what makes the PII policy computable and the firewall cannot judge a column whose flags it does not have. What follows from a cache miss is not a refusal, though: the object is looked up in the live inventory, and if the connection has it, it is profiled and the query then runs. This covers the case no amount of upfront exploration can, a relation built since the last inventory (a modeldbt runjust created), which is neither profiled nor inventoried. Three states trigger the profile: never profiled, present but inventory-only (no column detail), and profiled against a column signature the warehouse has since changed. Age does not: a cached profile whose columns still match is reused however old it is, so a probe never silently becomes a re-scan. The scan is disclosed, never silent. The result warns, naming what was profiled, anddata.profiled_on_demandlists it for a machine reader. The profile is a full one, same detection, samepii_overrides, same cache write, so the flags governing the query are the flags a deliberateexplore profilewould have produced. On a metered connector it is priced rather than implied: a single handshake covers profiling those objects and running the statements, itemized per table alongside a(the statement itself)entry (or one(statement N)entry each when the call carries several), and the confirmed call does both. A call is resolved as one set, so two statements over the same cold table pay for one scan rather than two. If the guard then refuses a statement anyway, the profile is still saved and the refusal says so, so a corrected query does not pay for it twice. An object the connection does not have is still refused, and only the statements that named it. The message names the connection rather than the cache, because no amount of profiling puts an absent object into it. A statement already refused is never scanned for: the objects worth profiling are the ones a statement that can still run will read.--no-auto-profile, orauto_profile: falsein.dex/config.yml, restores the strict prerequisite: the original refusals word for word, and no connection opened to produce them.explore clusterresolves its object the same way, with one difference in pricing that its shape forces. Its sample statement is built from the feature columns, which come out of the profile, so the profile is priced first and the sample passes a mid-command gate afterward: a budget too small for the sample returnsneeds_confirmationwith the profile already saved, rather than refusing and discarding it. - Classify the projection. Output may carry values only from columns the PII policy clears, which is what the profiles gathered in step 2 are for: the firewall cannot judge a column whose flags it does not have. Which aggregates carry a value out, what a sub-threshold flag does, and how a human clears one are in �2. Filters, join conditions, GROUP BY and ORDER BY are unrestricted: values flow in, not out.
-
Bound the result. LIMIT is clamped (default 50 rows), long cells are cut
(default 256 chars), the payload is byte-capped (default 16 KiB), at most 10
statements ride in one call, and every cut is announced in
notes. A watchdog interrupts queries that outlive their time budget (default 30s). All of them are configurable underquery:in.dex/config.yml;auto_profilesits at the top level instead, becauseexplore clusterhonors it too. The byte cap is the one that follows the call rather than the statement, because it is the one protecting agent context: a call answering ten statements must not emit ten times what one statement is allowed. -
Record. Every decision, allowed, refused, or failed, is appended to
.dex/queries.jsonl(SQL text and counts, never result values). An allowed query that projected sub-threshold flagged columns records those warnings underpii_warnings, so the audit trail keeps every such projection findable. A decision that profiled first records the objects underprofiled_on_demand(andprofile_plannedon aneeds_confirmation), so a scan is as findable in the ledger as a spend. One entry per statement whatever the call carried; a call carrying several addsbatch_indexandbatch_sizeto each and gives them all one timestamp, so an auditor can see that six statements were one authorization event rather than six.
columns, cells as a list of lists, row_count,
truncated, notes), which is cheaper in tokens than records and keeps the
envelope sanitizer’s list-of-dicts raw-row rule intact as a backstop.
A call carrying one statement puts that shape directly in data. A call carrying
several puts one entry per statement under data.results, each with the same
keys plus index, status (ok, refused, failed, skipped), error,
reason, stage (see the envelope rules below), and the source line when the
statement came from --sql-file;
statement_count, ok_count and failed_count sit beside them. A statement
that failed makes the envelope’s own status error while every answer stays in
data, the same way transform build reports a run that failed partway: an ok
envelope carrying a guard refusal would be the weaker claim, and dropping the
answers to report the refusal would discard work already paid for.
--across <ds,ds,...> (comma-separated, repeatable) asks one question of mirrored
environments: dev, qa and prod copies of the same datasets. Every statement names
its dataset as {ds} and runs once per target, so this is two statements in one
call:
dex explore query "select status, count(*) from {ds}.orders group by 1" --across dev_raw,prod_raw.
They are ordinary statements to everything downstream: each is adjudicated by the
firewall against its own relation’s profile and PII flags, and the whole call is
priced in one handshake and profiled once. Each entry in data.results adds
target and template (which of the caller’s statements it came from), the
entries run template by template so the variants of one probe sit together, and
data.across lists the targets in the order given. A refusal names its target
(statement 2 (target qa_raw) refused: ...). A target must be a plain dataset
name (letters, digits, _, -, dot-separated for a qualified one), since it is
substituted into the SQL; a statement with no {ds}, a repeated target, and a
call whose statements times targets exceeds query.max_statements are refused
before anything runs. A call without --across reports neither key.
The caps follow the call rather than the statement where the difference matters.
query.max_rows and query.max_cell_chars bound each statement;
query.max_payload_bytes is the budget for the whole call, spent in statement
order, so several statements cannot emit several times what one is allowed; and
query.max_statements (default 10) bounds how many a call may carry at all.
The envelope
Every command prints one object of this shape (exmergo_dex_core.envelope):
- Cost before spend.
costis a preflight estimate, and any command that would spend returnsneeds_confirmationuntil it is confirmed and budgeted. The handshake in full is in �2. - An estimate says how much it is worth.
estimate_qualityisexacton BigQuery, where a dry run is what the job will bill, andapproximateon every connector that models a run instead.unknownmeans dex tried to price the work and could not, so only the ceiling and the server-side per-statement cap are still binding;nullmeans nothing was priced at all. Those last two are different states, and a caller that collapses them admits an unpriced command believing it was priced.unitspells out what the magnitude counts, derived from the paradigm so no command can report bytes and label them seconds. - A priced phase the caller did not request is an offer, not a refusal. When
a command’s free half is a complete answer in its own right, the envelope is
okand the price of the optional half sits indata.offer, carrying the same estimate, breakdown, and--confirm --budgethint a refusal would.maintain checkandmaintain semanticare the two. The spend gate is unchanged (nothing runs without the confirmed re-issue) andcost.estimatestays unset, so a reader can keep treating a populatedcost.estimateon anokas settled preflight for work that ran.needs_confirmationstays reserved for work the caller asked for and has not authorized, which is the only case where dex is genuinely blocked. data.spendreports settled spend for every billed command, includingtransform build, and always matches what the same command appended to the ledger. It carries the connector’s unit (bytes_billedorseconds_billed) plussession_spent_today, which is what the next command’s cumulative ceiling will start from. A failed build reports it too: dbt bills for the statements it ran before it stopped.- And it is the only place spend is reported. No command puts a billed
magnitude anywhere else in
data, and every command that can bill carries the unit key whatever it settled at, zero included. Both halves are the same rule: a key that exists on one command and not another is worse than one that never exists, because at the top ofdataa missing key reads as a value, and the value it reads as is zero.transform buildused to stampdata.bytes_billedas well, so a caller reading that key saw a build’s spend and read amaintain checkthat had just scanned 0.89 GB as free. Under-reporting spend is the one envelope defect that breaks the cost-governance guarantee rather than annoying the caller, so where a figure is genuinely unavailable the key reportsnulland a note says why, rather than rounding an unknown down to zero. Key parity acrosstransform build,maintain check,explore query,explore mapandexplore profileis a contract test. - ClickHouse’s paradigm depends on its declared deployment. Config-free
callers retain the backward-compatible
db_loaddefault; an effectiveclickhouse.deployment: cloudusescompute_time. Cloud keeps seconds as the binding ceiling and ledger unit, and adds approximate compute-unit-hours from live per-replica memory plus optional USD from the configured price. Missing or partial capacity refuses before billed work. - Spend, the ledger, and the daily ceiling are governed by
�37. That file owns the
.dex/spend.jsonlrow shape, how reservations and settlements net, the cumulativebudget.session_ceilingand its one-time ask, and what a ledger that cannot be read does to a billed command. This section states only what the envelope carries. cost.paradigmnames the connector the command ran against, not what the command happened to cost. A free metadata command on BigQuery reportsbytes_scannedwith a null estimate, so a caller learns what a billed command will cost in before running one.free_localis DuckDB’s own paradigm and a positive claim that the connector bills nothing; when no connector was resolved the field isnullinstead. A refusal carries the paradigm, the estimate, and the ceiling that bound it, so “was this refused over money?” is answerable from the structured fields without parsing the error prose.- A warehouse refusal names the door it came through. A statement the
warehouse itself refused reads
reason: execution_failure, and on a connector that can tell where the refusal happened,data.stagesays which.plan: the warehouse would not plan the statement (on BigQuery, the dry run or the job insert answered 400), so nothing ran, nothing was billed, and the same statement will be refused again until it changes.run: it planned, ran, and failed, so it may have billed, and the failure can depend on the data or on resources rather than on the SQL. BigQuery is the one connector that sets it today. The key is absent, never null, on every other error envelope: a refusal from a connector that cannot tell its doors apart, and every refusal that is not the warehouse’s. A multi-statementexplore queryreports each statement’s verdict in its owndata.resultsentry, sostagesits there beside that entry’sreason, and is null on a statement that answered, one a guard refused, and one whose connector named no stage, the same wayreasonanderrorare always present in that shape. The error text is the same whichever door it was, so a host that matched on it keeps working and one that branches on the field no longer has to. connectionnames the target that answered. Its connector, safe target coordinates, and resolution source are stamped once at the CLI boundary so individual command handlers cannot omit or reshape them. The sanitizer scans this block as well asdata; credentials remain forbidden even if a future connector accidentally places one among its target coordinates.- Diffs, not silent writes. Proposed changes appear in
diffs; being there does not apply them. The user applies through their normal review and PR flow. - The config file speaks through
reasonandwarnings. A.dex/config.ymlno command can load (it does not parse, or a value fails the schema) refuses every command exceptconfigwithreason: configuration, naming each setting and its line rather than repeating the validator’s own text. A key dex does not read, a key written twice, or a credential-like key does not refuse anything: each rides the envelope’swarnings, so a typo in a setting is never a setting that silently does nothing.dex config validatereports all of them at once, anddex config unset <key>removes one. - No secrets, no uncleared values.
datais scanned before printing (envelope.sanitize): a secret-like key or a record-shaped raw-row payload is a hard failure, never a silent scrub. Result values appear only inexplore query’s columnar payload, and only after the query firewall has proven they come from profiled, PII-cleared columns.diffsis not scanned the waydatais, so aconfigdiff withholds its own: any value under a credential-like name, including one on a context line, reads<withheld>. This is a release-blocking safety guarantee.
Why this is the first artifact
Because every skill, test, and benchmark depends on it, the contract was locked before the engine logic was built, and the subcommands fill in against this fixed boundary. Exploration, authoring, and maintenance are live today; the Viz preview is the last stub, pending the Viz integration. Source:references/command-contract.md (edit on main). This page is generated; change the source, not the site.