Interbolt
Reference

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

Schema 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.jsonl

EVENT_SCHEMA_VERSION history

VersionWhat changed
11Added 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.
10Added Decision.run_ingress, required. Breaks stored logs: a record written under version 9 does not validate.
9Added Label.ingested_by, the de-duplicated set of agent ids that ingested or derived the value, reached through decision.contributing_labels.
8Added policy_fingerprint to RecordBase, so every record can be joined against the policy that produced it, even after that policy has since changed.
7Normalized 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.
6Added nullable trace_id/span_id to Event/Finding/Endorsement, captured from the active OpenTelemetry span context at construction.
5Added the Endorsement record type and Label.endorsements, backing endorse().
4Added Decision.matched_condition, the matched rule's original CEL when: text.
3Added Decision.untrusted_sources, the subset of contributing labels' lineage names that resolved untrusted.
2Added Decision.run_tainted, the run-level gating signal.
1Initial 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 fieldNever mapped because
decision.contributing_labelsUnbounded: a merge or a large split can carry many labels; the full record is what the native reporters are for.
decision.matched_conditionThe 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 fieldOTel attributeOn
schema_versioninterbolt.schema_versionall
policy_fingerprint (if not None)interbolt.policy_fingerprintall
decision.tool / toolgen_ai.tool.nameevent, finding
outcomeinterbolt.outcomeevent
decision.actioninterbolt.decision.actionevent
decision.decision_idinterbolt.decision.idevent
decision.matched_rule (if not None)interbolt.matched_ruleevent
decision.modeinterbolt.modeevent
decision.agent_id / agent_idinterbolt.agent_idall
decision.run_id / run_idinterbolt.run_idall
decision.session_id / session_id (if not None)interbolt.session_idall
decision.run_taintedinterbolt.run_taintedevent
sourcesinterbolt.sources (string sequence)event
decision.untrusted_sourcesinterbolt.untrusted_sources (string sequence)event
decision.trifectainterbolt.trifecta (string sequence)event
decision.run_trifectainterbolt.run_trifecta (string sequence)event
decision.run_ingress[].sourceinterbolt.run_ingested_sources (string sequence)event
decision.run_ingress[].source (untrusted only)interbolt.run_untrusted_sources (string sequence)event
decision.run_ingress[].ingested_byinterbolt.run_ingested_by (string sequence)event
sourceinterbolt.finding.sourcefinding
argumentinterbolt.finding.argumentfinding
kindinterbolt.endorsement.kindendorsement
note (if not None)interbolt.endorsement.noteendorsement

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

On this page