Manifest In, Contracts Out: A dbt Round Trip in Five Steps
Most integrations treat dbt as somewhere to read from. This one also publishes back: governed meaning leaves CoreModels as dbt model property files with enforced contracts, one file per model, colocated beside that model's own `.sql`. This walkthrough runs the whole loop — artifacts out of dbt, meaning governed on top of them, contracts back into the repo, `dbt build` green. Nothing here needs a warehouse credential or a dbt platform connection: artifacts in, artifacts out. CoreModels never writes to your repo and never opens a pull request. Generate returns files; your own PR flow lands them.
Most integrations treat dbt as somewhere to read from. This one also publishes back: governed meaning leaves CoreModels as dbt model property files with enforced contracts, one file per model, colocated beside that model's own .sql. This walkthrough runs the whole loop — artifacts out of dbt, meaning governed on top of them, contracts back into the repo, dbt build green. Nothing here needs a warehouse credential or a dbt platform connection: artifacts in, artifacts out. CoreModels never writes to your repo and never opens a pull request. Generate returns files; your own PR flow lands them.
What you need
- A dbt project that compiles (manifest schema v10–v12). Enforced contracts require dbt 1.5 or newer, and the generated files use the
data_tests:key that dbt 1.8 and newer read. - A CoreModels project and its 32-character hex id (
$PROJECT_ID), a token ($TOKEN), andcurl,jq, andgit. Import needs the Admin role; generate needs Viewer. The API host is written ashttps://coremodels.example.comthroughout — substitute your deployment's.
Step 1 — Produce the artifacts
dbt parse # writes target/manifest.json — required
dbt docs generate # writes target/catalog.json — optional, and worth it
Step 2 — Import the estate
jq -n --rawfile manifest target/manifest.json --rawfile catalog target/catalog.json \
'{artifacts: {manifest: $manifest, catalog: $catalog}}' |
curl -sS -X POST "https://coremodels.example.com/graph/integrations/dbt/import/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" --data-binary @-
{ "success": true, "vendor": "dbt", "projectName": "clinops", "datasetsAdded": 12,
"datasetsSkippedExisting": 0, "fieldsAdded": 96, "lineageEdgesAdded": 17,
"nodesEnriched": 108, "snapshotStored": true, "lossiness": [], "errors": [] }
Run the second command as well: under an enforced contract every column needs a data_type, and the catalog is where the warehouse-real type comes from. Models, seeds, snapshots, and sources became Types; columns became Elements; accepted_values tests became Taxonomies with controlled lists; relationships became governed references; parent_map became Depends On lineage. Import is additive — nothing already governed is mutated. Be clear-eyed about what you have at this point: a faithful description of the estate, not governance. The value arrives in the next step, and comes back out in the one after.
Step 3 — Govern meaning on top
Descriptions and vocabularies. The description Generate emits rides the dbt Metadata mixin on the Type or Element — the same slot the import filled from your manifest. Fill in the blanks in the CoreModels editor, or with create_mixin_value over MCP. Then find the anonymous controlled list that the accepted_values test on dim_patients.sex arrived as, name it, and point every other column carrying that concept at the same one: a single governed vocabulary generates an identical accepted_values block in every model that uses it.
Bind a term. suggest_ontology_terms queries the public Ontology Lookup Service and ranks candidates. It is advisory — nothing is persisted, nothing is auto-applied. An Admin binds the one they chose, and that writes a single mapsTo assertion on the node. A binding against a standard you already govern against — a schema.org class, an internal glossary IRI — is the same {standard, uri} assertion under a different standard name, and Generate carries it identically.
// update_node (Editor) — name the controlled list the import created
{ "graphProjectId": "$PROJECT_ID", "nodeUpdateDto": { "id": "7c1f…b2c3", "label": "Administrative Sex" } }
// suggest_ontology_terms (advisory) — ranked candidates, each { iri, curie, label, ontology }
{ "graphProjectId": "$PROJECT_ID", "nodeId": "b4e0…d0e1", "ontologies": "ncit", "limit": 5 }
// bind_ontology_term (Admin) — the human act; writes one mapsTo
{ "graphProjectId": "$PROJECT_ID", "nodeId": "b4e0…d0e1", "ontology": "ncit",
"iri": "http://purl.obolibrary.org/obo/NCIT_C28421", "label": "Sex" }
Step 4 — Generate the contracts back
curl -sS -X POST "https://coremodels.example.com/graph/integrations/dbt/generate/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"extra": {"layout": "model"}}' > generate-response.json
jq -r '.artifacts[] | "\(.kind) \(.name)"' generate-response.json
yaml models/staging/stg_patients.yml
yaml models/marts/dim_patients.yml
yaml models/marts/dim_providers.yml
yaml models/marts/fct_encounters.yml
One file per model, each landing beside its own .sql; colocation comes from the model's original_file_path, captured at import. "layout": "folder" gives one _coremodels__models.yml per model directory instead, and "layout": "single" the old repo-wide file. Add "targetVersion": "1.7" if you are on a 1.x below 1.8 and the run emits the legacy tests: key and records a note saying so. Ephemeral models and models with no columns are skipped, each with a record in the ledger; if nothing is eligible at all, the call fails rather than handing you an empty file. Here is models/marts/dim_patients.yml, exactly as emitted:
# Generated by CoreModels — governed model contracts.
# Meaning changes belong in CoreModels; regenerate this file rather than editing it.
version: 2
models:
- name: dim_patients
description: "One row per patient, conformed across the source systems."
config:
contract:
enforced: true
materialized: table
meta:
coremodels:
maps_to:
- standard: "schema.org"
uri: "https://schema.org/Patient"
columns:
- name: patient_id
description: "Surrogate key for the patient."
data_type: "NUMBER(38,0)"
constraints:
- type: not_null
data_tests:
- unique
- not_null
- name: primary_provider_id
description: "Provider currently responsible for this patient's care."
data_type: "NUMBER(38,0)"
data_tests:
- relationships:
to: ref('dim_providers')
field: provider_id
- name: sex
description: "Administrative sex recorded at registration. [ols:ncit: http://purl.obolibrary.org/obo/NCIT_C28421]"
data_type: TEXT
constraints:
- type: not_null
data_tests:
- not_null
- accepted_values:
values: ["female", "male", "unknown"]
meta:
coremodels:
maps_to:
- standard: "ols:ncit"
uri: "http://purl.obolibrary.org/obo/NCIT_C28421"
vocabulary: "Administrative Sex"
Read it back against Step 3 and every line is traceable. enforced: true on each model. data_type on every column, because dbt fails at parse without it. not_null twice — once as a contract constraint the warehouse enforces, once as a test. accepted_values from the governed vocabulary rather than a hand-kept list. relationships from the governed reference, pointed at the field that was actually recorded. The meta.coremodels block carries what dbt has no first-class slot for, on the model and on each column, so a catalog or an agent can resolve a column to its governed term instead of guessing from the name. The IRI also appears inside the description, because description is the only slot persist_docs carries into the warehouse column comment; turn that off with "iriInDescription": "false" in extra, and the structural block still rides. The same run's ledger:
[
{ "kind": "StructuralDrop", "path": "stg_patients", "explanation": "This model already has properties in 'models/staging/_stg__models.yml'. Remove its block there, or dbt will report a duplicate patch for the generated 'models/staging/stg_patients.yml'." },
{ "kind": "SemanticNarrowing", "path": "fct_encounters.referring_provider_id", "explanation": "The reference's target field is not recorded and the target has no unique-tested column; relationships test omitted." }
]
The first record needs a decision from you. dbt refuses two property blocks for one model, and stg_patients already has one. CoreModels does not rewrite _stg__models.yml, because that file may carry properties for models it does not govern — it names the file and leaves the edit to you. Delete that model's block there in the same PR, or drop the generated stg_patients.yml. The second is the generator declining to guess: a relationships test aimed at an invented field passes review and fails in production, so it is omitted and explained instead.
Step 5 — Land the files and build
git checkout -b coremodels/contracts
jq -r '.artifacts[] | [.name, (.content|@base64)] | @tsv' generate-response.json |
while IFS=$'\t' read -r f c; do mkdir -p "$(dirname "$f")"; printf '%s' "$c" | base64 -d > "$f"; done
dbt build
Your reviewers see an ordinary diff of YAML files sitting next to the models they describe, and dbt enforces them from the next build onward. Regenerate after the next meaning change in CoreModels and you get the same files again, so the second PR is a diff of what changed and nothing else.
The dbt quickstart shipped with the CoreModels integration docs covers the audit route, the CI gate, and the MCP equivalent of every call above.
Why this matters: the dbt guides on coremodels.io. This page is also available as Markdown.