Events
The stable, versioned JSON shape of Event/Finding/Endorsement, the OpenTelemetry attribute mapping, and the EVENT_SCHEMA_VERSION history.
Events: the stable record schema
Event, Finding, and Endorsement are Interbolt's own versioned records,
the source of truth for what a guarded call decided, what the laundering
audit found, and what was explicitly endorsed. Any reporter, native or
OpenTelemetry, is derived from these records, not the source of them. This
page is the integration contract for anything that consumes them.
Shared shape
Every record carries the same five fields, from a common base:
{
"schema_version": 11,
"trace_id": null,
"span_id": null,
"policy_fingerprint": "sha256:9f2c...",
"timestamp": "2026-01-01T12:00:00Z"
}trace_id/span_id are the active OpenTelemetry span's W3C hex identifiers
at construction time, and null when OpenTelemetry is absent or no span was
active. policy_fingerprint is the producing policy's policy.fingerprint
(see Policy evaluation internals: the fingerprint);
it is null only when no policy was reachable at all, which in practice
means an endorse() call made before configure() has run. Finding and
Endorsement additionally carry the identity triple (agent_id, run_id,
session_id) directly. Event does not, because its embedded Decision
already carries it.
A policy's fingerprint covers the normalized document, so it changes when the
document's shape changes and not only when its rules do. The 2.0 sink entry
shape changed every fingerprint, which means records emitted before and after
that migration will not join on policy_fingerprint even where the policy is
semantically unchanged.
record_type is not a model field. JsonlReporter injects it alongside
record.model_dump(mode="json") so a reader can recover the concrete type
("event", "finding", or "endorsement") without guessing from shape.
Versioning policy
schema_version (backed by constants.EVENT_SCHEMA_VERSION) is bumped on
any field addition or semantic change. Additions are backward-compatible: a
new field defaults to None or an equivalent empty value, so an older
record never crashes a parser on a missing key, and interbolt inspect
parses every schema version it has ever emitted, side by side, in one run.
Removals are a different matter before 1.0. Schema version 7 removed nine
duplicated top-level fields from Event (see the
history below), so a consumer written
against version 6 that read event["agent_id"] needs to read
event["decision"]["agent_id"] instead. Once the package reaches 1.0,
removals and renames stop happening within a major version, and a field
that becomes irrelevant is left in place rather than deleted.
Schema version 9 was additive only: ingested_by joined Label, so every
existing field kept its shape and location. Schema version 10 is not: it
adds Decision.run_ingress, and the field is required, so a version-9
record fails validation rather than defaulting. interbolt inspect skips
such a line with a warning and reads on. Migrate an existing log by
inserting an empty list, which reads as "this run's ingress was never
recorded":
jq -c 'if .record_type == "event" then .decision.run_ingress = [] else . end' old.jsonl > new.jsonlSchema version 11 is the same shape of break: it adds Decision.run_trifecta,
also required. Migrate a version-10 log the same way, inserting an empty
list, which reads as "this run's trifecta was never computed":
jq -c 'if .record_type == "event" then .decision.run_trifecta = [] else . end' old.jsonl > new.jsonlEVENT_SCHEMA_VERSION history
| Version | What changed |
|---|---|
| 11 | Added Decision.run_trifecta, required: every lethal-trifecta leg satisfied at run scope. Decision.trifecta is unchanged in shape but can now carry reads_private and reaches_external alongside from_untrusted, since both became computable from the capabilities: key on a sink entry. |
| 10 | Added Decision.run_ingress, required. Breaks stored logs: a record written under version 9 does not validate. |
| 9 | Added Label.ingested_by, the de-duplicated set of agent ids that ingested or derived the value, reached through decision.contributing_labels. |
| 8 | Added policy_fingerprint to RecordBase, so every record can be joined against the policy that produced it, even after that policy has since changed. |
| 7 | Normalized Event: removed nine fields duplicated from its embedded Decision (read them via event.decision instead) and the redundant lineage field; outcome became the Outcome enum. |
| 6 | Added nullable trace_id/span_id to Event/Finding/Endorsement, captured from the active OpenTelemetry span context at construction. |
| 5 | Added the Endorsement record type and Label.endorsements, backing endorse(). |
| 4 | Added Decision.matched_condition, the matched rule's original CEL when: text. |
| 3 | Added Decision.untrusted_sources, the subset of contributing labels' lineage names that resolved untrusted. |
| 2 | Added Decision.run_tainted, the run-level gating signal. |
| 1 | Initial schema. |
Event (as JsonlReporter serializes it)
{
"record_type": "event",
"schema_version": 11,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"policy_fingerprint": "sha256:9f2c...",
"timestamp": "2026-01-01T12:00:00Z",
"decision": {
"action": "block",
"matched_rule": "block_untrusted_exfil",
"matched_condition": "taint.exists(t, t.trust == \"untrusted\") && args.to.endsWith(\"@external.com\")",
"tool": "default.send_email",
"contributing_labels": [
{
"source": "web_search",
"value_id": "b2c1...",
"lineage": ["web_search"],
"ingested_by": ["research-agent"],
"endorsements": []
}
],
"trifecta": ["from_untrusted", "reaches_external"],
"untrusted_sources": ["web_search"],
"run_tainted": true,
"run_ingress": [
{"source": "web_search", "trust": "untrusted", "ingested_by": ["research-agent"]}
],
"run_trifecta": ["from_untrusted", "reaches_external"],
"mode": "enforce",
"decision_id": "b7e0...",
"agent_id": "support-agent",
"run_id": "8f3a...",
"session_id": null
},
"sources": ["web_search"],
"outcome": "block"
}decision is the full, nested Decision (see
the API reference) and the single source of truth for what
was decided. Everything a consumer filters or aggregates on
(matched_rule, trifecta, untrusted_sources, run_tainted,
run_trifecta, mode, and the identity triple) is read from there. Note
that the label's ingested_by ("research-agent") and the decision's
agent_id ("support-agent") differ in this example: the label records who
brought the data in, and the decision records who made this call.
run_ingress names the sources that entered this run, one entry per source
name. Each entry carries source, the trust it resolved to at decision
time, and ingested_by, the agent ids that called taint() with it.
Ordered by first ingestion, and empty for a call made outside any
agent_context, where there is no run to attribute ingress to.
run_tainted is true exactly when one of these entries resolved untrusted.
This field is what explains a run-level decision. When the model authors
the arguments to a tool call, those arguments carry no labels, so
contributing_labels, sources, and untrusted_sources are all empty
while run_tainted is true. run_ingress names the source that entered
the run and the agent that brought it in, which is the only place that fact
survives the handoff.
Label.ingested_by and run_ingress[].ingested_by answer different
questions. The first names the agents that ingested or derived one specific
value, and it is empty whenever the call's arguments carry no labels. The
second names the agents that ingested anything into the run, and it is
populated whether or not the value itself survived to the sink.
run_trifecta is every lethal-trifecta leg satisfied anywhere in the run:
from_untrusted when run_tainted is true, plus every capability declared
for any tool guarded during the run, including this call. Like run_ingress,
it is empty for a call made outside any agent_context. It is the field that
explains a size(run.trifecta) >= 3 decision when trifecta alone is
smaller, the same way run_ingress explains a run_tainted-driven one.
Only two fields sit outside it. sources is every source name contributing
to the call, trusted or not, which is a property of the call rather than of
the decision. outcome is what check() actually computed before any
mode-based downgrade, so under dry_run it can read "block" while
decision.action reads "allow". It takes one of four values: allow,
block, require_approval, or evaluation_error, the last having no
Action counterpart.
Finding
{
"record_type": "finding",
"schema_version": 11,
"trace_id": null,
"span_id": null,
"policy_fingerprint": "sha256:9f2c...",
"timestamp": "2026-01-01T12:00:00Z",
"agent_id": "support-agent",
"run_id": "8f3a...",
"session_id": null,
"source": "web_search",
"tool": "default.send_email",
"argument": "body"
}A laundering-audit hit: content from source reached argument at tool
with no label. See Auditing.
Endorsement
{
"record_type": "endorsement",
"schema_version": 11,
"trace_id": null,
"span_id": null,
"policy_fingerprint": "sha256:9f2c...",
"timestamp": "2026-01-01T12:00:00Z",
"agent_id": "support-agent",
"run_id": "8f3a...",
"session_id": null,
"kind": "recipient_allowlisted",
"note": "checked against the outbound allowlist",
"lineage": ["web_search"],
"value_id": "b2c1..."
}policy_fingerprint is the one case where this field is legitimately
null: endorse() has no runtime dependency and can run before
configure(), and taint/ (where endorse() lives) never imports
policy/. When a runtime is configured, the fingerprint is attached at
export time from that runtime's live policy; with none configured, the
endorsement is only logged, and if it were emitted it would carry null.
Emitted on every endorse() call. lineage is the endorsed label's
lineage and value_id the fresh label id minted for the endorsement hop.
See Auditing: endorsement.
OpenTelemetry attribute mapping
OTelReporter maps these records onto span
events, or onto a fallback span (see
the OTel guide), as a mapping applied at export
time rather than treating OpenTelemetry as the native format. Attributes are
flattened, None-valued fields are omitted, and two fields are never
serialized:
| Interbolt field | Never mapped because |
|---|---|
decision.contributing_labels | Unbounded: a merge or a large split can carry many labels; the full record is what the native reporters are for. |
decision.matched_condition | The matched rule's CEL when: text may embed a literal (a domain, a path) the policy author did not intend to ship into a third-party trace backend. |
| Interbolt field | OTel attribute | On |
|---|---|---|
schema_version | interbolt.schema_version | all |
policy_fingerprint (if not None) | interbolt.policy_fingerprint | all |
decision.tool / tool | gen_ai.tool.name | event, finding |
outcome | interbolt.outcome | event |
decision.action | interbolt.decision.action | event |
decision.decision_id | interbolt.decision.id | event |
decision.matched_rule (if not None) | interbolt.matched_rule | event |
decision.mode | interbolt.mode | event |
decision.agent_id / agent_id | interbolt.agent_id | all |
decision.run_id / run_id | interbolt.run_id | all |
decision.session_id / session_id (if not None) | interbolt.session_id | all |
decision.run_tainted | interbolt.run_tainted | event |
sources | interbolt.sources (string sequence) | event |
decision.untrusted_sources | interbolt.untrusted_sources (string sequence) | event |
decision.trifecta | interbolt.trifecta (string sequence) | event |
decision.run_trifecta | interbolt.run_trifecta (string sequence) | event |
decision.run_ingress[].source | interbolt.run_ingested_sources (string sequence) | event |
decision.run_ingress[].source (untrusted only) | interbolt.run_untrusted_sources (string sequence) | event |
decision.run_ingress[].ingested_by | interbolt.run_ingested_by (string sequence) | event |
source | interbolt.finding.source | finding |
argument | interbolt.finding.argument | finding |
kind | interbolt.endorsement.kind | endorsement |
note (if not None) | interbolt.endorsement.note | endorsement |
trace_id/span_id are not mapped as attributes, since the span the record
is attached to already carries that context.
The set-valued attributes (sources, untrusted_sources, trifecta,
run_ingested_sources, run_untrusted_sources, run_ingested_by) are
sorted before export, so the attribute value is stable across runs rather
than following frozenset iteration order. The pairing between a source and
the agents that ingested it, decision.run_ingress[].ingested_by keyed by
source, is not exported: span attributes are flat and the three sorted
lists above are the derived summary; the native records keep the full
shape.
gen_ai.tool.name is the one attribute mapped outside the interbolt.
namespace: it matches OpenTelemetry's standard GenAI attribute for "name of
the tool utilized," which lines up exactly with Interbolt's qualified tool
name. No other Interbolt field has an exact match among OpenTelemetry's
standard GenAI attributes, so the rest stay under interbolt.*.