Makoto
Menu
dbt integration

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.

The code this page is about. Repeated from 5. What a gate failure looks like below.
python makoto_dbt.py --tamper-in-transit
13: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 digest

Implementation 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.

  1. SeedSigned as the origin before dbt reads it.
  2. dbt buildLoads the seed and builds the model. A failure stops the wrapper before anything is attested.
  3. External materializationdbt-duckdb writes the model to target/orders_normalized.csv; the path is read back from the manifest, not hard-coded twice.
  4. Transformation statementBinds the exported file to the seed bytes and origin statement, with the model’s unique_id as the operation name.
  5. 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.json

2. 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_id

3. 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.py
13: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 c74202b1c774

5. What a gate failure looks like

python makoto_dbt.py --tamper-in-transit
13: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 digest

dbt 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.success from dbtRunner and the model’s status in run_results.json before signing, as the wrapper does. dbt build runs 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 by to 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.