Governance events
Every action the agent attempts is converted into a GovernanceEvent, evaluated by the policy gate, and either allowed, denied, escalated, or budget-blocked. There are five event types, distinguished by the event_type field on GovernanceEvent:
| Event type | Fired when… | Common policy questions |
|---|---|---|
tool_call | The agent invokes a tool | "Can this agent call this tool with these args?" |
agent.spawn | The agent creates a sub-agent | "How deep can the delegation tree go?" |
agent.delegate | The agent hands a task to another agent | "Are the child's scopes a subset of the parent's?" |
agent.plan | The agent emits a multi-step plan before executing | "Does this plan exceed the step limit or contain blocked tools?" |
agent.budget | The agent is about to exceed a configured budget | "Has this session spent its token / API-call / cost ceiling?" |
A sixth concern — data classification flow — is not a separate event type. It rides on every event as the data_classification field (e.g. "PII", "CONFIDENTIAL") so the same policy can gate "tool X with PII data" rather than "any tool that touches PII".
The shared event shape
Every event carries the same envelope, defined in kitelogik.tether.models:
class GovernanceEvent(BaseModel):
event_type: Literal["tool_call", "agent.spawn", "agent.delegate",
"agent.plan", "agent.budget"]
session_id: str
action: str # The policy-layer name
tool_name: str | None = None # tool_call only
args: dict = {} # tool_call only
resource_path: str | None = None # tool_call only (file/URL paths)
context: SessionContext # role, scopes, delegation depth, budgets
requested_capabilities: list[str] = [] # spawn / delegate only
delegation_target: str | None = None # delegate only
steps: list[dict] = [] # agent.plan only
data_classification: str | None = None # cross-cuttingThe policy receives this payload as the input document. input.context carries the per-session identity and budget envelope — see the SessionContext reference below.
Per-event policy patterns
tool_call
The most common event. Every tool invocation goes through it.
package kitelogik.financial
import future.keywords.if
import future.keywords.in
default allow := false
allow if {
input.action == "approve_refund"
input.context.user_role in {"support_agent", "manager"}
"approve_refund_under_100" in input.context.session_scopes
input.args.amount <= 100
}agent.spawn
Fires when the agent tries to create a sub-agent. The policy enforces spawn caps and capability bounds.
package kitelogik.agent_lifecycle
import future.keywords.if
import future.keywords.in
import future.keywords.every
default allow := false
default deny := false
allow if {
input.event_type == "agent.spawn"
input.context.delegation_depth <= 2
every cap in input.requested_capabilities {
cap in input.context.session_scopes
}
}
deny if {
input.event_type == "agent.spawn"
input.context.delegation_depth > 2
}agent.delegate
Hand-off between agents. Policy enforces scope-narrowing — a child can inherit at most what the parent had.
agent.plan
Emitted when an agent produces an explicit plan and asks permission to execute. input.steps is the proposed sequence; the policy can reject the whole plan rather than catching it mid-execution.
agent.budget
Fires when a budget threshold is about to be crossed. Read the budget state from input.context.budget_used_* and input.context.budget_total_*.
SessionContext
Attached to every event as input.context. The same shape regardless of event type, defined in kitelogik.tether.models:
class SessionContext(BaseModel):
session_id: str
user_role: str
session_scopes: list[str]
sandbox_verified: bool = False
token_id: str = ""
delegation_depth: int = 0
parent_token_id: str = ""
parent_session_id: str = ""
tenant_id: str | None = None
# Budget envelope — None means "no budget tracking"
budget_total_tokens: int | None = None
budget_used_tokens: int | None = None
budget_total_api_calls: int | None = None
budget_used_api_calls: int | None = None
budget_total_cost_cents: int | None = None
budget_used_cost_cents: int | None = NoneTreat SessionContext as immutable for the duration of a session. Use model_copy(update=...) if you need a derived context (e.g. when a delegated token is attached).
How a decision comes back
Every evaluation returns a PolicyDecision:
class PolicyDecision(BaseModel):
allow: bool
deny: bool
risk_tier: RiskTier
requires_hitl: bool
reason: str
rule_matched: str | None = None
resolution_trace: list[ResolutionStep] = []Four meaningful flag combinations:
allow | deny | requires_hitl | Outcome |
|---|---|---|---|
True | False | — | Proceed |
False | True | — | Hard block (model cannot override) |
False | False | True | Pause for HITL — see risk tiers & HITL |
False | False | False | Soft deny |
rule_matched is the canonical anchor for audit logs and traces.