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}, and GET /v1/.well-known/cycles-jwks.json) is defined in cycles-protocol-v0.yaml. The envelope and signer-authority rules are specified in cycles-evidence-v0.2.yaml. The wire schema_version remains cycles-evidence/v0.1 for compatibility; v0.2 adds the normative JWK Set authority layer around that envelope shape.
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 normative v0.2 spec 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. - Resolve signer authority by fetching
GET {server_id}/.well-known/cycles-jwks.jsonand selecting the Ed25519 JWK whose[cycles_nbf_ms, cycles_exp_ms)window covers the envelope'sissued_at_ms. The selected key's public bytes must matchsigner_did. - Verify the Ed25519
signature(withevidence_idpopulated,signatureemptied) against that public key. - Check the
artifact_type↔payloadpairing (e.g.artifact_type: commitrequirespayload.commit).
Signature validity proves the envelope was signed by the key in signer_did. Signer authority proves that key was published by the issuing server_id for the envelope's issuance window. The JWK Set is the normative v0.2 authority layer. If a server does not publish JWKS, consumers can still run in a pinned-signer (binding_only) posture by comparing signer_did to an expected signer out of band. Why validity and authority are different questions: A Valid Signature Doesn't Tell You Who Signed It.
Verification dispositions
A conformant verifier reports exactly one of five dispositions, keeping the two axes distinct:
authentic— signature valid and authority established: the verifying key is the one window-covering key selected deterministically fromserver_id's JWK Set.binding_only— signature cryptographically valid, but no JWKS authority was resolved (raw-hex with no set lookup, or a pin-only deployment). The companionsigner_pin_matchedboolean says whether anexpected_signerpin was present and matchedsigner_did.signer_authority_failed— resolution succeeded (the JWK Set was fetched and parsed) but the key is not authorized for this envelope: DID↔server_idhash mismatch, no window-covering key, the raw-hex key absent from the set, or an ambiguous / duplicate-kidselection. Neither a network failure nor a forgery.signer_resolution_failed— the JWK Set could not be retrieved or parsed (network, 404/transient, unparseable body). About obtaining the set, not searching it; establishes nothing about the bytes and must never be reported assignature_invalid.signature_invalid— the bytes do not verify against the resolved/named key (tamper).
Signer-key resolution and rotation
The two signer_did forms
signer_did names the Ed25519 signer in one of two forms:
- Raw hex — the 32-byte public key as 64 lowercase hex chars. Self-describing: a verifier checks the
signatureagainst it directly, with no resolution (the only form v0.1 requires). Authority then means "a window-covering JWK whosexdecodes to the same 32 bytes exists inserver_id's set" — nokidneeded (a JWK'sxisbase64url(pubkey), the raw-hex form ishex(pubkey); same bytes). did:cycles:<server_id_hash>#<kid>—<server_id_hash>is lowercasehex(sha256(server_id)), binding the DID to the envelope'sserver_id; the#<kid>fragment names the key within the server's JWK Set (it equals that JWK'skid). A verifier resolves this form to establish signer authority, not just signature validity. A DID whose<server_id_hash>doesn't equalhex(sha256(server_id))fails authority — the DID does not bind to this envelope'sserver_id.
Step 2 needs the right public key. A server publishes its keys as a JWK Set:
GET {server_id}/.well-known/cycles-jwks.json # operationId: getEvidenceJwksPublic and unauthenticated (it carries public keys only). Each entry is an Ed25519 OKP JWK with a validity window:
{
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA",
"x": "<base64url of the 32 raw public-key bytes>",
"kid": "2026-h2",
"cycles_nbf_ms": 1781000000000,
"cycles_exp_ms": 1796000000000,
"status": "retired"
}The active key omits cycles_exp_ms (open-ended) and has status: active. A server not doing signer-key resolution publishes nothing — the endpoint 404s, and consumers stay on the pinned-signer (binding_only) path.
Window-gated selection. A verifier selects the key whose [cycles_nbf_ms, cycles_exp_ms) window covers the envelope's issued_at_ms — never "the current key." So an envelope signed two rotations ago still verifies against the key that was valid when it was signed; the set keeps retired keys for exactly this. status is advisory — selection is by window. (The forgery this prevents: Rotating Keys Shouldn't Rewrite History.)
Exactly one candidate. Selection is a total function with no implementation discretion: the window gate must be satisfied by exactly one candidate key. Zero covering keys, overlapping windows that leave two candidates, a duplicate kid in the set, a did:cycles fragment with no matching kid, or (raw-hex) zero or multiple window-covering JWKs matching the key bytes — all are signer_authority_failed, never a silent pick.
Rotating the signing key (operator procedure)
The windows must tile without overlapping. On rotation:
- Generate the new Ed25519 key pair and deploy the private key only to
cycles-server-eventsasEVIDENCE_SIGNING_PRIVATE_KEY_HEX. - Make the new public key active on the runtime server —
EVIDENCE_SIGNING_SIGNER_DID= the new raw-hex public key,EVIDENCE_SIGNING_KID= the active JWKkid, andEVIDENCE_SIGNING_NBF_MS= the rotation time (epoch ms). - Deploy the same public
EVIDENCE_SIGNING_SIGNER_DIDandEVIDENCE_SERVER_IDtocycles-server-eventsso the worker signs envelopes with the same identity the runtime publishes and used when computingevidence_id. - Append the old public key to the runtime server's
EVIDENCE_SIGNING_RETIRED_KEYS— a JSON array of{"signer_did","kid","nbf_ms","exp_ms"}— withexp_ms= that same rotation time.
The retiring key's window then ends exactly where the new key's begins.
Fail-safe, never fail-closed. If nbf-ms is left below the latest retired exp_ms, the published active window is clamped up to that boundary (with a warning), so the current key is never published as authoritative for pre-rotation issued_at_ms by accident. A retired entry that can't be published (malformed hex, empty/inverted window, out-of-range bound, duplicate kid) is dropped, not fatal; if the whole retired-keys value is unusable, the server logs an error and keeps serving the active key — it never refuses to publish, which would break verification of all current evidence.
Producer / signer split
cycles-servercomputesevidence_idsynchronously, returnscycles_evidence, servesGET /v1/evidence/{id}andGET /v1/.well-known/cycles-jwks.json, and 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_IDon both services — the issuer base URL, including/v1, used in evidence URLs and envelopes.EVIDENCE_SIGNING_SIGNER_DIDon both services — the raw-hex public Ed25519 key.EVIDENCE_SIGNING_PRIVATE_KEY_HEXonly oncycles-server-events— the raw-hex private Ed25519 key.EVIDENCE_SIGNING_KID,EVIDENCE_SIGNING_NBF_MS, andEVIDENCE_SIGNING_RETIRED_KEYSonly oncycles-server— public JWKS metadata and rotation history.
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.