Action records
An action record is the portable unit of evidence: one JSON object per governed verdict, canonicalised with RFC 8785 (JCS), identified by the SHA-256 of those bytes, and signed as a Signed Statement. The Core Proxy builds records in the same batched writer that persists the audit row, and appends them to the tenant’s Merkle log.
A gateway that only seals successes cannot prove its gates ever fired, so every verdict is sealed — refusals included.
What gets a record
Section titled “What gets a record”| Surface | Sealed when | Records |
|---|---|---|
MCP tools/call, A2A send (v0.3 and v1), skill execution |
after the upstream reply, or at refusal | 1 outcome record; 2 (planned + outcome) when two_phase_actions is on |
| LLM inference (completions, not embeddings) | after the completion | only when inference_records is on; otherwise the audit row chain covers it |
| Approval enqueued / decided / expired | at enqueue and resolution | awaiting_human → outcome, chained (see chaining) |
| Guardrail block, policy or grant deny, autonomy / residency / quota / run-cap refusal, access denied, invalid approval token | at refusal | blocked or denied |
| A gate that failed closed because its engine was down | at refusal | engine_failure |
| Operator run abort | at the intervention | resolved (human); every later step of the run denied |
| Contract promotion | at promotion | epoch_boundary |
| Portal AI-interaction notice | at render | recorded (transparency) |
| Judged verdict, human blind grade, rubric freeze | at write | assessment / adjudication / recorded (judged evidence) |
| Compliance profile save, notice config, report, incident change, documentation export | at write | recorded |
| Row-chain head, bundle, disclosure, key rotation | by the core | recorded |
The consequential rule
Section titled “The consequential rule”Commands seal, queries do not, and unknown defaults to sealed:
- An MCP
tools/callis sealed unless the tool is annotatedreadOnlyHint: true. - A read-only tool is sealed when the resource group or its AI System is tagged
sensitive (
phi_resourceorsensitive_data) — the privileged-read rule HIPAA and PCI share. - An unknown annotation is sealed.
- Every refusal is sealed, whatever the tool.
- LLM completions are sealed only under
inference_records.
Options per AI System
Section titled “Options per AI System”Three switches live on the AI System’s compliance profile
(core.*). When a switch is unset, it defaults to on if the system is high-risk in any
framework (frameworks.*.risk_tier == "high", or the legacy eu_ai_act_risk_tier):
| Option | Effect |
|---|---|
two_phase_actions |
A planned record is sealed immediately before dispatch; the outcome chains confirms to it |
inference_records |
LLM completions get executed/errored records (effect type inference_completion) |
salt_digests |
Content digests are salted per record (see data admission); also on when the tenant’s salt_digests_default is set |
Options are cached for 60 seconds per (tenant, resource group).
The record, member by member
Section titled “The record, member by member”schema is ai.brutor/action-record/v1. Canonical bytes are RFC 8785 JCS. No floats
anywhere; integers must lie within ±(253−1); quantities are strings.
| Member | Value | Admission |
|---|---|---|
schema |
"ai.brutor/action-record/v1" |
— |
record_id |
lowercase-hex SHA-256 of the JCS of every top-level member except record_id; verifiers recompute it |
— |
action_id |
<surface>/<proxy_log id> for gateway actions; <record_type>/<ulid> for records sealed at write |
clear-safe |
kind |
decide or fyi |
— |
operator |
the tenant id | clear-safe |
system |
{ai_system_id, contract_version?, contract_hash?}, or the string "ungoverned" when the action resolved to no AI System |
clear-safe |
timestamp |
RFC 3339 UTC, Z |
— |
epoch |
contract:<contract_hash> of the contract in force |
clear-safe |
verdict |
one of the 14 verdicts | — |
disposition |
{decision: accept|reject|needs_input|deferred, approver: human|policy|counterparty, human_disposed, authority?, reason_digest?} |
clear-safe |
effect |
{type, status: planned|dispatched|confirmed|failed|reverted, request_digest?, response_digest?, attestation: "gate_observed", irreversibility?} — absent on refusals |
digests only |
controls[] |
{id: "ai.brutor.<control>[.<check>]", result: pass|fail|n/a, blocking, severity?, kind?, evidence_digest?} — the gate that decided, never the content it evaluated |
clear-safe |
model |
{model_id?, provider?, deciding_model?} |
clear-safe |
attestation |
{runtime: "brutor-gateway", observation: "in_path", input_digest?, output_digest?, digest_salt?, numbers_canonicalized} |
digests only |
context |
surface, proxy_log_id, root_task_id, turn_id, proxy_subtype, server_version, tool_definition_hash, autonomy_level, autonomy_provenance, http_status, tags (the row’s compliance tags), conversation_handle, approval_request_id; records sealed at write carry record_type and an allow-listed set of keys (API) |
clear-safe |
chain |
{parent_record_id, relation: confirms|supersedes|epoch_opens|assesses|adjudicates} |
— |
references[] |
{type, digest_alg: "SHA-256", digest, purpose?} — ai.brutor.proxy-log-row (the row hash), ai.brutor.ai-system-contract, ai.brutor.mcp-tool-definition, ai.brutor.notice (purpose responds_to), ai.brutor.notice-text, ai.brutor.rubric, ai.brutor.payload |
— |
The controls[].id values the gateway emits include ai.brutor.guardrail.<check>,
ai.brutor.argument_policy, ai.brutor.semantic_policy, ai.brutor.agent_grant,
ai.brutor.autonomy, ai.brutor.residency, ai.brutor.rate_limit_*,
ai.brutor.run_abort, ai.brutor.run_cap, ai.brutor.access, ai.brutor.engine,
ai.brutor.skill_quota, ai.brutor.skill_concurrency, ai.brutor.approval_token,
ai.brutor.notice.rendered and ai.brutor.judge.<template>.
Two invariants are enforced when the record is built, so a record that violates them is never signed — and checked again by every verifier:
- Honesty:
human_disposed: truerequiresapprover: "human". A producer must not claim a human disposed of an action when policy did. - Confirmed-effect binding:
effect.status: "confirmed"requires aresponse_digestover the response actually observed;plannedforbids both digests. Confirmed is an observed result, never a promise.
A real (ungoverned, unsalted) record:
{ "schema": "ai.brutor/action-record/v1", "record_id": "674a3e6c6f2e1f4c3ec911ee41be115653f4703f3715945aabb85094d52fb857", "action_id": "mcp/01M2WW30R8ANWCAJDF9F02X85J", "kind": "decide", "operator": "default", "system": "ungoverned", "timestamp": "2026-09-19T13:01:02.088Z", "verdict": "executed", "disposition": { "decision": "accept", "approver": "policy", "human_disposed": false }, "effect": { "type": "tool_0", "status": "dispatched", "attestation": "gate_observed" }, "attestation": { "runtime": "brutor-gateway", "observation": "in_path", "numbers_canonicalized": false }, "context": { "surface": "mcp", "http_status": 200, "proxy_log_id": "01M2WW30R8ANWCAJDF9F02X85J" }, "references": [ { "type": "ai.brutor.proxy-log-row", "digest_alg": "SHA-256", "digest": "c45717860f30e411b88cae15d4f23d11d91cf08db7f13ed298d58d56ecc95070" } ]}Note effect.status: "dispatched": this call’s response body was not captured, so there
is no response_digest, so the record does not claim confirmed. Its derived effect
mode is dispatched_unconfirmed.
Verdicts
Section titled “Verdicts”Fourteen verdicts, pinned identically in Rust (brutor_evidence::record::verdict::ALL)
and Python (RECORD_VERDICTS) by a parity test. All except executed, errored and
timeout are never-dispatching: their derived effect mode must be not_applicable.
| Verdict | When | decision / approver / human | effect.status |
Effect mode |
|---|---|---|---|---|
executed |
allowed, upstream answered | accept / policy / false (human / true when it consumed an approval, authority = request id) |
confirmed with a response digest, else dispatched |
confirmed or dispatched_unconfirmed |
errored |
allowed, tool isError or upstream 4xx/5xx |
accept / policy / false | failed |
dispatched_unconfirmed |
timeout |
allowed, upstream timed out | accept / policy / false | dispatched |
dispatched_unconfirmed |
blocked |
guardrail block | reject / policy / false | absent | not_applicable |
denied |
grant or policy deny, autonomy suspended, residency, quota/rate limit, run cap, access denied, invalid approval token, a step of an aborted run | reject / policy / false | absent | not_applicable |
engine_failure |
a gate failed closed because its engine could not decide | reject / policy / false | absent | not_applicable |
awaiting_human |
approval enqueued | needs_input / policy / false | absent | not_applicable |
resolved |
approval approved or rejected by a person; operator run abort | accept or reject / human / true | absent | not_applicable |
expired |
approval lapsed unanswered | deferred / policy / false | absent | not_applicable |
epoch_boundary |
contract promoted (kind: fyi) |
accept / human or policy | absent | not_applicable |
assessment |
a judged verdict over earlier records | — | absent | not_applicable |
adjudication |
a human blind grade of an assessment | accept or reject / human / true | absent | not_applicable |
recorded |
an fyi statement: declaration, notice, rubric, report, incident change, row-chain head, bundle, disclosure, key rotation |
— | absent | not_applicable |
planned |
first half of a two-phase action: the gates accepted it and it is being dispatched | accept / policy (human when an approval token authorised it) / false | planned, no digests |
not_applicable |
effect.status is never blocked. The derived effect mode is a verifier rule, not a
stored field: no effect, or planned ⇒ not_applicable; confirmed with a 64-hex
response_digest ⇒ confirmed; anything else ⇒ dispatched_unconfirmed. A
never-dispatching verdict with any other mode fails verification. Unknown values never
grade up.
Chaining
Section titled “Chaining”Records form chains only where one record answers another. Ordinary consecutive actions
carry no chain; they are correlated by context.root_task_id.
| Relation | From → to | Meaning |
|---|---|---|
supersedes |
outcome → awaiting_human |
the approval chain’s closing record: the executed retry that consumed the approval, a resolved rejection, or an expired lapse. The dispatch record is never mutated; when two records supersede one parent, the earliest is authoritative and later ones are reported as concurrent_supersedes |
confirms |
outcome → planned |
two-phase action: the outcome confirms the plan. Also used by an approval decision row (resolved accept) → awaiting_human, which is non-terminal — the executed retry is what closes the chain |
assesses |
assessment → turn record |
a judged verdict names what it judged |
adjudicates |
adjudication → assessment |
a human grade names the verdict it graded |
epoch_opens |
reserved | in the vocabulary; epochs are currently carried by the epoch member and the epoch_boundary record sealed at each promotion |
The approval chain in full:
awaiting_human (needs_input / policy) ← enqueue ├─ resolved (accept / human) confirms ← a person approved ├─ executed (accept / human) supersedes ← the retry that consumed the approval ├─ resolved (reject / human) supersedes ← a person rejected └─ expired (deferred / policy) supersedes ← nobody answered in timeEpochs. Every record carries epoch = contract:<hash> of the contract in force. A
contract promotion seals an epoch_boundary record (approver human when the contract
carries a human approval) referencing the new contract, so a verifier can check that
epoch changes line up with the contract history (records.chain_complete).
Cross-stream citations — a record citing the notice it responds to, a rubric, a payload —
use references, never chain. A reference may not duplicate the chain parent.
Data admission
Section titled “Data admission”The record builder reads an explicit allow-list of fields. There are three classes.
Never enters, in any form — including salted digests. Subject and user ids, API key
ids and names, end-user and portal session ids, IdP identities and e-mail, client IPs,
user agents, headers and authorization values, body previews, and PHI. The verifier
rejects any member with one of these names at any depth (never_enters_member):
subject_id subject_kind api_key_id api_key_name end_user_session_id session_iduser_id gateway_user_id gateway_user_username idp_user_id idp_user_usernameidp_user_email email client_ip ip_address user_agent headers request_headersresponse_headers authorization request_body_preview response_body_previewactor_agent_idA static test greps the gateway’s builders for the same names, so adding one is a build failure. This single rule makes the log GDPR-erasable (identities were never in it), HIPAA-safe (no PHI in a permanent record) and SOC 2 C1-safe at once.
Digest-only. Tool arguments and results, prompts, completions, guardrail findings.
By default the record carries the audit row’s content digests (SHA-256 over the content
as captured, sealed before retention touches it — see the audit row).
Under salt_digests it carries salted digests instead:
salted = hex( SHA-256( JCS(value) ‖ "|" ‖ salt_hex ) )where salt_hex is the 32-character lowercase hex of a fresh 128-bit per-record salt,
carried in attestation.digest_salt and appended as ASCII bytes, and value is the
captured body parsed as JSON with floats converted to exact decimal strings
(numbers_canonicalized: true when anything changed) — or, if the body is not JSON, the
body as a JSON string. Anyone who holds the original value and the record can recompute
it. A salting system whose bodies were not captured gets no digests, never unsalted
ones.
Clear-safe. Opaque handles scoped to a run or conversation — root_task_id,
turn_id, the conversation handle (a fresh ULID per conversation, never a session or
user id) — plus tenant and system ids, contract hashes, gate ids and tags.
RECORD_ADMITTED_FIELDS
Section titled “RECORD_ADMITTED_FIELDS”The top-level members a record may carry. Anything else is structurally unreachable from
the builder and an unadmitted_field error in the verifier:
schema record_id action_id kind operator system timestamp epoch verdictdisposition effect controls model attestation context chain referencesStorage, retention and erasure
Section titled “Storage, retention and erasure”Records are stored once as JSON (action_records.record) beside their ~200-byte
statement. proxy_logs.record_id links an audit row to its record. Record bodies
follow retention; record ids, statements, log nodes and tree heads are permanent, so
inclusion proofs survive pruning, and prunes are declared and sealed. Erasure never
touches the evidence tables — identities never entered them.
Observability
Section titled “Observability”brutor_evidence_record_lag_seconds{surface}— seal time minus row time (histogram)brutor_evidence_capture_gap_total{reason}—disabled,build_failed,seal_failed,planned_unconfirmed
Records shed or failed are a capture_gap inbox finding,
never silent. Rows written before the key was set (or by seeds) can be sealed late with
POST /v1/evidence/backfill; they carry
context.backfilled: true and resolvers report them separately (“sealed late: n”).

