Evidence API reference
Two planes, one rule: the Core Proxy holds the key and does all cryptography; the Control
Plane holds none. The Control Plane reaches the core’s system endpoints over
PROXY_INTERNAL_URL with the internal service key, and every admin endpoint that seals,
builds or verifies delegates to them.
Core Proxy (:8100)
Section titled “Core Proxy (:8100)”Public
Section titled “Public”GET /.well-known/brutor-evidence-keys.json
Section titled “GET /.well-known/brutor-evidence-keys.json”No authentication. Every key the deployment signs or signed with — see the public key document.
System-scoped: /v1/evidence/*
Section titled “System-scoped: /v1/evidence/*”Every route under /v1/evidence requires a system-scoped caller: the global service key
(Authorization: Bearer $PROXY_HEALTH_CHECK_API_KEY from the Control Plane) or a system
admin session. Errors are {"error": "<code>", "detail": "…"}; 503 evidence_disabled when
BRUTOR_EVIDENCE_SIGNING_KEY is not set.
POST /v1/evidence/seal
Section titled “POST /v1/evidence/seal”Seal records that do not come from a proxy-log row (declarations, epochs, reports…). Max 100 records per call, all in one transaction.
{ "tenant_id": "default", "records": [{ "record_type": "ai.brutor.declaration", "verdict": "recorded", "kind": "fyi", "ai_system_id": "rg-claims-agent", "timestamp": "2026-09-19T12:00:00Z", "action_id": "ai.brutor.declaration/01K5…", "disposition": { "decision": "accept", "approver": "human", "human_disposed": true, "authority": "…" }, "controls": [{ "id": "ai.brutor.notice.rendered", "result": "pass", "blocking": false }], "context": { "framework_id": "eu-ai-act" }, "payload": { "any": "json" }, "references": [{ "type": "ai.brutor.ai-system-contract", "digest": "<hex64>", "purpose": "…" }], "chain": { "parent_record_id": "<hex64>", "relation": "supersedes" } }] }- Defaults:
verdict: recorded,kind: fyi,timestampnow (normalised to UTC msZ),action_id<record_type>/<ulid>. ai_system_idmust be one of the tenant’s AI Systems; the core attachessystem(contract version and hash) andepoch.payloadis never stored: the core adds a reference{type: "ai.brutor.payload", digest: SHA-256(JCS(payload)), purpose: <record_type>}.contextkeys are allow-listed:record_type surface root_task_id turn_id conversation_handle framework_id catalog_version obligation_key check_id evaluator_fingerprint report_id cadence period_start period_end incident_id state bundle_id contract_version approval_request_id intervention_id tags locale method backfilled— short opaque scalars (≤ 256 chars, no newline) or, fortags, ≤ 64 short strings. Free text enters only throughpayload.- Unknown members anywhere are a
422 invalid_recordnaming the path; so is a record that breaks the honesty invariant.
→ 200 {"sealed": [{"record_id", "log_id", "seq", "statement_b64", "record", "payload_digest"?}]}
POST /v1/evidence/logs/{tenant_id}/tree-head
Section titled “POST /v1/evidence/logs/{tenant_id}/tree-head”Force a head if anything is uncovered →
{"issued": true, "head": {"id", "tree_size", "root", "statement_b64"}} or
{"issued": false, "head": null}.
Verify family
Section titled “Verify family”Pure — no database writes. The trust set is this deployment’s current and retired keys
unless the body passes trusted_kids: [hex…].
| Endpoint | Body | Response |
|---|---|---|
POST /v1/evidence/verify/record |
{record, statement_b64?, store?: [record…], trusted_kids?} |
{ok, findings[{check, code, severity, detail}], record_id, effect_mode, statement: {verified, error, iss, sub, kid, kid_is_this_deployment, kid_status, payload_record_id} | null} |
POST /v1/evidence/verify/statement |
{statement_b64, record?, trusted_kids?} |
{verified, iss, sub, kid, content_type, record_id, error} |
POST /v1/evidence/verify/tree-head |
{statement_b64, previous_statement_b64?, consistency_proof?: [hex], trusted_kids?} |
{verified, log_id, tree_size, root, prev_size, issued_at, consistent_with_previous?, error?} — consistent_with_previous is null with an error when undeterminable |
POST /v1/evidence/verify/receipt |
{receipt_b64, statement_b64, pinned_key_hex? | pinned_key? | witness_id?, kind?: "scitt"|"rfc3161"} |
{grade: witnessed|timestamped|unverified|invalid, kind, tree_size, leaf_index, iat, vendor_ext, errors[]} |
POST /v1/evidence/verify/bundle |
{bundle, bundle_digest?, trusted_kids?} |
{ok, findings[{code, severity, detail, record_id?}], bundle_digest, records_verified, proofs_verified, heads_verified, missing[]} — without bundle_digest the digest is computed but not compared |
Finding codes: semantic rules and bundle codes.
POST /v1/evidence/bundles
Section titled “POST /v1/evidence/bundles”{ "tenant_id": "default", "selector": { "root_task_id": "run_01K5…" }, "audience": "Example Audit LLP", "payloads": "none", "framework_id": "eu-ai-act", "catalog_version": "2026-09", "obligation_key": "art12-record-keeping", "extensions": {}, "closure_depth": 1, "requested_by": "<admin id>" }selector is one of {record_ids: [...]}, {root_task_id}, {ai_system_id, from, to}.
A non-null audience seals a disclosure. requested_by is stored on the bundle row only,
never in a record.
→ {"bundle_id", "bundle_digest", "record_id", "disclosure_id"?, "bundle", "persisted"}.
Errors: 409 payloads_unavailable, 413 bundle_too_large (> 10 000 records incl. closure,
or > 50 MB), 404 no_records, 422 invalid_request (closure_depth ≤ 8).
POST /v1/evidence/backfill
Section titled “POST /v1/evidence/backfill”{tenant_id, from, to, limit?, after?} (limit ≤ 10 000, default 1 000) — seal governed rows
in the window that have no record (rows written by seeds or before the key was set). Records
carry context.backfilled: true and resolvers report them separately. A row whose record
already exists is re-linked, never re-sealed. → {scanned, sealed, relinked, not_governed, failed, truncated, next_cursor?}; 409 backfill_in_progress. Rows the consequential rule
never seals (queries) keep no record, so a large window is paged: when truncated is true,
pass the returned next_cursor ({timestamp, id}) back as after and repeat until it is
false.
POST /v1/evidence/keys/{tenant_id}/rotate
Section titled “POST /v1/evidence/keys/{tenant_id}/rotate”Create or rotate the tenant’s own key →
{tenant_id, kid, previous_kid, rotation_record_id, log_id, seq};
409 per_tenant_key_disabled.
GET /v1/evidence/records/{record_id}/aac
Section titled “GET /v1/evidence/records/{record_id}/aac”The optional Agent Action Capsule export. 404
unless BRUTOR_EVIDENCE_PROFILE_AAC=1.
Portal: /v1/portal/evidence/*
Section titled “Portal: /v1/portal/evidence/*”Portal JWT. See transparency.
| Method | Path | Body → response |
|---|---|---|
| GET | /v1/portal/evidence/notice-config?ai_system_id= |
→ {enabled, surface, text?, text_sha256?, exemption?} |
| POST | /v1/portal/evidence/notice |
{conversation_handle, locale, method, text_sha256, ai_system_id?} → {record_id}; 422 invalid_notice, 429 rate_limited, 503 evidence_disabled |
Related headers: requests carry X-Brutor-Conversation-Handle: <ulid>; successful LLM
responses carry X-Brutor-AI-Generated: true.
Control Plane (:5050)
Section titled “Control Plane (:5050)”Admin JWT. Mounted at /v1/admin/…. System admins may pass ?tenant_id=; a mismatched
value is refused. Permissions: evidence:read / evidence:manage,
compliance:read / compliance:manage, assurance-check:* for reviews. Every write that
seals returns a seal block: {sealed, record_id, seal_error, seal_detail} — sealed: false means the change is saved but no record exists for it (evidence_disabled,
core_unreachable, …).
Evidence ledger — /v1/admin/evidence
Section titled “Evidence ledger — /v1/admin/evidence”| Method | Path | Purpose / shape |
|---|---|---|
| GET | /records |
?ai_system_id&verdict&kind&surface&root_task_id&since&until&limit≤200&offset → {items[{record_id, log_id, seq, ai_system_id, root_task_id, proxy_log_id, verdict, kind, surface, epoch, timestamp, tags, action_id, effect_type, effect_status, effect_mode, human_disposed, chain}], total, limit, offset} |
| GET | /records/{record_id} |
the summary plus {record, statement_b64, kid, created_at, log: {log_id, seq, tree_size_now}} |
| GET | /records/{record_id}/proof |
?tree_size= → {record_id, log_id, leaf_index, tree_size, root, path[], leaf_hash} — computed from the stored nodes |
| GET | /summary |
{records_total, by_verdict, by_kind, by_surface, human_disposed_count, systems_covered, log: {log_id, tree_size, root, last_tree_head, unwitnessed_heads}} |
| GET | /logs |
{items[{log_id, tenant_id, tree_size, root, kid, last_tree_head, heads_count, receipts_count, latest_grade}], total} |
| GET | /logs/{log_id}/tree-heads |
paged {items[TreeHead], total, limit, offset} |
| GET | /tree-heads/{head_id} |
{id, log_id, tree_size, root, prev_size, prev_root, issued_at, stamp_seq, grade, statement_b64, transparent_statement_b64, receipts[{id, witness_id, kind, verdict, verified_at, leaf_index, tree_size, witness_iat, error}]} |
| GET / POST | /witnesses |
list / add {scope: tenant|platform, kind: scitt|rfc3161, url, operator_label, pinned_key?, enabled, same_operator} |
| PATCH / DELETE | /witnesses/{witness_id} |
enable, relabel, re-pin / remove |
| GET | /disclosures |
{items[{id, log_id, seq, audience, selector, completeness, disclosed_record_ids, disclosed_count, suppressed_fields, bundle_digest, statement_b64, created_at, created_by}], total, …} |
| POST | /verify |
{record_id} | {record, statement_b64?} → the core’s verify-record result |
| GET / PUT | /settings |
{tenant_id, per_tenant_key, salt_digests_default, updated_at, updated_by} |
| POST | /keys/rotate |
→ {kid, previous_kid, tenant_id, rotation_record_id, created_at} |
| GET | /keys |
{items[{kid, scope: deployment|tenant, status: active|retired, created_at, retired_at, rotation_record_id}], total, log_kid, per_tenant_key} |
| POST | /bundles |
{selector: {record_ids? | root_task_id? | ai_system_id + from + to}, audience?, closure_depth (0–8), framework_id?} → the core’s bundle response |
| POST | /bundles/verify |
{bundle, bundle_digest?} → the core’s bundle verification |
Registry and profiles — /v1/admin/compliance
Section titled “Registry and profiles — /v1/admin/compliance”| Method | Path | Purpose / shape |
|---|---|---|
| GET | /frameworks |
{items[{id, tag_key, title, kind, jurisdiction, reference, catalog_version, verified_against_instrument, retention_floor_days, deadline_rules, sort_order, enabled, obligation_count}]} |
| PATCH | /frameworks/{framework_id} |
{"enabled": true} — the same switch as the tagging profile |
| GET / PUT | /ai-systems/{ai_system_id}/profile |
{core, frameworks} → {ai_system_id, core, frameworks, declaration_record_id, exists, sealed, seal_error, seal_detail, …} |
| GET | /systems |
{items[{ai_system_id, name, lifecycle_stage, core, frameworks, declaration_record_id, sealed, exists, active_frameworks, retention_floor_days, …}], frameworks_enabled} |
| GET | /retention |
{policy, floor_days, effective, raised, systems[{ai_system_id, name, floor_days, sources, effective_log_days, effective_run_days}]} |
Obligations and evaluation
Section titled “Obligations and evaluation”| Method | Path | Purpose / shape |
|---|---|---|
| GET | /v1/admin/compliance/obligation-frameworks |
installed catalogs |
| GET | /v1/admin/compliance/obligations |
?framework=eu-ai-act&ai_system_id&from&to&include_matrix=true — the dated board plus matrix{window, catalog_versions, systems[{cells[{obligation_key, applicability, in_force, worst_status, status_counts, by_basis{basis: {met, measured}}, summary}]}]} |
| GET | /v1/admin/compliance/obligations/{key} |
?framework&ai_system_id&from&to → {framework, key, resolved_keys, title, reference, summary, limit, not_legal_advice, window, systems[{ai_system_id, name, obligations[…statements]}]}; each statement {key, statement, basis, resolver, params, status, n, m, detail, limit, citations, catalog_version, window_start, window_end, evaluated_at, facts, refs, other_frameworks}. Old keys resolve to successors |
| GET | /v1/admin/compliance/overview |
?framework&from&to → {framework, window, evidence{latest_head, grade, record_lag, open_capture_gap_items}, timeline[{key, title, applies_from, in_force}], systems[{ai_system_id, name, risk_tier, role, profile_declared, profile_sealed, obligations_in_force, obligations_upcoming, undetermined, statement_counts, capture, latest_report}]} |
| GET | /v1/admin/ai-systems/{system_id}/obligations |
one system’s board |
Reports and oversight
Section titled “Reports and oversight”| Method | Path | Purpose / shape |
|---|---|---|
| GET | /v1/admin/compliance/reports |
?framework&ai_system_id (or "estate")&cadence&limit≤500 → {items[ReportSummary], total} |
| POST | /v1/admin/compliance/reports |
{framework_id, cadence: daily|weekly|monthly, ai_system_id?, period_start?, force?} → {report, created, regenerated, seal, bundle_error} |
| GET | /v1/admin/compliance/reports/{report_id} |
the report with rows; ReportSummary = {id, framework_id, ai_system_id, cadence, period_start, period_end, catalog_version, status, summary, error, record_id, bundle_id, countersignatures, countersignature_status, permalink, …} |
| GET | /v1/admin/compliance/reports/{report_id}/bundle |
the monthly report’s ai.brutor/evidence-bundle/v1 JSON |
| GET | /v1/admin/compliance/oversight |
?ai_system_id&from&to → {window_start, window_end, approvals, systems, review_queue, judged_checks, movement_note} |
Transparency
Section titled “Transparency”| Method | Path | Purpose / shape |
|---|---|---|
| GET | /v1/admin/compliance/notice-configs |
{items[NoticeConfig], total} |
| GET | /v1/admin/compliance/notice-configs/effective |
?ai_system_id → the config the portal resolves to, with source: system|tenant|default |
| PUT | /v1/admin/compliance/notice-configs |
?ai_system_id · {enabled, surface, texts: {locale: text}, exemption?: {asserted, basis, rationale}} → {config, seal, declaration?} |
| DELETE | /v1/admin/compliance/notice-configs/{config_id} |
→ {deleted, seal} |
| GET | /v1/admin/compliance/transparency |
?ai_system_id → {config, notice_evidence, notice_text, synthetic_content, generates_synthetic_content, interacts_with_natural_persons, limits[]} |
Incidents
Section titled “Incidents”Base /v1/admin/compliance/incidents.
| Method | Path | Purpose / shape |
|---|---|---|
| GET / POST | (base) | list / create {title, description, classification, severity: low|medium|high|critical, became_aware_at, frameworks[], ai_system_id?, linked_run_ids[], linked_inbox_item_ids[]} → {incident, event, seal} |
| GET / PATCH | /{incident_id} |
read / update (any create field plus authority, external_reference) |
| POST | /{incident_id}/report |
{authority, reported_at?, external_reference?} |
| POST | /{incident_id}/close |
{note?} |
| GET / POST | /{incident_id}/events |
the sealed history / add a note {note} |
An incident: {id, ai_system_id, title, description, classification, severity, became_aware_at, frameworks, deadlines, deadline_status[{framework_id, rule, stage, due_at, status, hours_remaining}], deadlines_enforced: false, status: open|reported|closed, reported_at, authority, external_reference, linked_run_ids, linked_inbox_item_ids, last_record_id, …}.
Documentation
Section titled “Documentation”| Method | Path | Purpose |
|---|---|---|
| GET | /v1/admin/ai-systems/{ai_system_id}/documentation |
?framework=eu-ai-act&format=json|html — render (and seal) the skeleton |
| GET | /v1/admin/ai-systems/{ai_system_id}/documentation/exports |
{items[{id, framework_id, template, contract_version, digest, record_id, created_by, created_at}], total} |
Assurance-check reviews
Section titled “Assurance-check reviews”See judged evidence:
GET /v1/admin/assurance-checks/{check_id}/reviews, POST …/reviews/sample,
POST …/reviews/{review_id}, GET …/agreement.
Kept per-framework exports
Section titled “Kept per-framework exports”/v1/admin/compliance/{profile, dashboard, gdpr/article-30, eu-ai-act/risk-classification, soc2/control-coverage, hipaa/phi-access, iso-42001/activity} are unchanged — see
compliance tagging.
The vector corpus
Section titled “The vector corpus”brutor-gateway-core/vectors/ is the normative test corpus: generated by
cargo run -p brutor-verify --example gen-vectors, pinned by SHA256SUMS, and checked by
crates/brutor-verify/tests/vectors.rs (including that regenerating reproduces it byte for
byte). Use it to test your own verifier.
| Directory | Contents |
|---|---|
records/positive/, records/negative/ |
one case per verdict, chain relation and rule; each {description, record, canonical?, preimages?, statement_b64?, expect: {ok, codes[], record_id, effect_mode}} — canonical is the literal JCS string and preimages the literal digest inputs |
canon/jcs.json |
RFC 8785 cases |
logs/ |
RFC 6962 inclusion and consistency cases and a node store |
heads/ |
first head, adjacent and non-adjacent heads (with and without a consistency proof), forks, bad signatures, different logs, untrusted keys; expect: {ok, codes, log_id, tree_size, root, consistent_with_previous} |
receipts/ |
scitt receipts (witnessed, unpinned, wrong pin, other statement) and RFC 3161 tokens (RSA-2048, ECDSA P-256/P-384, unpinned, other statement); expect: {ok, codes, grade, tree_size, leaf_index, iat} |
bundles/golden/, bundles/tampered/, bundles/expect.json |
golden bundles (single record, a contiguous run, producer-selected with closure, declared incomplete) and fifteen tampered variants, each with expected digest, codes and counts |
keys/ |
a well-known key document, a witness key, and a stranger’s key |
interop/verify_with_scitt_cose.py |
verifies the Rust-produced corpus with the upstream scitt-cose Python package (and optionally its Go verifier and openssl ts) — nothing from Brutor |
The Agent Action Capsule profile (optional)
Section titled “The Agent Action Capsule profile (optional)”Enable with BRUTOR_EVIDENCE_PROFILE_AAC=1 on the Core Proxy. Then
GET /v1/evidence/records/{record_id}/aac (system-scoped) returns:
{ "profile": "draft-mih-scitt-agent-action-capsule-04", "mapping": "…", "record_id": "b812…", "capsule_id": "…", "capsule": { }, "lossy": [ ], "producer_envelope": { "envelope_b64": "…", "kid": "…", "content_type": "application/agent-action-capsule-id" }, "class1": { }, "lineage": [{ "record_id": "…", "capsule_id": "…" }]}The capsule is a deterministic mapping of the record (format 4, plain JCS; the withdrawn
jcs-n fails closed), with every lossy field listed. A chained record is mapped with its
lineage, walked within its tenant. The producer envelope is a COSE_Sign1 over the raw
capsule id, signed with the tenant’s evidence key. class1 is the upstream Class-1
verifier’s result. Errors: 400 invalid_record_id, 404 record_not_found,
409 parent_record_unavailable, 422 lineage_too_deep / not_mappable. Nothing is
written.

