Skip to content

Evidence sources

The run ledger has two ways of learning that a step happened.

Source How it arrives What the gateway can say
gateway The call was proxied. The gateway authenticated it, applied guardrails, policy and budgets, signed the chain link and recorded the outcome. Witnessed. Every control that applied is on the row, and the audit hash chain covers it.
otel The system exported an OpenTelemetry GenAI span of its own and posted it to the inlet. Reported. The gateway did not sit in the path, enforced nothing, and holds only what the exporter chose to send.

Both feed the same ledger, the same learned baselines and the same liveness expectations, so a system that never routes through the gateway can still be observed — which is the precondition for it being judged at all. What never happens is one being quietly promoted to the other: the provenance travels on every row, into every rollup, and onto the Assurance Report.

POST /v1/ingest/otel/v1/traces
Content-Type: application/json # OTLP/HTTP JSON; protobuf is refused with 415
Authorization: Bearer sk_brutor_api_… # a gateway API key
X-Tenant-ID: <tenant>
X-Brutor-AI-System: <ai-system id> # optional — see attribution below

The body is a standard ExportTraceServiceRequest. Point an OpenTelemetry Collector at it with the otlphttp exporter in JSON mode (the exporter appends /v1/traces itself):

otel-collector.yaml
exporters:
otlphttp/brutor:
endpoint: http://gateway.internal:8100/v1/ingest/otel
encoding: json
headers:
Authorization: "Bearer ${BRUTOR_API_KEY}"
X-Tenant-ID: acme
X-Brutor-AI-System: sys-claims-agent
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp/brutor]

Only spans carrying GenAI semantic-convention attributes become evidence:

gen_ai.operation.name Becomes Read from the span
chat, text_completion, generate_content, embeddings a model call gen_ai.request.model, gen_ai.response.model, gen_ai.provider.name (or legacy gen_ai.system), gen_ai.usage.input_tokens / output_tokens (legacy prompt_tokens / completion_tokens)
execute_tool a tool call gen_ai.tool.name
invoke_agent an agent call gen_ai.agent.name

A span with no operation name is classified from what it carries (a tool name → tool call; a request model → model call). Spans with no gen_ai.* attributes at all — HTTP server spans, database calls — are skipped, not rejected: the exporter can send its whole trace and the inlet keeps what is evidence, while using the rest as topology.

One trace is one run. The run id is otel:<trace id>; steps keep their span and parent ids, and a step’s depth counts only its GenAI ancestors, so a chat span under an HTTP span is a root step. The run closes when the trace’s top-level span arrives in an export — errored if that span’s status is ERROR, otherwise completed. If the root never arrives, the idle sweeper closes the run exactly as it would for a proxied client that never sent X-Brutor-Run-End. Late spans reopen the run the same way late steps do.

The response is the OTLP partialSuccess envelope plus what the inlet did with the batch, so a collector pipeline treats it like any other OTLP endpoint:

Response 200
{
"partialSuccess": { "rejectedSpans": 0, "errorMessage": "" },
"accepted": 3,
"skipped": 1,
"runs": ["otel:aaaa0000aaaa0000aaaa0000aaaa0000"],
"aiSystemId": "sys-claims-agent"
}
Status Meaning
400 Not JSON, or not an ExportTraceServiceRequest (no resourceSpans)
401 Missing or invalid API key
413 More than 5 000 spans in one request — batch smaller
415 Protobuf body — set the exporter to encoding: json

The run is attributed to an AI System through the same resolver every proxied call uses, in order: the X-Brutor-AI-System header on the request, the brutor.ai_system attribute on a span or on the resource, then the resource group the API key is bound to. A claim the key cannot reach is refused and recorded, and a batch that resolves to nothing lands in Unattributed runs on Mission Control rather than being dropped.

An ingested run carries:

  • source = otel on every step and on the run — or mixed when some steps were proxied, such as an agent whose model calls run through the gateway but whose tool spans arrive by export;
  • trace_continuity = external on every step, and chain_integrity = observed_external on the run — complete as reported. It counts toward observation coverage and may inform a baseline, because a whole run teaches a baseline what normal looks like. It never counts as gateway-signed or client-asserted linkage: nothing about it was signed or bound to a credential.

It does not carry prompts, responses, headers, costs, or any control decision. The inlet never reads gen_ai.prompt, gen_ai.completion or event bodies. Retention, erasure and body permissions apply to these rows like any other (see Data handling), and there is nothing in them for store_bodies to withhold.

  • Runs table — the Chain column reads external in its own tone, and the run’s source is on the row.
  • Run signals and the status stripexternal_pct is disclosed beside asserted_pct; both sit inside observed_pct.
  • Assurance ReportWhat this report does not cover names the ingested share and states that no control applied. Recommended controls raises route_through_gateway (high) whenever any run was ingested.
  • Verdict — a period evidenced only by ingested runs carries basis: reported_telemetry, which caps the badge at Conditionally assured even under a signed operating envelope. The gateway cannot assure what it did not witness.
  • Standing checkssource is a run fact ('gateway' | 'otel' | 'mixed'), so a Tier-A check can require witnessed evidence, or fire only on reported runs.