Makoto
Menu
Case 01 · poisoned pipeline

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.

The code this page is about. Repeated from The output that catches it below.
$ 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 $?
1

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

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

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

    The signed digest is ecd821db…. The bytes that arrived hash to 87b8382c…. Nothing in the bundle says so; only a receiver that hashes what it actually received can tell.

  3. 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 bundle against every received handoff, keeps the machine-readable report as a build artifact, and fails the job on any deny. The load job declares needs: 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.

  4. The output that catches it

    The gate step runs the verifier twice: once with --json to 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 except artifact-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 $?
    1

    The machine-readable report

    Output of jq '{decision, primaryError, errors, notPassed: [.checks[] | select(.status != "pass") | {id, status}]}' makoto-report.mutated-final-data.json against 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 FAILED with E_REPORT_DENIED, because the decision is deny. Its evaluation sidecar still records each verified property's exact rule against the report's checks:

    PropertyWhat its rule reads in this reportResult
    MAKOTO_AUTHORIZEDsignatures pass, authorization-thresholds pass, authorization pass; handoff authorization passEstablished
    MAKOTO_GRAPH_COMPLETEindex-payloads pass, graph-dependency-artifacts pass, graph pass, roots-and-heads pass, completeness-anchor passEstablished
    MAKOTO_FRESHNESS_ANCHOREDfreshness-anchors pass; methods: expected-artifacts pass, expected-heads pass, expected-manifest pass, max-age not_checked, nonce not_checkedEstablished
    MAKOTO_SCHEMA_CONFORMANTcore-schemas pass, metadata-profiles pass, artifact-profiles pass; the policy requires 2 digest-pinned profilesNot established: E_PROPERTY_SCHEMA_CONFORMANT
    MAKOTO_REPRODUCEDNeeds a second allow report from an independent Transform L2-or-higher platform; none is suppliedNot established: E_PROPERTY_REPRODUCED

    Computed 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 $?
    0

    What a verification summary records

    A verification summary over this allow report records each verified property's exact rule against its checks:

    PropertyWhat its rule reads in this reportResult
    MAKOTO_AUTHORIZEDsignatures pass, authorization-thresholds pass, authorization pass; handoff authorization passEstablished
    MAKOTO_GRAPH_COMPLETEindex-payloads pass, graph-dependency-artifacts pass, graph pass, roots-and-heads pass, completeness-anchor passEstablished
    MAKOTO_FRESHNESS_ANCHOREDfreshness-anchors pass; methods: expected-artifacts pass, expected-heads pass, expected-manifest pass, max-age not_checked, nonce not_checkedEstablished
    MAKOTO_SCHEMA_CONFORMANTcore-schemas pass, metadata-profiles pass, artifact-profiles pass; the policy requires 2 digest-pinned profilesEstablished
    MAKOTO_REPRODUCEDNeeds a second allow report from an independent Transform L2-or-higher platform; none is suppliedNot established: E_PROPERTY_REPRODUCED

    Computed 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 allow report.

The evidence path

  1. Origin The collector signs the exact source batch and source claims at acquisition.
  2. Normalization The next statement binds the raw batch and predecessor envelope before producing cleaned bytes.
  3. Aggregation Downstream outputs append their own statements instead of editing the earlier history.
  4. 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.

Reproduce the denial

The complete fixture mutates final data and expects an artifact-digest failure.