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:
| Family | Integrity mechanism | What that buys |
|---|---|---|
| Execution receipts | Ed25519 signature | Origin authentication plus tamper-evidence. GET /v1/receipts/{id}/signature returns the signature event. |
| Retrieval receipts | BLAKE3 content addressing | Tamper-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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
GET | /v1/receipts/list | observations.rs:2023 get_receipts_list | receipts:read, tenant-bound · Read | query: 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_counts | 400, 401, 500 | - | R · receipt |
GET | /v1/receipts/{receiptId} | receipts.rs:520 get_receipt_body_v1 | receipts:read, tenant-bound · Read | path receiptId; query: tenant_id (String, req) | tenant_id, receipt_id, seq, occurred_at, ingested_at, content_type, payload_base64, payload_hash | 401, 404, 500, 501 | - | R · receipt |
GET | /v1/receipts/{receiptId}/signature | receipts.rs:633 get_receipt_signature_v1 | receipts:read, tenant-bound · Read | path receiptId; query: tenant_id (String, req) | Same envelope shape as the body route, carrying the signature event | 401, 404, 500, 501 | - | R · receipt |
GET | /v1/receipts/{receiptId}/verification | receipts.rs:746 get_receipt_verification_v1 | receipts:read, tenant-bound · Read | path receiptId; query: tenant_id (String, req) | Verification report, signature check plus hash-chain check | 401, 404, 500, 501 | - | R · receipt |
GET | /v1/replay/answers/{answerId} | replay.rs:28 get_answer_replay | admin:read, or tenant-bound replay:answer · Read | path 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, schema | 404 | - | R |
GET | /v1/replay/answers/{answerId}/validity | replay.rs:62 get_answer_replay_validity | admin:read, or tenant-bound replay:answer · Read | path 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, schema | 404 | - | R |
GET | /v1/replay/exports/receipts/{receiptId} | receipts.rs:828 get_receipt_export_v1 | all-of exports:read and receipts:read, tenant-bound · Read | path receiptId; query: tenant_id (String, req), include (String, opt), redaction (String, opt), format (String, opt) | Export bundle | 400, 404, 500, 501 | - | R · receipt |
GET | /v1/replay/exports/answers/{answerId} | receipts.rs:859 get_answer_export_v1 | all-of exports:read and receipts:read, tenant-bound · Read | path answerId; query: tenant_id (String, req), mode (String, opt, latest or verified or audit), include (String, opt), redaction (String, opt), format (String, opt) | Export bundle | 400, 404, 500 | - | R · receipt |
GET | /v1/replay/exports/actions/{actionId} | receipts.rs:910 get_action_export_v1 | all-of exports:read and receipts:read, tenant-bound · Read | path actionId; query: tenant_id (String, req), mode (String, opt, latest or verified or audit), include (String, opt), redaction (String, opt), format (String, opt) | Export bundle | 400, 404, 500 | - | R · receipt |
GET | /v1/replay/exports/streams/{streamType}/{streamId} | receipts.rs:950 get_stream_export_v1 | exports:read, tenant-bound · Read | path 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 bundle | 400, 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 group | Documented in the old file | What the handler actually requires |
|---|---|---|
/v1/receipts/* | events:read | receipts:read, tenant-bound (receipts.rs:526) |
/v1/replay/exports/{receipts,answers,actions}/* | events:read | exports:read and receipts:read, both, tenant-bound (receipts.rs:835) |
/v1/replay/exports/streams/* | events:read | exports: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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/audit/bundle/verify | audit_verify.rs:43 post_audit_bundle_verify | no handler check · Read | body: a compressed audit bundle, 8 MiB maximum | Verification verdict | 400, 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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/provenance/sign | provenance.rs:660 post_provenance_sign | any-of provenance:write, admin:write · FeatureGated | body: 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 off | W |
POST | /v1/provenance/verify | provenance.rs:675 post_provenance_verify | any-of provenance:write, admin:write · FeatureGated | body: manifest_envelope_b64 (String, opt), content_b64 (String, opt), tenant_id (String, opt) | Verification verdict | - | CORECRUXD_FEATURE_PROVENANCE_API, default off | W |
POST | /v1/provenance/verify-record | provenance.rs:690 post_provenance_verify_record | any-of provenance:write, admin:write · FeatureGated | body: manifest_envelope_b64 (String, opt), content_b64 (String, opt), tenant_id (String, opt) | Verification verdict, recorded | - | CORECRUXD_FEATURE_PROVENANCE_API, default off | W |
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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/observe/sessions/{id}/steps | observe_audit.rs:274 open_step | any-of facts:write, admin:write · FeatureGated | path 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 node | 400 | CORECRUXD_OBSERVE, default off | W · receipt |
PATCH | /v1/observe/sessions/{id}/steps/{node_id} | observe_audit.rs:339 close_step | any-of facts:write, admin:write · FeatureGated | path 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 node | 400, 404 | CORECRUXD_OBSERVE, default off | W |
GET | /v1/observe/sessions/{id}/audit | observe_audit.rs:432 get_session_audit | any-of facts:read, admin:read · FeatureGated | path id | Session audit | - | CORECRUXD_OBSERVE, default off | R |
GET | /v1/observe/sessions/{id}/audit/conformance | observe_audit.rs:650 get_session_audit_conformance | any-of facts:read, admin:read · FeatureGated | path id | Conformance report | - | CORECRUXD_OBSERVE, default off | R |
GET | /v1/observe/sessions/{id}/audit/export | observe_audit.rs:482 get_session_audit_export | any-of facts:read, admin:read · FeatureGated | path id | keys session_id, steps, receipts, conformance, contract_version, dataplane_verification_available, ai_act_articles, schema | - | CORECRUXD_OBSERVE, default off | R · 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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/incidents | incidents.rs:853 post_incident | any-of facts:write, admin:write, tenant-bound · FeatureGated | body: 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_id | 400, 500 | CORECRUXD_FEATURE_INCIDENTS, default off | W · receipt, facts |
GET | /v1/incidents | incidents.rs:903 list_incidents | any-of query:read, admin:read, tenant-bound · FeatureGated | query: tenant_id (String, req) | keys cases, count, tenant_id, schema | 400 | CORECRUXD_FEATURE_INCIDENTS, default off | R |
GET | /v1/incidents/{id} | incidents.rs:932 get_incident | any-of query:read, admin:read, tenant-bound · FeatureGated | path id | keys case, case_record_id | 400, 404 | CORECRUXD_FEATURE_INCIDENTS, default off | R |
POST | /v1/incidents/{id}/export | incidents.rs:958 export_incident | any-of admin:read, exports:read, query:read, tenant-bound · FeatureGated | path id | Export bundle | 400, 404, 500 | CORECRUXD_FEATURE_INCIDENTS, default off | R, 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.
4.7 Legal holds
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.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/legal-holds | legal_holds.rs:156 post_legal_hold | admin:write · FeatureGated | body: tenant_id (String, req), reason (String, req), entity_prefixes (Vec String, dflt) | 201, keys hold, receipt, receipt_record_id, schema | 400, 404, 500 | CORECRUXD_FEATURE_LEGAL_HOLD, default off | W · receipt, facts |
DELETE | /v1/legal-holds/{id} | legal_holds.rs:213 delete_legal_hold | admin:write · FeatureGated | path id | 200, keys hold, receipt, receipt_record_id, schema | 404, 409 | CORECRUXD_FEATURE_LEGAL_HOLD, default off | W · 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
| Symptom | Most likely cause |
|---|---|
403 on /v1/receipts/* with an events:read token | events:read is a gRPC scope. HTTP receipts need receipts:read. |
403 on a subject export with exports:read alone | The three subject exports need exports:read and receipts:read. The stream export needs only exports:read. |
501 from a receipt or export route | The underlying store for that operation is not in this build. Not retryable. |
/v1/receipts/list returning 404 | Registration order has been disturbed; the static list segment must precede {receiptId}. |
An unauthenticated caller successfully posting to /v1/audit/bundle/verify | Expected 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 trace | CORECRUXD_OBSERVE is off by default. |
404 on /v1/incidents or /v1/legal-holds | CORECRUXD_FEATURE_INCIDENTS and CORECRUXD_FEATURE_LEGAL_HOLD are both off by default. |
| A verification report that "passes" on an unsigned receipt | Retrieval receipts are BLAKE3 content-addressed and carry no signature. The hash chain verified; nothing was authenticated. |
Sources
- crates/corecruxd/src/http/receipts.rs:526,
receipts:read, tenant-bound - crates/corecruxd/src/http/receipts.rs:835, subject exports, all-of
exports:readandreceipts:read - crates/corecruxd/src/http/receipts.rs:956, stream export,
exports:read - crates/corecruxd/src/http/observations.rs:2029, receipts list scope
- crates/corecruxd/src/http/replay.rs:204,
admin:reador tenant-boundreplay:answer - crates/corecruxd/src/grpc.rs:777,
events:readis a gRPC scope - crates/corecruxd/src/http/audit_verify.rs:41, 8 MiB bundle cap
- crates/corecruxd/src/http/audit_verify.rs:43, the handler with no scope check
- crates/corecruxd/src/http/provenance.rs:47,
CORECRUXD_FEATURE_PROVENANCE_API - crates/corecruxd/src/http/provenance.rs:622,
PROVENANCE_SCOPES - crates/corecruxd/src/http/provenance.rs:624, the four-stage pre-handler gate
- crates/corecruxd/src/http/mod.rs:1496, conditional mounting of the provenance group
- crates/corecruxd/src/http/observe_audit.rs:158, observe read and write scope sets
- crates/corecruxd/src/agentgraph_kinds.rs:139,
CORECRUXD_OBSERVE - crates/corecruxd/src/http/incidents.rs:34,
CORECRUXD_FEATURE_INCIDENTS - crates/corecruxd/src/http/legal_holds.rs:22,
CORECRUXD_FEATURE_LEGAL_HOLD - crates/corecruxd/src/http/mod.rs:505, receipts route ordering

