Skip to main content
This is the integration keystone. It is the boundary between the agent and the engine, and it is what keeps every agent surface thin: each surface calls the same subcommands and reads the same envelope, so no surface re-implements logic.

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 via uv run --no-project against the pinned engine version, installing the connector extra it resolves at runtime (an explicit --connector, then the connector: in the .dex/config.yml found by walking up from the run directory to the git root, then DuckDB), so the pin stays connector-neutral. uv is therefore a prerequisite, and the wrapper holds the envelope contract even there: with no uv on PATH it refuses with a reason: prerequisite error 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 cluster adds [cluster], and explore semantic adds [semantic-api] plus, where a statement might be rendered locally (any mode but list, without --api), [semantic]. A repo that runs neither resolves neither scikit-learn nor MetricFlow.
  • That environment is uv’s own, and --no-project is what keeps it so. Without it uv discovers whatever Python project the caller is standing in, builds it, leaves a .venv/ and a uv.lock in 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.
  • --warm is 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 connection block. 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, or directory-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_DIR locates profiles.yml for dbt operations and the last-resort credential fallback. It does not select dex’s connector or override --connector/--path or .dex/config.yml; help and no-connector refusals say so explicitly.
  • The agent reads the envelope and decides the next step.
State persists in the dbt project (the source of truth) and the .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/ and models/marts/, and a project-local profiles.yml with a single dev target wired to the warehouse dex already knows (--path, or duckdb.path in .dex/config.yml). The profile is written owner-only (0600), as is any profiles.yml a later apply rewrites: dex renders only env_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 as create diffs. Init never defaults the connector: the DuckDB on-ramp a read-only command may take (an explicit --path, or a committed config that omits connector:) is wrong here, because init bakes the connector into the generated profile. --connector wins, a connector: already committed in .dex/config.yml is accepted (the envelope names which source was used), and bare init is an error listing the valid connectors. On success init writes connector, dbt_project_dir, and dbt_target: dev back 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-schemas opts into per-layer schema routing: init additionally scaffolds models/intermediate/, the shipped generate_schema_name macro override, and a models: block with +schema: staging|intermediate|marts. The override composes <custom schema>_<target name> (layer first, target last), so a dev build lands in staging_dev / intermediate_dev / marts_dev; a model with no custom schema falls back to target.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 via transform 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-schemas is 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 plan also accepts --scaffold <table> (repeatable): a deterministic staging skeleton (stg_<table>.sql plus per-model YAML with key tests under data_tests: and PII flags in model and column config.meta) generated from the .dex/ cache.
  • transform plan reports the row-population consequence of an edit to a model that already exists, under data.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, HAVING and QUALIFY predicates, a join added, removed or retyped between inner and left, a swapped driving relation, and DISTINCT or GROUP BY changes. 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 and interacts says 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 a COUNT aggregate 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-rows and then goes through the ordinary estimate and --confirm --budget handshake, with the priced ask returned beside the stored plan so a confirmed re-run measures without re-planning. --no-attribute-rows turns counting off anywhere. Every counting statement is one SELECT 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 reports attributed: false and 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 plan raises 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’s schema.yml declares, 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 (a select *, a t.*, 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 own schema.yml edits 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 *_key convention 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 a ref(). 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: false in .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 with needs_confirmation, and only an explicit --confirm overrides 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 dbt remains the semantic-scoped spelling). transform plans lists what is stored, pending and applied.
  • A plan can leave the process that made it. transform export returns the stored plan as a document, and transform apply --plan-file applies 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 is dbt_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 --confirm does not reach them; confirmation is the handshake for a human edit somebody can look at and accept. transform ground and transform classify are 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. See references/host-integration.md for the whole seam, including the conformance vectors shipped for a consumer to assert its own reader against.
  • transform build accepts --target, --select and --verify. The target must be dev (or the dbt_target named in .dex/config.yml); production-looking targets are refused outright, before the cost gate, and --confirm cannot override the refusal. On a billed connector the cost gate is priced upfront: dex runs a free dbt compile and dry-runs each compiled node through the connector’s own estimator (the same one explore uses), summing the result into the needs_confirmation estimate. 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 (relative profiles.yml paths resolve there, never against the caller’s shell). When packages.yml (or a dependencies.yml with packages) is declared and dbt_packages/ is missing, dbt deps runs automatically first; transform deps is 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’s errors[0] carries the first real dbt message; the rest land in warnings, per-entry capped, deduplicated, with a pointer to the full log when anything was trimmed. Each node in data.nodes carries dbt’s unique_id alongside a readable name. The two are distinct because a generic test’s id ends in a content hash, so a name taken from the last segment reads as 3249b83c15; a green build reporting seventeen warning tests has to be able to say which seventeen.
  • transform build’s success is dbt’s exit code, and data.outcome is what the run established. The two are not the same question and the gap between them is not small: a build whose --select matched no nodes at all exits zero, and so does a build of a model the change never touched. success keeps meaning exactly what it meant, because that is the right meaning for a command line and because consumers read it. outcome is validated only when every node the change required ran and passed against artifacts that still describe the tree; otherwise it is empty_selection, unrelated, partial, skipped, stale, failed, or not_run, each of which a caller acts on differently. data.evidence is 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. coverage needs 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, a schema.yml the 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.complete is null for 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. stale is worth reading twice. stale_artifacts is 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_drift is 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-deps refuses a missing package instead of installing it. The default is unchanged and still runs dbt deps post-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 free dbt compile that 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 --verify runs the maintain verify sweep over the nodes this build touched and reports it under data.verification, in one envelope with the build result. Opt-in on every connector, free ones included. data.verification.ran is 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 adds scope (the models this build ran), findings and finding_count in the shape maintain verify returns them, and suppressed, naming every finding class that did not run and why. Model names. A model is named the way dbt selects it, here and in maintain verify, in the build’s nodes, 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 one name and 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 of dim_customers.v2 never judges dim_customers.v1. As in dbt’s --select, maintain verify dim_customers selects every version and maintain verify dim_customers.v2 selects one. Findings never fail the build. A build dbt completed is a build that completed; whether a row_loss should gate a pipeline is a caller’s policy, so findings stay out of errors and never change the status. A pointer line in warnings names 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_relation is never reported from a build: dbt’s run results are authoritative for the nodes it just ran, and a node it reports as success has 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 free dbt compile wrote, and folded into the same estimate under a (row counts) entry in the per-table breakdown, so one --budget covers 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 returns ok with the counts priced in data.offer, never needs_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. --verify also 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.
    • --scope narrows 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.
    The dev namespace shows in the envelope’s connection.target while 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 define refuses names that already exist in the project (use update); update refuses names that do not (use define); semantic plan accepts a mix and classifies per name, reporting defined, updated, unchanged, and removed in the envelope. updated means the definition’s parsed content actually differs from the project’s; a definition re-stated identically in the file that already holds it is unchanged. 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 as updated.
  • Two payload units. --edits-file carries whole files. --definitions-file carries individual definitions: {"definitions": [{"kind", "path", "content"}, ...]}, kind being semantic_model or metric, content that definition’s YAML body (a mapping, written without the leading - ). The name is read from the content, so the two cannot disagree. path is 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-file named 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 by create_metric: true measures 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-parse skips it. A stored semantic plan is applied with transform apply like any other plan (no id applies the latest unapplied one).
  • Removing a definition is an entry with "op": "delete", which carries the name (there is no body to read one from) and no content. 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 define refuses a removal; update and plan accept 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_metric names 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 (a Metric() 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 at transform 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, and transform apply see the unit they always have.
  • semantic ossie define|update|plan is the corresponding native-file route. Its namespace guard is the semantic-model namespace across every configured document: define rejects existing names, update rejects missing names, and plan reports a mixed change under defined and updated. Each edit is a complete configured .ossie.yaml, .ossie.yml, or .ossie.json document; 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|plan is the same route for SLayer model files. Each edit is one whole model file keyed as semantic.slayer.models names it, relative to semantic.slayer.base_dir, and nothing else under the storage root is writable. The prospective set is validated with the pinned release’s own SlayerModel (current format version, unique names, path agreeing with name and data source, joins resolving within the set onto columns both sides have) and, for an edited model, a default_time_dimension that 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.
Skill-to-subcommand mapping: 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:
  1. 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.
  2. 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 model dbt run just 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, and data.profiled_on_demand lists it for a machine reader. The profile is a full one, same detection, same pii_overrides, same cache write, so the flags governing the query are the flags a deliberate explore profile would 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, or auto_profile: false in .dex/config.yml, restores the strict prerequisite: the original refusals word for word, and no connection opened to produce them. explore cluster resolves 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 returns needs_confirmation with the profile already saved, rather than refusing and discarding it.
  3. 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.
  4. 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 under query: in .dex/config.yml; auto_profile sits at the top level instead, because explore cluster honors 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.
  5. 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 under pii_warnings, so the audit trail keeps every such projection findable. A decision that profiled first records the objects under profiled_on_demand (and profile_planned on a needs_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 adds batch_index and batch_size to each and gives them all one timestamp, so an auditor can see that six statements were one authorization event rather than six.
Results are row-major (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):
Rules the envelope enforces, all of them Tier-2 eval targets:
  • Cost before spend. cost is a preflight estimate, and any command that would spend returns needs_confirmation until it is confirmed and budgeted. The handshake in full is in �2.
  • An estimate says how much it is worth. estimate_quality is exact on BigQuery, where a dry run is what the job will bill, and approximate on every connector that models a run instead. unknown means dex tried to price the work and could not, so only the ceiling and the server-side per-statement cap are still binding; null means 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. unit spells 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 ok and the price of the optional half sits in data.offer, carrying the same estimate, breakdown, and --confirm --budget hint a refusal would. maintain check and maintain semantic are the two. The spend gate is unchanged (nothing runs without the confirmed re-issue) and cost.estimate stays unset, so a reader can keep treating a populated cost.estimate on an ok as settled preflight for work that ran. needs_confirmation stays reserved for work the caller asked for and has not authorized, which is the only case where dex is genuinely blocked.
  • data.spend reports settled spend for every billed command, including transform build, and always matches what the same command appended to the ledger. It carries the connector’s unit (bytes_billed or seconds_billed) plus session_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 of data a missing key reads as a value, and the value it reads as is zero. transform build used to stamp data.bytes_billed as well, so a caller reading that key saw a build’s spend and read a maintain check that 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 reports null and a note says why, rather than rounding an unknown down to zero. Key parity across transform build, maintain check, explore query, explore map and explore profile is a contract test.
  • ClickHouse’s paradigm depends on its declared deployment. Config-free callers retain the backward-compatible db_load default; an effective clickhouse.deployment: cloud uses compute_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.jsonl row shape, how reservations and settlements net, the cumulative budget.session_ceiling and 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.paradigm names the connector the command ran against, not what the command happened to cost. A free metadata command on BigQuery reports bytes_scanned with a null estimate, so a caller learns what a billed command will cost in before running one. free_local is DuckDB’s own paradigm and a positive claim that the connector bills nothing; when no connector was resolved the field is null instead. 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.stage says 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-statement explore query reports each statement’s verdict in its own data.results entry, so stage sits there beside that entry’s reason, and is null on a statement that answered, one a guard refused, and one whose connector named no stage, the same way reason and error are 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.
  • connection names 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 as data; 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 reason and warnings. A .dex/config.yml no command can load (it does not parse, or a value fails the schema) refuses every command except config with reason: 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’s warnings, so a typo in a setting is never a setting that silently does nothing. dex config validate reports all of them at once, and dex config unset <key> removes one.
  • No secrets, no uncleared values. data is 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 in explore query’s columnar payload, and only after the query firewall has proven they come from profiled, PII-cleared columns. diffs is not scanned the way data is, so a config diff 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.