CyclesEvidence Envelopes in Cycles
This page is the protocol reference for CyclesEvidence — the signed, content-addressed audit envelope behind the verifiable-audit concept. For why it exists, start there; this page is the how.
The consumer surface (cycles_evidence on responses, GET /v1/evidence/{id}) is defined in cycles-protocol-v0.yaml. The envelope itself is specified in the draft companion cycles-evidence-v0.1.yaml (pre-normative).
The cycles_evidence reference
Every decide / reserve / commit / release response — and budget/lifecycle denial responses — may carry an optional cycles_evidence:
"cycles_evidence": {
"evidence_id": "8403bed4…7030",
"cycles_evidence_url": "https://cycles.example.com/v1/evidence/8403bed4…7030"
}evidence_id— lowercase 64-hex SHA-256, the content address of the signed envelope.cycles_evidence_url—{server_id}/evidence/{evidence_id}.server_idalready includes the/v1base, so the join adds only/evidence/{id}.
It is transport metadata, not attested — present for the caller's convenience and computed over the response without this field (see Non-self-referential below). It is absent when evidence emission is disabled, or for errors raised before a decision was reached (validation/auth failures). Additive and @JsonInclude(NON_NULL): a client that ignores it is unaffected.
The envelope and its five artifact types
GET /v1/evidence/{id} returns the signed envelope verbatim:
{
"schema_version": "cycles-evidence/v0.1",
"artifact_type": "reserve",
"server_id": "https://cycles.example.com/v1",
"signer_did": "b10554…c522",
"issued_at_ms": 1781436904050,
"trace_id": "b2a0ab88…dc02",
"payload": { "reserve": { "request": { … }, "response": { … } } },
"evidence_id": "8403bed4…7030",
"signature": "4bc8cb9a…8c08"
}artifact_type | Endpoint | Payload |
|---|---|---|
decide | POST /v1/decide | { request, response } |
reserve | POST /v1/reservations | { request, response } |
commit | POST /v1/reservations/{id}/commit | { reservation_id, request, response } |
release | POST /v1/reservations/{id}/release | { reservation_id, request, response } |
error | any of the above (4xx/5xx) | { endpoint, http_status, [reservation_id], [request], response } |
commit / release (and commit/release errors) hoist reservation_id into the payload so an evidence-only reader can reconstruct the authorization → settlement chain without the URL.
Denials → the error artifact
A non-dry reserve over budget is not a 200 with decision: DENY — it is an HTTP 409 with error: BUDGET_EXCEEDED, captured as an error envelope (endpoint: "POST /v1/reservations", http_status: 409). The other budget/lifecycle denials behave the same — BUDGET_FROZEN, BUDGET_CLOSED, OVERDRAFT_LIMIT_EXCEEDED, DEBT_OUTSTANDING, UNIT_MISMATCH, and the commit/release terminal-state denials RESERVATION_FINALIZED (409) and RESERVATION_EXPIRED (410). Pre-evaluation failures (validation, auth, malformed body) carry no cycles_evidence — no decision was reached, so there is nothing to attest. (A dry-run preflight denial, by contrast, is a 200 captured as reserve evidence — it is the canonical "would this be allowed?" attestation.)
evidence_id — the content-hash recipe (normative)
- Build the envelope with every field populated except
evidence_idandsignature, both set to the empty string"". - Canonicalize per RFC 8785 (JCS); UTF-8 encode.
evidence_id= lowercase hex SHA-256 of those bytes.
Because the id is a pure function of the contents (no private key), Cycles computes it synchronously and returns it on the response, even though signing happens later.
Signature derivation (normative)
- Take the envelope with
evidence_idnow populated andsignaturestill"". - Canonicalize again (JCS), UTF-8 encode.
signature= lowercase hex of the Ed25519 signature over those bytes, using the server's signing key (named bysigner_did).
This is the same id-then-signature ordering used elsewhere in the agent-trust ecosystem, so a consumer that can verify one of those receipts can verify a CyclesEvidence envelope with the same primitives.
Non-self-referential
The cycles_evidence ref is stamped onto the response after evidence_id is computed. So the payload.<artifact>.response inside the envelope never contains cycles_evidence — the content hash is never self-referential. The response mirrors in the draft keep additionalProperties: false and omit the ref to make this explicit.
How to verify
Given an envelope:
- Re-derive
evidence_idper the recipe above and compare byte-for-byte. Mismatch ⇒ tampered or canonicalization error. - Verify the Ed25519
signature(withevidence_idpopulated,signatureemptied) against the key insigner_did. - Check the
artifact_type↔payloadpairing (e.g.artifact_type: commitrequirespayload.commit).
Signature validity against signer_did is fully specified today. Signer authority — proving signer_did is genuinely the legitimate Cycles signer for server_id at issued_at_ms (did:cycles / JWKS / key rotation) — is the v0.2 work tracked in cycles-protocol#103. Until then, pin the expected signer (expected_signer) for issuer trust.
Producer / signer split
cycles-servercomputesevidence_idsynchronously, returnscycles_evidence, and servesGET /v1/evidence/{id}. It holds only the public identity.cycles-server-eventsasynchronously builds, Ed25519-signs (the private key lives only here), and stores the envelope content-addressed. It recomputes the id and dead-letters on drift, so producer/signer config mismatch fails closed.
Because signing is async, a fetch immediately after the response may return a transient 404 — treat it as not-yet-available and retry.
Enabling it
Evidence is off until a shared signing identity is configured (EVIDENCE_SERVER_ID + EVIDENCE_SIGNING_SIGNER_DID on both services; the private EVIDENCE_SIGNING_PRIVATE_KEY_HEX only on cycles-server-events). See the operator identity enablement runbook.
Related
- CyclesEvidence: Verifiable Audit for Agent Decisions — the why.
- Error Codes and Error Handling — the denial codes that surface as
errorevidence. - Correlation and Tracing — the
trace_idcarried on every envelope.