Kyvern — Architecture¶
Last updated: 2026-10-08 · Status: pre-1.0
1. Overview¶
Kyvern is a decision-provenance layer that sits between autonomous/AI systems (sensors, perception, planners) and downstream actuators (robots, vehicles, effectors). It does not replace the autonomy stack — it wraps the decision boundary so that every consequential action is traceable to a human-authored policy rule, every guardrail evaluation is recorded, and the full chain is cryptographically replayable. The core guarantee: an external auditor can take any historical decision, feed in the same inputs, and reproduce the exact same output — including which rules fired, which LLM advice was considered and overridden, and which guardrails downgraded the action.
2. Core Components¶
2.1 services/decision/ — Rule Engine + LLM Advisor + Guardrails¶
The decision layer is the heart of the system. It has three sub-layers that execute in a fixed order:
| Sub-layer | Key files | Role |
|---|---|---|
| Rule engine | rules.py (assess_threat), roe.py (evaluate_roe) |
Deterministic, weighted-score assessment. Factors: zone proximity, transponder presence, speed, heading, confidence. Thresholds map to ThreatLevel enum (LOW/MEDIUM/HIGH/CRITICAL). Policy rules are loaded from YAML (config/policies/default.yaml) via the ROERule Pydantic model. First matching enabled rule wins. |
| LLM advisor | llm_client.py (query_llm), llm_graph.py (_reconcile_action) |
Optional. Queries an LLM for an independent assessment. Provider fallback chain: Anthropic Claude → Ollama (local) → None. The advisor cannot recommend ENGAGE — only LOG, ALERT, or HANDOFF. Prompt injection defense is handled by sanitize.py (sanitize_track_for_llm), which applies allowlist filtering, control-char stripping, and injection-pattern detection before any track data reaches the LLM prompt. |
| Guardrails | guardrails.py (apply_guardrails) |
Post-decision safety filters. Three implemented guardrails: input_track_guardrail (rejects low-confidence or single-tick tracks), friendly_zone_guardrail (blocks action inside protected areas), civilian_pattern_guardrail (detects civil transponder codes and airliner flight profiles). Guardrails can only downgrade — see §6. |
The full pipeline is orchestrated by a 5-node state machine in
llm_graph.py (run_graph): classify → retrieve_roe → reason →
guardrail → finalize. retrieve_roe is a hook for a policy-retrieval
(RAG) module that this repository does not ship; without one it passes
the state through. When LangGraph is installed, it runs as a
StateGraph; otherwise, it falls back to plain sequential await calls.
Both paths produce the same Decision output.
Entry points:
- threat_graph.decide() — sync, rule-only fast path (no LLM).
- threat_graph.decide_full() — sync wrapper over run_graph (full pipeline).
- llm_graph.run_graph() — async, production entry point.
2.2 What is not in this repository¶
The original deployment also had sensor adapters (camera, RF/OpenDroneID,
Wi-Fi), multi-sensor tracking (Kalman/IMM fusion over NATS) and counter-UAS
autonomy (intercept planning, MAVSDK). That code now lives in a separate
private repository. Kyvern does not need it: it records decisions from
whatever perception and planning stack produces them — its own example
engine (§2.1) or yours, through RuntimeEvent (§8).
2.3 shared/¶
| Module | Purpose |
|---|---|
paths.py |
Per-user locations: ~/.kyvern, the default chain path (KYVERN_CHAIN_PATH), the pre-rename ~/.kernel guard. |
schemas.py |
RuntimeEvent, the record type for upstream evidence (§8). |
3. Data Flow¶
The system processes data through a linear pipeline:
-
Input — A track or situation report from your perception stack: a dict with an identifier, position, velocity, confidence and source information. Kyvern does not do perception or tracking itself.
-
Decision engine — Each track is evaluated by the rule engine (
assess_threat→evaluate_roe). If the LLM advisor is enabled (KYVERN_DECISION_LLM_ENABLED=true), the track is independently assessed by the LLM viaquery_llm. The rule engine and LLM outputs are reconciled: the LLM can escalate (LOG → ALERT → HANDOFF) but never to ENGAGE, and it cannot downgrade. -
Guardrails —
apply_guardrailsruns all registered guardrail functions against the pre-decision. Any triggered guardrail can only downgrade the action severity (see §6). Guardrail IDs and reasoning are appended to theDecisionobject without truncation. -
Audit chain — The finalized
Decision(including raw LLM response, guardrail trace, rule reference, and full reasoning) is signed and appended to the JSONL audit chain byChainWriter(services/decision/chain_writer.py): under an inter-process file lock it reads the last entry, links and signs the new one, writes it and fsyncs. The chain file isrun_graph(chain_path=...), else$KYVERN_CHAIN_PATH, else~/.kyvern/chain.jsonl— the same filekyvern-verify,kyvern-reportandkyvern-mcpread. If the decision cannot be recorded,run_graph()raisesAuditWriteErrorand returns nothing. -
Action — The
Decisionis published for downstream consumption. For ENGAGE actions,requires_operator_approvalis hardcoded totrueregardless of policy configuration. See §7 for action sinks.
4. Audit Chain Design¶
Every Decision object carries full provenance: the originating track
state, the rule that fired (roe_reference), the raw LLM response
(unsanitized, stored in llm_raw_response), the provider and model used,
which guardrails triggered (guardrails_triggered list), and the
guardrail reasoning (untruncated in guardrail_reasoning, separate from
the main reasoning field which is capped at 500 chars).
The planned cryptographic signing pattern works as follows: each
Decision is serialized to a canonical JSON form, hashed (SHA-256), and
the hash is signed with a deployment-specific Ed25519 key. The previous
decision's hash is included in the current record, forming a hash chain.
This means any tampering with a historical record breaks the chain from
that point forward. A verifier can replay the entire decision sequence:
feed the same track inputs through the same rule set and guardrails, and
confirm that the outputs match the signed records.
Current status: Every entry carries signature, prev_hash,
payload_hash, chain_index and key_id (the first 16 hex chars of
SHA-256 over the raw public key, covered by the signature). Decisions
from run_graph() and RuntimeEvents from append_runtime_event() are
both appended through ChainWriter, so they share one chain and one
chain_index sequence. Several processes on one host can append
safely; several hosts writing one chain is not supported.
Code example:
from services.decision.audit_chain import Keyring, describe_chain_failure, verify_chain
keys = Keyring.from_pem_files(["signing.pub", "signing-<old key id>.pub"])
is_valid, broken_idx = verify_chain(decisions, keys)
if not is_valid:
print(f"Chain broken at index {broken_idx}: "
f"{describe_chain_failure(decisions, broken_idx, keys)}")
Anchoring. kyvern-anchor (cli/kyvern_anchor.py) timestamps the chain
head with an external authority. The statement it anchors is the canonical
JSON {"chain_index": n, "kyvern_anchor": 1, "payload_hash": h}; the receipt
goes to <chain stem>.anchors.jsonl. services/decision/anchors.py defines
the Anchor protocol (name, request(statement), verify(statement,
receipt)); services/decision/rfc3161_anchor.py is the RFC 3161
implementation. Another kind of anchor implements the same two methods under
a new name and is added to the mapping kyvern-verify passes to
check_anchors().
The same chain also carries signed evidence events from external
systems (RuntimeEvent) — see §8.
5. Policy Versioning¶
Every Decision is cryptographically tied to a specific policy version.
When the rules are evaluated, a canonical, deterministic SHA-256 hash
(version_id) of the default.yaml policy is computed. This hash
is attached to the Decision prior to signing.
If someone edits the YAML between two decisions, the hash changes,
creating an immutable record of the divergence. An external auditor
can verify a historical decision against the policy file claimed by
its policy_version_id:
from services.decision.audit_chain import verify_decision_against_policy
is_valid, reason = verify_decision_against_policy(decision, "config/policies/default.yaml", public_key)
if not is_valid:
print(f"Verification failed: {reason}")
6. Guardrail Downgrade-Only Invariant¶
Guardrails enforce a one-way safety property: they can only reduce the severity of a decision, never increase it. This is the downgrade-only invariant.
The Action enum has a strict severity ordering maintained in
guardrails.py:
LOG (0) < ALERT (1) < HANDOFF (2) < ENGAGE (3)
When a guardrail triggers, it proposes a downgrade_to action. The
orchestrator (apply_guardrails) only applies the downgrade if
_SEVERITY[proposed] < _SEVERITY[current_action]. A guardrail that
returns downgrade_to=ENGAGE when the current action is ALERT is
silently ignored — the comparison fails and the action stays at ALERT.
This means a false-positive guardrail trigger produces a safer (more conservative) outcome, never a dangerous one. The worst case of a guardrail bug is an unnecessary downgrade to LOG, which results in logging-only — the safest possible state. The system cannot be tricked into escalation through guardrail manipulation.
The same principle applies to the LLM advisor reconciliation (in
llm_graph.py, _reconcile_action): the LLM can escalate (propose
a higher severity than the rule engine), but guardrails run after
reconciliation and can only bring it back down.
7. Integration Points¶
LLM Backends (implemented)¶
- Ollama — Default for air-gapped deployments. Connects to
localhost:11434, model configurable viaOLLAMA_MODELenv (default:llama3.1:8b). Structured output viaformat=json. - Anthropic Claude — Used when
ANTHROPIC_API_KEYis set; model fromKYVERN_LLM_MODEL(defaultclaude-sonnet-4-6). Structured output via tool-use (submit_assessment). Provider chain: Anthropic → Ollama → None (graceful degradation). - OpenAI — Not yet implemented.
llm_client.pyis structured for adding a_try_openaistep in the provider chain.
Action Sinks¶
- ROS2 —
services/integrations/ros2_bridge.pypublishes signed Decisions as JSON on a ROS2 topic (std_msgs/String);ros2_subscriber_example.pyverifies them on the receiving side. - Custom — The
DecisionPydantic model serializes to JSON. Any system that can consume JSON over NATS, HTTP, or direct import can act as a sink today.
Audit Query Interface (implemented)¶
- MCP (Model Context Protocol) —
kyvern-mcp(kyvern/mcp/) is a read-only stdio MCP server that lets MCP-compatible AI agents query the JSONL audit chain: 5 tools (query_events,get_event,get_stats,verify_chain,search_events) and 4kyvern://resources. It covers both Decisions and upstream RuntimeEvents — see §8.
8. Upstream Evidence Events¶
RuntimeEvent is a typed record for signing evidence events from
external systems (sensor monitors, guard middleware, external policy
adapters) into the audit chain. How it differs from a Decision:
- A Decision comes out of Kyvern's own decision graph ("I did this") — controlled.
- A RuntimeEvent comes from an external source ("I observed this") — uncontrolled.
Both are written to the same JSONL audit chain file with the same
Ed25519 signature scheme, the same SHA-256 hash link, and the same
chain_index counter. RuntimeEvent records carry a
record_type: "runtime_event" discriminator field; Decisions do not
have this field (backward compatibility — a record without
record_type is read as a Decision).
Your own decisions. A third record type, RecordedDecision
(record_type: "decision", in shared/schemas.py), holds a decision made
by the user's own system — a robot's safety controller, a planner, an
operator — appended with kyvern.record_decision(). The auditor tools
treat it as a decision: it is counted with decisions and its
policy_version_id is checked against --policy. Its fields are
domain-neutral: action, source (who decided; "operator" for a
person), reasoning, inputs (what the decision was based on, at most
64 KB), rule_id, subject_id, requires_operator_approval,
timestamp_iso (UTC), and policy_version_id / policy_path when
policy_path= is given. A policy is any YAML file with a rules: list;
its version id is the SHA-256 of its canonical content.
Use cases:
- Sensor anomaly reports (e.g. event_type="sensor_anomaly",
source="lidar_monitor")
- Downgrades by external guard middleware
(event_type="guardrail_downgrade", source="kinematic_guard")
- Violation reports from policy adapters
(event_type="policy_violation")
Mixed-chain verification: verify_chain() is type-agnostic; it
verifies a chain of interleaved RuntimeEvents and Decisions in a single
linear scan. chain_index forms one monotonic sequence across both
record types — there are no separate counters.
Asymmetric protection: Because a RuntimeEvent comes from an external
system, its payload is capped at 64 KB (PayloadTooLargeError).
Decisions have no such limit, since they are produced by Kyvern's own
controlled policy engine.
API:
from shared.schemas import RuntimeEvent
from services.decision.audit_chain import append_runtime_event
event = RuntimeEvent(
event_type="sensor_anomaly",
source="lidar_monitor",
source_id="lidar-front-01",
timestamp_iso="2026-05-20T12:00:00+00:00",
payload={"distance_m": 4.2, "object_class": "vehicle"},
)
signed = append_runtime_event(event, chain_path, signing_key) # policy_version_id= is optional
The MCP query_events tool filters RuntimeEvents via its event_type
and source parameters; its action and threat_level parameters
filter Decisions.
Appendix: Directory Map¶
kyvern/
├── cli/
│ ├── kyvern_anchor.py # kyvern-anchor: RFC 3161 receipts for the chain head
│ ├── kyvern_report.py # kyvern-report: EU AI Act evidence PDF
│ └── kyvern_verify.py # kyvern-verify: offline chain, policy and anchor check
├── config/
│ └── policies/
│ └── default.yaml # Decision policy rules (YAML)
├── kyvern/
│ ├── audit/store.py # AuditChainStore: read, filter and verify the JSONL chain
│ ├── mcp/ # kyvern-mcp: read-only MCP server (tools, resources)
│ └── sandwich/ # Dual-LLM (privileged/quarantined) isolation
├── services/
│ ├── decision/
│ │ ├── anchors.py # Anchor protocol, receipts, check_anchors
│ │ ├── audit_chain.py # Sign/verify entries, keyring, policy binding, RuntimeEvent append
│ │ ├── chain_writer.py # ChainWriter: locked, fsynced appends
│ │ ├── guardrails.py # apply_guardrails, GuardrailResult, haversine_m
│ │ ├── llm_client.py # LLMResponse, query_llm, provider chain
│ │ ├── llm_graph.py # 5-node GraphState pipeline, run_graph
│ │ ├── policy_loader.py # load_policy, SHA-256 policy version id
│ │ ├── rfc3161_anchor.py # RFC 3161 timestamp authority client
│ │ ├── roe.py # load_roe, evaluate_roe
│ │ ├── rules.py # assess_threat, ThreatAssessment
│ │ ├── sanitize.py # sanitize_track_for_llm, UnsafeContent
│ │ ├── schemas.py # Decision, Action, ThreatLevel, ROERule
│ │ └── threat_graph.py # decide (sync), decide_full (sync wrapper)
│ └── integrations/
│ ├── ros2_bridge.py # Publish signed Decisions on a ROS2 topic
│ └── ros2_subscriber_example.py # Verify them on the receiving side
├── shared/
│ ├── paths.py # ~/.kyvern, default chain path, ~/.kernel guard
│ └── schemas.py # RuntimeEvent
├── scripts/ # Demo chain generator, core-install smoke test
└── tests/ # audit, cli, decision, integrations, mcp, sandwich