The Audit Row
Every call through the Core Proxy — LLM, MCP, Virtual MCP, skill, OAuth, A2A, and every span that arrives through the OpenTelemetry inlet — writes one row to proxy_logs. It is the record the run ledger, the Assurance Report, the compliance reports and the audit export are all computed from, so what it holds is the ceiling on what the platform can ever prove.
This page lists the fields by the question they answer. Three words recur, and they are the point of the design:
- Witnessed — the gateway saw it happen.
- Asserted — the caller said so (a trace id, a run id, a release name). Recorded as a claim and never “upgraded” into a witnessed fact.
- Derived — computed by the gateway at write time from other rows or the response.
Whether a field is inside the row hash is a separate question, answered field by field in What the hash covers. From format v3, every scalar field the gateway records before it seals the row is covered — including declarations the caller makes that governance acts on, such as the agent’s release — and from format v4 so is every JSON decision record: the checks that were skipped or degraded, the state a quota decision was taken against, the compliance tags. Hashing a claim does not make it true; it proves the record of the claim was not altered afterwards.
Absence is recorded as absence: a field the gateway could not establish is null, never a default that reads like a fact.
Identity and time
Section titled “Identity and time”| Field | Meaning |
|---|---|
id |
ULID — time-sortable, so rows order by id inside a millisecond |
tenant_id |
The tenant; every query is scoped by it |
timestamp |
When the call was recorded, microsecond precision, UTC |
created_at |
When the row was inserted (the writer batches, so this can trail timestamp) |
What was called
Section titled “What was called”| Field | Meaning |
|---|---|
proxy_type |
llm, mcp, virtual_mcp, skill, oauth, a2a, portal, batch |
proxy_subtype |
The shape within the type — chat_completions, embeddings, anthropic_messages, mcp_jsonrpc, sse_stream — and governance outcomes such as approval_required, semantic_policy_block, residency_violation, llm_governance_block (an LLM parameter or media policy refused the request before the provider), and rate_limit_<scope> for a rate, quota or concurrency ceiling (e.g. rate_limit_quota, rate_limit_concurrency, rate_limit_media_quota) |
http_method, endpoint, target_url |
The gateway route, and the upstream URL the call was forwarded to |
server_id |
The MCP or LLM server the call targeted |
mcp_method, mcp_request_id, mcp_capability_name |
JSON-RPC method (tools/call, tools/list, …), the client’s JSON-RPC id, and the tool, resource or prompt name |
skill_id, skill_execution_id |
The skill that initiated the call, when a skill did |
llm_model_requested, llm_model_used, llm_provider |
What the client asked for and what routing resolved it to |
llm_prompt_tokens, llm_completion_tokens, llm_total_tokens, llm_estimated_cost_usd |
The bill |
llm_streaming, llm_temperature, llm_max_tokens, llm_function_calling |
Request shape |
llm_used_fallback, llm_fallback_reason |
Whether a resilience fallback served the call, and why |
llm_cache_hit, llm_cache_layer, llm_tokens_saved, llm_cache_similarity_score |
Response-cache outcome (the Caching tab in Mission Control → Analytics) |
prompt_cache_write_tokens, prompt_cache_read_tokens, prompt_cache_savings_usd |
Provider prompt-cache outcome |
rag_enabled, rag_chunks_retrieved, rag_context_tokens, rag_collection_ids, rag_top_similarity_score, rag_retrieval_duration_ms |
Knowledge-base retrieval that was injected |
prompt_fingerprint |
SHA-256 of the instruction text sent to the model — a hash, never the prompt — so the drift cause ladder can tell “the system prompt changed” from “the model changed” |
media_images_generated, media_audio_chars, media_audio_seconds_input, media_video_seconds_output |
Media metering for image, speech, transcription and video modes |
oauth_flow_type, oauth_scope |
For OAuth proxy rows |
Who called it
Section titled “Who called it”| Field | Meaning |
|---|---|
api_key_id, api_key_name |
The API key that authenticated the call; null on JWT-authenticated calls |
resource_group_id |
The resource group — for an agent, its AI System — the call executed inside |
gateway_user_id, gateway_user_username |
The gateway user, when a user is present |
idp_user_id, idp_user_username, idp_user_email |
The identity-provider subject behind the credential |
actor_agent_id |
The agent identity that performed the action; null for human and plain API-key callers |
subject_id, subject_kind |
Who the agent acted for — a user, an upstream agent, or a service |
end_user_session_id |
The conversation, when the client asserted one (X-Brutor-Session-Id, HMAC-derived so one caller cannot file traffic under another’s session) |
client_ip, user_agent |
The calling process |
is_auth_event |
The row is itself an authentication event (token issue, login) |
How calls link together
Section titled “How calls link together”| Field | Kind | Meaning |
|---|---|---|
correlation_id |
asserted | x-correlation-id from the client, or one the gateway minted; the debugging join from a user complaint to its rows |
request_id, parent_request_id |
asserted | Client request id (X-Request-ID) and, for nested gateway calls, the request that caused this one |
root_task_id |
witnessed / asserted | The run this action belongs to. Minted by the gateway for delegation chains; derived from the client’s x-brutor-run-id (keyed to the credential, so it cannot collide with another principal’s run). See the run ledger |
current_task_id, parent_task_id, delegation_depth |
witnessed | This step’s id, the step that invoked it, and how deep in the delegation it sits; a2a_protocol_version records the dialect |
trace_continuity |
witnessed | How the row joined its chain: minted, verified (gateway-signed), client_dropped, sig_failed, client_asserted, external (OTel inlet) |
trace_root_kind |
witnessed | What started the run: llm_user, agent_invocation, skill_execution, scheduled_job, external_api, external_telemetry |
step_id, step_name |
asserted | The logical phase the client named (X-Brutor-Step-Id); nothing is inferred when absent |
turn_id, turn_seq |
asserted | The pass of the agent loop the client named; when absent, turns are inferred from model-call boundaries and the ledger says so |
run_end_state |
asserted | The terminal state the client declared on this step (X-Brutor-Run-End): completed, completed_degraded, exhausted, errored, blocked_policy, cancelled |
trace_id, span_id |
asserted | The caller’s W3C traceparent ids, validated and lower-cased; on OTel-inlet rows, the span’s own. The join from your tracing tool to the governed record |
upstream_request_id |
witnessed | The provider’s own request id (x-request-id) — the only join between this trail and the vendor’s |
What went in and what came out
Section titled “What went in and what came out”| Field | Kind | Meaning |
|---|---|---|
request_size_bytes, response_size_bytes |
witnessed | Sizes as captured |
request_headers, response_headers |
witnessed | Sanitised header JSON, subject to the retention policy |
request_body_preview, response_body_preview |
witnessed | The content as stored under the tenant’s store_bodies mode — full (capped at 64 KiB), redacted, or absent under none — and removed after body_days. For LLM rows the request is the outbound body: what the gateway sent the provider after routing and translation |
request_content_hash, response_content_hash |
witnessed, hashed | SHA-256 over the content exactly as captured, sealed before any retention mode or prune touches the preview. What the row proves about the content after the content is gone |
http_status_code, success, error_type, error_message, duration_ms |
witnessed | The outcome |
What decided it
Section titled “What decided it”| Field | Meaning |
|---|---|
guardrail_blocked, argument_policy_denied, pii_detected |
Report-level flags; the fields below say which rule and what it did |
guardrail_config_id, guardrail_check, guardrail_action |
The guardrail config that fired, the check (pii, prompt_injection, jailbreak, toxic_content, banned_words, banned_pattern, secrets) and its action (block, redact, warn) |
guardrails_skipped, guardrails_degraded |
Checks configured but not run (a detector failed and the config opted into log-and-allow), and checks that ran on a lower rung of the resilience ladder — so a clean row never silently means “unchecked” |
argument_policy_id, semantic_policy_id |
The argument or semantic policy that produced the decision |
agent_authz_decision, agent_grant_id |
The agent grant outcome — allow, approval_required, deny — and the grant that drove it |
autonomy_level, autonomy_provenance |
The autonomy in force (autonomous, approval_required, restricted, suspended) and how it was obtained: live, last_known_good, or floor — anything but live means enforcement ran on degraded information |
consumed_approval_request_id |
The approval whose one-time token authorised this call, set on the retried call so approval → action is walkable forward |
pre_action_state |
The observed aggregates the quota or budget decision was taken against ({kind, …}); the limits themselves are pinned by the AI System’s contract |
region_routed_to, residency_class |
Where the call actually went and the residency verdict: allowed, blocked, not_evaluated |
compliance_tags |
Regime tags the tagging engine fired (gdpr_art_30, soc2.cc7.2, eu_ai_act.high, …); null when the tenant has no profile, [] when it has one and nothing fired |
Tool provenance and the deciding model
Section titled “Tool provenance and the deciding model”Three fields answer the questions an incident review asks of a tool call first — which definition of the tool was in force, which server, and which model chose to call it:
| Field | Kind | Meaning |
|---|---|---|
tool_definition_hash |
witnessed, hashed | SHA-256 over the canonical JSON (keys sorted) of the tool definition as the upstream last listed it through the gateway — falling back to the latest discovery snapshot when no tools/list has passed through this instance. A changed description or schema under the same name is a different hash; a tool the gateway never saw listed gets no hash rather than a guessed one |
server_version |
witnessed, hashed | The version the MCP server announced in its initialize result, as this proxy instance last saw it |
deciding_model |
derived, hashed (v3) | For a tool, skill or A2A action: the model whose completion decided it — the run’s most recent model call, same turn preferred. Resolved at write time so the answer is on the row an exporter reads, not in a join they would have to know to write. Never set on model calls, never guessed from another run. Inside the hash from v3: it is resolved before the row is sealed, and the model call it came from may sit on another writer’s chain or be pruned, so the row itself has to prove what it said |
Integrity and provenance
Section titled “Integrity and provenance”| Field | Meaning |
|---|---|
chain_id, chain_seq |
The writer chain (one per proxy process) and the row’s dense position in it; a gap in chain_seq means rows were deleted |
prev_hash, row_hash |
The predecessor’s hash and this row’s own: SHA-256(canonical bytes), where the canonical bytes end with prev_hash. Batches are signed into audit_chain_checkpoints with Ed25519 when a signing key is configured |
chain_version |
The canonical format the row was sealed under — v4 for every row written since 0.11.3, v3 for rows written by 0.11.2; null on older rows, whose format (v1 or v2) the verifier recovers by recomputation |
source |
gateway — the proxy saw the call; otel — reported through the OTel inlet, never witnessed |
bodies_pruned_at |
When the retention sweeper removed the bodies and headers |
erased_at |
When a data-subject erasure scrubbed personal data from the row |
custom_metadata, tags |
Type-specific extras (for OTel rows: span name, operation, service and scope; the portal’s conversation handle; an approval’s request id) and searchable tags — inside the hash from v4 |
What the hash covers
Section titled “What the hash covers”The canonical bytes are versioned, and the version tag is the first field inside them, so a row can never pass under a format it was not written in. Rows keep the format they were sealed with — nothing is rewritten on upgrade — and the verifier dispatches per row: on chain_version when it is set, by recomputation when it is null (v2 first, then v1 for a row that carries none of the v2 fields). One chain may therefore hold v2, v3 and v4 rows in sequence, and each verifies on its own terms.
| Format | Written by | Covered fields |
|---|---|---|
| v1 | before 0.10.71 | id, tenant_id, timestamp, proxy_type, proxy_subtype · actor_agent_id, subject_id, subject_kind, api_key_id, resource_group_id · http_method, endpoint, mcp_capability_name, llm_model_used · agent_authz_decision, agent_grant_id, semantic_policy_id, argument_policy_id, guardrail_config_id, guardrail_check, guardrail_action, autonomy_level, autonomy_provenance, consumed_approval_request_id, guardrail_blocked, argument_policy_denied · http_status_code, success · root_task_id, turn_id |
| v2 | 0.10.71 – 0.11.1 | everything in v1, then request_content_hash, response_content_hash, server_version, tool_definition_hash |
| v3 | 0.11.2 | the 68 fields below, in this order |
| v4 | 0.11.3 and later | the 68 v3 fields, then the six JSON decision records — 74 fields |
v3 field order. Identity and provenance: id, tenant_id, timestamp, source · what was called: proxy_type, proxy_subtype, http_method, endpoint, target_url, server_id, idp_server_id, mcp_method, mcp_capability_name, skill_id, skill_execution_id, llm_provider, llm_model_requested, llm_model_used, oauth_flow_type, oauth_scope, rag_collection_ids, prompt_fingerprint · who: api_key_id, resource_group_id, gateway_user_id, idp_user_id, actor_agent_id, subject_id, subject_kind, is_auth_event · which software and release (agent implementation): agent_impl_name, agent_impl_version, agent_impl_build, agent_impl_source, client_label · what was decided: agent_authz_decision, agent_grant_id, semantic_policy_id, argument_policy_id, guardrail_config_id, guardrail_check, guardrail_action, guardrail_blocked, argument_policy_denied, pii_detected, autonomy_level, autonomy_provenance, consumed_approval_request_id, region_routed_to, residency_class · outcome: http_status_code, success, error_type, upstream_request_id, a2a_task_state · run and call graph: root_task_id, current_task_id, parent_task_id, delegation_depth, trace_root_kind, trace_continuity, turn_id, run_end_state, deciding_model · content and tool provenance: request_content_hash, response_content_hash, server_version, tool_definition_hash.
v4 field order. The v3 fields in the order above, then the JSON decision records: compliance_tags, guardrails_skipped, guardrails_degraded, pre_action_state, custom_metadata, tags.
The byte encoding
Section titled “The byte encoding”v1 and v2 are NUL-terminated text: each field as UTF-8 followed by 0x00 (the version tag first), a null written as an empty field, a missing http_status_code as 0, booleans as 1/0, the timestamp as RFC 3339 with +00:00 and a fraction of 0, 3 or 6 digits depending on the value; prev_hash is appended last with no terminator.
v3 is length-prefixed, so it is unambiguous whatever a value contains and it tells null from the empty string:
bytes = "v3" 0x00 for each field in the v3 order above, then prev_hash: null -> 0x00 present value -> 0x01 ‖ uint32 big-endian byte length ‖ UTF-8 bytesrow_hash = hex(SHA-256(bytes))Values are text: booleans 1/0, integers in decimal, the timestamp RFC 3339 in UTC with exactly six fractional digits and Z (2026-09-27T14:03:07.123456Z) — truncated to the microseconds the database stores. The first row of a chain has prev_hash = 64 zeroes.
A test vector: a row whose fields are all null except id = 01K6V3VECTOR0000000000SPARSE, tenant_id = tenant-vec, timestamp = 2026-09-27T14:03:07.000000Z, source = otel, proxy_type = llm, http_method = POST, endpoint = /v1/chat/completions and the five booleans 0, with prev_hash = 64 × 1, hashes to 7b21eb3d2170cfea3653551f381321aa7721da0344e24afea70112fe1ed91bb5 under v3 (and 136de62c0b96c0b5be6bdc821bfb214407c189c9fdf151679ca11fac7e76e1c7 under v2, 9e96cc27d103dee60a7b4d8df573f76eedffb7f5f6f496f14e4f3abf8fab2557 under v4, whose six JSON fields are all null here).
v4 uses exactly the v3 framing, with the tag v4 and the six JSON fields appended after the v3 fields. Each JSON value is encoded as its RFC 8785 JSON Canonicalization Scheme text:
- object members sorted by the UTF-16 code units of their names (so
😀sorts beforeדּ, although its code point is larger), no whitespace; - strings escaped as ECMAScript
JSON.stringifyescapes them —\",\\,\b,\f,\n,\r,\t, other control characters as\u00xxin lower-case hex, everything else as literal UTF-8; - every number serialised as the IEEE-754 double it denotes, printed as ECMAScript
Number.prototype.toStringprints it:1.50→1.5,1e1→10,1e21→1e+21,0.0000001→1e-7,-0→0, and integers beyond 2⁵³ rounded to the nearest double.
JCS depends only on the JSON data, not on how it was written, which is what lets these fields join the hash at all: the database re-orders jsonb keys and re-spells numbers, and an export is re-serialised again, but every copy of the same data canonicalises to the same bytes. A SQL NULL and a JSON null document are the same absence and both encode as 0x00; an empty object or array is a value.
A v4 test vector: the row above with pre_action_state = {"window":"day","used":1.50,"kind":"llm_quota"} — canonical text {"kind":"llm_quota","used":1.5,"window":"day"} — hashes to c18201c95d62f47743a543738a18ddd4dbc97e5eaa99a85daae869ea562c45ff. The core and the control plane’s reference implementation (app/services/audit_chain_canonical.py, standard library only) are independent implementations of this encoding and of JCS; both pin the same row vectors for every format and the RFC 8785 test vectors.
What is outside, and why
Section titled “What is outside, and why”Every column of the row is either in the v4 list or on an explicit exclusion list with one of these reasons — a build fails if a new column is added without that decision, and another fails if this table and the code disagree:
| Reason | Columns |
|---|---|
| Chain mechanics — protected by linkage, sequence density and the tag inside the bytes | chain_id, chain_seq, prev_hash, row_hash, chain_version |
| Content retention removes — the content digests stand in for it | request_body_preview, response_body_preview, request_headers, response_headers |
| Direct identifiers a data-subject erasure scrubs — the pseudonymous ids beside them are covered | idp_user_email, idp_user_username, gateway_user_username, client_ip, user_agent |
| A display label; the id beside it is covered | api_key_name |
| Caller-asserted join keys — they organise the trail and decide nothing | correlation_id, request_id, parent_request_id, mcp_request_id, end_user_session_id, trace_id, span_id, step_id, step_name, turn_seq |
| Metering and measurement — tokens, cost, cache, sizes, latency, retrieval scores | request_size_bytes, response_size_bytes, duration_ms, llm_prompt_tokens, llm_completion_tokens, llm_total_tokens, llm_estimated_cost_usd, media_images_generated, media_audio_chars, media_audio_seconds_input, media_video_seconds_output, llm_cache_hit, llm_cache_layer, llm_tokens_saved, llm_cache_similarity_score, prompt_cache_write_tokens, prompt_cache_read_tokens, prompt_cache_savings_usd, rag_enabled, rag_chunks_retrieved, rag_context_tokens, rag_top_similarity_score, rag_retrieval_duration_ms |
| Request shape and resilience telemetry — the model that actually served is covered | llm_streaming, llm_temperature, llm_max_tokens, llm_function_calling, llm_used_fallback, llm_fallback_reason |
Free-text diagnostics — the classification (error_type) is covered |
error_message |
| Set after the row is sealed | record_id, created_at, bodies_pruned_at, erased_at, a2a_protocol_version |
Editing an excluded column is not undetectable in every sense — it is simply not what the chain is for. A row covers only its own format’s fields: editing, say, agent_impl_name on a row sealed before v3, or guardrails_skipped on a row sealed before v4, is not detected, which is the honest limit of the format it was written in.
Verify a chain from Administration → System → Audit Chains, or POST /v1/admin/audit/chains/{chain_id}/verify. The six verdicts are explained on the assurance journey.
An example row
Section titled “An example row”A tools/call from a support agent, after retention has pruned the bodies. Economics and cache fields omitted.
{ "id": "01K4A2ZK5QH0P4N6R9T2VXW8YB", "tenant_id": "acme", "timestamp": "2026-09-03T14:02:11.482913Z", "proxy_type": "mcp", "proxy_subtype": "mcp_jsonrpc", "endpoint": "mcp-proxy", "server_id": "srv-crm", "server_version": "2.4.1", "mcp_method": "tools/call", "mcp_capability_name": "issue_refund", "tool_definition_hash": "4653538b0e55911dbc661c55961a9f8de1e27c7ae950acb842d82ede364e0319", "deciding_model": "gpt-5.2",
"api_key_id": "key-01J…", "resource_group_id": "sys-support-copilot", "actor_agent_id": "agent-support-copilot", "subject_id": "u-4471", "subject_kind": "user",
"correlation_id": "ticket-8841", "root_task_id": "run-01K4A2Z…", "current_task_id": "01K4A2ZK5Q…", "delegation_depth": 0, "trace_continuity": "client_asserted", "turn_id": "turn-3", "step_id": "settlement", "trace_id": "0af7651916cd43dd8448eb211c80319c", "span_id": "b7ad6b7169203331",
"request_size_bytes": 212, "response_size_bytes": 88, "request_body_preview": null, "response_body_preview": null, "bodies_pruned_at": "2026-10-03T02:00:00Z", "request_content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "response_content_hash": "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752", "http_status_code": 200, "success": true, "duration_ms": 412.7,
"agent_authz_decision": "approval_required", "agent_grant_id": "grant-refunds", "consumed_approval_request_id": "appr-01K4A2Y…", "autonomy_level": "approval_required", "autonomy_provenance": "live", "guardrail_blocked": false, "argument_policy_denied": false, "compliance_tags": ["soc2.cc6.6", "eu_ai_act.high"],
"source": "gateway", "chain_id": "core-1-01K49…", "chain_seq": 184220, "prev_hash": "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2", "row_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"}Read the same row in the Admin UI under Operations → Logs & Audit → Proxy Logs (expand the row for the decision record, or open its details), query it with POST /v1/admin/proxy-logs/query, or ship it through the audit export. Each exported row carries a chain object — the chain columns and every field its format covers, under the column names above — so a recipient can recompute row_hash offline with the encoding on this page, and check the links between consecutive rows of the same chain. The export manifest records whether that object was included (chain_columns_included), because a field allowlist can drop it and the recipient cannot tell from the file.

