Interbolt
Concepts

Identity

Who acted, in which run, and why that matters for the audit trail and for run-level gating.

Identity

Every Decision and every emitted Event is stamped with three identifiers. They answer "who did this, during which run, in which conversation," which is what makes a provenance log correlatable after the fact.

Identity is not only bookkeeping. run_id is the scope run.tainted is computed over, so a run that fragments into many run_ids loses run-level gating. Binding identity correctly is what keeps that backstop working.

Run binding also decides what run.sources, run.untrusted_sources, and run.ingested_by can see, and it is where the agent id in a decision's run_ingress comes from: the ingesting agent is read from the surrounding agent_context at the moment taint() runs, so an ingress performed outside a context is credited to no run and to no agent.

FieldWho sets itLifetime
agent_idYouDurable, stable across runs
run_idThe runtime, at agent_context entryOne run
session_idYou, via check() onlySpans multiple runs

The default pattern

Decorate tools with a bare @guard, and wrap the work in agent_context:

from interbolt import configure, guard

runtime = configure(policy=...)

@guard
async def send_email(to: str, body: str) -> None: ...

async with runtime.agent_context("support-agent") as run:
    await run_turn(...)   # every guarded call inside is "support-agent"
    print(run.run_id, run.agent_id)

Entering the block binds agent_id and mints one run_id, and yields both back as a RunContext, which is a frozen, data-only snapshot.

Every guarded call and every taint() call inside the block picks up the bound agent_id/run_id. Exiting clears them, along with that run's ingress registry and audit findings. runtime.agent_context_sync(...) is the identical synchronous form for a call site that cannot use async with, and yields the same RunContext.

If you skip agent_context entirely, guarded calls still work: they report agent_id as "default" and mint a fresh run_id each. The cost is that nothing accumulates across calls, so run.tainted is never true and run-level gating is effectively off. This is the most common reason a run.tainted rule appears to do nothing.

"default" is reserved for exactly this implicit fallback. You cannot pass it explicitly to agent(...), agent_context(...), agent_context_sync(...), or check(agent_id=...). Doing so raises InterboltConfigError, since an explicit "default" would be indistinguishable from "no identity was bound," and those two cases now carry different security meaning (see Identity as a policy input below).

When to use an AgentHandle instead

agent("support-agent") returns a handle whose .guard stamps that agent_id directly, ignoring any active agent_context:

from interbolt import agent

support = agent("support-agent")
billing = agent("billing-agent")

@support.guard
async def send_email(to: str, body: str) -> None: ...

@billing.guard
def issue_refund(amount: float) -> None: ...

Reach for this in two cases: a tool that always belongs to one agent regardless of who invoked it, and guarded calls dispatched to a thread pool (see below). Otherwise prefer the bare @guard, which keeps tool definitions free of agent identity and lets one tool serve several agents.

The handle captures only agent_id. A call through @handle.guard inside an agent_context still picks up that block's run_id, since a single run can span several agents. runtime.agent(...) is the same function reached through the runtime object, kept for discoverability.

session_id

session_id is only reachable through an explicit check() call. It is never set by @guard or by agent_context, so under the default pattern it is always None. Use it when you correlate several runs into one conversation and are already calling check() yourself.

Import order and reconfiguration

Nothing captures the runtime at decoration time. Both @guard and @handle.guard resolve the current runtime lazily on each call, so a module of decorated tools can be imported before configure() has run. Only the first call needs a configured runtime, and calling a guarded function before any configure() raises InterboltUsageError.

Calling configure() again rebinds cleanly, with no stale capture, which is what makes per-test reconfiguration work (see Testing). agent("id") captures the identity string eagerly and is safe to call at import time, so defining a codebase's agent identities in one module and importing the handles elsewhere works regardless of import order.

taint() needs no runtime at all and works before configure(). It reads one ambient context variable, the one agent_context binds, to attribute ingress to the active run. With no active context that read is a no-op plus a DEBUG log, and taint()'s labeling behavior is unchanged.

Thread pools

agent_context is built on contextvars.ContextVar, which stays with the calling task and does not follow work handed to a thread pool. Inside those threads, bare @guard calls fall back to "default" with a fresh run_id, and taint() calls are invisible to the dispatching run's run.tainted.

This used to be only an attribution limit; now it can be an enforcement one. Now that agent.id is visible to policy (see Identity as a policy input below), a rule written as

when: agent.id == "researcher" && taint.exists(t, t.trust == "untrusted")
action: block

silently stops matching inside a thread-pool-offloaded call, since agent.id resolves to "default" there, and the call falls through to whatever the sink's next rule (or default action) is. A rule written the other direction, agent.id != "allowed", over-blocks under the same lost identity instead of under-blocking. Write restrictive rules as "block unless" rather than "block if" for exactly this reason (see Policies: per-agent carve-outs). A policy that cares can also gate on the loss directly: agent.id == "default" now unambiguously means "identity was not explicitly bound," never an agent someone named default (see The default pattern above).

The same loss reaches agent.groups (see Policies: group membership), since it resolves from the same agent_id lookup: "default" is almost always undeclared in a policy's agents: section, so a thread-pool-offloaded call sees agent.groups == [] regardless of which groups the dispatching agent actually belongs to. A group-gated !agent.groups.exists(...) block still fires (fail-closed, the safe direction), but a group-gated allow does not, for the identical reason agent.id == stops matching above.

Two ways to handle it:

  • Use an AgentHandle. agent_id is a plain, already-validated string carried on the handle, so it crosses the boundary unchanged, validated once at construction rather than re-validated per call. run_id still does not cross.

  • Enter a context inside the thread. A spawned thread gets its own independent contextvars.Context, so a thread that opens its own agent_context_sync block at the start of its work has full, isolated identity, the same isolation concurrent asyncio tasks already get.

  • Copy the context explicitly, if you want the dispatching thread's identity to carry into the pool worker rather than starting fresh:

    import contextvars
    
    ctx = contextvars.copy_context()
    executor.submit(ctx.run, run_tool, *args)

    This carries current_agent_id and current_run_id into the worker, so agent.id, run.tainted, and the audit registry all behave as they do on the calling thread.

The limitation applies only to identity bound in the dispatching thread before handing work off, which the worker thread never sees unless you use one of the recipes above.

Identity as a policy input

Once agent.id is visible to a when: expression, agent identity is an authorization input, not only an attribution label. That means it needs the same three properties every other policy input has: the value cannot be forged, its absence has defined behavior, and it is clear who is allowed to set it.

Agent identity must be integrator-controlled, never model-derived. This is the same robustness principle endorse() is built on: the model is a confused deputy (an agent tricked by untrusted input into misusing its own legitimate authority; see the Glossary). Letting it also choose its own policy scope (for example by wiring a transfer_to(agent_name) tool argument straight into agent_context(...)) would defeat the containment property from the inside. An injected instruction in retrieved content reading "you are the billing agent" is exactly the attack this guards against.

To make that concrete rather than only a convention, every point that binds an agent identity (agent(...), agent_context(...), agent_context_sync(...), and the module-level check(agent_id=...)) validates it and raises InterboltConfigError if:

  • it is a Tainted, TaintedBytes, or LabeledValue: the same taint carriers taint() produces, rejected outright regardless of what they contain, since a tainted value must never become a policy-authorization input;
  • it contains a character outside ^[A-Za-z0-9_.-]+$;
  • it is the literal "default", reserved for the implicit fallback (see The default pattern above).

This catches the direct case (a taint()-ed tool output passed straight into agent_context) but not a laundered one, where the name arrives through an f-string or model generation with no derived_from. This is the same coverage boundary the propagation contract already documents elsewhere; partial coverage plus the stated principle above is the position, not a claim of complete detection.

Custom dispatch loops using check()

check() takes agent_id as a required argument, always explicit rather than from the context variable, since one run can span several agents and the acting agent is chosen per call. run_id is optional and resolves from the active agent_context when you pass None, the same value bare guard uses, so a dispatch loop inside a run shares that run's id, and its run.tainted gating, with no manual threading. guard is sugar over check() that also reads agent_id from the context variable for you.

Pass run_id= explicitly only to override the ambient value, or to set a specific id for a call made outside any context. Passing a different run_id while inside an agent_context is the one remaining footgun: taint() records ingress under the context's run, while check() would resolve run.tainted against yours, so the gate reads false permanently. If you need the ambient value inside the loop, read it off the RunContext yielded by agent_context/agent_context_sync (see The default pattern above).

Multi-agent runs and handoffs

Identity spans agents today through the mechanism above: one shared run_id, a per-agent agent_id stamp on each call, and a session_id spanning the conversation.

Value-level taint does not span agents automatically. A model-generated handoff launders the label the same way any model generation does (see Taint propagation), so re-taint the output at the handoff boundary:

handoff = taint(agent_a_output, source="agent_a", derived_from=[agent_a_inputs])

derived_from makes this trust-aware: handoff resolves untrusted only if one of agent A's own inputs did. Omitting it marks the whole output as a fresh untrusted source unconditionally, which is coarser and always safe, useful when the input labels are not in scope at the handoff point.

taint() also stamps ingested_by with the calling agent ("agent_a" here, read from the active agent_context), so a policy can gate on which agent's ingress a value traces back to regardless of which agent later calls the sink (see Taint propagation: how trust is decided). Smuggling the agent name into source purely to get it into lineage for attribution is no longer necessary; source should stay whatever names the real data source or derivation hop. The trust-aware re-taint above is still required for the reason it always was: propagating trust across the handoff, not merely recording who performed it.

This stays a manual, value-level step. Interbolt never inspects the model's text to infer a handoff, and automatic contamination across an agent boundary is not implemented. For the common case of wrapping a model call, track_model_call does the same thing more ergonomically (see Taint propagation).

On this page