Claude Cursor GitHub Copilot Skill

transform

Use this to author and change a dbt project or a semantic layer: bootstrap a project in a repo that has none (`transform init`), write or refactor model SQL from staging to marts, add tests and docs in schema.yml, manage dependencies, and define or update the semantic layer, whet

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download exmergo-dex-skills_transform-9823c5c.zip · 23 KB
exmergo/dex 25 8 forks Apache-2.0 Updated 1d ago
Part of exmergo/dex — 3 skills

Install

skills CLI npx skills add https://github.com/exmergo/dex/tree/main/skills/transform
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install exmergo-dex@llmmart
Git git clone https://github.com/exmergo/dex.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole exmergo/dex collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Transform

Author and refactor the dbt project: both the SQL transformations (staging to marts, tests, docs) and the semantic layer on top (entities, dimensions, measures, metrics). Both are the same job, writing reviewable diffs to the dbt project, which is the source of truth. This is the building half of the loop. It writes only to the repo, as reviewable diffs, and runs against a dev target only.

How to drive it

uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]

dex runs its engine through uv, which is a prerequisite and is not installed by Claude Code. If the shell reports uv: command not found, stop and tell the user to install it (curl -LsSf https://astral.sh/uv/install.sh | sh, or brew install uv, or pipx install uv), then re-run. Never fall back to editing the dbt project by hand instead: the validation, the diffs, and the dev-target gating live in the engine, so any other path is unguarded.

The first command in a fresh environment installs the engine, so it can take tens of seconds where later ones take well under a second. --warm pays that install up front and exits without running anything:

uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm

Offer it once at setup. It is not something to run before an ordinary command.

You author the dbt file content; the engine validates it, computes the diffs, and stores the proposal as a plan. Hand content over with --edits-file <path> (or - to read stdin), a JSON payload:

{"edits": [
  {"path": "models/staging/stg_orders.sql", "kind": "model_sql", "content": "..."},
  {"path": "models/staging/stg_orders.yml", "kind": "schema_yml", "content": "..."},
  {"path": "snapshots/snap_orders.sql", "kind": "snapshot_sql", "content": "..."},
  {"path": "seeds/country_vat.csv", "kind": "seed_csv", "content": "..."},
  {"path": "tests/assert_totals_reconcile.sql", "kind": "test_sql", "content": "..."},
  {"path": "analyses/email_skew.sql", "kind": "analysis_sql", "content": "..."},
  {"path": "models/marts/dim_orders.sql", "kind": "model_sql", "op": "delete"}
]}

kind is model_sql, schema_yml, semantic_yml (optional on semantic define|update|plan, which imply it), macro_sql (a macro file under the project's macro paths), snapshot_sql (a snapshot under the snapshot paths), seed_csv (a seed's CSV under the seed paths), test_sql (a singular test or a generic test definition under the test paths), analysis_sql (SQL dbt compiles but never runs, under the analysis paths), packages_yml, project_yml (the project-root dbt_project.yml), or profiles_yml (the project-root profiles.yml). Model SQL must be a single read-only SELECT once its jinja is stripped; semantic YAML is validated against MetricFlow's schemas, cross-reference-checked, and (when dbt is available) parsed by dbt itself before the plan is accepted; a macro file must hold only macro definitions and jinja comments. A snapshot 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), and whose body is a single read-only SELECT. A seed must parse as CSV with a named, duplicate-free header and one field per column on every row, and stays under 5,000 data rows and 1 MiB (past that it is data rather than a lookup: load it into the warehouse and source() it). A test_sql file is read to decide which of the two shapes sharing the test paths it is: one holding {% test %} blocks is a generic test definition and must hold only those and jinja comments, balanced; anything else is a singular test and must be a single read-only SELECT. A singular test that names no ref() or source() is warned about, not refused, because it runs against nothing and passes unconditionally. An analysis must be a single read-only SELECT too, even though dbt only compiles it. project_yml must keep a name; profiles_yml must reference every secret via {{ env_var('NAME') }} (a literal credential is refused so none reaches the diff). Config kinds, snapshots and seeds are all parsed by dbt at plan time.

Each kind is confined to its own family of paths, and filing one in the wrong family is refused naming both fixes. schema_yml is the exception, accepted beside a model, a snapshot, a seed, a test or an analysis, because that is where dbt expects a snapshot's tests, a seed's column types, a singular test's severity and an analysis's description declared.

Three things here are called a test, and they are not interchangeable. Generic tests are declared inside a schema.yml (data_tests: on a model or a column). Unit tests come from transform test --scaffold <model>, which writes a unit_tests: block, also schema_yml. Singular tests and generic test definitions are files under test-paths, and test_sql is the kind for those. transform test --mutate <model> measures all three at once, since a defect has to get past every one of them to reach production.

A seed puts values, not logic, into a diff, and a diff goes into git and stays there. So a seed whose header names a column that looks like personal data is refused, and the refusal names the pii_overrides entry in .dex/config.yml that a human can add to clear it. Detection reads names and types and never values (everywhere in dex), so it cannot see personal data hiding under a neutral column name: do not build a seed out of warehouse rows you have not looked at.

dbt build runs seeds, snapshots and singular tests natively, so transform build after an apply is all it takes; there is no separate seed or test step. A snapshot writes a table and a test runs a scanning SELECT, so both are priced in the cost handshake; a seed scans nothing and an analysis is never built at all, so neither is. A singular test and an analysis build no relation and nothing can ref() either, so neither is a node: neither enters maintain's drift baseline, and deleting one raises no dangling-reference guard.

op is upsert (the default: create or update, carrying content) or delete (remove the file, no content). A delete is a first-class reviewable diff like any other edit, so a reclassification or refactor is one plan rather than a plan plus a manual rm. Deletes are guarded: the plan is refused if any file that survives it still ref()s a deleted model, naming the offenders. Carry the edits that remove those references in the same plan (for a rename, delete the old model, create the new one, and update every referrer to point at it, all together) so the post-change project is validated as one unit. An unconfirmed delete against a file a human edited after planning surfaces as needs_confirmation, never a silent removal.

For a rename or a removal, reach for transform rename / transform remove instead of assembling the edits yourself. They generate the whole change from the reference graph and refuse when they cannot promise it is complete, which is the guarantee hand-assembly cannot give you.

Bootstrapping a project

If no dbt project exists in the repo, offer transform init before anything else: transform plan needs a project to edit. Ask the user for the project name and confirm the connector with them, then run:

uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" transform init "<name>" --connector <c>

The engine renders the whole skeleton (dbt_project.yml, models/staging/ and models/marts/, a profiles.yml with a single dev target and no secrets) and records connector, dbt_project_dir, and dbt_target: dev in .dex/config.yml; do not hand-write these files yourself. Init never assumes a connector: it errors rather than defaulting, so always pass the user's confirmed choice (a connector: already committed in .dex/config.yml also counts). Every connector is supported: DuckDB, BigQuery, Snowflake, Databricks, Postgres, Redshift, and ClickHouse. DuckDB needs a warehouse path (--path, or the duckdb.path config). BigQuery needs a GCP project (usually bigquery.project in .dex/config.yml; confirm it with the user) and writes builds to a dedicated dev dataset (bigquery.dev_dataset, default dbt_dev); auth is Application Default Credentials, so if credentials are missing tell the user to run gcloud auth application-default login, never ask for a key. Snowflake writes builds to a dedicated snowflake.dev_database/dev_schema on the pinned warehouse; Databricks writes builds to a dedicated databricks.dev_catalog/dev_schema on the pinned SQL warehouse (if credentials are missing tell the user to run databricks auth login, never ask for a token); Postgres writes builds to a dedicated postgres.dev_schema (default dbt_dev), with the password reaching dbt only through the PGPASSWORD environment variable. Redshift writes builds to a dedicated redshift.dev_schema (default dbt_dev): with a redshift.workgroup pinned the profile renders IAM auth (temporary credentials from the AWS chain, nothing persisted), otherwise the password reaches dbt only through the REDSHIFT_PASSWORD environment variable. ClickHouse writes builds to a dedicated clickhouse.dev_database (default dbt_dev), rendered as the profile's schema: because dbt-clickhouse has no database: key, with the password reaching dbt only through the CLICKHOUSE_PASSWORD environment variable; the rendered profile also carries a custom_settings block whose env_var references are how transform build turns the confirmed budget into a per-statement server-side cap, so do not strip them from a profile you edit. All of them discover their connections and refuse with the fix named when none resolves. Init refuses if any dbt project already exists.

When the user wants staging/intermediate/marts isolated in their own datasets/schemas (a common ask when the warehouse is shared with unrelated work), offer --layered-schemas: init then also scaffolds models/intermediate/, a generate_schema_name override, and per-folder +schema: config, so builds land in staging_dev / intermediate_dev / marts_dev instead of one shared dev namespace. Do not hand-write that macro; existing projects can adopt it later via transform macro generate_schema_name. Note dbt warns about "unused configuration paths" until the first model lands in each layer folder; that resolves itself.

Init also checks (free, metadata-only) whether each namespace the project would build into already exists with content, and warns naming the namespace and a few object names. The warning is advisory: relay it to the user and ask whether the content is theirs (a previous dev build) or unrelated; when it is unrelated, discard the freshly scaffolded project (nothing has been built), point the config at a different dev namespace, and re-run init. A "could not check" note just means no connection was reachable at init time.

dbt SQL models

  • transform plan "<intent>" --edits-file <path|-> validates the edits and returns them as diffs with a plan id. Nothing is applied yet. Add --scaffold <table> (repeatable) to generate a staging skeleton (stg_<table>.sql plus per-model YAML with key tests and PII meta) from the .dex/ cache instead of, or on top of, hand-authored edits.

  • When you edit a model that already exists, the plan reports what your change does to its row population under data.row_attribution: every predicate, join, source and grain change is named, and each is measured on its own against the prior model. Read it before applying. A change you were not asked to make carrying a non-zero delta is the signal to look at: the model still compiles and the columns are still right, and it is now returning a different set of rows. It is advisory, never a refusal, because changing the filter is sometimes the job. On DuckDB the deltas are measured automatically; on a billed connector the changes are named for free and measuring them needs --attribute-rows (then the usual --confirm --budget once priced), so ask the user before spending. A change reported with attributed: false names why it could not be measured; treat that as unknown, not as zero.

  • The plan also warns about the shape of what you authored, in warnings. A SELECT list that diverges from the columns the model's schema.yml declares is named in both directions; fix whichever side is actually stale, and say which one you decided it was.

  • A warning that the model exposes a raw foreign key with no resolved counterpart is dex reading a convention out of the project's own models: the siblings it names all resolve keys of that shape, and it names the parent model yours could resolve against. Prefer resolving it, by joining that parent the way the siblings do. Where the raw key is deliberate (a fact-shaped model in a dimension folder, a key the consumer needs verbatim), say so plainly to the user rather than quietly leaving it. Never switch the check off to make the warning go away: conventions.resolved_keys: false in .dex/config.yml is a decision about the house's style, so recommend it for the user to accept, the same way you would a pii_overrides entry.

  • transform apply [plan-id] writes the plan into the dbt project (the latest unapplied plan when no id is given; any plan kind, semantic included). The result is still a reviewable git diff for the user. If a human edited a file after the plan was made, nothing is written: the divergence comes back as diffs with needs_confirmation, and you should re-plan against current state (or, only when the user says so, re-run with --confirm).

  • transform plans lists stored plans (pending and applied, newest first), so you never need to browse .dex/plans/ by hand.

  • transform references <name> [more...] answers "where is this used" before you change it. Reach for this whenever a change has to land in more than one place: removing a project variable, renaming a column, deleting a model, changing what a macro returns. Editing the files you happen to have open and hoping that was all of them is the failure this prevents, and it is a quiet one, because the project still compiles with one use left behind.

    It is repo-only and free on every connector, so there is never a cost reason not to run it. The positional is variadic, so one call covers a whole rename. --kind narrows to model, source, seed, snapshot, macro, var, column, metric, entity, dimension or measure; leave it off when you are not sure what the project calls the thing, and the answer will tell you.

    Read data.completeness before you act on the list. When it says incomplete, data.limits says why and data.indeterminate lists the call sites dex could not resolve, each with a file and a line. Those are references that may name what you asked about, so open them and decide yourself; do not treat the list of resolved hits as exhaustive when the verdict says it is not. A bare column name is matched across the project (scope: name_matched), so qualify it as model.column when you want the lineage separated from same-named columns elsewhere.

    Once you know where a name is used, transform rename and transform remove below make the change; you do not have to carry the list into hand edits.

  • transform rename <kind> <old> <new> generates every edit the rename needs and stores them as one plan: the definition, every model that selects the name, every schema.yml that documents or tests it, every semantic reference, and a seed header. Kinds are column, var, model, seed, snapshot, macro, source. Repo-only and free, like references.

    Use this instead of editing the files yourself. Retyping a rename across nine files and missing the tenth is the failure mode this exists for, and it is a quiet one: the project still compiles.

    Name a column as model.column. A bare name is refused, and the refusal lists the models that define a column of that name so you can pick. That asymmetry with references is deliberate: a report you read can afford to be imprecise and a rewrite cannot, because renaming a bare id project-wide would rewrite every unrelated id there is.

    It refuses rather than half-applying, and each refusal names what to fix: a reference dex could not resolve statically, a name an installed package also defines, a column handed to a macro as a literal string (dex cannot tell a column argument from a display label), a SELECT list it cannot read. Fix what it names and re-run. There is no override flag, because a completeness guarantee you can switch off is a suggestion. A bare select * is not a refusal: it carries the column through under the new name with no edit, and the plan's notes says so.

    Read data.sites against the transform references output you ran first. It counts occurrences per reference form in the same vocabulary, so the two agreeing is your evidence that nothing was dropped between reading and writing.

  • transform remove <kind> <name> removes the definition and verifies every read is gone, refusing while any survives and naming each with a file and line.

    It never rewrites a read, and that boundary is the point rather than a gap. {% if var('using_department') %} can be deleted or unguarded, and {{ var('x') }} sitting in an expression has no value dex may invent. You are the one who knows. Author those edits yourself and pass them with --edits-file in the same call: they are validated and stored in the same plan, so the removal is still atomic.

  • transform place <column> --targets <a,b> --expr "<sql>" answers where a derived column that several models need should be defined. It walks ref() upward from every target, takes the lowest model they all descend from that already projects the inputs your expression reads, defines the column there, and threads it down every chain. The inputs come from parsing --expr, so there is no separate list to get out of sync with it.

    Read data.reasoning before you apply. It names the ancestor, why it is the lowest, which targets descend from it, and the chain. You are supposed to be able to disagree with it; --explain gives you the same answer with no plan stored, which is the cheap way to ask.

    When data.strategy is per_target the shared definition was not available and the reasoning says why: no common ancestor, or the lowest one is missing an input, or two candidates tie. dex will not go further upstream to pull an input down, because that turns one placement into an unbounded rewrite of everything above it. The fallback duplicates the derivation in each target and those copies will drift, so relay the reason to the user rather than applying it on their behalf. Often the named fix (add the missing column to the ancestor first) is what they actually want.

  • transform build --target dev runs dbt build against a dev target. The engine surfaces a cost preflight first and runs only with --confirm (plus a --budget on billed connectors). dbt itself has no dry-run, but the engine compiles the project and dry-runs each node itself, so on BigQuery the first unconfirmed call already returns needs_confirmation with estimated_bytes and a per_table_bytes breakdown, the same shape the scanning explore commands use. Never invent a --budget figure: read the reported estimate (per_table_bytes is the actionable half, since it names which node is driving the cost) and confirm with a --budget grounded in that number. If the build is refused over the ceiling, the refusal carries a calibration line from .dex/spend.jsonl: what this connector's recent commands billed as a fraction of estimate, or a sentence saying there is too little history to say. Builds over-estimate most on a partitioned or clustered warehouse, so relay it, and note that the ceiling binds on the estimate rather than on what settles, so a budget set at that fraction of the estimate is refused again. A suggested_session_ceiling on that envelope is the project's one-time ask for a cumulative daily cap, separate from --budget: relay it and add the user's answer (--session-ceiling <value> or --no-session-ceiling) to the same re-issue, which records it in .dex/config.yml for good. Each statement dbt runs is capped server-side by the profile's maximum_bytes_billed, and the envelope reports billed bytes afterward. Production-looking targets are refused outright; --confirm cannot override that. dbt runs with its working directory pinned to the project dir, so relative paths in profiles.yml resolve against the project. When the project declares packages (packages.yml) and dbt_packages/ is missing, the engine runs dbt deps automatically before the build.

  • transform build --verify is how you answer "is it right", not just "did it run". A green build tells you dbt executed. It does not tell you the model holds the rows it should, and that is where the expensive defects live: an inner join written where a left join was meant loses rows, raises nothing, and passes every uniqueness and not-null test over the smaller result. --verify sweeps the nodes this build touched and reports the findings in the same envelope, under data.verification. Reach for it whenever the build was meant to prove a change is correct, which is most of the time you build at all.

    Read data.verification.ran before reading anything else. It is always present, because a build that did not verify and a build that verified and found nothing look identical otherwise, and only the second one means the models are clean. When it ran, findings is ranked the way maintain verify ranks it, scope names the models covered, and suppressed names each class that could not run and why. Relay a suppression rather than reading past it: it is the difference between "checked and clean" and "not checked".

    Findings never fail the build and never appear in errors. Do not treat one as a build failure or re-run to make it go away: relay the finding, its two counts, and the join it names, and let the user decide. A failed build still reports which node failed and which were skipped because of it, which is usually a faster read than the dbt log.

    On a billed connector the sweep is priced into the build's own estimate as a (row counts) line, so the --budget you already read off the unconfirmed envelope covers both. Never add a second budget for it. If the envelope comes back ok with a data.offer, the build is done and billed and the offer buys only the counts it could not afford; relay the number rather than re-running the build.

  • transform test --mutate <model> answers "are these tests worth anything". Writing a test is not the same as writing a test that would catch something, and nothing else in the dbt ecosystem tells the two apart. This plants one standard analytics defect at a time in the model's SQL (a flipped boundary, a dropped or negated filter, a swapped join type, a removed CASE branch, an inverted ratio, a shifted window frame, sum for max), runs the model's own tests against each, and reports which ones nothing caught.

    Reach for it right after you author or scaffold tests, and before telling the user the model is covered. It is also the honest answer when a user asks whether their tests are any good, which is otherwise unanswerable.

    Read data.counts and then the survivors, which are listed first. Each carries defect, a sentence saying what would now be wrong, and suggested_test, the test that would catch it. Relay those two: the user's next action is to write that test, not to read the SQL. A score is reported but it is a ratio of two small integers over one model, so quote it as context and never as a grade, and never compare it between models.

    Check baseline.excluded before trusting a clean-looking result. Every verdict is relative to the tests that passed against the unmutated model, so a test that was already failing is excluded and named there. And read cap.elided: the run is capped at 20 mutants, so a model with more sites than that was measured on a sample, spread across defect classes.

    It writes nothing. Mutants build as ephemeral models in a throwaway copy, so the project is untouched and no relation is created or replaced. On a billed connector the whole batch is one estimate and one --confirm, and if the budget runs out partway the rest come back not_run: relay that rather than reading a short list as a clean bill.

  • transform deps installs dbt packages explicitly (also the refresh path when dbt_packages/ exists but is stale). No confirmation needed: deps writes only inside the project and never touches the warehouse.

Shipped macros

  • transform macro lists the macros dex ships; transform macro <name> proposes scaffolding one into the project's macro directory as a plan, applied with transform apply like any other. The user's copy is theirs to edit; re-running the command diffs it back against the shipped version (a warning says whether it is customized or stale), and applying that plan overwrites deliberately.

  • unpivot_json_object turns a JSON object column with dynamic keys (the NoSQL-sourced shape: a Firestore/Mongo/DynamoDB document keyed by a related entity's id) into one row per top-level key. Use it instead of hand-rolling JSON SQL; it renders a complete SELECT:

    select id, key as related_id, value as attrs
    from (
      {{ unpivot_json_object(relation=ref('stg_entities'),
                             json_column='attributes', passthrough=['id']) }}
    )
    

    The contract on every connector: one row per top-level key, key a plain string, value the warehouse's native semi-structured type (BigQuery JSON, Snowflake VARIANT, Databricks VARIANT, Postgres jsonb, Redshift SUPER, DuckDB JSON, ClickHouse raw JSON text in a String), a NULL object yields no rows, and a nested object's own field names never surface as top-level keys. For a string-typed source column pass the parse expression as json_column (parse_json(payload) on BigQuery, Snowflake, and Databricks; json_parse(payload) on Redshift); Postgres, DuckDB, and ClickHouse accept JSON-bearing text directly. Databricks needs VARIANT support (DBR 15.3+ or a current SQL warehouse). Two BigQuery quirks are absorbed by the macro, so do not "fix" them back in: a JSON path argument must be a compile-time literal (the macro reads values with the subscript operator, which accepts a computed key), and JSON_KEYS recurses into nested objects unless depth-limited (the macro pins depth 1). When a planned model calls the macro and the project lacks it, the plan warns and names the scaffold command; scaffold it rather than inlining a copy.

Preparing the dev target

Before the cost gate, and for free, transform build refuses two things and names the fix for each. Neither costs anything to check, so both surface on the unconfirmed call rather than after a budget has been agreed.

Config that has drifted from the profile. transform init renders .dex/config.yml into the project's profiles.yml, and dbt reads only the profile from then on. If a later config edit never reached it (a retargeted dev_database, a different warehouse), the build refuses and names both values and both files. Edit one to match the other. The engine never rewrites profiles.yml, which you may legitimately have hand-edited.

A dev target that does not exist. On Snowflake, dbt creates schemas but never databases, so a missing dev_database is refused with the CREATE DATABASE statement to run; dex will not create it for you, because its only writes are reviewable diffs inside the repo. On Postgres, Redshift, and ClickHouse, dbt creates the dev namespace but only if the profile's user may, so the missing privilege is what gets refused, with the CREATE SCHEMA/GRANT statement to run. On ClickHouse that check can also come back with no verdict, because a server may not let dex read another user's grants; it then warns instead of guessing, and the build proceeds with dbt's own error as the backstop. On DuckDB the dev target is a database file, and dbt would happily create an empty one, then fail every source() relation with a confusing catalog error. The convention there: copy the shared source warehouse to the dev target path (for example cp shared/f1.duckdb <project>/dev.duckdb), or point the dev target at an existing file. Projects without sources just get a warning and an empty database, which is fine for model-only builds.

The semantic layer

  • semantic define ... and semantic update ... author and evolve the dbt semantic models (entities, dimensions, measures, metrics) as plans. define refuses names that already exist (use update); update refuses names that do not (use define). For one logical change that mixes both (evolve existing metrics and add the helpers they depend on), use semantic plan ...: it accepts mixed intent and classifies each name, and the envelope reports the split as defined, updated, unchanged, and removed.

  • Prefer --definitions-file over --edits-file for the semantic layer. A real project keeps its metrics in one shared file, so a whole-file payload means retyping every definition you are not touching: the diff and the updated list then describe the whole file instead of your change, and every restated line is a chance to corrupt a definition by hand. Send only what changes instead: {"definitions": [{"kind": "metric", "content": "name: ...\n..."}]}, where kind is semantic_model or metric and content is that definition's YAML body with no leading - . The name comes from the content, and path can be omitted for anything the project already declares (the engine rewrites it where it lives). Everything else in the file, comments included, is preserved byte for byte. Reach for --edits-file when you are creating a file, moving a definition between files, or emptying one, and when the engine refuses a layout it will not splice into.

  • Removing one definition is the same payload with "op": "delete": {"definitions": [{"kind": "metric", "name": "doubled", "op": "delete"}]}, the name declared (there is no content to read it from) and no content beside it. Nothing is removed for going unmentioned, so you can send a removal and an edit in one payload and everything you did not name stays as it is. Use semantic update or semantic plan, not define. The envelope reports it under removed.

  • If a metric still reads what you are removing (its input is that metric, or a measure of the semantic model you are removing), the plan is refused and the reader is named: add that reader's own delete or update to the same payload, in any order, and it goes through. A removal that would leave a file with no semantic model or metric in it is refused too, because deleting or emptying a file is a whole-file edit: do that with transform plan --edits-file and "op": "delete".

  • unchanged means you re-stated a definition exactly as the project already has it. It is not an error, but if a plan is entirely unchanged it changes nothing, and the envelope warns as much: check whether you meant to edit something.

  • Plan-time validation is layered so a plan that validates will build: MetricFlow's schemas check the shape; the engine resolves every metric input (ratio and derived metrics reference metrics, not measures; a measure only becomes a metric via create_metric: true, and the error names that fix); and finally the emitted YAML is run through dbt's own parser against a throwaway copy of the project. A plan that fails parse is refused, not stored. If dbt is not installed the parse degrades to a warning; --no-parse skips it explicitly.

  • A semantic plan is applied like any other: transform apply [plan-id] writes its YAML into the dbt project (no id applies the latest unapplied plan).

  • For native Ossie use semantic ossie define|update|plan with --edits-file <path|->. Supply whole documents whose paths are listed in semantic.ossie.files; the command implies the semantic_document kind, validates the complete prospective configured layer, and writes the accepted bytes exactly when the plan is later applied. This is a semantic-layer write surface and does not make Ossie the transformation project.

    The namespace guards match the dbt ones: define refuses a semantic-model name the layer already has, update refuses one it does not, and plan accepts both and reports each under defined or updated. Neither removes a model, and a configured file may be absent before define, so a new document is planned once its path is committed to config.

    What validates an Ossie plan is not what validates a dbt one, and the difference matters. There is no external parser to gate on: dex checks the document's structure against the Ossie schema it pins (needs [ossie]), its internal consistency in pure Python, and each SQL expression's syntax through the dialect engine (needs [sql], which every connector extra carries). Without [sql] the third layer degrades to a named skipped-validation note, never to a silent pass. All three run over the complete prospective layer, your edits overlaid on the other configured documents, before a plan is stored.

    It then checks the references against the exploration cache, opening no connection. A source relation the cached inventory positively lacks, or a column absent from a relation the cache profiled, refuses and stores no plan. Anything the cache cannot speak to is a named note instead: an unprofiled relation, a computed or non-SQL expression, a quoted identifier, a query-backed source. Read those notes rather than treating them as failures; they say what was not checked.

    Accepted bytes are written exactly as authored on apply. dex does not parse and re-serialize the document, so comments, key order, quoting and whitespace all survive, and a configured document the payload did not mention is untouched. A target file that changed after planning refuses the whole apply rather than writing part of it, unless you confirm the overwrite deliberately.

    references/ossie-walkthrough.md in the engine repository runs the whole sequence on a local warehouse if you want to see it end to end.

  • dbt cannot parse semantic models in a project without a MetricFlow time spine; the engine warns when one is missing and defers the parse gate until one exists. Author it like any other model (a day-grain date model plus YAML with a time_spine: config) in the same or a separate plan.

  • viz preview is not yet implemented (it returns not_implemented); the Viz integration arrives later.

Guardrails (enforced in the engine, not here)

  • Writes confined to the repo, and within it to two disjoint surfaces: the dbt project's authored path families (models, macros, snapshots, seeds, tests, analyses) plus the project-root manifests dbt keeps there, and the exact native semantic documents named in semantic.ossie.files. Neither surface can reach the other, an absolute path or a .. escape is refused on both, and dex never writes to source warehouse data.
  • Dev-target only. Prod-target execution is never initiated by dex.
  • Cost surfaced before any spend. A build that would spend requires explicit confirmation and a session budget. The cost guard in full, in the engine repository: references/cost-controls.md; the PII policy that governs what a seed may carry and what gets stamped into meta: references/pii-policy.md.
  • Propose, don't impose. Human edits to the project (SQL and semantic YAML) and to a native semantic document are authoritative; on conflict the engine surfaces a diff and asks rather than overwriting.
  • PII flags propagate from the cache into emitted dbt (model and column meta), never example values. Stamping is presence-based at any confidence; only a column cleared by a human pii_overrides entry in .dex/config.yml is scaffolded without the meta.
Files (dex)
  • evals
    • evals.json 5.2 KB
      {
        "skill_name": "transform",
        "triggering": {
          "positive": [
            "Set up a dbt project in this repo.",
            "Build a staging model for the raw orders table.",
            "Refactor stg_customers and add not_null and unique tests.",
            "Create a marts model that joins orders and customers.",
            "Add documentation and tests to this dbt model.",
            "Define a revenue metric on top of fct_orders.",
            "Add a customer_region dimension to the customer entity.",
            "Build dbt semantic models (MetricFlow) for the orders mart.",
            "Preview this semantic model in Viz.",
            "Fix the rpt_customer_metrics.sql model. It produces inf and NaN values in calculated columns and is missing the ltv_tier column.",
            "This model returns wrong numbers for Q4, the revenue is double counted. Fix it.",
            "Add three new columns to models/marts/customer_metrics.sql: engagement score, churn risk score, and an LTV tier.",
            "Add a revenue metric to my Ossie semantic model.",
            "Update semantics/commerce.ossie.yaml to add a lifetime_value field on customers."
          ],
          "negative": [
            "What's in this warehouse and which tables matter?",
            "Profile the events table and flag PII.",
            "What changed in the warehouse since I last looked?",
            "Reconcile my dbt project with the current source schema.",
            "Write the PR description for the branch I just pushed.",
            "Bump dbt-core to 1.10 in requirements.txt.",
            "Did my Ossie semantic layer drift from the warehouse?",
            "What metrics does this repo's semantic layer define, and what tables are behind them?"
          ]
        },
        "evals": [
          {
            "id": 0,
            "prompt": "Create a staging model for the raw orders table with appropriate tests, against my DuckDB dbt project.",
            "expected_output": "Proposed dbt model SQL and schema.yml tests presented as a reviewable diff; nothing applied until confirmed.",
            "files": [],
            "assertions": [
              "Changes are presented as diffs, not silently written (Principle 8)",
              "Generated SQL is valid dbt and SELECT-only against source data",
              "Any dev-target build surfaces a cost preflight before running (cost before spend)",
              "Prod-target execution is never proposed (dev-target only)"
            ]
          },
          {
            "id": 1,
            "prompt": "Refactor this model to be more readable and add tests, but don't run anything against the warehouse yet.",
            "expected_output": "A refactor proposed as a diff with tests added; no warehouse execution.",
            "files": [],
            "assertions": [
              "Human-written dbt is treated as authoritative; no silent overwrite",
              "No warehouse build runs without explicit confirmation and a budget"
            ]
          },
          {
            "id": 2,
            "prompt": "This repo has no dbt project yet. Set one up so I can start building staging models from my DuckDB warehouse.",
            "expected_output": "A dbt project skeleton bootstrapped via `transform init` with an explicitly confirmed connector, reported as create diffs; nothing hand-written by the agent.",
            "files": [],
            "assertions": [
              "`transform init` never runs without an explicit connector (a flag or a committed connector: in .dex/config.yml); the agent confirms the choice with the user rather than assuming one",
              "The skeleton is engine-rendered, not agent-freehand (no hand-written dbt_project.yml or profiles.yml)",
              "The generated profiles.yml has a single dev target, no prod-named target, and no secrets",
              "Everything created is reported as reviewable create diffs, and an existing dbt project is never overwritten"
            ]
          },
          {
            "id": 3,
            "prompt": "Define a revenue metric and a couple of dimensions on top of my orders mart as dbt semantic models.",
            "expected_output": "Semantic-layer edits written as dbt semantic models (MetricFlow YAML) in the dbt project, presented as a reviewable diff.",
            "files": [],
            "assertions": [
              "The dbt project is the source of truth; edits are diffs to dbt semantic YAML, not a parallel model",
              "Emitted dbt semantic models are valid MetricFlow YAML",
              "PII flags propagate into emitted dbt (model and column meta); no example values",
              "Nothing is applied silently; the change is a reviewable diff"
            ]
          },
          {
            "id": 4,
            "prompt": "I added tests to my orders mart last week. Are they actually any good?",
            "expected_output": "Mutation coverage run on that model, reporting which planted defects the tests failed to catch, each with the test that would catch it. Survivors are relayed as the finding; the score is context, not a grade.",
            "files": [],
            "assertions": [
              "Reaches for `transform test --mutate <model>` rather than reading the tests and judging them by eye",
              "Relays the surviving defects and their suggested tests, not just a count or a score",
              "Reports the cap and any excluded baseline tests rather than presenting a partial run as complete",
              "Any metered connector surfaces one batch estimate before running (cost before spend)",
              "Never claims the project or the warehouse was modified: mutants are ephemeral and confined to a throwaway copy"
            ]
          }
        ]
      }
      
  • scripts
    • run.py 16.1 KB
      # /// script
      # requires-python = ">=3.11"
      # dependencies = []
      # ///
      """Thin PEP 723 wrapper that drives dex-core via the command contract.
      
      The skill never re-implements logic. It forwards its arguments to the pinned
      `dex-core` engine and lets the engine print the sanitized JSON envelope. Run it
      with `uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <dex
      subcommand> ...`.
      
      `uv` is a hard prerequisite: it is what installs and runs the engine. When it is
      absent this wrapper refuses with an error envelope naming the install command,
      rather than letting the exec fail with a traceback. Invoked through `uv run` the
      shell fails first (`uv: command not found`), which is why each SKILL.md also tells
      the agent what that message means.
      
      Two execution modes, chosen automatically:
        - Monorepo checkout (this repo): `packages/dex-core` is found above the skill,
          so the engine runs from an editable local install. This is what makes the
          wrapper work before the package is published.
        - Installed plugin: no local package is present, so the pinned PyPI release is
          installed hermetically by uv.
      
      That environment is uv's own, and `--no-project` is what keeps it that way. Without
      it uv discovers whatever Python project the caller happens to be standing in, builds
      it, writes a `.venv/` and a `uv.lock` into their repo, and puts their dependencies on
      the engine's import path: dex would be leaving unreviewed artifacts in a repo it was
      asked only to read, and running against a closure it did not pin.
      
      `--warm` materializes that environment and exits without running a command, so the
      install can be paid once out of band instead of on the first caller's clock (see
      _warm).
      
      The engine version is pinned; the *extras* are chosen at runtime, so one published
      release serves every warehouse and the default install stays light. The connector
      extra comes from the active connector; two commands need more than a warehouse
      client and say so by being run (see _feature_extras). This wrapper is
      stdlib-only and runs before the engine is installed, so it resolves the connector
      itself (it cannot import the engine) with the same precedence the engine uses:
      an explicit --connector flag, then the top-level `connector:` in the
      `.dex/config.yml` found by walking up from the run directory to the git root (the
      way git and dbt find their project), then DuckDB. The walk-up must mirror the
      engine's: if it did not, a run from a subdirectory would install the DuckDB extra
      while the engine resolves the project's real connector and then fails for want of
      that connector's deps. The guess only picks which extra to install; the full argv
      is still forwarded, so the engine stays authoritative for the actual connection
      and a wrong guess surfaces as a clean error envelope.
      
      `DEX_CORE_VERSION` is the single line bumped at release time, by
      scripts/prepare_release.sh before the tag; nothing else here changes per release.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import shutil
      import subprocess
      import sys
      import time
      from pathlib import Path
      
      # Rewritten by scripts/prepare_release.sh to the tagged version. The connector
      # extra is deliberately NOT part of this pin: it is chosen at runtime (see
      # _resolve_connector), so a release artifact is connector-neutral.
      DEX_CORE_VERSION = "1.12.3"
      
      # Connector id -> packaging extra. The engine's connector ids and the pyproject
      # extras share names, so this is the identity set today. An unknown or unset
      # connector falls back to the light DuckDB on-ramp and lets the installed engine
      # emit the canonical error rather than the wrapper guessing wrong.
      _KNOWN_CONNECTORS = (
          "duckdb",
          "snowflake",
          "bigquery",
          "databricks",
          "postgres",
          "redshift",
          "clickhouse",
      )
      _DEFAULT_CONNECTOR = "duckdb"
      
      # The one flag the wrapper answers itself. It is stripped before anything else
      # reads the argv, so it never reaches the engine and never lands in the positionals
      # that pick the extras.
      _WARM_FLAG = "--warm"
      
      
      def _connector_from_config(config_path: Path) -> str | None:
          """Read the top-level scalar `connector:` from .dex/config.yml, stdlib only.
      
          This bootstrap script has no YAML dependency and only needs enough to pick the
          right extra to install, so it scans for a single unindented `connector:` key.
          The engine remains the source of truth for the full config; anything richer or
          malformed here just falls through to the DuckDB default.
          """
      
          try:
              text = config_path.read_text(encoding="utf-8")
          except OSError:
              return None
          for line in text.splitlines():
              if line.startswith("connector:"):  # top-level only; indented keys ignored
                  value = line.split(":", 1)[1].split("#", 1)[0].strip().strip("'\"")
                  return value or None
          return None
      
      
      def _find_config(start: Path) -> Path | None:
          """Nearest ancestor `.dex/config.yml` at or above `start`, mirroring the
          engine's resolution: walk up to the enclosing git repo (the ceiling), and
          without one do not walk above `start`. Anchors on the file so a subdirectory
          holding only a `.dex/` cache never shadows the real config higher up."""
      
          start = start.resolve()
          ceiling = start
          for directory in (start, *start.parents):
              if (directory / ".git").exists():
                  ceiling = directory
                  break
          for directory in (start, *start.parents):
              candidate = directory / ".dex" / "config.yml"
              if candidate.is_file():
                  return candidate
              if directory == ceiling:
                  break
          return None
      
      
      def _resolve_connector(argv: list[str], cwd: Path) -> str:
          """Pick the connector whose extra we install, mirroring the engine's order:
          explicit --connector, then the walked-up .dex/config.yml, then DuckDB."""
      
          # allow_abbrev=False and parse_known_args so we only peek at these two flags
          # and never consume or reorder the argv that is forwarded to the engine.
          parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
          parser.add_argument("--connector")
          parser.add_argument("--repo-root", default=".")
          known, _ = parser.parse_known_args(argv)
      
          connector = known.connector
          if connector is None:
              config_path = _find_config(cwd / known.repo_root)
              if config_path is not None:
                  connector = _connector_from_config(config_path)
          return connector if connector in _KNOWN_CONNECTORS else _DEFAULT_CONNECTOR
      
      
      # Every value-taking flag this peek has to consume, so a flag's *value* is never
      # mistaken for the group, subcommand, or mode being run. The global connection
      # flags, plus the ones `explore semantic` takes, because a metric read as a mode
      # picks the wrong extras. `tests/test_skill_wrapper.py` holds this list to the
      # real parser, since a flag added there and forgotten here fails silently.
      _VALUE_FLAGS = (
          "--connector",
          "--path",
          "--scope",
          "--project",
          "--dataset",
          "--repo-root",
          "--budget",
          "--session-ceiling",
          "--metric",
          "--for-dimension",
          "--search",
          "--group-by",
          "--where",
          "--order-by",
          "--grain",
          "--limit",
      )
      
      # The `explore semantic` modes that can render a statement here. Bare
      # `explore semantic` lists, so an absent mode is `list`, which renders none on
      # either backend and therefore never needs the renderer.
      _SEMANTIC_RENDERING_MODES = ("values", "query")
      
      
      def _positionals(argv: list[str]) -> list[str]:
          """The bare tokens of an invocation: group, subcommand, and whatever follows.
      
          Flag-position agnostic, which is the point: the value flags above are consumed
          so the group and subcommand are the first two bare tokens wherever the
          connection flags sit."""
      
          parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
          for flag in _VALUE_FLAGS:
              parser.add_argument(flag)
          _, remaining = parser.parse_known_args(argv)
          return [tok for tok in remaining if not tok.startswith("-")]
      
      
      def _feature_extras(argv: list[str]) -> list[str]:
          """The extras this invocation needs on top of the connector's.
      
          Two commands need more than a warehouse client, and both are resolved from the
          command rather than installed always, so a repo that never clusters and never
          queries a semantic layer resolves neither scikit-learn nor MetricFlow.
      
          `explore semantic` needs the hosted client on any of its subcommands, which is
          an httpx and nothing heavier. It needs MetricFlow only where a statement might
          be rendered *here*: `list` renders none on either backend, and `--api` sends
          the request to dbt Cloud, which renders and executes it there.
      
          Where the flag is absent the backend is ambient, chosen by `semantic.deployment`
          in a nested config block this bootstrap deliberately does not parse, so a mode
          that could execute either way takes both. That errs toward a heavier install
          and never toward a command that refuses for want of a dependency, which is the
          failure this exists to prevent.
          """
      
          positionals = _positionals(argv)
          if positionals[:2] == ["explore", "cluster"]:
              return ["cluster"]
          if positionals[:2] != ["explore", "semantic"]:
              return []
          mode = positionals[2] if len(positionals) > 2 else "list"
          if mode in _SEMANTIC_RENDERING_MODES and "--api" not in argv:
              return ["semantic-api", "semantic"]
          return ["semantic-api"]
      
      
      def _engine_spec(extras: str, skill_dir: Path | None = None) -> list[str]:
          skill_dir = skill_dir or Path(
              os.environ.get("CLAUDE_SKILL_DIR", Path(__file__).resolve().parent.parent)
          )
          local_pkg = (skill_dir / ".." / ".." / "packages" / "dex-core").resolve()
          if local_pkg.is_dir():
              # Resolve the local package WITH the resolved extras (a plain path drops
              # extras). Non-editable is fine: the engine is imported fresh each run.
              return ["--with", f"exmergo-dex-core[{extras}] @ {local_pkg.as_uri()}"]
          return ["--with", f"exmergo-dex-core[{extras}]=={DEX_CORE_VERSION}"]
      
      
      def _extras(argv: list[str]) -> list[str]:
          """The full extras list for an invocation: the connector's, plus whatever the
          command itself needs.
      
          Warm-up and a real run both resolve here, on the same argv, which is what stops
          a warmed environment from disagreeing with the one the next command asks for."""
      
          return [_resolve_connector(argv, Path.cwd()), *_feature_extras(argv)]
      
      
      def _uv_run(engine_spec: list[str], tail: list[str]) -> list[str]:
          """The single uv invocation this wrapper makes.
      
          `--no-project` is a correctness flag before it is a fast one: see the module
          docstring for what uv does to the caller's repo without it. The latency follows
          from the same thing, because installing the caller's project is far more work
          than resolving the engine."""
      
          return ["uv", "run", "--no-project", *engine_spec, *tail]
      
      
      def _envelope(
          status: str,
          *,
          data: dict[str, object] | None = None,
          errors: list[str] | None = None,
          reason: str | None = None,
      ) -> str:
          """One JSON line in the engine's envelope shape, built by hand.
      
          The wrapper prints an envelope for the few things that happen before the engine
          exists to print its own: the missing-uv refusal and the warm-up. It cannot
          import `exmergo_dex_core.envelope` for precisely that reason, so the keys are
          mirrored here and have to stay in step with Envelope and Cost. `cost.paradigm`
          stays null throughout, because nothing here opens a connection and `free_local`
          is a positive claim that the connector in play bills nothing rather than a
          stand-in for "no connector was involved". `reason` is populated only on an
          error, the same rule the engine's Reason enum follows.
          """
      
          return json.dumps(
              {
                  "status": status,
                  "data": data or {},
                  "cost": {"paradigm": None, "estimate": None, "ceiling": None},
                  "warnings": [],
                  "diffs": [],
                  "errors": errors or [],
                  "reason": reason,
              }
          )
      
      
      def _warm(argv: list[str]) -> int:
          """Materialize the engine environment and exit, without running a command.
      
          Nearly all of a first command's latency is uv resolving and installing the
          engine's closure. This pays it out of band, at container build, plugin install,
          or a CI setup step, so no interactive caller waits for it.
      
          The extras come from `_extras` on the argv that remains after `--warm` is
          stripped, so warm-up can never install something a later command contradicts:
          bare `--warm` warms the connector this directory resolves to, `--warm
          --connector snowflake` warms a named one before any project exists, and `--warm
          explore cluster` warms exactly what that command needs. The last form earns its
          keep because a feature extra resolves into an environment of its own, so
          warming the connector alone leaves `explore cluster` and `explore semantic`
          cold.
          """
      
          extras = _extras(argv)
          spec = _engine_spec(",".join(extras))
          started = time.monotonic()
          # Captured rather than inherited: uv narrates resolution and installation, and
          # stdout here carries one envelope and nothing else.
          completed = subprocess.run(
              _uv_run(spec, ["python", "-c", "import exmergo_dex_core"]),
              capture_output=True,
              text=True,
          )
          elapsed = round(time.monotonic() - started, 3)
          if completed.returncode != 0:
              lines = [line.strip() for line in completed.stderr.splitlines() if line.strip()]
              print(
                  _envelope(
                      "error",
                      errors=[
                          f"warm-up could not install {spec[1]}. uv reported: "
                          + (" ".join(lines[-3:]) or "nothing on stderr")
                      ],
                      reason="prerequisite",
                  )
              )
              return 1
          print(
              _envelope(
                  "ok",
                  data={
                      "engine": spec[1],
                      "connector": extras[0],
                      "extras": extras,
                      "elapsed_seconds": elapsed,
                  },
              )
          )
          return 0
      
      
      def main() -> int:
          argv = sys.argv[1:]
          if shutil.which("uv") is None:
              # The one refusal that happens before the engine exists, which is why the
              # envelope is hand-built. `prerequisite` is the engine's own classification
              # for a missing dependency the user installs and retries (the same one
              # DemoDependencyError and DialectDependencyError carry), so a caller reads
              # this exactly like any other refusal instead of parsing a traceback.
              print(
                  _envelope(
                      "error",
                      errors=[
                          "dex runs its engine through uv, which was not found on "
                          "PATH. Install it with: "
                          "curl -LsSf https://astral.sh/uv/install.sh | sh "
                          "(or `brew install uv`, or `pipx install uv`), then re-run."
                      ],
                      reason="prerequisite",
                  )
              )
              return 1
          # The engine runs in uv's own ephemeral environment, so an inherited
          # VIRTUAL_ENV (e.g. the user's activated venv) is irrelevant to both paths
          # below and only makes uv print a mismatch warning on every call. Drop it.
          os.environ.pop("VIRTUAL_ENV", None)
          if _WARM_FLAG in argv:
              return _warm([arg for arg in argv if arg != _WARM_FLAG])
          # The connector extra is always installed; `explore cluster` and
          # `explore semantic` add what only they need, so the default install stays
          # light for every repo that runs neither.
          cmd = _uv_run(
              _engine_spec(",".join(_extras(argv))),
              ["python", "-m", "exmergo_dex_core", *argv],
          )
          if os.name != "posix":
              # Windows keeps the spawn: exec there hands control back to the shell
              # before the child finishes, and the prompt would interleave with the one
              # envelope a caller is reading off stdout.
              return subprocess.call(cmd)
          # Exec rather than spawn everywhere else: the wrapper has nothing left to do
          # once the engine starts, and replacing the process hands over the terminal,
          # the signals, and the exit code directly instead of relaying them through a
          # parent that is only waiting.
          os.execvp(cmd[0], cmd)
          return 0  # os.execvp does not return; this keeps the signature a plain int
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 37.5 KB
    ---
    name: transform
    description: 'Use this to author and change a dbt project or a semantic layer: bootstrap a project in a repo that has none (`transform init`), write or refactor model SQL from staging to marts, add tests and docs in schema.yml, manage dependencies, and define or update the semantic layer, whether that is dbt semantic models (MetricFlow: entities, dimensions, measures, metrics) or native Apache Ossie documents in a repo with no dbt project at all. Reach for this rather than editing model files by hand whenever the change spans more than one file or has to stay consistent with the rest of the project: it validates the edit against the real schema before writing, returns the change as a reviewable diff with a plan id, and catches the class of error that only surfaces at `dbt run`, such as wrong column names, broken refs, or a materialization that fights the project config. On a large project that check is worth more than the round trip costs. It applies to bug-fix tickets too: "this model returns wrong numbers, fix it" is a transform task. Trigger it for requests like "set up a dbt project in this repo", "build a staging model for this table", "refactor this model", "add tests to this model", "create a mart for X", "define a revenue metric", "add a dimension to this entity", "add a metric to my Ossie semantic model", or "update semantics/commerce.ossie.yaml". Any warehouse build is dev-target only, gated, and cost-surfaced first. If you do not yet know the source tables'' columns or grain, use explore first, then come back. To reconcile a project that has drifted out of sync with the warehouse, use maintain.'
    ---
    
    # Transform
    
    Author and refactor the dbt project: both the SQL transformations (staging to
    marts, tests, docs) and the semantic layer on top (entities, dimensions,
    measures, metrics). Both are the same job, writing reviewable diffs to the dbt
    project, which is the source of truth. This is the building half of the loop. It
    writes only to the repo, as reviewable diffs, and runs against a dev target only.
    
    ## How to drive it
    
    ```bash
    uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]
    ```
    
    dex runs its engine through `uv`, which is a prerequisite and is not installed by
    Claude Code. If the shell reports `uv: command not found`, stop and tell the user
    to install it (`curl -LsSf https://astral.sh/uv/install.sh | sh`, or
    `brew install uv`, or `pipx install uv`), then re-run. Never fall back to editing
    the dbt project by hand instead: the validation, the diffs, and the dev-target
    gating live in the engine, so any other path is unguarded.
    
    The first command in a fresh environment installs the engine, so it can take tens
    of seconds where later ones take well under a second. `--warm` pays that install up
    front and exits without running anything:
    
    ```bash
    uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm
    ```
    
    Offer it once at setup. It is not something to run before an ordinary command.
    
    You author the dbt file content; the engine validates it, computes the diffs,
    and stores the proposal as a plan. Hand content over with `--edits-file <path>`
    (or `-` to read stdin), a JSON payload:
    
    ```json
    {"edits": [
      {"path": "models/staging/stg_orders.sql", "kind": "model_sql", "content": "..."},
      {"path": "models/staging/stg_orders.yml", "kind": "schema_yml", "content": "..."},
      {"path": "snapshots/snap_orders.sql", "kind": "snapshot_sql", "content": "..."},
      {"path": "seeds/country_vat.csv", "kind": "seed_csv", "content": "..."},
      {"path": "tests/assert_totals_reconcile.sql", "kind": "test_sql", "content": "..."},
      {"path": "analyses/email_skew.sql", "kind": "analysis_sql", "content": "..."},
      {"path": "models/marts/dim_orders.sql", "kind": "model_sql", "op": "delete"}
    ]}
    ```
    
    `kind` is `model_sql`, `schema_yml`, `semantic_yml` (optional on
    `semantic define|update|plan`, which imply it), `macro_sql` (a macro file under
    the project's macro paths), `snapshot_sql` (a snapshot under the snapshot
    paths), `seed_csv` (a seed's CSV under the seed paths), `test_sql` (a singular
    test or a generic test definition under the test paths), `analysis_sql` (SQL dbt
    compiles but never runs, under the analysis paths), `packages_yml`,
    `project_yml` (the project-root `dbt_project.yml`), or `profiles_yml` (the
    project-root `profiles.yml`). Model SQL must be a single read-only SELECT once
    its jinja is stripped; semantic YAML is validated against MetricFlow's schemas,
    cross-reference-checked, and (when dbt is available) parsed by dbt itself before
    the plan is accepted; a macro file must hold only macro definitions and jinja
    comments. A snapshot 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`), and whose body is a single
    read-only SELECT. A seed must parse as CSV with a named, duplicate-free header
    and one field per column on every row, and stays under 5,000 data rows and 1 MiB
    (past that it is data rather than a lookup: load it into the warehouse and
    `source()` it). A `test_sql` file is read to decide which of the two shapes
    sharing the test paths it is: one holding `{% test %}` blocks is a generic test
    definition and must hold only those and jinja comments, balanced; anything else
    is a singular test and must be a single read-only SELECT. A singular test that
    names no `ref()` or `source()` is warned about, not refused, because it runs
    against nothing and passes unconditionally. An analysis must be a single
    read-only SELECT too, even though dbt only compiles it. `project_yml` must keep
    a `name`; `profiles_yml` must reference
    every secret via `{{ env_var('NAME') }}` (a literal credential is refused so
    none reaches the diff). Config kinds, snapshots and seeds are all parsed by dbt
    at plan time.
    
    Each kind is confined to its own family of paths, and filing one in the wrong
    family is refused naming both fixes. `schema_yml` is the exception, accepted
    beside a model, a snapshot, a seed, a test or an analysis, because that is where
    dbt expects a snapshot's tests, a seed's column types, a singular test's severity
    and an analysis's description declared.
    
    **Three things here are called a test, and they are not interchangeable.**
    Generic tests are declared inside a `schema.yml` (`data_tests:` on a model or a
    column). Unit tests come from `transform test --scaffold <model>`, which writes a
    `unit_tests:` block, also `schema_yml`. Singular tests and generic test
    *definitions* are files under `test-paths`, and `test_sql` is the kind for those.
    `transform test --mutate <model>` measures all three at once, since a defect has
    to get past every one of them to reach production.
    
    **A seed puts values, not logic, into a diff, and a diff goes into git and stays
    there.** So a seed whose header names a column that looks like personal data is
    refused, and the refusal names the `pii_overrides` entry in `.dex/config.yml`
    that a human can add to clear it. Detection reads names and types and never
    values (everywhere in dex), so it cannot see personal data hiding under a
    neutral column name: do not build a seed out of warehouse rows you have not
    looked at.
    
    `dbt build` runs seeds, snapshots and singular tests natively, so `transform
    build` after an apply is all it takes; there is no separate seed or test step. A
    snapshot writes a table and a test runs a scanning SELECT, so both are priced in
    the cost handshake; a seed scans nothing and an analysis is never built at all,
    so neither is. A singular test and an analysis build no relation and nothing can
    `ref()` either, so neither is a node: neither enters `maintain`'s drift baseline,
    and deleting one raises no dangling-reference guard.
    
    `op` is `upsert` (the default: create or update, carrying `content`) or
    `delete` (remove the file, no `content`). A delete is a first-class reviewable
    diff like any other edit, so a reclassification or refactor is one plan rather
    than a plan plus a manual `rm`. Deletes are guarded: the plan is refused if any
    file that survives it still `ref()`s a deleted model, naming the offenders.
    Carry the edits that remove those references in the same plan (for a rename,
    `delete` the old model, `create` the new one, and `update` every referrer to
    point at it, all together) so the post-change project is validated as one unit.
    An unconfirmed delete against a file a human edited after planning surfaces as
    `needs_confirmation`, never a silent removal.
    
    For a rename or a removal, reach for `transform rename` / `transform remove`
    instead of assembling the edits yourself. They generate the whole change from the
    reference graph and refuse when they cannot promise it is complete, which is the
    guarantee hand-assembly cannot give you.
    
    ### Bootstrapping a project
    
    If no dbt project exists in the repo, offer `transform init` before anything
    else: `transform plan` needs a project to edit. Ask the user for the project
    name and **confirm the connector with them**, then run:
    
    ```bash
    uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" transform init "<name>" --connector <c>
    ```
    
    The engine renders the whole skeleton (`dbt_project.yml`, `models/staging/` and
    `models/marts/`, a `profiles.yml` with a single `dev` target and no secrets) and
    records `connector`, `dbt_project_dir`, and `dbt_target: dev` in
    `.dex/config.yml`; do not hand-write these files yourself. Init never assumes a
    connector: it errors rather than defaulting, so always pass the user's confirmed
    choice (a `connector:` already committed in `.dex/config.yml` also counts).
    Every connector is supported: DuckDB, BigQuery, Snowflake, Databricks,
    Postgres, Redshift, and ClickHouse. DuckDB needs a warehouse path (`--path`, or the
    `duckdb.path` config). BigQuery needs a GCP project (usually
    `bigquery.project` in `.dex/config.yml`; confirm it with the user) and writes
    builds to a dedicated dev dataset (`bigquery.dev_dataset`, default
    `dbt_dev`); auth is Application Default Credentials, so if credentials are
    missing tell the user to run `gcloud auth application-default login`, never
    ask for a key. Snowflake writes builds to a dedicated
    `snowflake.dev_database`/`dev_schema` on the pinned warehouse; Databricks
    writes builds to a dedicated `databricks.dev_catalog`/`dev_schema` on the
    pinned SQL warehouse (if credentials are missing tell the user to run
    `databricks auth login`, never ask for a token); Postgres writes builds to a
    dedicated `postgres.dev_schema` (default `dbt_dev`), with the password
    reaching dbt only through the `PGPASSWORD` environment variable. Redshift
    writes builds to a dedicated `redshift.dev_schema` (default `dbt_dev`): with
    a `redshift.workgroup` pinned the profile renders IAM auth (temporary
    credentials from the AWS chain, nothing persisted), otherwise the password
    reaches dbt only through the `REDSHIFT_PASSWORD` environment variable.
    ClickHouse writes builds to a dedicated `clickhouse.dev_database` (default
    `dbt_dev`), rendered as the profile's `schema:` because dbt-clickhouse has no
    `database:` key, with the password reaching dbt only through the
    `CLICKHOUSE_PASSWORD` environment variable; the rendered profile also carries
    a `custom_settings` block whose `env_var` references are how `transform build`
    turns the confirmed budget into a per-statement server-side cap, so do not
    strip them from a profile you edit. All of them discover their connections and refuse with the fix named when none
    resolves. Init refuses if any dbt project already exists.
    
    When the user wants staging/intermediate/marts isolated in their own
    datasets/schemas (a common ask when the warehouse is shared with unrelated
    work), offer `--layered-schemas`: init then also scaffolds
    `models/intermediate/`, a `generate_schema_name` override, and per-folder
    `+schema:` config, so builds land in `staging_dev` / `intermediate_dev` /
    `marts_dev` instead of one shared dev namespace. Do not hand-write that macro;
    existing projects can adopt it later via `transform macro
    generate_schema_name`. Note dbt warns about "unused configuration paths" until
    the first model lands in each layer folder; that resolves itself.
    
    Init also checks (free, metadata-only) whether each namespace the project
    would build into already exists with content, and warns naming the namespace
    and a few object names. The warning is advisory: relay it to the user and ask
    whether the content is theirs (a previous dev build) or unrelated; when it is
    unrelated, discard the freshly scaffolded project (nothing has been built),
    point the config at a different dev namespace, and re-run init. A "could not
    check" note just means no connection was reachable at init time.
    
    ### dbt SQL models
    
    - `transform plan "<intent>" --edits-file <path|->` validates the edits and
      returns them as diffs with a plan id. Nothing is applied yet. Add
      `--scaffold <table>` (repeatable) to generate a staging skeleton
      (`stg_<table>.sql` plus per-model YAML with key tests and PII meta) from the
      `.dex/` cache instead of, or on top of, hand-authored edits.
    - When you edit a model that already exists, the plan reports what your change
      does to its **row population** under `data.row_attribution`: every predicate,
      join, source and grain change is named, and each is measured on its own against
      the prior model. Read it before applying. A change you were not asked to make
      carrying a non-zero `delta` is the signal to look at: the model still compiles
      and the columns are still right, and it is now returning a different set of
      rows. It is advisory, never a refusal, because changing the filter is sometimes
      the job. On DuckDB the deltas are measured automatically; on a billed connector
      the changes are named for free and measuring them needs `--attribute-rows`
      (then the usual `--confirm --budget` once priced), so ask the user before
      spending. A change reported with `attributed: false` names why it could not be
      measured; treat that as unknown, not as zero.
    - The plan also warns about the **shape** of what you authored, in `warnings`.
      A SELECT list that diverges from the columns the model's `schema.yml` declares
      is named in both directions; fix whichever side is actually stale, and say
      which one you decided it was.
    - A warning that the model **exposes a raw foreign key with no resolved
      counterpart** is dex reading a convention out of the project's own models: the
      siblings it names all resolve keys of that shape, and it names the parent
      model yours could resolve against. Prefer resolving it, by joining that parent
      the way the siblings do. Where the raw key is deliberate (a fact-shaped model
      in a dimension folder, a key the consumer needs verbatim), say so plainly to
      the user rather than quietly leaving it. Never switch the check off to make
      the warning go away: `conventions.resolved_keys: false` in `.dex/config.yml`
      is a decision about the house's style, so recommend it for the user to accept,
      the same way you would a `pii_overrides` entry.
    - `transform apply [plan-id]` writes the plan into the dbt project (the latest
      unapplied plan when no id is given; any plan kind, semantic included). The
      result is still a reviewable git diff for the user. If a human edited a file
      after the plan was made, nothing is written: the divergence comes back as
      diffs with `needs_confirmation`, and you should re-plan against current state
      (or, only when the user says so, re-run with `--confirm`).
    - `transform plans` lists stored plans (pending and applied, newest first), so
      you never need to browse `.dex/plans/` by hand.
    - `transform references <name> [more...]` answers "where is this used" before you
      change it. **Reach for this whenever a change has to land in more than one
      place**: removing a project variable, renaming a column, deleting a model,
      changing what a macro returns. Editing the files you happen to have open and
      hoping that was all of them is the failure this prevents, and it is a quiet
      one, because the project still compiles with one use left behind.
    
      It is repo-only and free on every connector, so there is never a cost reason
      not to run it. The positional is variadic, so one call covers a whole rename.
      `--kind` narrows to `model`, `source`, `seed`, `snapshot`, `macro`, `var`,
      `column`, `metric`, `entity`, `dimension` or `measure`; leave it off when you
      are not sure what the project calls the thing, and the answer will tell you.
    
      Read `data.completeness` before you act on the list. When it says `incomplete`,
      `data.limits` says why and `data.indeterminate` lists the call sites dex could
      not resolve, each with a file and a line. Those are references that *may* name
      what you asked about, so open them and decide yourself; do not treat the list
      of resolved hits as exhaustive when the verdict says it is not. A bare column
      name is matched across the project (`scope: name_matched`), so qualify it as
      `model.column` when you want the lineage separated from same-named columns
      elsewhere.
    
      Once you know where a name is used, `transform rename` and `transform remove`
      below make the change; you do not have to carry the list into hand edits.
    - `transform rename <kind> <old> <new>` generates **every** edit the rename needs
      and stores them as one plan: the definition, every model that selects the name,
      every `schema.yml` that documents or tests it, every semantic reference, and a
      seed header. Kinds are `column`, `var`, `model`, `seed`, `snapshot`, `macro`,
      `source`. Repo-only and free, like `references`.
    
      **Use this instead of editing the files yourself.** Retyping a rename across
      nine files and missing the tenth is the failure mode this exists for, and it is
      a quiet one: the project still compiles.
    
      Name a column as `model.column`. A bare name is refused, and the refusal lists
      the models that define a column of that name so you can pick. That asymmetry
      with `references` is deliberate: a report you read can afford to be imprecise
      and a rewrite cannot, because renaming a bare `id` project-wide would rewrite
      every unrelated `id` there is.
    
      **It refuses rather than half-applying**, and each refusal names what to fix:
      a reference dex could not resolve statically, a name an installed package also
      defines, a column handed to a macro as a literal string (dex cannot tell a
      column argument from a display label), a SELECT list it cannot read. Fix what
      it names and re-run. There is no override flag, because a completeness
      guarantee you can switch off is a suggestion. A bare `select *` is *not* a
      refusal: it carries the column through under the new name with no edit, and the
      plan's `notes` says so.
    
      Read `data.sites` against the `transform references` output you ran first. It
      counts occurrences per reference form in the same vocabulary, so the two
      agreeing is your evidence that nothing was dropped between reading and writing.
    - `transform remove <kind> <name>` removes the **definition** and verifies every
      read is gone, refusing while any survives and naming each with a file and line.
    
      It never rewrites a read, and that boundary is the point rather than a gap.
      `{% if var('using_department') %}` can be deleted or unguarded, and
      `{{ var('x') }}` sitting in an expression has no value dex may invent. You are
      the one who knows. Author those edits yourself and pass them with
      `--edits-file` in the same call: they are validated and stored in the same
      plan, so the removal is still atomic.
    - `transform place <column> --targets <a,b> --expr "<sql>"` answers where a
      derived column that several models need should be *defined*. It walks `ref()`
      upward from every target, takes the lowest model they all descend from that
      already projects the inputs your expression reads, defines the column there,
      and threads it down every chain. The inputs come from parsing `--expr`, so
      there is no separate list to get out of sync with it.
    
      **Read `data.reasoning` before you apply.** It names the ancestor, why it is
      the lowest, which targets descend from it, and the chain. You are supposed to
      be able to disagree with it; `--explain` gives you the same answer with no plan
      stored, which is the cheap way to ask.
    
      When `data.strategy` is `per_target` the shared definition was not available
      and the reasoning says why: no common ancestor, or the lowest one is missing an
      input, or two candidates tie. dex will not go further upstream to pull an input
      down, because that turns one placement into an unbounded rewrite of everything
      above it. The fallback duplicates the derivation in each target and those
      copies will drift, so relay the reason to the user rather than applying it on
      their behalf. Often the named fix (add the missing column to the ancestor
      first) is what they actually want.
    - `transform build --target dev` runs `dbt build` against a dev target. The
      engine surfaces a cost preflight first and runs only with `--confirm` (plus a
      `--budget` on billed connectors). dbt itself has no dry-run, but the engine
      compiles the project and dry-runs each node itself, so on BigQuery the first
      unconfirmed call already returns `needs_confirmation` with `estimated_bytes`
      and a `per_table_bytes` breakdown, the same shape the scanning `explore`
      commands use. Never invent a `--budget` figure: read the reported estimate
      (`per_table_bytes` is the actionable half, since it names which node is
      driving the cost) and confirm with a `--budget` grounded in that number.
      If the build is refused over the ceiling, the refusal carries a calibration
      line from `.dex/spend.jsonl`: what this connector's recent commands billed as
      a fraction of estimate, or a sentence saying there is too little history to
      say. Builds over-estimate most on a partitioned or clustered warehouse, so
      relay it, and note that the ceiling binds on the estimate rather than on what
      settles, so a budget set at that fraction of the estimate is refused again.
      A `suggested_session_ceiling` on that envelope is the project's one-time ask
      for a cumulative daily cap, separate from `--budget`: relay it and add the
      user's answer (`--session-ceiling <value>` or `--no-session-ceiling`) to the
      same re-issue, which records it in `.dex/config.yml` for good.
      Each statement dbt runs is capped server-side by the profile's
      `maximum_bytes_billed`, and the envelope reports billed bytes afterward.
      Production-looking targets are refused
      outright; `--confirm` cannot override that. dbt runs with its working
      directory pinned to the project dir, so relative paths in `profiles.yml`
      resolve against the project. When the project declares packages
      (`packages.yml`) and `dbt_packages/` is missing, the engine runs `dbt deps`
      automatically before the build.
    - **`transform build --verify` is how you answer "is it right", not just "did it
      run".** A green build tells you dbt executed. It does not tell you the model
      holds the rows it should, and that is where the expensive defects live: an
      inner join written where a left join was meant loses rows, raises nothing, and
      passes every uniqueness and not-null test over the smaller result. `--verify`
      sweeps the nodes this build touched and reports the findings in the same
      envelope, under `data.verification`. Reach for it whenever the build was meant
      to prove a change is correct, which is most of the time you build at all.
    
      Read `data.verification.ran` before reading anything else. It is always
      present, because a build that did not verify and a build that verified and
      found nothing look identical otherwise, and only the second one means the
      models are clean. When it ran, `findings` is ranked the way `maintain verify`
      ranks it, `scope` names the models covered, and `suppressed` names each class
      that could not run and why. Relay a suppression rather than reading past it:
      it is the difference between "checked and clean" and "not checked".
    
      Findings never fail the build and never appear in `errors`. Do not treat one
      as a build failure or re-run to make it go away: relay the finding, its two
      counts, and the join it names, and let the user decide. A failed build still
      reports which node failed and which were skipped because of it, which is
      usually a faster read than the dbt log.
    
      On a billed connector the sweep is priced into the build's own estimate as a
      `(row counts)` line, so the `--budget` you already read off the unconfirmed
      envelope covers both. Never add a second budget for it. If the envelope comes
      back `ok` with a `data.offer`, the build is done and billed and the offer buys
      only the counts it could not afford; relay the number rather than re-running
      the build.
    - **`transform test --mutate <model>` answers "are these tests worth
      anything".** Writing a test is not the same as writing a test that would catch
      something, and nothing else in the dbt ecosystem tells the two apart. This
      plants one standard analytics defect at a time in the model's SQL (a flipped
      boundary, a dropped or negated filter, a swapped join type, a removed `CASE`
      branch, an inverted ratio, a shifted window frame, `sum` for `max`), runs the
      model's own tests against each, and reports which ones nothing caught.
    
      Reach for it right after you author or scaffold tests, and before telling the
      user the model is covered. It is also the honest answer when a user asks
      whether their tests are any good, which is otherwise unanswerable.
    
      Read `data.counts` and then the survivors, which are listed first. Each carries
      `defect`, a sentence saying what would now be wrong, and `suggested_test`, the
      test that would catch it. Relay those two: the user's next action is to write
      that test, not to read the SQL. A `score` is reported but it is a ratio of two
      small integers over one model, so quote it as context and never as a grade, and
      never compare it between models.
    
      Check `baseline.excluded` before trusting a clean-looking result. Every verdict
      is relative to the tests that passed against the unmutated model, so a test
      that was already failing is excluded and named there. And read `cap.elided`:
      the run is capped at 20 mutants, so a model with more sites than that was
      measured on a sample, spread across defect classes.
    
      It writes nothing. Mutants build as ephemeral models in a throwaway copy, so
      the project is untouched and no relation is created or replaced. On a billed
      connector the whole batch is one estimate and one `--confirm`, and if the
      budget runs out partway the rest come back `not_run`: relay that rather than
      reading a short list as a clean bill.
    - `transform deps` installs dbt packages explicitly (also the refresh path when
      `dbt_packages/` exists but is stale). No confirmation needed: deps writes only
      inside the project and never touches the warehouse.
    
    ### Shipped macros
    
    - `transform macro` lists the macros dex ships; `transform macro <name>`
      proposes scaffolding one into the project's macro directory as a plan,
      applied with `transform apply` like any other. The user's copy is theirs to
      edit; re-running the command diffs it back against the shipped version (a
      warning says whether it is customized or stale), and applying that plan
      overwrites deliberately.
    - `unpivot_json_object` turns a JSON object column with dynamic keys (the
      NoSQL-sourced shape: a Firestore/Mongo/DynamoDB document keyed by a related
      entity's id) into one row per top-level key. Use it instead of hand-rolling
      JSON SQL; it renders a complete SELECT:
    
      ```sql
      select id, key as related_id, value as attrs
      from (
        {{ unpivot_json_object(relation=ref('stg_entities'),
                               json_column='attributes', passthrough=['id']) }}
      )
      ```
    
      The contract on every connector: one row per top-level key, `key` a plain
      string, `value` the warehouse's native semi-structured type (BigQuery JSON,
      Snowflake VARIANT, Databricks VARIANT, Postgres jsonb, Redshift SUPER,
      DuckDB JSON, ClickHouse raw JSON text in a String), a NULL object yields no
      rows, and a nested object's own field
      names never surface as top-level keys. For a string-typed source column
      pass the parse expression as `json_column` (`parse_json(payload)` on
      BigQuery, Snowflake, and Databricks; `json_parse(payload)` on Redshift);
      Postgres, DuckDB, and ClickHouse accept JSON-bearing text directly. Databricks needs
      VARIANT support (DBR 15.3+ or a current SQL warehouse). Two BigQuery quirks
      are absorbed by the macro, so do not "fix" them back in: a JSON path
      argument must be a compile-time literal (the macro reads values with the
      subscript operator, which accepts a computed key), and `JSON_KEYS` recurses
      into nested objects unless depth-limited (the macro pins depth 1). When a
      planned model calls the macro and the project lacks it, the plan warns and
      names the scaffold command; scaffold it rather than inlining a copy.
    
    ### Preparing the dev target
    
    Before the cost gate, and for free, `transform build` refuses two things and
    names the fix for each. Neither costs anything to check, so both surface on the
    unconfirmed call rather than after a budget has been agreed.
    
    **Config that has drifted from the profile.** `transform init` renders
    `.dex/config.yml` into the project's `profiles.yml`, and dbt reads only the
    profile from then on. If a later config edit never reached it (a retargeted
    `dev_database`, a different warehouse), the build refuses and names both values
    and both files. Edit one to match the other. The engine never rewrites
    `profiles.yml`, which you may legitimately have hand-edited.
    
    **A dev target that does not exist.** On Snowflake, dbt creates schemas but never
    databases, so a missing `dev_database` is refused with the `CREATE DATABASE`
    statement to run; dex will not create it for you, because its only writes are
    reviewable diffs inside the repo. On Postgres, Redshift, and ClickHouse, dbt creates the dev
    namespace but only if the profile's user may, so the missing privilege is what
    gets refused, with the `CREATE SCHEMA`/`GRANT` statement to run. On ClickHouse
    that check can also come back with no verdict, because a server may not let dex
    read another user's grants; it then warns instead of guessing, and the build
    proceeds with dbt's own error as the backstop. On DuckDB the dev target is a database file,
    and dbt would happily create an empty one, then fail every `source()` relation
    with a confusing catalog error. The convention there: copy the shared source
    warehouse to the dev target path (for example
    `cp shared/f1.duckdb <project>/dev.duckdb`), or point the dev target at an
    existing file. Projects without sources just get a warning and an empty
    database, which is fine for model-only builds.
    
    ### The semantic layer
    
    - `semantic define ...` and `semantic update ...` author and evolve the dbt
      semantic models (entities, dimensions, measures, metrics) as plans. `define`
      refuses names that already exist (use `update`); `update` refuses names that
      do not (use `define`). For one logical change that mixes both (evolve existing
      metrics and add the helpers they depend on), use `semantic plan ...`: it
      accepts mixed intent and classifies each name, and the envelope reports the
      split as `defined`, `updated`, `unchanged`, and `removed`.
    - **Prefer `--definitions-file` over `--edits-file` for the semantic layer.** A
      real project keeps its metrics in one shared file, so a whole-file payload
      means retyping every definition you are not touching: the diff and the
      `updated` list then describe the whole file instead of your change, and every
      restated line is a chance to corrupt a definition by hand. Send only what
      changes instead:
      `{"definitions": [{"kind": "metric", "content": "name: ...\n..."}]}`, where
      `kind` is `semantic_model` or `metric` and `content` is that definition's YAML
      body with no leading `- `. The name comes from the content, and `path` can be
      omitted for anything the project already declares (the engine rewrites it
      where it lives). Everything else in the file, comments included, is preserved
      byte for byte. Reach for `--edits-file` when you are creating a file, moving a
      definition between files, or emptying one, and when the engine refuses a layout
      it will not splice into.
    - **Removing one definition is the same payload with `"op": "delete"`**:
      `{"definitions": [{"kind": "metric", "name": "doubled", "op": "delete"}]}`, the
      name declared (there is no content to read it from) and no `content` beside it.
      Nothing is removed for going unmentioned, so you can send a removal and an
      edit in one payload and everything you did not name stays as it is. Use
      `semantic update` or `semantic plan`, not `define`. The envelope reports it
      under `removed`.
    - If a metric still reads what you are removing (its input is that metric, or a
      measure of the semantic model you are removing), the plan is refused and the
      reader is named: add that reader's own delete or update to the same payload,
      in any order, and it goes through. A removal that would leave a file with no
      semantic model or metric in it is refused too, because deleting or emptying a
      file is a whole-file edit: do that with `transform plan --edits-file` and
      `"op": "delete"`.
    - `unchanged` means you re-stated a definition exactly as the project already
      has it. It is not an error, but if a plan is entirely `unchanged` it changes
      nothing, and the envelope warns as much: check whether you meant to edit
      something.
    - Plan-time validation is layered so a plan that validates will build:
      MetricFlow's schemas check the shape; the engine resolves every metric input
      (ratio and derived metrics reference **metrics**, not measures; a measure only
      becomes a metric via `create_metric: true`, and the error names that fix); and
      finally the emitted YAML is run through **dbt's own parser** against a
      throwaway copy of the project. A plan that fails parse is refused, not stored.
      If dbt is not installed the parse degrades to a warning; `--no-parse` skips it
      explicitly.
    - A semantic plan is applied like any other: `transform apply [plan-id]` writes
      its YAML into the dbt project (no id applies the latest unapplied plan).
    - For native Ossie use `semantic ossie define|update|plan` with
      `--edits-file <path|->`. Supply whole documents whose paths are listed in
      `semantic.ossie.files`; the command implies the `semantic_document` kind,
      validates the complete prospective configured layer, and writes the accepted
      bytes exactly when the plan is later applied. This is a semantic-layer write
      surface and does not make Ossie the transformation project.
    
      The namespace guards match the dbt ones: `define` refuses a semantic-model name
      the layer already has, `update` refuses one it does not, and `plan` accepts
      both and reports each under `defined` or `updated`. Neither removes a model, and
      a configured file may be absent before `define`, so a new document is planned
      once its path is committed to config.
    
      What validates an Ossie plan is not what validates a dbt one, and the
      difference matters. There is no external parser to gate on: dex checks the
      document's structure against the Ossie schema it pins (needs `[ossie]`), its
      internal consistency in pure Python, and each SQL expression's syntax through
      the dialect engine (needs `[sql]`, which every connector extra carries).
      Without `[sql]` the third layer degrades to a named skipped-validation note,
      never to a silent pass. All three run over the complete prospective layer, your
      edits overlaid on the other configured documents, before a plan is stored.
    
      It then checks the references against the exploration cache, opening no
      connection. A source relation the cached inventory positively lacks, or a
      column absent from a relation the cache profiled, refuses and stores no plan.
      Anything the cache cannot speak to is a named note instead: an unprofiled
      relation, a computed or non-SQL expression, a quoted identifier, a query-backed
      source. Read those notes rather than treating them as failures; they say what
      was not checked.
    
      Accepted bytes are written exactly as authored on apply. dex does not parse and
      re-serialize the document, so comments, key order, quoting and whitespace all
      survive, and a configured document the payload did not mention is untouched. A
      target file that changed after planning refuses the whole apply rather than
      writing part of it, unless you confirm the overwrite deliberately.
    
      `references/ossie-walkthrough.md` in the engine repository runs the whole
      sequence on a local warehouse if you want to see it end to end.
    - dbt cannot parse semantic models in a project without a MetricFlow **time
      spine**; the engine warns when one is missing and defers the parse gate until
      one exists. Author it like any other model (a day-grain date model plus YAML
      with a `time_spine:` config) in the same or a separate plan.
    - `viz preview` is not yet implemented (it returns `not_implemented`); the Viz
      integration arrives later.
    
    ## Guardrails (enforced in the engine, not here)
    
    - Writes confined to the repo, and within it to two disjoint surfaces: the dbt
      project's authored path families (models, macros, snapshots, seeds, tests,
      analyses) plus the project-root manifests dbt keeps there, and the exact
      native semantic documents named in `semantic.ossie.files`. Neither surface can
      reach the other, an absolute path or a `..` escape is refused on both, and dex
      never writes to source warehouse data.
    - Dev-target only. Prod-target execution is never initiated by dex.
    - Cost surfaced before any spend. A build that would spend requires explicit
      confirmation and a session budget. The cost guard in full, in the engine
      repository: `references/cost-controls.md`; the PII policy that governs what
      a seed may carry and what gets stamped into `meta`: `references/pii-policy.md`.
    - Propose, don't impose. Human edits to the project (SQL and semantic YAML) and
      to a native semantic document are authoritative; on conflict the engine
      surfaces a diff and asks rather than overwriting.
    - PII flags propagate from the cache into emitted dbt (model and column `meta`),
      never example values. Stamping is presence-based at any confidence; only a
      column cleared by a human `pii_overrides` entry in `.dex/config.yml` is
      scaffolded without the meta.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related