Skip to content

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.

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

Commands seal, queries do not, and unknown defaults to sealed:

  • An MCP tools/call is sealed unless the tool is annotated readOnlyHint: true.
  • A read-only tool is sealed when the resource group or its AI System is tagged sensitive (phi_resource or sensitive_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.

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

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: true requires approver: "human". A producer must not claim a human disposed of an action when policy did.
  • Confirmed-effect binding: effect.status: "confirmed" requires a response_digest over the response actually observed; planned forbids 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.

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.

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 time

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

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_id
user_id gateway_user_id gateway_user_username idp_user_id idp_user_username
idp_user_email email client_ip ip_address user_agent headers request_headers
response_headers authorization request_body_preview response_body_preview
actor_agent_id

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

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 verdict
disposition effect controls model attestation context chain references

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.

  • 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”).