Chio/Docs
LOGIN · JOIN

BuildOperations

Receipt Verification

An auditor can use a saved evidence package and the Chio CLI to verify a kernel decision offline without a trust-control connection.

Where the code lives

examples/hello-receipt-verify/, driven by ./smoke.sh. The example ships a checked-in fixture under fixtures/minimal-evidence/; no live services are needed. That fixture does not verify at this commit, and Run It shows what it does instead.

What It Shows

  • Receipt verification from a static captured package, with no live kernel and no network calls.
  • Local lineage inspection of the capability that produced the receipt.
  • Tamper detection: an attacker who edits any file under the package directory breaks verification at the manifest hash check.

Stops at offline verification

This example does not call chio evidence import. Import is intentionally stricter and requires a signed bilateral federation policy, so it belongs in a federation-focused example.

Files

text
examples/hello-receipt-verify/
  ARCHITECTURE.md
  README.md
  fixtures/minimal-evidence/
    README.txt
    capability-lineage.ndjson   one line per capability on the chain
    checkpoints.ndjson          merkle checkpoint(s)
    child-receipts.ndjson       child-request receipts
    inclusion-proofs.ndjson     merkle inclusion proofs per receipt
    manifest.json               SHA-256 over every file in the package
    query.json                  read-boundary metadata
    receipts.ndjson             one line per signed tool receipt
    retention.json              retention policy fingerprint
  smoke.sh                      verify, tamper, then run verify_artifacts.py
  verify_artifacts.py           deep artifact assertions (--write-summary)
  test_verify_artifacts.py      unit tests for the assertions

Run It

bash
# From the chio workspace root
cargo build --bin chio
cd examples/hello-receipt-verify
./smoke.sh

The shipped fixture does not verify at this commit

smoke.sh:20 is the first chio evidence verify call, and it exits 1, so nothing after it runs. The committed capability-lineage.ndjson record carries no provenance field, which reads as LegacyProjection, and crates/kernel/chio-kernel/src/capability_lineage.rs:115-118 refuses that provenance across an evidence boundary. The fixture needs regenerating; the rest of this page shows what does run.
receipt-verify · fixturetranscript
$ cp -R examples/hello-receipt-verify/fixtures/minimal-evidence ./input-package
$ chio evidence verify --input ./input-package --json
{"code":"urn:chio:error:attest:provenance-missing","message":"invalid capability lineage snapshot in evidence package: conflict: capability lineage cap-019d921b-9aa3-7220-a3e9-436bde9e2fc3 uses legacy projection provenance outside the local migration boundary","context":{"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"},"suggested_fix":"Regenerate the evidence bundle and include provenance before submitting the operation."}
exit 1

Two things on this page are unaffected by that, and both are shown below with their real output. The tamper check still fails closed, because the manifest rehash runs before the lineage read. And the SDK verifiers read the receipt out of receipts.ndjson directly rather than through the package, so the fixture's signed receipt still verifies.


Phase 1: Load the Package

The smoke copies fixtures/minimal-evidence/ into two scratch directories under the run output root: one untouched (input-package/) and one we will mutate later (tampered-package/).

examples/hello-receipt-verify/smoke.sh17-18bash
cp -R "${EXAMPLE_ROOT}/fixtures/minimal-evidence" "${INPUT_DIR}"
cp -R "${EXAMPLE_ROOT}/fixtures/minimal-evidence" "${TAMPERED_DIR}"

The manifest records SHA-256 hashes over every file, the export-time counts the verifier asserts, and the proof-coverage, receipt-semantics, and child-receipt-scope summaries the verifier re-checks:

examples/hello-receipt-verify/fixtures/minimal-evidence/manifest.jsonjson
{
  "schema": "chio.evidence_export_manifest.v1",
  "exportedAt": 1776272775,
  "query": {
    "readBoundary": {
      "kind": "admin_all"
    }
  },
  "counts": {
    "toolReceipts": 1,
    "childReceipts": 0,
    "checkpoints": 0,
    "capabilityLineage": 1,
    "inclusionProofs": 0,
    "uncheckpointedReceipts": 1
  },
  "proofCoverage": {
    "checkpointedReceipts": 0,
    "uncheckpointedReceipts": 1
  },
  "receiptSemantics": {
    "mediatedDecisions": 1,
    "traceObservations": 0,
    "advisoryEvaluations": 0,
    "prevent": 1,
    "detectOnly": 0,
    "advisoryOnly": 0,
    "cannotSee": 0,
    "authorized": 1
  },
  "childReceiptScope": "full_query_window",
  "files": [
    {
      "path": "query.json",
      "sha256": "389b6d7ad6a818c323ab4f296b58876ccad9d3173783a2e4ec1bbd1676b96614",
      "bytes": 37
    },
    {
      "path": "receipts.ndjson",
      "sha256": "5954824cd998e84ba4b0d99591bdae63547024a2fbcc8fabaeaaf9fc4be90a00",
      "bytes": 1207
    },
    {
      "path": "child-receipts.ndjson",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "bytes": 0
    },
    {
      "path": "checkpoints.ndjson",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "bytes": 0
    },
    {
      "path": "capability-lineage.ndjson",
      "sha256": "953e149fabde8614b6b2fb4f3d05abd275734eb852f0d0f2ed858c09b53b3ef7",
      "bytes": 427
    },
    {
      "path": "inclusion-proofs.ndjson",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "bytes": 0
    },
    {
      "path": "retention.json",
      "sha256": "f92db6f4a99fbc039e8c68267ca9a6e512d00e3a2318423c49af9634916c318f",
      "bytes": 75
    },
    {
      "path": "README.txt",
      "sha256": "e32367c0f07d9d8f4bdc6eec860fce170bf6865ed60ef58ecb5fdbcbad5b1062",
      "bytes": 374
    }
  ]
}

Phase 2: Verify Offline

chio evidence verify opens the package directory, parses the manifest, rehashes every listed file, parses every receipt out of receipts.ndjson, verifies each signature against the kernel public key embedded in the package, and confirms every Merkle inclusion proof in inclusion-proofs.ndjson.

examples/hello-receipt-verify/smoke.sh:20bash
"${CHIO_BIN}" evidence verify --input "${INPUT_DIR}" --json > "${ARTIFACT_ROOT}/verify.json"

On the shipped fixture that call is the one that exits 1 above. To see the shape it returns on a package that does verify, export one from a local receipt store and verify that instead:

receipt-verify · exporttranscript
$ chio --receipt-db ./receipts.db evidence export --admin-all --output ./fresh-package
$ ls fresh-package
README.txt
capability-lineage.ndjson
checkpoint-consistency-proofs.ndjson
checkpoint-equivocations.ndjson
checkpoint-publications.ndjson
checkpoint-witnesses.ndjson
checkpoints.ndjson
child-receipts.ndjson
inclusion-proofs.ndjson
manifest.json
query.json
receipts.ndjson
retention.json
exit 0

Thirteen files where the fixture has nine. The four checkpoint-*.ndjson files are the transparency lane the fixture predates, which is also why verifiedFiles below is 12 rather than the fixture manifest's 8: the manifest covers every file in the package except itself.

receipt-verify · verifytranscript
$ chio evidence verify --input ./fresh-package --json
{
  "schema": "chio.evidence_export_manifest.v1",
  "verifiedAt": 1788610040,
  "toolReceipts": 1,
  "childReceipts": 0,
  "checkpoints": 0,
  "checkpointPublications": 0,
  "checkpointWitnesses": 0,
  "checkpointConsistencyProofs": 0,
  "checkpointEquivocations": 0,
  "capabilityLineage": 1,
  "inclusionProofs": 0,
  "uncheckpointedReceipts": 1,
  "receiptSemantics": {
    "mediatedDecisions": 1,
    "traceObservations": 0,
    "advisoryEvaluations": 0,
    "prevent": 1,
    "detectOnly": 0,
    "advisoryOnly": 0,
    "cannotSee": 0,
    "authorized": 1
  },
  "verifiedFiles": 12,
  "childReceiptScope": "full_query_window",
  "claimBoundary": {
    "schema": "chio.evidence_transparency_claims.v1",
    "publicationState": "transparency_preview",
    "audit": {
      "checkpointLogs": [],
      "signedCheckpoints": 0,
      "checkpointPublications": 0,
      "checkpointWitnesses": 0,
      "checkpointConsistencyProofs": 0,
      "inclusionProofs": 0,
      "capabilityLineageRecords": 1
    }
  }
}
exit 0

That is the whole emitter, not an excerpt. Six of those keys report checkpoint transparency (checkpoints, checkpointPublications, checkpointWitnesses, checkpointConsistencyProofs, checkpointEquivocations, inclusionProofs) and are zero on a package with no anchored checkpoint, which is why claimBoundary.publicationState reads transparency_preview rather than trust_anchored. The field order is the struct order of EvidenceVerificationResult (crates/platform/chio-control-plane/src/evidence_export.rs:300-319).

After the two chio evidence verify calls, the smoke passes the output directory to verify_artifacts.py, which re-checks the verifier output against the fixture. The schema check comes first (verify_artifacts.py:224-227), then the counts, the child-receipt scope, the receipt-semantics tallies, and the transparency claimBoundary schema:

examples/hello-receipt-verify/verify_artifacts.py228-241python
require(verify.get("toolReceipts") == 1, "verify.json toolReceipts drifted")
require(verify.get("capabilityLineage") == 1, "verify.json capabilityLineage drifted")
require(verify.get("uncheckpointedReceipts") == 1, "verify.json uncheckpointedReceipts drifted")
require(verify.get("childReceipts") == 0, "verify.json childReceipts drifted")
require(verify.get("checkpoints") == 0, "verify.json checkpoints drifted")
require(verify.get("inclusionProofs") == 0, "verify.json inclusionProofs drifted")
require(verify.get("verifiedFiles") == manifest_file_count, "verify.json verifiedFiles drifted")
require(verify.get("childReceiptScope") == "full_query_window", "child receipt scope drifted")
validate_receipt_semantics(verify)
claim_boundary = require_object(verify, "claimBoundary", "verify.json")
require(
    claim_boundary.get("schema") == "chio.evidence_transparency_claims.v1",
    "claim boundary schema drifted",
)

Phase 3: Inspect the Receipt

Once verified, verify_artifacts.py extracts the relevant fields from the receipt and lineage record and writes them into summary.json:

examples/hello-receipt-verify/verify_artifacts.py210-219python
return {
    "example": "hello-receipt-verify",
    "receipt_id": receipt_id,
    "capability_id": capability_id,
    "tool_name": "read_file",
    "subject_key": subject_key,
    "issuer_key": issuer_key,
    "read_boundary": "admin_all",
    "verified": True,
}

Before writing the summary, the script checks the receipt fields: tool_server == "*", receipt_kind == "mediated_decision", boundary_class == "prevent", and a nested decision.verdict == "allow". It parses the lineage record's grants_json and asserts it equals the single read_file/invoke grant, then cross-references receipt.metadata.attribution.subject_key and issuer_key against the lineage record's own subject_key and issuer_key. That last check links the receipt attribution keys to the capability lineage record and issuer key.


Phase 4: Tamper Check

The smoke writes a single rogue byte sequence into tampered-package/query.json and runs chio evidence verify against the modified directory. Verification fails closed:

examples/hello-receipt-verify/smoke.sh22-32bash
python3 - "${TAMPERED_DIR}/query.json" <<'PY'
from pathlib import Path
import sys

Path(sys.argv[1]).write_text('{"tampered":true}\n', encoding="utf-8")
PY

if "${CHIO_BIN}" evidence verify --input "${TAMPERED_DIR}" --json > "${ARTIFACT_ROOT}/tamper-out.json" 2> "${ARTIFACT_ROOT}/tamper-err.json"; then
  echo "expected tampered package verification to fail" >&2
  exit 1
fi

verify_artifacts.py then asserts the structured CLI error is the manifest hash mismatch, routed through the attest error registry. It first requires tamper-out.json to be empty and the code to be the stable error URN (verify_artifacts.py:245-252), then reads the rest. There is no context.detail field, so the file name lives in the message:

examples/hello-receipt-verify/verify_artifacts.py253-261python
message = require_string(payload, "message", "tamper-err.json")
require("hash mismatch" in message and "query.json" in message, "tamper error message drifted")
context = require_object(payload, "context", "tamper-err.json")
require(context.get("domain") == "attest", "tamper error domain drifted")
require(context.get("severity") == "error", "tamper error severity drifted")
require(
    context.get("string_code") == "CHIO-ATTEST-PROVENANCE-MISSING",
    "tamper error string_code drifted",
)

Reproduce the failure by hand: copy the fixture, write one byte into any file the manifest covers, run chio evidence verify again. The CLI exits non-zero with a structured error:

receipt-verify · tampertranscript
$ cp -R examples/hello-receipt-verify/fixtures/minimal-evidence ./tampered-package
$ printf '{"tampered":true}\n' > ./tampered-package/query.json
$ chio evidence verify --input ./tampered-package --json
{"code":"urn:chio:error:attest:provenance-missing","message":"evidence package file hash mismatch for query.json","context":{"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"},"suggested_fix":"Regenerate the evidence bundle and include provenance before submitting the operation."}
exit 1

What the manifest verifies

manifest.json contains SHA-256 of every file in the package. The kernel pre-signs the receipts. The manifest detects a file replacement even when each individual receipt still has a valid signature.

Verify Without the CLI

A receipt is a self-contained signed object, so verification is a pure function over JSON. Three SDKs ship it as verify_receipt_with_trusted_signers in an invariants module, and the three return the same twelve fields. The programs below read the first line of the checked-in fixture and print the verdict.

pip install chio-sdknpm install @chio-protocol/sdkmain.go
import json
from chio.invariants import verify_receipt_with_trusted_signers

KERNEL_KEY = "7e93a27f379f38c0ab7568b9e39f871d6d7f7aa3cfbcb48c9f1c80a536fedf9a"

with open("fixtures/minimal-evidence/receipts.ndjson") as handle:
    receipt = json.loads(handle.readline())["receipt"]

result = verify_receipt_with_trusted_signers(receipt, [KERNEL_KEY])
print(json.dumps(result, indent=2, sort_keys=True))

Run from examples/hello-receipt-verify/. All three print this, modulo key order:

stdout, fixtures/minimal-evidence receiptjson
{
  "authorized": true,
  "boundary_class": "prevent",
  "decision": "allow",
  "ok": true,
  "parameter_hash_valid": true,
  "receipt_id_valid": true,
  "receipt_kind": "mediated_decision",
  "result": "Authorized",
  "signature_valid": true,
  "signer_key_hex": "7e93a27f379f38c0ab7568b9e39f871d6d7f7aa3cfbcb48c9f1c80a536fedf9a",
  "signer_trusted": true,
  "trust_level": "mediated"
}

Three of those fields carry the whole result. signature_valid says the kernel signed this exact body. receipt_id_valid says the id is the content address of the body, so the id cannot be moved to another receipt. signer_trusted says the signing key is one you named.

That last one is a caller obligation, not a property of the file. Drop the trusted-signer list and the same fixture verifies its cryptography and still refuses to say it is authorized:

stdout, same receipt through verify_receipt(receipt)json
{
  "authorized": false,
  "boundary_class": "prevent",
  "decision": "allow",
  "ok": false,
  "parameter_hash_valid": true,
  "receipt_id_valid": true,
  "receipt_kind": "mediated_decision",
  "result": "Authorized",
  "signature_valid": true,
  "signer_key_hex": "7e93a27f379f38c0ab7568b9e39f871d6d7f7aa3cfbcb48c9f1c80a536fedf9a",
  "signer_trusted": false,
  "trust_level": "mediated"
}

Tampering fails the other way. Change one byte inside action.parameters before the call and three checks flip together, because the parameter hash feeds the content address and the content address feeds the signature:

stdout, after receipt["action"]["parameters"]["path"] = "/etc/passwd"json
{
  "authorized": false,
  "boundary_class": "prevent",
  "decision": "allow",
  "ok": false,
  "parameter_hash_valid": false,
  "receipt_id_valid": false,
  "receipt_kind": "mediated_decision",
  "result": "Authorized",
  "signature_valid": false,
  "signer_key_hex": "7e93a27f379f38c0ab7568b9e39f871d6d7f7aa3cfbcb48c9f1c80a536fedf9a",
  "signer_trusted": true,
  "trust_level": "mediated"
}

result stays Authorized in both failing cases, and that is the intended reading: it labels what the receipt claims to be, from receipt_kind, boundary_class, and decision. authorized is whether the claim holds. Branch on authorized.

This is the library form of Phase 4. The CLI checks a whole package including the manifest and any inclusion proofs; these functions check one receipt. Use the CLI to accept a delivery, and these to keep verifying inside a service that already holds the receipt.


Auditor Workflow

An auditor can pull a captured evidence package from storage, run chio evidence verify, and read the receipts. No live kernel access, no trust-control connectivity, no shared secrets beyond the kernel's long-lived public key. A typical session:

bash
# Pull the package from cold storage
tar -xzf evidence-2026-04-15.tar.gz -C ./review

# Verify offline
chio evidence verify --input ./review/evidence-2026-04-15 --json | jq '.'

# Inspect the receipts
cat ./review/evidence-2026-04-15/receipts.ndjson | jq -c '{id: .receipt.id, tool: .receipt.tool_name, verdict: .receipt.verdict}'

# Inspect the capability that authorized them
cat ./review/evidence-2026-04-15/capability-lineage.ndjson | jq '.'

# Spot-check the manifest if you want: each entry is the SHA-256 the verifier rehashes
cat ./review/evidence-2026-04-15/manifest.json | jq '.files'

For audits that span multiple parties, both kernels can publish their own packages and the auditor verifies each independently. Bilateral Receipts covers the cross-pair check that confirms two packages commit to the same governed transaction.


Inspect the Run Output

A completed run leaves everything under .artifacts/<timestamp>/. At this commit the run stops after the tamper step, so verify.json is empty and summary.json is never written; the two tamper files are the ones with content.

bash
cd .artifacts/<timestamp>

# Verifier output
cat verify.json          # toolReceipts: 1, capabilityLineage: 1
cat summary.json         # receipt_id, capability_id, tool_name, ...

# Tamper run
cat tamper-out.json      # empty (stdout is empty on failure)
cat tamper-err.json      # attest:provenance-missing, "... hash mismatch for query.json"

# Inputs we verified against
ls input-package/
ls tampered-package/

Smoke Assertions

smoke.sh runs the two verify calls and the tamper check shown above, then shells out to verify_artifacts.py, which contains the assertions. A companion test_verify_artifacts.py unit-tests the checker itself. At this commit the run never reaches this line, because the first verify call exits 1.

examples/hello-receipt-verify/smoke.sh34-37bash
python3 "${EXAMPLE_ROOT}/verify_artifacts.py" \
  "${ARTIFACT_ROOT}" \
  --write-summary \
  > "${ARTIFACT_ROOT}/artifact-validation.json"

verify_artifacts.py checks more than the counts. It re-hashes each manifest file, checks the receipt shape ( tool_server == "*", receipt_kind == "mediated_decision", boundary_class == "prevent", and a nested decision.verdict == "allow"), asserts the lineage grants_json is structurally equal to the single read_file/invoke grant, cross-references the receipt's attribution keys against the lineage record's subject_key and issuer_key, and confirms the tamper run failed closed with the attest provenance-missing error.


Decision rule

Use this example when an auditor needs offline verification of a captured evidence package, with no live trust-control connection. Pick trust-control when you also need to mint receipts and exercise the issue / revoke surface. Cross-package bilateral verification is out of scope here: see Bilateral Receipts for that.

Where to read more

Verify Receipts Offline for the auditor workflow. Receipts for the receipt schema and signature shape. Compliance Certificates for how packages support certificate records.