Skip to main content
The dbt project is the source of truth for the transformation model. dex maintains no parallel model: it loads the project, reasons over it together with warehouse introspection and the .dex/ cache, and writes changes back into the source files as reviewable diffs. It is not dex’s only write surface. A semantic layer can own its own definitions on the semantic axis, and semantic ossie define|update|plan followed by transform apply writes the exact documents named in semantic.ossie.files, confined to the repository and byte for byte. That surface shares this one’s plan and apply spine and none of the path families below, which is why it is described in �3 rather than here.

What dex reads

  • dbt_project.yml: the project name, profile name, and the six authored path families (model-paths, macro-paths, snapshot-paths, seed-paths, test-paths, analysis-paths), each defaulted the way dbt defaults it when the key is absent.
  • The source files under those families, scanned for the suffixes each one can hold: *.sql / *.yml / *.yaml under the model, macro, snapshot, test and analysis paths, and *.csv / *.yml / *.yaml under the seed paths. Together they are the editing surface (model SQL, schema.yml, dbt semantic models, macros, snapshots, seeds, singular and generic tests, analyses). A file dex can author but does not load would hash as absent, so a later edit to it would register as a create and the apply after it would conflict on a file nobody touched; the scan covers every family for that reason, not for completeness.
  • target/manifest.json when the project has been compiled; a fresh project loads fine without one.
  • profiles.yml (searched the way dbt searches: $DBT_PROFILES_DIR, the project directory, ~/.dbt), but only to resolve a target’s name and adapter type. Connection fields, credentials included, never leave the engine.
  • dbt_packages/ when packages are installed, each package loaded as the dbt project it is, so a package that configures its own path families is read the way dbt reads it.

The reference index

One projection over all of the above answers “where is this name used”, and transform references returns it. Three things about it are worth knowing before relying on it. It reads calls, not text. Jinja is scanned for the calls it contains, so a var() read inside a macro body is a use of that var, a {% if %} block is not a blind spot, and the two-argument ref('package', 'model') form resolves to the model rather than to the package. An argument dex did not read is indeterminate, never absent. A {{ ref(var('x')) }} names a model only dbt can know, and a dimension whose expr is a computed expression names a column dex will not guess at. Both are reported by file and line with no name attached, because the fact is about the call site and not about any one target. Collapsing “dex could not resolve this” into “there is nothing here” is how a use goes missing, so the two stay apart. It states whether it believes itself complete. completeness is complete only when every reason to doubt the answer has been ruled out, and limits names each remaining one: an unresolved reference, packages declared but not installed, sqlglot absent on a column query, a file that would not parse. A completeness claim that is not complete is worse than no claim, because it is the one a caller acts on without checking. Names are matched at the grain the question allows. A model, macro, var, source, metric, entity, dimension or measure name is exact. A bare column name is matched across the project and labelled name_matched; a qualified model.column is resolved through the ref() graph, with occurrences outside that lineage still listed and labelled same_name_elsewhere rather than dropped. A dotted name reads as a source before it reads as a qualified column, since raw.orders is the more common thing to ask about. A seed contributes its header row and never a data row: the header names columns, which is what an index is for, and the rows are project data, which never enters agent context. The project is discovered automatically (the repo root, or a unique child directory holding a dbt_project.yml); dbt_project_dir in .dex/config.yml pins it when discovery would be ambiguous. Absent a dbt project, explore still works, writing only to the .dex/ cache. dbt authoring and maintain reconcile’s mechanical edits do need one, since dbt is what they edit and diff. The semantic axis does not: a repository whose only declarations are native semantic documents authors them through semantic ossie, and maintain still fingerprints that layer and runs its free drift axes against it. dbt is the format dex ships, not the only one it can read. maintain’s four detection commands read whichever format project.format names, so a host whose models are not a dbt project can still be a drift baseline; transform and maintain reconcile’s mechanical edits are dbt throughout. See �4 for the seam and how to name another format.

How dex writes

Every proposed change is a plan: the agent-authored file contents, validated by the engine, pinned to the sha256 of each file they would change, and stored under .dex/plans/. Applying a plan re-hashes every file first:
  • Hash matches (or the file is a clean create): apply, all-or-nothing.
  • File already carries the proposed content: a no-op, not a conflict.
  • Anything else means a human edited the file after the plan was made. That is a conflict: nothing is written, the divergence is surfaced as a diff, and the caller either re-plans against current state or overrides with an explicit --confirm.
Each edit carries an op: upsert (create or update, the default) or delete. A delete removes a file as a first-class reviewable diff, pinned to the file’s hash like any other edit, so it obeys the same conflict rule: a file a human touched after planning is never silently removed. Deletes are guarded as a unit: a plan is refused if any file that survives it still ref()s a deleted model, so the post-change project is proven to have no dangling reference before the plan is stored (and, when dbt is available, the same post-deletion tree is confirmed by dbt’s own parser). The guard reads the reference index described under What dex reads, not a text search, so it resolves a two-argument ref(), does not mistake a seed’s data row for source, and warns rather than refusing on a reference it could not resolve. A rename is expressed as one plan: delete the old model, create the new one, and update every referrer, validated together. Human edits to dbt are authoritative by construction; dex holds no competing copy to overwrite them from. Writes are confined to the six authored path families plus the project-root manifests dbt keeps there (dbt_project.yml, profiles.yml, packages.yml, dependencies.yml); path escapes are refused. A native semantic document is not in this surface and is never reached through it: it carries its own confinement rule, the exact paths the semantic axis declares. Within the surface, an edit’s kind and its location have to agree: a snapshot belongs under the snapshot paths and nowhere else, a seed under the seed paths, a macro under the macro paths, a singular or generic test under the test paths, an analysis under the analysis paths, and model SQL and semantic YAML under the model paths. schema.yml is the one kind several families admit, because dbt expects a seed’s column types, a snapshot’s tests, a singular test’s severity and an analysis’s description declared beside the thing they describe. Filing a kind in the wrong family is refused at plan time naming both fixes (move the file, or relabel the kind), since dbt would otherwise parse a snapshot or a test as a model, or never load a seed or an analysis at all. A model, a snapshot and a seed each build a relation dbt names after the file and each is ref()-able, so all three count as nodes: deleting one is guarded against surviving references exactly like deleting a model, and all three are what maintain fingerprints as the transformation layer. A macro is not a node and never was one. Neither a singular test nor an analysis is a node here, and the word is worth being careful with. dbt calls a singular test a node (test.<project>.<name>) and it is one in dbt’s graph, but it builds no relation and nothing can ref() it, so it is not one of “the things this project builds”: counting it would put a name into the drift baseline that no warehouse table will ever back. An analysis is further out still, compiled by dbt compile into target/compiled/, never built, and absent from a dbt build entirely. Both are loaded so they can be authored and hashed like anything else; neither enters the transform-layer model list, and deleting either raises no dangling-reference guard because there is nothing that could dangle. dex never builds to a non-dev target, and a delete only ever removes a file from the repo, never a relation from the warehouse.

Running dbt (build, deps, parse)

Every dbt subprocess runs with its working directory pinned to the project dir, so relative paths in profiles.yml and anywhere else resolve against the project, not the caller’s shell cwd. When the project declares packages (packages.yml, or a dependencies.yml with a packages: key) and dbt_packages/ is missing or empty, transform build runs dbt deps automatically before building; transform deps is the explicit install/refresh. Package installation writes only dbt_packages/ and the lockfile inside the project and spends nothing against the warehouse, so it runs without the cost gate. Semantic plans are validated up to and including dbt’s own parser before they are stored: dex copies the project (minus warehouse files, target, and logs) into a throwaway directory, overlays the proposed YAML, and runs dbt parse there, so nothing the parser writes touches the real project. When dbt is unavailable the parse degrades to a warning rather than a hard failure. Source: references/dbt-project.md (edit on main). This page is generated; change the source, not the site.