dbt: record the model run; bind the exported data.
A dbt model, manifest, and run result explain how warehouse relations were built. A recipient-verifiable handoff still needs a stable artifact whose exact bytes both sides can hash. This page wraps dbt build with dbtRunner, exports the model to a file, and signs that file against the seed it was built from.
python makoto_dbt.py --tamper-in-transit13:04:25 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=2
REFUSED orders_normalized.csv: Makoto deny (E_ARTIFACT_DIGEST): step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digestImplementation boundary. This is a pattern built from the platform’s own APIs and the pinned reference CLI. It is not a shipped adapter, plugin, or package. Executed with dbt-core 1.12.5 and dbt-duckdb 1.11.0, once clean and once with the bundle corrupted in transit. The same wrapper applies to warehouse adapters that unload to files.
Where Makoto sits
dbt hooks run SQL inside the warehouse; they cannot hash a file or hold a signing key. The attachment point is therefore the process that invokes dbt, using dbt’s own manifest.json and run_results.json to find exactly what was built.
- SeedSigned as the origin before dbt reads it.
dbt buildLoads the seed and builds the model. A failure stops the wrapper before anything is attested.- External materializationdbt-duckdb writes the model to
target/orders_normalized.csv; the path is read back from the manifest, not hard-coded twice. - Transformation statementBinds the exported file to the seed bytes and origin statement, with the model’s
unique_idas the operation name. - GateThe receiver verifies the delivered bundle before loading it anywhere.
1. Install
Every platform on this site uses the same two files from the integration index: makoto_boundary.py, a standard-library helper that signs, hands off, and verifies through the pinned CLI, and makoto_bootstrap.py, which creates one key per claim role and the receiver’s trust policy. Put both next to the code below.
git clone https://github.com/makoto-project/makoto.git /opt/makoto
git -C /opt/makoto checkout 276008eeec0f10d5322c4c627b8c103222fee841
(cd /opt/makoto && uv sync --locked --dev)
export MAKOTO_CORE=/opt/makoto
uv pip install "dbt-core==1.12.5" "dbt-duckdb==1.11.0"uv run makoto_bootstrap.py /srv/makoto \
--source-kind urn:example:source:orders-export \
--source-uri urn:example:orders:daily \
--operation-type urn:example:operation:dbt-model
export MAKOTO_KEYS=/srv/makoto/keys MAKOTO_POLICY=/srv/makoto/policy.json2. The project
A two-node project: one seed and one model exported as a file.
# dbt_project.yml
name: orders
version: "1.0.0"
profile: orders
seed-paths: ["seeds"]
model-paths: ["models"]# profiles.yml
orders:
target: dev
outputs:
dev:
type: duckdb
path: target/orders.duckdb# seeds/orders_raw.csv
order_id,region,total
1001, us ,42.00
1002,GB,17.50-- models/orders_normalized.sql
{{ config(materialized="external", location="target/orders_normalized.csv", format="csv") }}
select
order_id,
lower(trim(region)) as region,
total
from {{ ref("orders_raw") }}
order by order_id3. The wrapper
Save this as makoto_dbt.py in the project root.
"""Run dbt, sign the exported model against the seed it read, and gate the handoff.
python makoto_dbt.py # build, attest, hand off, verify
python makoto_dbt.py --tamper-in-transit # same, with the delivered bytes changed
"""
import json
import shutil
import sys
import uuid
from pathlib import Path
from dbt.cli.main import dbtRunner
import makoto_boundary as mb
PROJECT = Path(__file__).resolve().parent
MODEL = "model.orders.orders_normalized"
SEED = PROJECT / "seeds" / "orders_raw.csv"
RECIPIENT = "example:analytics-team"
def main() -> int:
invocation = str(uuid.uuid4())
run = PROJECT / "makoto" / invocation
attestations = run / "attestations"
event = lambda name: f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, f'{invocation}/{name}')}"
# 1. The seed is where data enters this project: sign it before dbt reads it.
origin = mb.sign_origin(
SEED, attestations / "01-origin.dsse.json",
source_kind="urn:example:source:orders-export",
source_uri="urn:example:orders:daily",
event_id=event("origin"),
)
# 2. Build. A failed model must never be attested.
result = dbtRunner().invoke(["build", "--project-dir", str(PROJECT), "--profiles-dir", str(PROJECT)])
if not result.success:
print("dbt build failed; nothing attested", file=sys.stderr)
return 1
# 3. Resolve the exact file the model wrote from dbt's own artifacts.
manifest = json.loads((PROJECT / "target" / "manifest.json").read_text())
node = manifest["nodes"][MODEL]
exported = PROJECT / node["config"]["location"]
statuses = {r["unique_id"]: r["status"] for r in json.loads((PROJECT / "target" / "run_results.json").read_text())["results"]}
assert statuses[MODEL] == "success", statuses
head = mb.sign_transform(
exported, attestations / "02-model.dsse.json",
inputs=[(SEED, origin)],
operation_type="urn:example:operation:dbt-model",
operation_name=MODEL,
event_id=event("model"),
)
anchors = mb.create_handoff(
head, exported, attestations, run / "bundle",
recipient=RECIPIENT, bundle_id=event("bundle"), media_type="text/csv",
)
# 4. Receiver side: verify what arrived before anything loads it.
delivered = run / "receiver" / "bundle"
shutil.copytree(run / "bundle", delivered)
if "--tamper-in-transit" in sys.argv:
final = next((delivered / "artifacts" / "final").glob("*.bin"))
final.write_bytes(final.read_bytes() + b"1003,us,0.01\n")
try:
report = mb.verify(delivered, anchors, recipient=RECIPIENT, report_path=run / "receiver" / "verification.json")
except mb.MakotoDenied as denied:
print(f"REFUSED {exported.name}: {denied}", file=sys.stderr)
return 1
print(f"ALLOWED {exported.name}: {report['summary']['statementsValid']} statements, "
f"policy {report['policyDigest']['sha256'][:12]}")
return 0
if __name__ == "__main__":
sys.exit(main())4. Run it
python makoto_dbt.py13:04:21 1 of 2 OK loaded seed file main.orders_raw ..................................... [INSERT 2 in 0.03s]
13:04:21 2 of 2 OK created sql external model main.orders_normalized .................... [OK in 0.04s]
13:04:21 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=2
ALLOWED orders_normalized.csv: 2 statements, policy c74202b1c7745. What a gate failure looks like
python makoto_dbt.py --tamper-in-transit13:04:25 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=2
REFUSED orders_normalized.csv: Makoto deny (E_ARTIFACT_DIGEST): step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digestdbt itself succeeded; the export was signed honestly. The refusal happens where it belongs, before the receiver loads the file.
Other attachment points on this platform
- Model materialization
- Capture model unique ID, compiled SQL digest, invocation ID, adapter, relation, and test results after the materialization succeeds.
- Exact artifact
- Export a stable file set or create a deterministic manifest over the handed-off relation snapshot. A relation name alone is mutable.
- Predecessors
- Resolve exact upstream artifact and statement digests; do not equate the dbt DAG edge with a signed Makoto edge.
- Project boundary
- Create the signed handoff only when the exact output set and required private profiles are known.
What the evidence can say
It can bind a stable dbt-produced export to compiled-model context and exact predecessors. It cannot prove SQL semantics, regulatory compliance, or a warehouse relation’s future contents.
Troubleshooting
IO Error: Cannot open file "target/export/orders_normalized.csv": No such file or directory- dbt-duckdb does not create parent directories for an external location. Point the location at an existing directory, or create it before
dbt build. - A model is signed although its tests failed
- Check
result.successfromdbtRunnerand the model’s status inrun_results.jsonbefore signing, as the wrapper does.dbt buildruns tests, so a failed test fails the invocation. - The export differs between two runs of the same model
- Row order is part of the bytes. Add a deterministic
order byto exported models, or the same data will hash differently.
Try it on your warehouse adapter.
Snowflake, BigQuery, and Databricks adapters unload differently. Contribute the export step for yours.