Risk tiers & HITL
Every policy decision carries a risk_tier label. The tier doesn't drive enforcement directly — allow / deny / requires_hitl do that — but it is the canonical signal for audit logs, dashboards, and reviewer routing.
The six tiers
Defined as RiskTier in kitelogik.tether.models:
| Tier | Typical use | Default convention |
|---|---|---|
INFORMATIONAL | Read-only lookups, memory queries | Auto-allow |
OPERATIONAL | Write/update operations within scope | Allow if scope check passes |
TRANSACTIONAL_LOW | Low-value financial / state changes | Allow if scope check passes |
TRANSACTIONAL_HIGH | High-value financial operations | Often requires_hitl := true |
DESTRUCTIVE | Delete operations, bulk modifications | Policy-defined — allow with caution or escalate |
SECURITY_CRITICAL | Shell access, credential ops, path traversal | Hard block by default |
The README's marketing-tier table compresses these to five — the code ships six (the scaffolded policies/policy.yaml from kitelogik init uses TRANSACTIONAL_LOW for the small-refund rule). Treat the code as authoritative.
Assigning a tier
Risk tiers are assigned by main.rego, which inspects the action and its arguments and picks a tier deterministically. The shipped rules classify a security or delegation hard-deny as SECURITY_CRITICAL, approve_refund with amount > 100 as TRANSACTIONAL_HIGH, and so on, falling back to OPERATIONAL.
YAML risk_tier: is not yet honored
The YAML compiler accepts a risk_tier: field and emits it into the kitelogik.userpolicy package, but main.rego does not aggregate it, so it has no effect on the decision today. Aggregating user-authored tiers — with a defined precedence against the built-in rules — is a planned enhancement. To customise tiers now, edit the risk_tier rules in main.rego directly:
# kitelogik/policies/main.rego
risk_tier := "TRANSACTIONAL_HIGH" if {
not security.deny
not delegation.deny
input.action == "approve_refund"
is_number(input.args.amount)
input.args.amount > 1000
}How HITL escalation actually fires
HITL is only triggered when an OPA policy explicitly sets requires_hitl := true. There is no automatic escalation by tier — even SECURITY_CRITICAL decisions don't escalate unless a rule says so. Most governance decisions resolve instantly with no human delay.
default requires_hitl := false
requires_hitl := true if {
input.action == "approve_refund"
input.args.amount > 1000
}When the gate returns requires_hitl=True, the runtime:
- Builds a
PendingActionwith the tool name, args, andrisk_tier - Inserts it into
HITLQueue(SQLite-backed by default) - The agent's
await_decision()blocks on the queue's event for that action ID - A reviewer calls
queue.approve(action_id, decided_by="…")orqueue.deny(action_id, decided_by="…", denial_reason="…")— directly from your operator UI, Slack handler, or any other surface you wire to the queue API. - The agent receives the decision and resumes
- If no decision arrives within the configured timeout (default 300 seconds), the action transitions to
TIMED_OUTand the agent raisesGovernanceError
The four ActionStatus values
Defined in kitelogik.anchor.models:
class ActionStatus(StrEnum):
PENDING = "PENDING"
APPROVED = "APPROVED"
DENIED = "DENIED"
TIMED_OUT = "TIMED_OUT"Every status transition is recorded with decided_at and decided_by for the audit log.
Storage
HITLQueue uses SQLite by default — single-file persistence, fine for single-process and small teams. The queue API is a small surface (enqueue, approve, deny, wait_for_decision, get_pending), so swapping in a Postgres backend or a custom store for higher-throughput deployments is a subclass-and-register exercise.
Patterns
- HITL as the high-stakes default for
TRANSACTIONAL_HIGH— common for refunds above a threshold, wire transfers, large grants. - Hard deny for
SECURITY_CRITICAL— shell, credential extraction, arbitrary file read. There is no escalation path; a human cannot approve out of these. To run code, the session must be launched inside a verified sandbox (sandbox_verified=TrueonSessionContext). - Tier as a trace dimension — record
decision.risk_tieron every audit row so you can query "how often doesTRANSACTIONAL_HIGHescalate?" without joining tables.