Ship the data and the evidence needed to refuse it.
A release process can attach more than a checksum. It can publish signed origin and transformation statements, required profiles, an exact handoff, and the verifier inputs a consumer needs.
$ makoto verify bundle handoffs/unauthorized-signer/bundle \
--policy receiver/policy.json \
--schema-catalog receiver/catalog.json \
--expected-manifest sha256:6cce864b287264e55e4a22549d5d534547f778f497157510af68fc6c4f1204a1 \
--expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
--expected-artifact receiver/expected/unauthorized-signer.artifact.json \
--evaluation-time 2026-09-16T16:00:00Z
PASS load-safely
PASS parse-strictly
PASS index-payloads
PASS core-schemas
PASS signatures
FAIL authorization-thresholds
PASS metadata-profiles
FAIL authorization
PASS graph-dependency-artifacts
SKIPPED graph
SKIPPED roots-and-heads
FAIL completeness-anchor
PASS freshness-anchors
SKIPPED artifact-bytes
SKIPPED artifact-profiles
PASS decision
DENY decision
manifest sha256:6cce864b287264e55e4a22549d5d534547f778f497157510af68fc6c4f1204a1
E_SIGNER_UNAUTHORIZED: no matching policy rule met its signature threshold
E_MANIFEST_SET: final artifact is not an exact terminal subject of its declared head
E_MANIFEST_SET: signed manifest sets do not equal the verified graph and bundle index
$ 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 report is byte-for-byte the file published under the end-to-end proof. Nothing below was typed by hand.
The problem
A team releases data every sprint: refreshed training sets, configuration bundles, public extracts. Each release should carry evidence of where the data came from and who vouched for it. When that evidence is a manual step it gets skipped, and when it is only a checksum it proves nothing about who produced the bytes.
The harder case is a release that looks perfect. Here the data is byte-identical to the legitimate release, the signed claim is byte-identical to the legitimate claim, and the signature is cryptographically valid. The only difference is whose key made it. A consumer that checks "is this signature valid?" instead of "is this signer allowed to make this claim?" mirrors it.
The raw example
The release receipt is a signed handoff manifest. Decoded, it commits to the exact final artifact digest, the graph head that produced it, the root it traces to, and every statement in between.
$ jq -r .payload handoffs/unauthorized-signer/bundle/manifest.dsse.json | base64 -d \ | jq '{bundleId, recipient, issuedAt, artifacts, heads, roots, statements}' { "bundleId": "urn:makoto:demo:negative:unauthorized-signer", "recipient": "example:downstream-team", "issuedAt": "2026-09-16T16:00:00Z", "artifacts": [ { "digest": { "sha256": "ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e" }, "head": { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" }, "mediaType": "application/json", "name": "customers.public.json" } ], "heads": [ { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" } ], "roots": [ { "sha256": "56b7be4394fe09c62ec7a3d5763cecc251e9696f267f35b2acc717b0d170a27a" } ], "statements": [ { "sha256": "1f28b72bcd4c1e9b7df71403ac6bb1670c2f2b09628ca6d76a2fa384db9a0848" }, { "sha256": "56b7be4394fe09c62ec7a3d5763cecc251e9696f267f35b2acc717b0d170a27a" }, { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" } ] }How it failed
The data in the poisoned release hashes to the same digest as the real one, and the head statement has the same digest,
99aee76d…, as the legitimate public-safe statement. What changed is the key that signed that statement.$ final=artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ shasum -a 256 handoffs/positive/bundle/$final ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e handoffs/positive/bundle/artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ shasum -a 256 handoffs/unauthorized-signer/bundle/artifacts/final/*.bin ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e handoffs/unauthorized-signer/bundle/artifacts/final/71895faa8bce0d0426b2e447bb12d15e50e9f9d27135ebabbb94bc9d59426d4e.bin $ envelope() { b=handoffs/$1/bundle; d=$(jq -r '.artifacts[0].statementDigest.sha256' $b/bundle.json); cat $b/attestations/$d.dsse.json; } $ envelope positive | jq -r '.signatures[].keyid' sha256:8cef065b7af83669150b7d32704d3d3e75c3e9aee0d4f6740544c68e81b377aa $ envelope unauthorized-signer | jq -r '.signatures[].keyid' sha256:3774845b9147b50cf00771ca20eb29cc3043c078f79ee13ebd0c63062a133b4dThe legitimate release was signed by
8cef065b…. The poisoned one by3774845b…. Both signatures verify against their own public keys.Put Makoto in the path
The consumer mirrors a release internally only after the gate allows it. The gate is the same verifier run as every other case; what makes this case bite is the receiver's trust policy.
The workflow
# Consumer-side gate for a published dataset release. The # release is mirrored internally only after Makoto allows it. # # 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 release mirror 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: unauthorized-signer manifest: sha256:6cce864b287264e55e4a22549d5d534547f778f497157510af68fc6c4f1204a1 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 mirror: # Runs only when every handoff in the matrix was allowed. needs: gate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Mirror the verified release run: echo "replace with your existing step"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.
Knowing a key is not authorizing it
This receiver has deliberately configured the attacker's public key, so the signature is not simply unknown. The policy lists five keys, but the only rule for the public-safe operation authorizes exactly one of them.
$ jq -r '.keys | to_entries[] | "\(.key) \(.value.label)"' receiver/policy.json sha256:3774845b9147b50cf00771ca20eb29cc3043c078f79ee13ebd0c63062a133b4d insecure demo-only attacker key sha256:47dea58ea00fae9417ee19d76755bfef690899021132effb04fe1f9e4f0c8059 insecure demo-only normalize key sha256:8cef065b7af83669150b7d32704d3d3e75c3e9aee0d4f6740544c68e81b377aa insecure demo-only public key sha256:d016df3d83373617c06b5e1d6359caa06eeba8b3af2c7077234f109f2194516c insecure demo-only handoff key sha256:fd110d301d2f077de1414b8f99f441b1403fab207b2052fbd2c065e4ee8e7dc2 insecure demo-only origin key $ jq '.rules[] | select(.operationTypes | index("urn:makoto:demo:operation:public-safe")) | {id, authorizedKeyIds, minimumSignatures}' receiver/policy.json { "id": "urn:makoto:demo:rule:public-safe", "authorizedKeyIds": [ "sha256:8cef065b7af83669150b7d32704d3d3e75c3e9aee0d4f6740544c68e81b377aa" ], "minimumSignatures": 1 }Inspect the attacker-known policy used for this run.
The output that catches it
signaturespasses: the math is sound.authorization-thresholdsandauthorizationfail, because no rule for this operation lists the signing key. Checks that depend on an authorized graph are skipped, and the job exits 1.$ makoto verify bundle handoffs/unauthorized-signer/bundle \ --policy receiver/policy.json \ --schema-catalog receiver/catalog.json \ --expected-manifest sha256:6cce864b287264e55e4a22549d5d534547f778f497157510af68fc6c4f1204a1 \ --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \ --expected-artifact receiver/expected/unauthorized-signer.artifact.json \ --evaluation-time 2026-09-16T16:00:00Z PASS load-safely PASS parse-strictly PASS index-payloads PASS core-schemas PASS signatures FAIL authorization-thresholds PASS metadata-profiles FAIL authorization PASS graph-dependency-artifacts SKIPPED graph SKIPPED roots-and-heads FAIL completeness-anchor PASS freshness-anchors SKIPPED artifact-bytes SKIPPED artifact-profiles PASS decision DENY decision manifest sha256:6cce864b287264e55e4a22549d5d534547f778f497157510af68fc6c4f1204a1 E_SIGNER_UNAUTHORIZED: no matching policy rule met its signature threshold E_MANIFEST_SET: final artifact is not an exact terminal subject of its declared head E_MANIFEST_SET: signed manifest sets do not equal the verified graph and bundle index $ echo $? 1The machine-readable report
Output of
jq '{decision, primaryError, errors, notPassed: [.checks[] | select(.status != "pass") | {id, status}]}' makoto-report.unauthorized-signer.jsonagainst the report the workflow step wrote.{ "decision": "deny", "primaryError": "E_SIGNER_UNAUTHORIZED", "errors": [ { "causedByCheck": "authorization-thresholds", "code": "E_SIGNER_UNAUTHORIZED", "context": { "statementDigest": { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" } }, "message": "no matching policy rule met its signature threshold", "step": 6 }, { "causedByCheck": "completeness-anchor", "code": "E_MANIFEST_SET", "context": { "artifactDigest": { "sha256": "ecd821dbe6f4204c4aad64878643d7954cb8c94abbf8d0b28f9c2bfc272f781e" }, "bundleId": "urn:makoto:demo:negative:unauthorized-signer", "head": { "sha256": "99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce" }, "subjectName": "customers.public.json" }, "message": "final artifact is not an exact terminal subject of its declared head", "step": 11 }, { "causedByCheck": "completeness-anchor", "code": "E_MANIFEST_SET", "context": { "bundleId": "urn:makoto:demo:negative:unauthorized-signer" }, "message": "signed manifest sets do not equal the verified graph and bundle index", "step": 11 } ], "notPassed": [ { "id": "authorization-thresholds", "status": "fail" }, { "id": "authorization", "status": "fail" }, { "id": "graph", "status": "skipped" }, { "id": "roots-and-heads", "status": "skipped" }, { "id": "completeness-anchor", "status": "fail" }, { "id": "artifact-bytes", "status": "skipped" }, { "id": "artifact-profiles", "status": "skipped" } ] }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-thresholdsfail,authorizationfail; handoff authorization passNot established: E_PROPERTY_AUTHORIZEDMAKOTO_GRAPH_COMPLETEindex-payloadspass,graph-dependency-artifactspass,graphskipped,roots-and-headsskipped,completeness-anchorfailNot established: E_PROPERTY_GRAPH_COMPLETEMAKOTO_FRESHNESS_ANCHOREDfreshness-anchorspass; methods:expected-artifactspass,expected-headspass,expected-manifestpass,max-agenot_checked,noncenot_checkedEstablished MAKOTO_SCHEMA_CONFORMANTcore-schemaspass,metadata-profilespass,artifact-profilesskipped; 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.
A release has two independently reviewed artifacts
| Data artifact | Evidence handoff |
|---|---|
| Exact files or signed dataset manifest, media types, sizes, and digests. | Signed statement graph, roots, heads, required profiles, and final artifact bindings. |
| Published through the normal data distribution channel. | Published with immutable digests and a receiver-verifiable policy example. |
| Can be mirrored without changing its identity. | Can be verified without access to the producer's CI or warehouse. |
CI is a trigger, not the attester
A workflow event can initiate generation after release bytes are final. The signer identity, key custody, source claim, exact predecessor graph, and expected release manifest still require explicit policy. A green CI badge alone is not provenance.
Concrete release receipt
| Input | The exact dataset files or deterministic dataset manifest selected for publication. |
|---|---|
| Release step | CI triggers generation only after bytes are final; a release signer attests the exact graph and handoff. |
| Consumer step | The recipient downloads the release and hashes those bytes against the signed final-artifact binding. |
| Expected failures | A changed file denies on artifact digest (case 01); a correctly signed receipt from an untrusted key denies on authorization (this case). |
What else this handles
- Evidence generated on every release tag instead of remembered by a person.
- Existing pipelines on any CI system, because the gate is one command and a report file.
- Batch releases of many files through a signed dataset manifest.
- Rotating or revoking a release signer by editing receiver policy, without touching published data.
Key insight. Authenticity asks whether a signature is valid for a key. Authorization asks whether the receiver trusts that key for this claim. A release gate that only asks the first question will mirror a perfect forgery.