HTTP API · 4. Receipts and verification

Twenty-five registrations produce, retrieve or check evidence. This chapter documents all of them, and states the boundary of what that evidence supports.

Planes covered: Receipts and replay exports · Audit bundle verification · Provenance marking gateway · Observe-audit sessions · Incidents · Legal holds.

Read chapter 0 first for the auth model, request-field notation and error shape.

4.1 What a receipt proves, and what it does not

Before the tables, the boundary, because everything on this plane is worth less if you overstate it.

A receipt is a verifiable record of what was stored and retrieved. It is tamper-evidence, not attestation of conduct. A verified receipt tells you that a specific record existed in a specific chain at a specific position and has not been altered since. It does not tell you that an agent performed the action described, that the description is accurate, or that nothing else happened. Mediated or external capture is the honest bar for attesting to conduct, and it is not built.

The two receipt families have different guarantees, and they must not be collapsed:

FamilyIntegrity mechanismWhat that buys
Execution receiptsEd25519 signatureOrigin authentication plus tamper-evidence. GET /v1/receipts/{id}/signature returns the signature event.
Retrieval receiptsBLAKE3 content addressingTamper-evidence only. These are not signed. A content hash proves the bytes are the bytes; it says nothing about who produced them.

GET /v1/receipts/{receiptId}/verification returns a report covering both the signature check and the hash-chain check. Read which of the two passed. A report that verifies a hash chain on an unsigned retrieval receipt has verified integrity, not origin.

The API's own wording on these routes is already honest. Where you are quoting a guarantee to a third party, quote the API.

4.2 Receipts and replay exports

Ten registrations.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
GET/v1/receipts/listobservations.rs:2023 get_receipts_listreceipts:read, tenant-bound · Readquery: tenant_id (String, dflt default_receipts_list_tenant), before (String, dflt), limit (usize, dflt), kind (String, dflt), principal (String, dflt), session (String, dflt)rows, returned, matched, next_cursor, has_more, kind_counts, principal_counts400, 401, 500-R · receipt
GET/v1/receipts/{receiptId}receipts.rs:520 get_receipt_body_v1receipts:read, tenant-bound · Readpath receiptId; query: tenant_id (String, req)tenant_id, receipt_id, seq, occurred_at, ingested_at, content_type, payload_base64, payload_hash401, 404, 500, 501-R · receipt
GET/v1/receipts/{receiptId}/signaturereceipts.rs:633 get_receipt_signature_v1receipts:read, tenant-bound · Readpath receiptId; query: tenant_id (String, req)Same envelope shape as the body route, carrying the signature event401, 404, 500, 501-R · receipt
GET/v1/receipts/{receiptId}/verificationreceipts.rs:746 get_receipt_verification_v1receipts:read, tenant-bound · Readpath receiptId; query: tenant_id (String, req)Verification report, signature check plus hash-chain check401, 404, 500, 501-R · receipt
GET/v1/replay/answers/{answerId}replay.rs:28 get_answer_replayadmin:read, or tenant-bound replay:answer · Readpath answerId; query: tenant_id (String, req), shard_id (String, dflt)keys answer_id, status, mode, capsule, evidence, stored_answer, rendered_answer, agent_required, llm_required, tenant_id, schema404-R
GET/v1/replay/answers/{answerId}/validityreplay.rs:62 get_answer_replay_validityadmin:read, or tenant-bound replay:answer · Readpath answerId; query: tenant_id (String, req), shard_id (String, dflt)keys overall, stale, drift_categories, capsule_hash, current_answer, historical_answer, historical_replay_available, evidence, living_objects, projection_modules, render_strategy, semantic_profile, agent_required, llm_required, answer_id, tenant_id, schema404-R
GET/v1/replay/exports/receipts/{receiptId}receipts.rs:828 get_receipt_export_v1all-of exports:read and receipts:read, tenant-bound · Readpath receiptId; query: tenant_id (String, req), include (String, opt), redaction (String, opt), format (String, opt)Export bundle400, 404, 500, 501-R · receipt
GET/v1/replay/exports/answers/{answerId}receipts.rs:859 get_answer_export_v1all-of exports:read and receipts:read, tenant-bound · Readpath answerId; query: tenant_id (String, req), mode (String, opt, latest or verified or audit), include (String, opt), redaction (String, opt), format (String, opt)Export bundle400, 404, 500-R · receipt
GET/v1/replay/exports/actions/{actionId}receipts.rs:910 get_action_export_v1all-of exports:read and receipts:read, tenant-bound · Readpath actionId; query: tenant_id (String, req), mode (String, opt, latest or verified or audit), include (String, opt), redaction (String, opt), format (String, opt)Export bundle400, 404, 500-R · receipt
GET/v1/replay/exports/streams/{streamType}/{streamId}receipts.rs:950 get_stream_export_v1exports:read, tenant-bound · Readpath streamType, streamId; query: tenant_id (String, req), from_seq (u64, opt), to_seq (u64, opt), max_events (u32, opt), include (String, opt), redaction (String, opt), format (String, opt)Export bundle400, 500, 501-R · receipt

The scope corrections

The repository's older docs/api-reference.md documents the receipt routes as requiring events:read and the replay exports as requiring events:read. Both are wrong, and in a way that will not surface until an operator enables enforce:

Route groupDocumented in the old fileWhat the handler actually requires
/v1/receipts/*events:readreceipts:read, tenant-bound (receipts.rs:526)
/v1/replay/exports/{receipts,answers,actions}/*events:readexports:read and receipts:read, both, tenant-bound (receipts.rs:835)
/v1/replay/exports/streams/*events:readexports:read, tenant-bound (receipts.rs:956)

events:read is a gRPC scope. It authorises CoreCruxDataPlaneV1.ReadStream on port 4007 (grpc.rs:777) and nothing on the HTTP surface. A token minted with events:read for receipt access reads nothing.

The three subject exports are the only routes in the daemon besides the extension-mutation group that require two scopes at once. exports:read alone is not enough for them; it is enough for the stream export.

Route ordering

GET /v1/receipts/list must stay registered before GET /v1/receipts/{receiptId} (mod.rs:505). matchit resolves the static segment first, and a router test covers it. If you ever see /v1/receipts/list returning a 404 or 501 from the by-id path, the registration order has been disturbed.

501 on the receipt routes

Four of these routes can return 501. That means the underlying store for that operation is not present in this build; it is not a malformed request and retrying will not help. GET /v1/receipts/{id} also accepts an Accept header requesting raw CBOR instead of the JSON envelope.

4.3 Audit bundle verification

One registration, and the one route on this plane you should read the caveat on before exposing.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/audit/bundle/verifyaudit_verify.rs:43 post_audit_bundle_verifyno handler check · Readbody: a compressed audit bundle, 8 MiB maximumVerification verdict400, 413-W

This handler performs no scope check at all. Its signature is post_audit_bundle_verify(body: Bytes), no State, no HeaderMap, nothing to check a scope against. Its only protection is the route-auth middleware, and that middleware defaults to shadow, which is non-blocking.

On a default install this is an unauthenticated 8 MiB upload followed by a decompression. The route is correctly contracted as Read; the gap exists purely because CORECRUXD_ROUTE_AUTH defaults to shadow rather than enforce. Two independent size caps apply, 8 MiB compressed at the router (audit_verify.rs:41) and a decompressed-size cap inside the verifier that returns 413 bundle_too_large, so the decompression is bounded, but both the upload and the work happen before anything checks who you are.

If this daemon is reachable from a network you do not control, set CORECRUXD_ROUTE_AUTH=enforce. It is safe to do so: classify_route() covers all 347 registrations, so nothing becomes unreachable.

The other route with the same shape is GET /v1/console/onboarding, see chapter 9.

4.4 Provenance marking gateway (BYOK)

Three registrations, all behind a flag that is off by default, and the only routes in the daemon that are not mounted when their flag is off.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/provenance/signprovenance.rs:660 post_provenance_signany-of provenance:write, admin:write · FeatureGatedbody: content_b64 (String, req), signing_key_pem (String, req), cert_chain_pem (String, req), manifest (ManifestParams, dflt), content_type (String, opt), tenant_id (String, opt), key_id (String, opt)Signed manifest envelope-CORECRUXD_FEATURE_PROVENANCE_API, default offW
POST/v1/provenance/verifyprovenance.rs:675 post_provenance_verifyany-of provenance:write, admin:write · FeatureGatedbody: manifest_envelope_b64 (String, opt), content_b64 (String, opt), tenant_id (String, opt)Verification verdict-CORECRUXD_FEATURE_PROVENANCE_API, default offW
POST/v1/provenance/verify-recordprovenance.rs:690 post_provenance_verify_recordany-of provenance:write, admin:write · FeatureGatedbody: manifest_envelope_b64 (String, opt), content_b64 (String, opt), tenant_id (String, opt)Verification verdict, recorded-CORECRUXD_FEATURE_PROVENANCE_API, default offW

Bring your own key. POST /v1/provenance/sign takes the signing key and certificate chain in the request body. The daemon does not hold them.

That is exactly why this group is conditionally mounted rather than gated inside the handler (mod.rs:1496). With CORECRUXD_FEATURE_PROVENANCE_API unset, the routes do not exist, so a request carrying key material gets a 404 from the router before any extractor reads the body. No key bytes are ever buffered on a daemon that is not offering the feature. Every other flag in the daemon refuses from inside a handler, after the body has been read. This one does not, deliberately.

Each of the three handlers runs a common pre-handler gate in a fixed order, flag, then refuse-spoofable-auth, then scope, then a per-handler rate limit (provenance.rs:624). Their body limit is 16 MiB each (provenance.rs:68).

4.5 Observe-audit sessions

Five registrations, gated off by default. This is the step-level trace: open a step, close it with outputs and a receipt reference, then read or export the audit.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/observe/sessions/{id}/stepsobserve_audit.rs:274 open_stepany-of facts:write, admin:write · FeatureGatedpath id; body: label (String, req), actor (String, req), ts_start (String, req), node_id (String, dflt), parent_id (String, dflt), kind (NodeKind, dflt default_step_kind), risk_class (RiskClass, dflt default_low_risk), inputs (Vec TraceInput, dflt), enrich_ref (String, dflt), private (bool, dflt true)keys node400CORECRUXD_OBSERVE, default offW · receipt
PATCH/v1/observe/sessions/{id}/steps/{node_id}observe_audit.rs:339 close_stepany-of facts:write, admin:write · FeatureGatedpath id, node_id; body: outputs (Vec TraceOutput, dflt), receipt_id (String, dflt), mutation_receipt_id (String, dflt), ts_end (String, dflt), status (StepStatus, dflt), reasoning_ref (ReasoningRef, dflt), enrich_ref (String, dflt)keys node400, 404CORECRUXD_OBSERVE, default offW
GET/v1/observe/sessions/{id}/auditobserve_audit.rs:432 get_session_auditany-of facts:read, admin:read · FeatureGatedpath idSession audit-CORECRUXD_OBSERVE, default offR
GET/v1/observe/sessions/{id}/audit/conformanceobserve_audit.rs:650 get_session_audit_conformanceany-of facts:read, admin:read · FeatureGatedpath idConformance report-CORECRUXD_OBSERVE, default offR
GET/v1/observe/sessions/{id}/audit/exportobserve_audit.rs:482 get_session_audit_exportany-of facts:read, admin:read · FeatureGatedpath idkeys session_id, steps, receipts, conformance, contract_version, dataplane_verification_available, ai_act_articles, schema-CORECRUXD_OBSERVE, default offR · receipt

With CORECRUXD_OBSERVE unset, the default, every route returns an explicit observe-disabled response rather than a bare 404 (agentgraph_kinds.rs:139).

CORECRUXD_OBSERVE_REDACT selects the redaction mode on capture; the default is Audit mode (observe_audit.rs:241). Note that private on open_step defaults to true: steps are private unless you say otherwise.

On the ai_act_articles key. The export carries an article mapping. That is a mapping, not a conformity assessment, and regulatory alignment is a forward track rather than a shipped guarantee. Treat the field as an aid to an auditor, not as evidence of compliance.

dataplane_verification_available tells you whether the deeper verification path exists in this build. When it is false, the export is still a record; it just has not been checked against the dataplane.

4.6 Incidents

Four registrations, gated off by default. An incident is a case record over a time window, a set of sessions, agents and entities.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/incidentsincidents.rs:853 post_incidentany-of facts:write, admin:write, tenant-bound · FeatureGatedbody: tenant_id (String, req), title (String, req), window (IncidentWindow, req), session_ids (Vec String, dflt), agent_ids (Vec String, dflt), entities (Vec String, dflt), notes (String, dflt)keys case, case_record_id400, 500CORECRUXD_FEATURE_INCIDENTS, default offW · receipt, facts
GET/v1/incidentsincidents.rs:903 list_incidentsany-of query:read, admin:read, tenant-bound · FeatureGatedquery: tenant_id (String, req)keys cases, count, tenant_id, schema400CORECRUXD_FEATURE_INCIDENTS, default offR
GET/v1/incidents/{id}incidents.rs:932 get_incidentany-of query:read, admin:read, tenant-bound · FeatureGatedpath idkeys case, case_record_id400, 404CORECRUXD_FEATURE_INCIDENTS, default offR
POST/v1/incidents/{id}/exportincidents.rs:958 export_incidentany-of admin:read, exports:read, query:read, tenant-bound · FeatureGatedpath idExport bundle400, 404, 500CORECRUXD_FEATURE_INCIDENTS, default offR, mounted as a write-class POST

The tenant is taken from the request, and the check is tenant-bound, so a non-admin token can only see its own tenant's incidents. An admin:* token sees all of them, see the admin: carve-out in chapter 0 §0.4.

Two registrations, gated off by default. A legal hold pins a set of entity prefixes against deletion and produces a signed receipt for both placing and releasing it.

MethodPathHandlerAuthRequestSuccess responseErrorsGateR/W
POST/v1/legal-holdslegal_holds.rs:156 post_legal_holdadmin:write · FeatureGatedbody: tenant_id (String, req), reason (String, req), entity_prefixes (Vec String, dflt)201, keys hold, receipt, receipt_record_id, schema400, 404, 500CORECRUXD_FEATURE_LEGAL_HOLD, default offW · receipt, facts
DELETE/v1/legal-holds/{id}legal_holds.rs:213 delete_legal_holdadmin:write · FeatureGatedpath id200, keys hold, receipt, receipt_record_id, schema404, 409CORECRUXD_FEATURE_LEGAL_HOLD, default offW · receipt

409 on release means the hold is not in a releasable state. Both operations produce a signed receipt; this is one of the execution-receipt paths, so the Ed25519 guarantee in §4.1 applies.

Note the asymmetry with retention: CORECRUXD_RETENTION_DAYS unset means retention is off entirely, and the compact-facts admin action then only scrubs facts that were already soft-deleted (mod.rs:374). A legal hold on a daemon with no retention policy is defending against a process that is not running.

4.8 Failure modes on this plane

SymptomMost likely cause
403 on /v1/receipts/* with an events:read tokenevents:read is a gRPC scope. HTTP receipts need receipts:read.
403 on a subject export with exports:read aloneThe three subject exports need exports:read and receipts:read. The stream export needs only exports:read.
501 from a receipt or export routeThe underlying store for that operation is not in this build. Not retryable.
/v1/receipts/list returning 404Registration order has been disturbed; the static list segment must precede {receiptId}.
An unauthenticated caller successfully posting to /v1/audit/bundle/verifyExpected under the default shadow route-auth mode. Set CORECRUXD_ROUTE_AUTH=enforce.
404 on /v1/provenance/*The routes are not mounted. CORECRUXD_FEATURE_PROVENANCE_API is off by default, and off means unmounted, not refused.
An observe-disabled response instead of a traceCORECRUXD_OBSERVE is off by default.
404 on /v1/incidents or /v1/legal-holdsCORECRUXD_FEATURE_INCIDENTS and CORECRUXD_FEATURE_LEGAL_HOLD are both off by default.
A verification report that "passes" on an unsigned receiptRetrieval receipts are BLAKE3 content-addressed and carry no signature. The hash chain verified; nothing was authenticated.

Sources