# Add Model Contracts to a dbt Project

Yes. An agent enforces contracts on your dbt marts, lists every column with its data type in the model YAML, checks parse and build, and opens a pull request.

[Run this on Rush](https://cloud.getrush.ai/run/add-model-contracts/dbt/), in a Project that has your repository.

## What the agent does

The agent clones the project into its own cloud sandbox and reads `dbt_project.yml` first. In a project like dbt-labs/jaffle-shop it is `jaffle_shop` at version 3.0.0 with `require-dbt-version: ">=2.0.0"`, a `dbt-cloud` block with the project id, `staging` models materialized as views and `marts` as tables, and seeds under `jaffle-data` enabled only when the `load_source_data` var is true. `packages.yml` pins `dbt-labs/dbt_utils` 1.4.1 and `dbt-audit-helper` from git. The mart YAML files under `models/marts/` already carry a lot: `semantic_model`, `metrics`, `saved_queries`, `data_tests` with `dbt_utils.expression_is_true`, and `unit_tests` such as `test_order_items_compute_to_bools_correctly` in `orders.yml`. None of them sets `config.contract.enforced`, and no column has a `data_type`.

A contract needs every column the model produces, named and typed, so the agent reads the SQL rather than the YAML to find them. `models/marts/orders.sql` selects `orders.*` from `stg_orders` and adds `order_cost`, `order_items_subtotal`, `count_food_items`, `count_drink_items`, `count_order_items`, `is_food_order`, `is_drink_order` and `customer_order_number`; `stg_orders.sql` in turn renames `id` to `order_id`, `store_id` to `location_id`, `customer` to `customer_id`, keeps `subtotal_cents`, `tax_paid_cents` and `order_total_cents`, derives `subtotal`, `tax_paid` and `order_total` through the `cents_to_dollars` macro, and truncates `ordered_at` to the day. `orders.yml` documents nine of those columns. The agent lists all of them, with the types the macro and the staging casts produce, and does the same for `customers`, `order_items`, `products`, `locations` and `supplies`.

Types are where contracts bite, because `macros/cents_to_dollars.sql` dispatches per adapter: `numeric(16, 2)` by default and on Postgres, a rounded `numeric` on BigQuery, `numeric(16,2)` on Fabric, and `.sqlfluff` says the project's dialect is Snowflake. The agent writes the `data_type` for each column in the warehouse's own spelling, taken from the macro branch for that adapter rather than from a generic list, and keeps the existing `description`, `data_tests`, `entity` and `dimension` keys on each column so the semantic layer is untouched. It adds `config: contract: enforced: true` to each mart and leaves the staging views alone, since contracts on the layer downstream dashboards read is where they pay off.

To check its work without warehouse credentials, it runs `dbt parse`, which validates the YAML and the contract shape, and then a local build against DuckDB with a `profiles.yml` it writes in the sandbox only, seeding with `dbt seed --vars '{"load_source_data": true}'` and running `dbt build --select marts`, which fails when a column in the SQL is missing from the YAML or the other way round. It runs the repository's `pre-commit` hooks (`check-yaml`, `end-of-file-fixer`, `ruff`) on the changed files. The sandbox `profiles.yml` and `target/` are not committed.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Add enforced model contracts to the marts in this dbt project. Read dbt_project.yml, packages.yml, .sqlfluff for the dialect, macros/cents_to_dollars.sql for the per-adapter cast, and every model under models/marts with its SQL and YAML. For each mart, list every column the SQL produces (follow select * back through the staging model it reads) in the model YAML with a data_type in the warehouse's spelling, keep every existing description, data_tests, entity, dimension, metrics, saved_queries and unit_tests key, and add config.contract.enforced: true. Leave staging models as they are. Run dbt parse, then write a sandbox-only profiles.yml for DuckDB, run dbt seed --vars '{\"load_source_data\": true}' and dbt build --select marts, and fix any column the build reports as missing or extra. Run pre-commit on the changed files. Commit no profiles.yml or target/. Open a pull request that lists the columns added per model and says that the data_type check against the production warehouse happens in the dbt Cloud CI job on this pull request.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request that changes only the YAML files under `models/marts/`: each mart gains a `config` block with `contract.enforced: true` and a complete column list with `data_type`, with the semantic model, metrics, tests and unit tests left where they were. The description holds one table per model naming the columns that were added, the type each got and the SQL line it came from.

The pull request's own check is `.github/workflows/ci.yml`, which starts a dbt Cloud job for Snowflake, BigQuery and Postgres through `.github/workflows/scripts/dbt_cloud_run_job.py` with the branch's schema override. That job is where the contract meets the real warehouse: a `data_type` spelled for the wrong adapter fails there with the column named. The sandbox build on DuckDB catches the missing and extra columns before that, which is most of the work.

A reviewer reads the column tables against the SQL once, then watches the three dbt Cloud jobs. If one adapter rejects a type, a follow-up on the same session fixes that spelling without redoing the rest, and the transcript shows the `dbt parse` and `dbt build` runs that passed in the sandbox.

## Run it on every Linear issue labelled agent

In the console, add an automation to the Project with this prompt and a `linear` trigger on `linear.issue.labeled`, filtered to the `agent` label. When an analytics engineer labels an issue such as "enforce contracts on the marts before the dashboard migration", the trigger puts the issue after the prompt and starts a run in the project's repository, and the pull request links back to the issue. Only changes made by a person in your Rush org fire it.

The same request can be sent from a script with `POST /o/{org}/sessions`, with the repository in `resources` and the Linear issue beside it, read through your Linear connection. A `request_id` made from the issue id keeps a retried script from starting a second run.

## Why run it on Rush?

Writing contracts by hand means tracing every `select *` back to its source and typing out dozens of columns per mart, and the first `dbt build` usually says a column is missing. The agent does the tracing from the SQL, builds the marts on DuckDB in its own sandbox before the pull request exists, and the transcript shows the parse and the build that passed, so the reviewer reads tables rather than retyping them.

The warehouse-specific part, the `data_type` spelling, is left to the project's own dbt Cloud CI on the pull request, and the description says so rather than claiming the sandbox proved it. Several marts, or several projects, can be done at once, each in its own sandbox, and a type the CI rejects is fixed with a follow-up on the same run.

## Frequently asked questions

### Will it touch the semantic layer or the unit tests?

No. The column entries keep their `entity`, `dimension` and `granularity` keys, and the `metrics`, `saved_queries` and `unit_tests` blocks are left byte for byte. The agent adds `data_type` to existing columns, adds the columns that were undocumented, and adds the `config` block. `dbt parse` in the sandbox confirms the semantic manifest still builds.

### Why DuckDB in the sandbox rather than my warehouse?

The sandbox has no warehouse credentials, and the project's own CI already runs the dbt Cloud jobs on every pull request. DuckDB with the seeds is enough to catch a column missing from the YAML or present in the YAML but not the SQL, which is where most contract work goes wrong. The `profiles.yml` it writes stays in the sandbox.

### Does it add contracts to staging models too?

Not in this run. Staging models are views that rename source columns, and the prompt scopes contracts to the marts that downstream tools read. A follow-up on the same session can extend them to `models/staging/`, where the column lists are shorter and the types come straight from `__sources.yml`.

### What if a data_type is wrong for Snowflake?

The dbt Cloud Snowflake job on the pull request fails and names the column and the type it found. The agent chose the spelling from the adapter branch in `cents_to_dollars` and the staging casts, but a type it could only infer is listed in the pull request as such. Send the job's message as a follow-up and the same session corrects it.
