Airflow: attest after the task commits its output.
Airflow knows which task ran and in what order. It does not know which exact bytes a task produced, which statement those bytes descend from, or whether the data that reached the next team is the data that was signed. This page wires Makoto into a real TaskFlow DAG so every boundary produces signed evidence and the handoff is refused unless the reference verifier allows it. The DAG below was run end to end with airflow dags test on Airflow 3.3.1, once clean and once with the data corrupted in transit.
airflow dags test makoto_orders -c '{"tamper_in_transit": true}'airflow.sdk.exceptions.AirflowFailException: Makoto deny (E_ARTIFACT_DIGEST); report at /srv/makoto/runs/manual__2026-09-16T12_46_45.122670_00_00/reports/verification.json; step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digestImplementation boundary. This is a pattern built from Airflow’s own Task SDK and the pinned reference CLI. It is not a shipped adapter, provider package, or custom operator, and no Makoto package is installed into Airflow. Everything Makoto-specific is the makoto command called through subprocess.
Where Makoto sits in the DAG
Each data-producing task is followed by a task that signs what it just committed. Signing is a separate task on purpose: the transformation can be retried or rewritten without ever touching key material, and a failed signature is visible in the graph as its own red box.
extractCommits the raw export for this run to a run-scoped directory.attest_originSigns an origin statement over the exact raw bytes with theoriginkey, naming the source kind and URI the receiver’s policy trusts.normalizeYour transformation. It knows nothing about Makoto.attest_normalizeSigns a transformation statement that binds the output to the origin statement and the exact input digest, with thenormalizekey.create_handoffPackages the statements and final artifact into a bundle signed by thehandoffkey, addressed to one recipient, and computes the three anchors the receiver will check.deliverMoves the bundle to the receiver. In this demonstration a DAG parameter can corrupt it on the way.makoto_gateRuns the verifier with the receiver’s policy and independent anchors. A denial raisesAirflowFailException, so nothing downstream runs and nothing is retried.publishRuns only on an allow, and records the manifest and policy digests the decision was made under.
1. Install the verifier beside Airflow
The workers call the reference CLI, so the pinned core checkout and uv must exist on every machine that runs a Makoto task. Airflow and Makoto keep separate Python environments; nothing is imported across them.
# The reference verifier, pinned and synced once per worker image.
git clone https://github.com/makoto-project/makoto.git /opt/makoto
git -C /opt/makoto checkout 276008eeec0f10d5322c4c627b8c103222fee841
(cd /opt/makoto && uv sync --locked --dev)
# Airflow itself, installed with its published constraints.
uv pip install "apache-airflow==3.3.1" \
--constraint https://raw.githubusercontent.com/apache/airflow/constraints-3.3.1/constraints-3.12.txt
# Every scheduler, DAG processor, and worker needs both variables.
export MAKOTO_CORE=/opt/makoto
export MAKOTO_WORK=/srv/makoto2. Create role keys and the receiver policy
Run this once per environment, outside Airflow. It creates one Ed25519 key pair per claim role and writes the receiver’s trust policy: which key may sign origins for which source, which key may sign which operation, and what the handoff must prove. The last line runs makoto policy check, so a malformed policy fails here rather than inside a task.
"""One-time setup: signing keys per claim role and the receiver's trust policy.
Run once per environment, outside Airflow:
MAKOTO_CORE=/path/to/makoto MAKOTO_WORK=/srv/makoto uv run bootstrap_makoto.py
"""
import base64
import json
import os
import subprocess
from pathlib import Path
CORE = Path(os.environ["MAKOTO_CORE"]).resolve()
WORK = Path(os.environ["MAKOTO_WORK"]).resolve()
ROLES = ("origin", "normalize", "handoff")
def makoto(*args: object) -> str:
result = subprocess.run(
["uv", "run", "--quiet", "makoto", *map(str, args)],
cwd=CORE, capture_output=True, text=True, check=False,
)
if result.returncode != 0:
raise SystemExit(result.stderr.strip())
return result.stdout
def public_key_entry(role: str) -> tuple[str, dict]:
public_pem = WORK / "keys" / f"{role}.public.pem"
keyid = json.loads(makoto("key", "inspect", "--public", public_pem, "--json"))["keyid"]
# The policy stores the DER SubjectPublicKeyInfo, base64-encoded: the PEM body.
der = base64.b64decode("".join(public_pem.read_text().strip().splitlines()[1:-1]))
return keyid, {"type": "ed25519", "publicKey": base64.b64encode(der).decode(), "label": f"orders pipeline {role} key"}
(WORK / "keys").mkdir(parents=True, exist_ok=True)
(WORK / "receiver").mkdir(parents=True, exist_ok=True)
for role in ROLES:
if not (WORK / "keys" / f"{role}.private.pem").exists():
makoto(
"key", "generate",
"--private-out", WORK / "keys" / f"{role}.private.pem",
"--public-out", WORK / "keys" / f"{role}.public.pem",
)
keys = dict(public_key_entry(role) for role in ROLES)
keyid = {role: kid for role, kid in zip(ROLES, keys)}
policy = {
"version": "0.2",
"keys": keys,
"rules": [
{
"id": "urn:example:rule:orders-origin",
"predicateTypes": ["https://usemakoto.dev/predicate/v0.2/origin"],
"authorizedKeyIds": [keyid["origin"]],
"minimumSignatures": 1,
"sourceKinds": ["urn:example:source:orders-export"],
"sourceUris": ["urn:example:orders:daily"],
},
{
"id": "urn:example:rule:orders-normalize",
"predicateTypes": ["https://usemakoto.dev/predicate/v0.2/transform"],
"authorizedKeyIds": [keyid["normalize"]],
"minimumSignatures": 1,
"operationTypes": ["urn:example:operation:normalize-orders"],
},
],
"handoff": {
"authorizedKeyIds": [keyid["handoff"]],
"minimumSignatures": 1,
"requireExpectedManifest": True,
"requireExpectedHead": True,
"requireExpectedArtifacts": True,
"requireRecipient": True,
"requireNonce": False,
"allowReplayableHandoff": False,
"maxAgeSeconds": 86400,
"maxFutureSkewSeconds": 300,
},
"requiredProfiles": [],
"limits": {
"maxBundleFiles": 10000,
"maxMetadataBytes": 104857600,
"maxArtifactBytesPerFile": 10737418240,
"maxAggregateArtifactBytes": 53687091200,
"maxSnapshotBytes": 53687091200,
"maxArtifactValidationBytes": 104857600,
"maxJsonDepth": 128,
"maxJsonNumberChars": 1024,
"maxJsonExponentMagnitude": 10000,
"maxSchemaBytes": 2097152,
"maxSchemaResources": 256,
"maxSchemaEvaluationDepth": 256,
"maxSchemaOperations": 10000000,
"maxRegexLength": 4096,
"profileEvaluationTimeoutSeconds": 5,
"profileWorkerMemoryBytes": 536870912,
"maxNdjsonLineBytes": 1048576,
"maxSignaturesTotal": 10000,
"maxProfileEvaluations": 10000,
"maxDiagnostics": 10000,
"maxReportRecords": 20000,
"maxReportBytes": 67108864,
},
}
# The verifier requires canonical order: rules sorted by id, compared as bytes.
policy["rules"].sort(key=lambda rule: rule["id"].encode())
policy_path = WORK / "receiver" / "policy.json"
policy_path.write_text(json.dumps(policy, indent=2) + "\n", encoding="utf-8")
makoto("policy", "check", "--policy", policy_path)
print(f"keys in {WORK / 'keys'}; policy checked at {policy_path}")MAKOTO_CORE=/opt/makoto MAKOTO_WORK=/srv/makoto uv run bootstrap_makoto.py
keys in /srv/makoto/keys; policy checked at /srv/makoto/receiver/policy.jsonIn a real deployment these are two parties. The producer holds the private keys. The receiver owns policy.json and needs only the public keys. The script creates both halves in one place so the example runs on one machine.
3. The DAG
Save this as dags/makoto_orders.py. It uses only the Airflow Task SDK and the standard library.
"""Attest every task boundary with Makoto, hand off a bundle, and gate on the verdict."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import uuid
from pathlib import Path
import pendulum
from airflow.sdk import Param, dag, get_current_context, task
from airflow.sdk.exceptions import AirflowFailException
CORE = Path(os.environ["MAKOTO_CORE"]) # reviewed core checkout, synced with uv
WORK = Path(os.environ["MAKOTO_WORK"]) # keys/ and receiver/ from bootstrap_makoto.py
RECIPIENT = "example:analytics-team"
NORMALIZE_OP = "urn:example:operation:normalize-orders"
# --------------------------------------------------------------------------- helpers
def makoto(*args: object) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["uv", "run", "--quiet", "makoto", *map(str, args)],
cwd=CORE, capture_output=True, text=True, check=False,
)
def run_dir(*parts: str) -> Path:
"""One directory per DAG run, so retries and reruns never mix evidence."""
run_id = re.sub(r"[^A-Za-z0-9._-]", "_", get_current_context()["run_id"])
path = WORK.joinpath("runs", run_id, *parts)
path.mkdir(parents=True, exist_ok=True)
return path
def event_id(name: str) -> str:
"""Stable across task retries, unique across runs."""
context = get_current_context()
return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, f'{context['ti'].dag_id}/{context['run_id']}/{name}')}"
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def payload(envelope: Path) -> bytes:
return base64.b64decode(json.loads(envelope.read_text())["payload"])
def sign(kind: str, role: str, subject: Path, out: Path, *extra: object) -> str:
"""Sign once. A retry reuses the envelope only if it covers the same bytes."""
if out.exists():
signed = json.loads(payload(out))["subject"][0]["digest"]["sha256"]
if signed == sha256(subject):
return str(out)
raise AirflowFailException(f"{out.name} already attests different bytes; refusing a second history")
result = makoto(
"attest", kind,
"--subject", f"{subject.name}={subject}",
*extra,
"--event-id", event_id(out.name),
"--key", WORK / "keys" / f"{role}.private.pem",
"--out", out,
)
if result.returncode != 0:
raise RuntimeError(f"makoto attest {kind} failed: {result.stderr.strip()}") # retryable
return str(out)
# --------------------------------------------------------------------------- DAG
@dag(
dag_id="makoto_orders",
schedule=None,
start_date=pendulum.datetime(2026, 9, 1, tz="UTC"),
catchup=False,
default_args={"retries": 2},
params={"tamper_in_transit": Param(False, type="boolean")},
tags=["makoto"],
)
def makoto_orders():
@task
def extract() -> str:
raw = run_dir("data") / "orders.raw.csv"
raw.write_text("order_id,region,total\n1001, us ,42.00\n1002,GB,17.50\n")
return str(raw)
@task
def attest_origin(raw: str) -> str:
return sign(
"origin", "origin", Path(raw), run_dir("attestations") / "01-origin.dsse.json",
"--source-kind", "urn:example:source:orders-export",
"--source-uri", "urn:example:orders:daily",
)
@task
def normalize(raw: str) -> str:
header, *rows = Path(raw).read_text().splitlines()
out = run_dir("data") / "orders.normalized.csv"
cleaned = [",".join(cell.strip().lower() for cell in row.split(",")) for row in rows]
out.write_text("\n".join([header, *cleaned]) + "\n")
return str(out)
@task
def attest_normalize(raw: str, origin: str, normalized: str) -> str:
binding = run_dir("bindings") / "normalize-input.json"
binding.write_text(json.dumps({
"name": "raw",
"path": raw,
"predecessor": origin,
"subjectName": Path(raw).name,
}))
return sign(
"transform", "normalize", Path(normalized),
run_dir("attestations") / "02-normalize.dsse.json",
"--input-binding", binding,
"--operation-type", NORMALIZE_OP,
"--operation-name", "Normalize order regions",
)
@task
def create_handoff(head: str, normalized: str) -> dict:
binding = run_dir("bindings") / "final-artifact.json"
binding.write_text(json.dumps({
"head": head,
"subjectName": Path(normalized).name,
"path": normalized,
"mediaType": "text/csv",
}))
bundle = run_dir() / "bundle"
result = makoto(
"handoff", "create",
"--head", head,
"--attestations", run_dir("attestations"),
"--artifact-binding", binding,
"--recipient", RECIPIENT,
"--bundle-id", event_id("bundle"),
"--key", WORK / "keys" / "handoff.private.pem",
"--out", bundle,
"--force", # a retry rebuilds the same bundle from the same signed inputs
)
if result.returncode != 0:
raise RuntimeError(f"makoto handoff create failed: {result.stderr.strip()}")
index = json.loads((bundle / "bundle.json").read_text())
# These three anchors travel to the receiver out of band: a ticket, a
# release record, a catalog entry. Never inside the bundle they describe.
return {
"bundle": str(bundle),
"manifest": "sha256:" + hashlib.sha256(payload(bundle / index["manifest"])).hexdigest(),
"head": "sha256:" + hashlib.sha256(payload(Path(head))).hexdigest(),
"artifact": {
"head": {"sha256": hashlib.sha256(payload(Path(head))).hexdigest()},
"subjectName": Path(normalized).name,
"digest": {"sha256": sha256(Path(normalized))},
},
}
@task
def deliver(handoff: dict) -> str:
inbox = run_dir("receiver-inbox") / "bundle"
shutil.copytree(handoff["bundle"], inbox, dirs_exist_ok=True)
if get_current_context()["params"]["tamper_in_transit"]:
final = next((inbox / "artifacts" / "final").glob("*.bin"))
final.write_bytes(final.read_bytes() + b"1003,us,0.01\n")
return str(inbox)
@task
def makoto_gate(delivered: str, handoff: dict) -> dict:
expected_artifact = run_dir("receiver-inbox") / "expected-artifact.json"
expected_artifact.write_text(json.dumps(handoff["artifact"]))
result = makoto(
"verify", "bundle", delivered,
"--policy", WORK / "receiver" / "policy.json",
"--expected-manifest", handoff["manifest"],
"--expected-head", handoff["head"],
"--expected-artifact", expected_artifact,
"--expected-recipient", RECIPIENT,
"--json",
)
if result.returncode not in (0, 1):
raise RuntimeError(f"verifier could not run: {result.stderr.strip()}") # retryable
report_path = run_dir("reports") / "verification.json"
report_path.write_text(result.stdout)
report = json.loads(result.stdout)
if report["decision"] != "allow":
failed = [f"step {e['step']} {e['code']}: {e['message']}" for e in report["errors"]]
# AirflowFailException skips retries: a denial will not change on retry.
raise AirflowFailException(
f"Makoto {report['decision']} ({report['primaryError']}); "
f"report at {report_path}; " + "; ".join(failed)
)
return {
"decision": report["decision"],
"manifest": report["manifestDigest"]["sha256"],
"policy": report["policyDigest"]["sha256"],
"report": str(report_path),
}
@task
def publish(verdict: dict, normalized: str) -> None:
print(f"publishing {normalized} under Makoto {verdict['decision']} "
f"(manifest {verdict['manifest'][:12]}, policy {verdict['policy'][:12]})")
raw = extract()
origin = attest_origin(raw)
normalized = normalize(raw)
head = attest_normalize(raw, origin, normalized)
handoff = create_handoff(head, normalized)
verdict = makoto_gate(deliver(handoff), handoff)
publish(verdict, normalized)
makoto_orders()4. Run it
airflow db migrate
airflow dags test makoto_ordersAll eight tasks succeed. The publish task logs the decision and the digests it was made under:
publishing /srv/makoto/runs/manual__2026-09-16T12_46_35.467633_00_00/data/orders.normalized.csv under Makoto allow (manifest 712954fd6b0d, policy b0b749267a9b)5. What a gate failure looks like
Trigger the same DAG with the data corrupted between handoff and verification. Everything upstream still succeeds, because every signature was honest when it was made. The gate is where the change is caught.
airflow dags test makoto_orders -c '{"tamper_in_transit": true}'airflow.sdk.exceptions.AirflowFailException: Makoto deny (E_ARTIFACT_DIGEST); report at /srv/makoto/runs/manual__2026-09-16T12_46_45.122670_00_00/reports/verification.json; step 12 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digestTask states for that run. makoto_gate fails once with no retries, and publish never starts:
task_id state
extract success
attest_origin success
normalize success
attest_normalize success
create_handoff success
deliver success
makoto_gate failed
publish upstream_failedThe report written by the gate says precisely what failed and what still held. Signatures, authorization, the graph, and the recipient all pass; only the delivered bytes do not match:
jq '{decision, primaryError,
notPassed: [.checks[] | select(.status != "pass") | {id, status}],
errors: [.errors[] | {step, code, message}],
recipient: .actualRecipient, policy: .policyDigest.sha256}' \
/srv/makoto/runs/<run>/reports/verification.json
{
"decision": "deny",
"primaryError": "E_ARTIFACT_DIGEST",
"notPassed": [
{
"id": "artifact-bytes",
"status": "fail"
}
],
"errors": [
{
"step": 12,
"code": "E_ARTIFACT_DIGEST",
"message": "final artifact bytes differ from the signed digest"
}
],
"recipient": "example:analytics-team",
"policy": "b0b749267a9b3bbfce5dc0a8a9fd570f49525af8a4332c43aa11d9ce1d529a69"
}Signing that survives retries
Airflow retries tasks. Signed evidence must not fork because of it. sign derives the event identifier from the DAG, run, and statement name, so a retry produces the same event. If the envelope already exists, it is reused only when it covers the same bytes; if the task output changed between attempts, the task fails permanently instead of emitting a second, conflicting history.
def sign(kind: str, role: str, subject: Path, out: Path, *extra: object) -> str:
"""Sign once. A retry reuses the envelope only if it covers the same bytes."""
if out.exists():
signed = json.loads(payload(out))["subject"][0]["digest"]["sha256"]
if signed == sha256(subject):
return str(out)
raise AirflowFailException(f"{out.name} already attests different bytes; refusing a second history")
result = makoto(
"attest", kind,
"--subject", f"{subject.name}={subject}",
*extra,
"--event-id", event_id(out.name),
"--key", WORK / "keys" / f"{role}.private.pem",
"--out", out,
)
if result.returncode != 0:
raise RuntimeError(f"makoto attest {kind} failed: {result.stderr.strip()}") # retryable
return str(out)Both behaviours were exercised against the run above: a retry over identical bytes returned the existing envelope unchanged, and a retry after the raw file changed raised AirflowFailException: 01-origin.dsse.json already attests different bytes; refusing a second history.
What travels through XCom, and what never does
- Artifact and envelope paths
- Passed through XCom. They are small strings pointing into the run-scoped directory.
- Manifest, head, and artifact digests
- Returned by
create_handoff. In production, publish them through a channel the bundle cannot rewrite and have the receiver read them from there. - Private keys
- Never. Tasks read them from the worker filesystem under
MAKOTO_WORK/keys; mount them from your secrets backend, one key per role. - Bundles and reports
- Never. XCom rows live in the metadata database. The gate writes the report to disk and returns only its path and digests.
The gate, and why its exceptions differ
def makoto_gate(delivered: str, handoff: dict) -> dict:
expected_artifact = run_dir("receiver-inbox") / "expected-artifact.json"
expected_artifact.write_text(json.dumps(handoff["artifact"]))
result = makoto(
"verify", "bundle", delivered,
"--policy", WORK / "receiver" / "policy.json",
"--expected-manifest", handoff["manifest"],
"--expected-head", handoff["head"],
"--expected-artifact", expected_artifact,
"--expected-recipient", RECIPIENT,
"--json",
)
if result.returncode not in (0, 1):
raise RuntimeError(f"verifier could not run: {result.stderr.strip()}") # retryable
report_path = run_dir("reports") / "verification.json"
report_path.write_text(result.stdout)
report = json.loads(result.stdout)
if report["decision"] != "allow":
failed = [f"step {e['step']} {e['code']}: {e['message']}" for e in report["errors"]]
# AirflowFailException skips retries: a denial will not change on retry.
raise AirflowFailException(
f"Makoto {report['decision']} ({report['primaryError']}); "
f"report at {report_path}; " + "; ".join(failed)
)
return {
"decision": report["decision"],
"manifest": report["manifestDigest"]["sha256"],
"policy": report["policyDigest"]["sha256"],
"report": str(report_path),
}Two exception types carry two meanings. RuntimeError means the verifier could not run, exit status 2 or 3, which may be transient, so the task’s retries apply. AirflowFailException means the verifier ran and refused the data. Retrying cannot change a denial, and retrying would hide it in the UI, so it fails immediately.
Taking it to production
- Split producer and receiver
- Run
makoto_gateandpublishin the receiving team’s own DAG and deployment, with its own policy file. A gate that shares a filesystem with the producer can be satisfied by the producer. - Pin evaluation time for backfills
- The gate uses the current time, which enforces the policy’s 24-hour
maxAgeSeconds. When re-verifying historical runs, pass--evaluation-timefrom the run’s data interval and keep the policy snapshot that was in force. - Keep the report
- Ship
reports/verification.jsonto durable storage with the policy digest, allowed or not. It is the record of why data was accepted. - Keep the run directory immutable
- Never rewrite a signed file in place to append a step. A new step is a new statement that names its predecessor.
What the evidence can say
It can bind one committed task output to exact inputs, predecessor statements, and the key that signed it, and prove that the bytes a receiver holds are the bytes that were signed. It cannot prove the task code executed honestly, that the source system told the truth, or that DAG ownership implies a recipient is authorized. Those remain controls around the pipeline, not properties of the evidence.
Troubleshooting
{"errorClass":"invalid-input","message":"$.rules: array is not in canonical order"}- Policy rules must be sorted by
id, compared as bytes. The bootstrap script sorts them before writing. {"errorClass":"invalid-input","message":"output already exists: ..."}on a retry- The CLI refuses to overwrite an existing envelope unless given
--force, and exits 2. Thesignhelper handles this by reusing a matching envelope; do not add--forceto attestation calls, or a retry can silently replace history. FileNotFoundError: [Errno 2] No such file or directory: 'uv'in a task log- The worker’s
PATHdoes not includeuv. Install it in the worker image, or call$MAKOTO_CORE/.venv/bin/makotodirectly. - The gate denies with
E_HANDOFF_STALE - The handoff is older than the policy’s
maxAgeSecondsat evaluation time. Verifying the clean run above two days later with--evaluation-timeproduces exactly this denial and no other error. Verify promptly after delivery, or pass an explicit--evaluation-timewhen re-verifying.
Run it on your Airflow and tell us what broke.
The most useful contribution is a real deployment detail this DAG gets wrong: a secrets backend, a Kubernetes executor, an object-store handoff, or a dataset-triggered receiver.