Decisions & verdicts
Every AI request that flows through a kilasec collector produces exactly one decision. A decision is an immutable audit record consisting of: who made the request, what they tried to do, where to, which policy rule matched, and what verdict was applied.
Anatomy of a decision
The wire format that the collector ships to the cloud:
{
"id": "ev_W9zQoQ3bk",
"ts": 1715520000.12, // unix seconds, float
"status": "redact", // verdict, lowercased
"request": {
"id": "req_k753ucn5",
"agent": "support-bot-v2",
"action": "http_request", // tool_call | http_request | llm_prompt
"destination": {
"host": "api.anthropic.com",
"ip": "160.79.104.10",
"port": 443
},
"args": {
"method": "POST",
"path": "/v1/messages"
},
"metadata": {
"url": "https://api.anthropic.com/v1/messages",
"method": "POST",
"source_ip": "10.0.3.42",
"source_port": 51234,
"user_agent": "anthropic-sdk-python/0.30",
"identity": {
"name": "support-bot-v2",
"hostname": "ops-mbp",
"mac": "3c:22:fb:a1:b2:c3",
"principal_type": "agent",
"display_name": "support-bot-v2",
"labels": { "hostname_source": "reverse_dns" }
}
}
},
"decision": {
"action": "redact",
"matched_rule": "redact_pii_on_egress",
"reason": "PII detected in egress payload",
"redacted_fields": ["body.card_number"],
"duration_ms": 1.83
}
}Three things to notice:
request.metadata.identityis rich. Source IP, MAC, hostname, principal_type, plus provenance for where the hostname came from. This is what the Agents page renders.decision.redacted_fieldslists field names, not values. The values are scrubbed at the collector — cloud only knows that the field was redacted.- No
Authorizationheader. Scrubbed in_classify_requestbefore the event ever ships. The collector keeps the original; the cloud doesn't see it.
The five verdicts
| Verdict | Wire | What it means | Where you see it |
|---|---|---|---|
allow | allow | Request passes through unchanged | most rows on Live Traffic |
log | log | Passes through, but a row is written for audit | low-noise observability rules |
redact | redact | Request body gets rewritten with masks before egress | Live Traffic with amber pill |
require_approval | require_approval | Request hangs until a human resolves on the Approvals page | Approvals page |
deny | deny | Request is blocked; agent gets the reason string | Incidents page (red) |
Verdicts are exclusive — exactly one applies per decision. They're decided by the first matching rule in policies.yaml, or by the policy's default_action if nothing matches.
What "redact" actually does
When a rule with action: redact matches, the collector:
- Walks the request body.
- For each
redact_fieldspath, replaces the value with a[REDACTED:<type>]token. Example:body.card_number: "4242424242424242"becomesbody.card_number: "[REDACTED:credit_card]". - Forwards the rewritten body to the destination.
- Records the original field path (not the value) in
decision.redacted_fieldsand ships the decision to the cloud.
This is the only verdict that mutates the request. Allow / log / deny / require_approval all leave the body alone.
What "require_approval" actually does
The collector parks the request and waits, with a configurable timeout (default 60s). During the wait:
- The agent's HTTP call is literally hanging — the connection is held open.
- The decision shows up on the Approvals page.
- A human clicks Allow → the verdict flips to
allowand the collector finishes forwarding the request. - A human clicks Deny, or the timeout expires → the verdict flips to
denyand the collector returns the failure to the agent.
This is the only verdict that blocks the agent until a human acts. Use sparingly — a backlog on the Approvals page means agents are sitting idle.
How fast a decision is
The decision.duration_ms field is the wall-clock time from "request entered the engine" to "verdict ready" — typically single-digit milliseconds because the policy is just a list of compiled predicates evaluated in order. Network round-trip to the destination is not included (the verdict happens before the request leaves the collector).
For require_approval, duration_ms includes the human-wait time. So a 47-second approval shows duration_ms: 47000. The Live Traffic page also exposes this as resolution_latency_ms in the right drawer.
What rule matched
Every audit row carries decision.matched_rule. Two things to know:
- The rule name is durable. Even if you rename a rule on the editor, old audit rows keep the original name — you can find them by grepping the rule history.
<default>means the policy'sdefault_action. If no listed rule matched, the engine falls back to the policy default. Most policies set this todeny(default-deny is the security posture); some test policies set it toallow.
Decisions vs. events vs. requests
Three terms that get used interchangeably in the codebase:
- Request — what the agent tried to do (the input to
/evaluate). - Decision — the verdict for that request, including the matched rule.
- Event — the cloud-side audit row that combines request + decision into one JSON blob. Plural shows up at
/v1/ingest/events.
When you read the code: "event" is the wire format, "decision" is the in-memory record on the collector, "request" is what the agent sent.
→ Up next: Policy semantics covers exactly how the engine picks a rule.