Find every artifact descended from the bad source.
Bytes change after they were signed. The signatures still check out and the metadata still describes the original. The immediate gate matters, and so does the blast radius once the batch has already moved.
$ makoto verify bundle handoffs/mutated-final-data/bundle \
--policy receiver/policy.json \
--schema-catalog receiver/catalog.json \
--expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \
--expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
--expected-artifact receiver/expected/mutated-final-data.artifact.json \
--evaluation-time 2026-09-16T16:00:00Z
PASS load-safely
PASS parse-strictly
PASS index-payloads
PASS core-schemas
PASS signatures
PASS authorization-thresholds
PASS metadata-profiles
PASS authorization
PASS graph-dependency-artifacts
PASS graph
PASS roots-and-heads
PASS completeness-anchor
PASS freshness-anchors
FAIL artifact-bytes
PASS artifact-profiles
PASS decision
DENY decision
manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7
E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digest
$ echo $?
1Evidence boundary. Every terminal block on this page was captured by running the command shown against the checked-in reference fixtures with the Makoto core verifier at the pinned commit 276008e, which the gate below runs. Verification-summary tables are computed from the published reports with its property rules. The verification reports are byte-for-byte the files published under the end-to-end proof. Nothing below was typed by hand.
The problem
An ingestion pipeline takes files from partner systems every hour and loads them straight into analytics. Nobody checks where a file came from or whether it changed on the way. The pipeline trusts whatever lands in the drop directory.
One morning a file slips through that is not the file the producer released. Dashboards absorb it, alerts fire on phantom anomalies, and the team spends days finding the source, because there is no record of who produced the file, which exact bytes they vouched for, or what else was built from it.
This case runs that failure against the reference customer-data handoff: a source export, two signed transformations, and a signed handoff to a downstream team. After the handoff is signed, one byte is appended to the final artifact.
The raw example
This is what the downstream team is supposed to receive: three customer records with direct identifiers removed and ages bucketed. The final signed statement names those exact bytes by SHA-256 digest.
$ final=artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ jq . handoffs/positive/bundle/$final [ { "age_band": "25-34", "customer_id": "4956cf12a64fd24a0815", "marketing_consent": true, "region": "US" }, { "age_band": "0-17", "customer_id": "b8610bcb16fd5eb07f52", "marketing_consent": true, "region": "CA" }, { "age_band": "65+", "customer_id": "d7ab36aae7b1e4237278", "marketing_consent": false, "region": "GB" } ] $ statement() { b=handoffs/$1/bundle; d=$(jq -r '.artifacts[0].statementDigest.sha256' $b/bundle.json); jq -r .payload $b/attestations/$d.dsse.json | base64 -d; } $ statement positive | jq .subject [ { "digest": { "sha256": "ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e" }, "name": "customers.public.json" } ]Inspect the source export, the normalized intermediate, and the final public-safe file.
How it failed
The poisoned copy differs from the signed file by a single trailing byte. Everything that describes the file is untouched: the signed handoff manifest and all three signed statements are byte-identical to the good bundle. A pipeline that trusts the metadata, or only checks that the signatures are valid, accepts it.
$ final=artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ cmp handoffs/positive/bundle/$final handoffs/mutated-final-data/bundle/$final cmp: EOF on 'handoffs/positive/bundle/artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin' after byte 291, line 1 $ tail -c 24 handoffs/mutated-final-data/bundle/$final | od -c | tail -n 3 0000000 : f a l s e , " r e g i o n " : 0000020 " G B " } ] \n x 0000030 $ shasum -a 256 handoffs/*/bundle/$final 87b8382c73288bf07cec11080071d0e6a425e88ef4bd3a662d0f103c523c5f64 handoffs/mutated-final-data/bundle/artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e handoffs/positive/bundle/artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ cmp handoffs/positive/bundle/manifest.dsse.json handoffs/mutated-final-data/bundle/manifest.dsse.json && echo 'signed handoff manifest: byte-identical' signed handoff manifest: byte-identical $ diff -r handoffs/positive/bundle/attestations handoffs/mutated-final-data/bundle/attestations && echo 'signed statements: byte-identical' signed statements: byte-identicalThe signed digest is
ecd821db…. The bytes that arrived hash to87b8382c…. Nothing in the bundle says so; only a receiver that hashes what it actually received can tell.Put Makoto in the path
The receiver adds one gate job in front of the load job. It checks out the verifier at a pinned core commit, runs
makoto verify bundleagainst every received handoff, keeps the machine-readable report as a build artifact, and fails the job on any deny. The load job declaresneeds: gate, so poisoned bytes never reach it.The workflow
# Receiver-side gate for the poisoned-pipeline case. Nothing # downstream loads until Makoto allows the exact bytes that arrived. # # Copy to .github/workflows/makoto-gate.yml in the repository that # receives handoffs. Layout it expects: # handoffs/<name>/bundle/ what the sender transferred # receiver/policy.json receiver-owned trust policy # receiver/catalog.json, receiver/resources/ pinned private schemas # receiver/expected/<name>.artifact.json expected final artifact name: Makoto gate before load on: workflow_dispatch: push: paths: - "handoffs/**" permissions: contents: read env: # Pinned so these fixtures replay identically. Delete this line to # evaluate against the runner clock. EVALUATION_TIME: "2026-09-16T16:00:00Z" jobs: gate: runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: # The sender publishes manifest and head digests out of band. # The receiver pins them here, never inside the bundle. - handoff: positive manifest: sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 head: sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce - handoff: mutated-final-data manifest: sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 head: sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce steps: - uses: actions/checkout@v4 # Pin the verifier to an exact core commit, like any dependency. # Run it from a checkout: the CLI resolves its core schemas from # the repository tree. This is the core commit the hosted # schemas are pinned to. - uses: actions/checkout@v4 with: repository: makoto-project/makoto ref: 276008eeec0f10d5322c4c627b8c103222fee841 path: .makoto-core persist-credentials: false - uses: astral-sh/setup-uv@v6 - name: Verify the received bundle id: verify shell: bash env: HANDOFF: ${{ matrix.handoff }} EXPECTED_MANIFEST: ${{ matrix.manifest }} EXPECTED_HEAD: ${{ matrix.head }} run: | verify() { uv run --project .makoto-core makoto verify bundle "handoffs/$HANDOFF/bundle" \ --policy receiver/policy.json \ --schema-catalog receiver/catalog.json \ --expected-manifest "$EXPECTED_MANIFEST" \ --expected-head "$EXPECTED_HEAD" \ --expected-artifact "receiver/expected/$HANDOFF.artifact.json" \ --evaluation-time "$EVALUATION_TIME" "$@" } status=0 verify --json > makoto-report.json || status=$? verify || true exit "$status" - name: Keep the verification report if: always() uses: actions/upload-artifact@v4 with: name: makoto-report-${{ matrix.handoff }} path: makoto-report.json load: # Runs only when every handoff in the matrix was allowed. needs: gate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Load the verified customer data run: echo "replace with your existing step"Download makoto-gate.yml. The matrix carries both the untampered handoff and the poisoned one so the same step shows an allow and a deny.
The gate keeps the JSON report as a build artifact, so a later job can summarize it into a signed verification summary without running verification again.
What the receiver pins
The expected artifact is receiver-owned. It lives outside the bundle, so a sender cannot rewrite it along with the bytes.
$ jq . receiver/expected/mutated-final-data.artifact.json { "digest": { "sha256": "ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e" }, "head": { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" }, "subjectName": "customers.public.json" }The trust policy and private schema catalog are the published receiver policy and catalog.
The output that catches it
The gate step runs the verifier twice: once with
--jsonto write the report, once for the human-readable log. This is the second run, exactly as the step invokes it, followed by its exit status. Every check passes exceptartifact-bytes, and the job exits 1.$ makoto verify bundle handoffs/mutated-final-data/bundle \ --policy receiver/policy.json \ --schema-catalog receiver/catalog.json \ --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \ --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \ --expected-artifact receiver/expected/mutated-final-data.artifact.json \ --evaluation-time 2026-09-16T16:00:00Z PASS load-safely PASS parse-strictly PASS index-payloads PASS core-schemas PASS signatures PASS authorization-thresholds PASS metadata-profiles PASS authorization PASS graph-dependency-artifacts PASS graph PASS roots-and-heads PASS completeness-anchor PASS freshness-anchors FAIL artifact-bytes PASS artifact-profiles PASS decision DENY decision manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 E_ARTIFACT_DIGEST: final artifact bytes differ from the signed digest $ echo $? 1The machine-readable report
Output of
jq '{decision, primaryError, errors, notPassed: [.checks[] | select(.status != "pass") | {id, status}]}' makoto-report.mutated-final-data.jsonagainst the report the workflow step wrote.{ "decision": "deny", "primaryError": "E_ARTIFACT_DIGEST", "errors": [ { "causedByCheck": "artifact-bytes", "code": "E_ARTIFACT_DIGEST", "context": { "artifactDigest": { "sha256": "ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e" }, "head": { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" }, "subjectName": "customers.public.json" }, "message": "final artifact bytes differ from the signed digest", "step": 12 } ], "notPassed": [ { "id": "artifact-bytes", "status": "fail" } ] }What a verification summary records
A verification summary over this report is
FAILEDwithE_REPORT_DENIED, because the decision isdeny. Its evaluation sidecar still records each verified property's exact rule against the report's checks:Property What its rule reads in this report Result MAKOTO_AUTHORIZEDsignaturespass,authorization-thresholdspass,authorizationpass; handoff authorization passEstablished MAKOTO_GRAPH_COMPLETEindex-payloadspass,graph-dependency-artifactspass,graphpass,roots-and-headspass,completeness-anchorpassEstablished MAKOTO_FRESHNESS_ANCHOREDfreshness-anchorspass; methods:expected-artifactspass,expected-headspass,expected-manifestpass,max-agenot_checked,noncenot_checkedEstablished MAKOTO_SCHEMA_CONFORMANTcore-schemaspass,metadata-profilespass,artifact-profilespass; the policy requires 2 digest-pinned profilesNot established: E_PROPERTY_SCHEMA_CONFORMANTMAKOTO_REPRODUCEDNeeds a second allowreport from an independent Transform L2-or-higher platform; none is suppliedNot established: E_PROPERTY_REPRODUCEDComputed from the published report and the policy whose digest it records, with the property rules in core at the pinned commit. Track levels are not shown: they also need the statements themselves and any platform assessments.
Open the complete denial report. It records sixteen checks, the evaluation time, the expected and actual heads, and the catalog digest the receiver used.
Control run: the untampered handoff through the same gate
$ makoto verify bundle handoffs/positive/bundle \ --policy receiver/policy.json \ --schema-catalog receiver/catalog.json \ --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \ --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \ --expected-artifact receiver/expected/positive.artifact.json \ --evaluation-time 2026-09-16T16:00:00Z PASS load-safely PASS parse-strictly PASS index-payloads PASS core-schemas PASS signatures PASS authorization-thresholds PASS metadata-profiles PASS authorization PASS graph-dependency-artifacts PASS graph PASS roots-and-heads PASS completeness-anchor PASS freshness-anchors PASS artifact-bytes PASS artifact-profiles PASS decision ALLOW decision manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 $ echo $? 0What a verification summary records
A verification summary over this
allowreport records each verified property's exact rule against its checks:Property What its rule reads in this report Result MAKOTO_AUTHORIZEDsignaturespass,authorization-thresholdspass,authorizationpass; handoff authorization passEstablished MAKOTO_GRAPH_COMPLETEindex-payloadspass,graph-dependency-artifactspass,graphpass,roots-and-headspass,completeness-anchorpassEstablished MAKOTO_FRESHNESS_ANCHOREDfreshness-anchorspass; methods:expected-artifactspass,expected-headspass,expected-manifestpass,max-agenot_checked,noncenot_checkedEstablished MAKOTO_SCHEMA_CONFORMANTcore-schemaspass,metadata-profilespass,artifact-profilespass; the policy requires 2 digest-pinned profilesEstablished MAKOTO_REPRODUCEDNeeds a second allowreport from an independent Transform L2-or-higher platform; none is suppliedNot established: E_PROPERTY_REPRODUCEDComputed from the published report and the policy whose digest it records, with the property rules in core at the pinned commit. Track levels are not shown: they also need the statements themselves and any platform assessments.
The evidence path
- Origin The collector signs the exact source batch and source claims at acquisition.
- Normalization The next statement binds the raw batch and predecessor envelope before producing cleaned bytes.
- Aggregation Downstream outputs append their own statements instead of editing the earlier history.
- Receiver gate The receiver hashes the bytes that arrived, while predecessor links identify every affected descendant.
What the scenario proves
| Useful result | A deterministic deny naming the failed check, plus a graph that supports impact analysis. |
|---|---|
| It does not prove | That every hidden copy participates, that the source was truthful, or that a signature alone makes the batch safe. |
| Why metadata alone fails | The manifest and statements in the poisoned bundle are byte-identical to the good ones. Only hashing the received bytes against the signed subject digest exposes the change. |
What else this handles
- Third-party data feeds where you cannot inspect the source infrastructure.
- Regulated environments that require data lineage documentation.
- Multi-team pipelines where one team's output is another team's input.
- Automated exchanges between organizations with different trust levels.
Key insight. Without verified provenance a pipeline has no immune system: it accepts everything, including poison. With it, the receiver's own expectations, not the sender's metadata, decide what loads.