Skip to content

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.

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.

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.

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, timestamp now (normalised to UTC ms Z), action_id <record_type>/<ulid>.
  • ai_system_id must be one of the tenant’s AI Systems; the core attaches system (contract version and hash) and epoch.
  • payload is never stored: the core adds a reference {type: "ai.brutor.payload", digest: SHA-256(JCS(payload)), purpose: <record_type>}.
  • context keys 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, for tags, ≤ 64 short strings. Free text enters only through payload.
  • Unknown members anywhere are a 422 invalid_record naming 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}.

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.

{ "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).

{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.

Create or rotate the tenant’s own key → {tenant_id, kid, previous_kid, rotation_record_id, log_id, seq}; 409 per_tenant_key_disabled.

The optional Agent Action Capsule export. 404 unless BRUTOR_EVIDENCE_PROFILE_AAC=1.

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.

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, …).

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}]}
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
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}
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[]}

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, …}.

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}

See judged evidence: GET /v1/admin/assurance-checks/{check_id}/reviews, POST …/reviews/sample, POST …/reviews/{review_id}, GET …/agreement.

/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.

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.