Signed governance receipts
When the broker decides a call, it writes the audit row and a signed receipt in the same database write. The receipt is a compact JWS (ES256, typ: agentvalet-receipt+jwt) whose jti is the audit row’s id. Anyone holding the receipt can verify what AgentValet decided, when, for which agent, under which policy version, using only the public key set below. No database access, no AgentValet account.
Receipts are issued for every terminating outcome on POST /v1/actions: allowed, held for approval, denied by grant, policy or guardrail, blocked by response screening, refused for secrets or size, errored on redaction. They are also issued for every approval replay (executed, denied at replay, failed). Denials get receipts too: proof that a call was refused is usually the more valuable evidence.
Finding the audit id
The call itself tells you:
- On a 2xx,
_meta.audit_idin the response envelope. - On a refusal (400, 403, 409, 412, 502) or a hold (202),
audit_idin the body next tocorrelation_id. - In an MCP host, the receipt card’s
audit_idfield.
It is a uuid, never the receipt itself, so the model’s context is not spent on 800 bytes of base64 per call.
Fetching the receipt
GET /v1/audit/ff505bfd-6a3f-4b1d-85a0-35b89d431bff/receipt
Authorization: Bearer <agent JWT or owner session>{
"audit_id": "ff505bfd-6a3f-4b1d-85a0-35b89d431bff",
"kid": "av-receipt-2026-09b",
"receipt": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50dmFsZXQtcmVjZWlwdCtqd3QiLCJraWQiOiJhdi1yZWNlaXB0LTIwMjYtMDliIn0...",
"jwks_url": "https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json",
"did": "did:web:api.agentvalet.ai",
"verify_hint": "Compact JWS. Verify with alg ES256, typ agentvalet-receipt+jwt, iss https://api.agentvalet.ai, and a key from jwks_url ..."
}An agent bearer may fetch receipts for its own rows only; an owner session may fetch any row in its organisation. Accept: application/jose returns the bare JWS. 404 for a row that has no receipt: rows written before 16 September 2026, broker-ingested rows, Observe Mode relays, connect events and circuit-breaker transitions. Receipts also appear as the last column of the audit export (Team and above).
What the receipt says
{
"iss": "https://api.agentvalet.ai",
"sub": "agt_7o00saqajyahinuuc6g83",
"jti": "ff505bfd-6a3f-4b1d-85a0-35b89d431bff",
"iat": 1789553665,
"av": {
"v": 1,
"trace_id": "287bf627-cbd1-44d2-97fa-80402981b137",
"org_id": "360c08d0-…",
"owner_id": "…",
"agent_id": "agt_7o00saqajyahinuuc6g83",
"parent_agent_id": null,
"session_id": "…",
"session_id_verified": false,
"platform": "github",
"connection_id": null,
"credential_source": null,
"caller_hash": null,
"caller_verified": null,
"scope": "github:repo.read",
"method": "GET",
"endpoint": "/user",
"result": "denied",
"decision": {
"reason": "endpoint_scope_mismatch",
"policy_id": "5604561a-…",
"policy_version": 6184,
"guardrail_id": null,
"matched": [{ "kind": "policy_allow_matched", "policyId": "5604561a-…", "rule": { "targetType": "platform", "targetValue": "github" } }],
"scope_unmapped": false,
"screen": null,
"containment": null,
"constraint": null,
"reach": null
},
"approval": null,
"agent_reason_present": null,
"agent_reason_sha256": null,
"pii": { "detected": false, "redacted": false, "categories": [] },
"upstream_status": null,
"row_hash": "sha256:119da7d8…"
}
}| Field | Meaning |
|---|---|
trace_id | The correlation_id the agent was handed, on every plan |
parent_agent_id | Set for a child agent |
session_id_verified: false | The session id is asserted by the caller and never verified; it is attribution, not identity |
endpoint | Path only, never the query string |
decision.matched | The policy or guardrail rule events that decided the call, from the kernel’s trace |
approval | On a replay: approval id, who approved, when, via which channel, and the pending_receipt_jti of the receipt issued when the call was first held, so the chain is explicit |
agent_reason_sha256 | A hash of the agent’s free-text justification, so an auditor can match it to the row without the text travelling |
row_hash | SHA-256 over the stored row’s identity fields and iat; an auditor with database access can additionally check the row was not edited after signing |
Never in a receipt: request or response bodies, IP address, user agent, geolocation, the free-text agent reason, connection labels (often an email), recipient addresses, the full policy trace, or any credential. A receipt is designed to leave the building.
Verifying offline
Keys are published in two forms: an RFC 7517 JWK Set at https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json (active and retired keys, each with a kid), and as did:web:api.agentvalet.ai#receipt-<kid> verification methods in the root DID document at /.well-known/did.json. The set is deliberately separate from the OAuth bearer key at /v1/oauth/jwks.json.
TypeScript (jose):
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json"));
export async function verifyReceipt(jws: string) {
const { payload, protectedHeader } = await jwtVerify(jws, JWKS, {
issuer: "https://api.agentvalet.ai",
typ: "agentvalet-receipt+jwt",
algorithms: ["ES256"],
});
return { audit_id: payload.jti, agent: payload.sub, kid: protectedHeader.kid, ...(payload.av as object) };
}Python (PyJWT):
import jwt
from jwt import PyJWKClient
JWKS = PyJWKClient("https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json")
def verify_receipt(jws: str) -> dict:
if jwt.get_unverified_header(jws).get("typ") != "agentvalet-receipt+jwt":
raise ValueError("not an AgentValet receipt")
key = JWKS.get_signing_key_from_jwt(jws).key
claims = jwt.decode(jws, key, algorithms=["ES256"], issuer="https://api.agentvalet.ai",
options={"require": ["iss", "sub", "jti", "iat"]})
return {"audit_id": claims["jti"], "agent": claims["sub"], **claims["av"]}Check alg, typ, iss and that kid is in the key set; then trust the payload.
Honest limits
- A signature proves a row existed and what it said at decision time. It does not prove a row was never deleted; a signed daily checkpoint that would make deletion detectable is planned, not built. Do not read “receipt” as “tamper-evident chain”.
- Receipts cover decisions made by the AgentValet proxy on
/v1/actionsand approval replay. Events decided elsewhere (the open-core broker, Observe Mode, dashboard mutations) carry none. - Receipts are retained and purged with their audit row, per your plan’s retention window. Export them if you need them to outlive it.
- Key rotation keeps retired public keys published, so an old receipt keeps verifying; a key that is ever removed would orphan the receipts it signed, which is why removal is gated on a count of rows still referencing it.