The engine contract¶
What the engine does, what it returns, and what you may rely on when you build on it. The public interface and its versioning are in section 8.
1. What the engine does¶
The engine governs an AI agent one task at a time. Before the agent acts, it
evaluates the intended behaviour against a policy set and the history of the
task so far, and returns a verdict: allow, warn or block. It records
what the agent then does, and sends those records to the sinks you configure.
Behaviours follow the AAG vocabulary of action kinds and verbs, so the same
policy reads a LangChain tool call, a Claude Code hook and a decorated Python
function alike. The engine runs in the agent's own process, or beside it as
kyvvu serve.
2. Concepts¶
- Agent: the program being governed, identified by
agent_id. - Task: one run of an agent, identified by
task_id, from itstask.startto itstask.endortask.error. - Behaviour (
Behavior): one event in a task. Itskindsays what it is (thetask.*kinds mark task boundaries, thestep.*kinds describe what the agent does), and astep.*carries averb, itsproperties, itsinputand, once it has completed, itsoutput. A step with nooutputis intended rather than done. - Evaluation context (
EvalContext): the agent identity a behaviour is evaluated for. - Policy: a rule with parameters and a severity. A policy set is loaded whole or not at all.
- Verdict: the action evaluation recommends, allow, warn or block, with the outcome of each policy behind it.
3. Running a governed agent¶
A governed agent meets the engine at five points: it registers, opens a task,
has each step evaluated before it acts and recorded after, and ends the task.
Each point is shown below in Python, with a KyvvuRunner in the agent's own
process, and then as the request a kyvvu serve sidecar takes for it (the
serve HTTP API). The Python is one program, which the
documentation check runs against a stand-in platform and then offline.
Register the agent¶
PlatformClient.register_agent() registers the agent with the platform and
returns the agent_id the platform assigned. A KyvvuRunner built from the
same settings fetches that agent's policies, and evaluate_registration()
evaluates its agent_registration policies, raising KyvvuBlockedError on a
block.
import os
import uuid
from kyvvu_engine import (
ActionKind,
AgentProfile,
Behavior,
EvalContext,
KyvvuBlockedError,
KyvvuRunner,
KyvvuSettings,
PlatformClient,
Verb,
)
TOOLS = ["read_faq", "run_script"]
settings = KyvvuSettings(api_url="https://platform.kyvvu.com", api_key=os.environ["KV_API_KEY"],
agent_key="support-agent")
registration = PlatformClient(settings).register_agent(AgentProfile(
name="Support agent", purpose="Answer customer questions about orders",
owner_id="ops@example.com", declared_tools=TOOLS),
risk_classification="minimal")
runner = KyvvuRunner(settings=settings)
context = EvalContext(agent_id=registration.agent_id, environment=settings.environment,
risk_classification="minimal", agent_allowed_tools=TOOLS)
runner.evaluate_registration(registration.evaluation_data, context) # a block raises
A kyvvu serve started against the platform learns the agent_id from the
platform's policy answer and prints it; send it as agent_id in every request.
/register_agent evaluates the registration policies:
POST http://127.0.0.1:8080/register_agent
Content-Type: application/json
{
"agent_data": {
"name": "Support agent",
"purpose": "Answer customer questions about orders",
"owner_id": "ops@example.com",
"declared_tools": ["read_faq", "run_script"],
"risk_classification": "minimal"
},
"context": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"risk_classification": "minimal",
"agent_allowed_tools": ["read_faq", "run_script"]
}
}
Open a task¶
A task is opened by recording its task.start; until then the engine refuses
every other write into it (section 6.2).
task_id = str(uuid.uuid4())
context = context.model_copy(update={"task_id": task_id})
runner.record(Behavior(agent_id=context.agent_id, task_id=task_id,
kind=ActionKind.task_start, step_name="task.start"))
POST http://127.0.0.1:8080/record
Content-Type: application/json
{
"step": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"kind": "task.start",
"step_name": "task.start"
}
}
Evaluate, act, record¶
For each step, evaluate the intended behaviour, act only if it was not
blocked, and record the completed step, with its output, under the
action_id the evaluation returned.
intended = Behavior(agent_id=context.agent_id, task_id=task_id, kind=ActionKind.step_resource,
verb=Verb.GET, step_name="read_faq", input={"query": "refund policy"})
result = runner.evaluate(intended, context) # allow or warn; a block raises
output = {"answer": "Refunds within 30 days."} # the agent acts
runner.record(intended.model_copy(update={"action_id": result.action_id, "output": output}))
Over HTTP, /evaluate answers with the verdict and the action_id, and
/record takes the completed step under that action_id:
POST http://127.0.0.1:8080/evaluate
Content-Type: application/json
{
"intended": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"action_id": "act-1",
"kind": "step.resource",
"verb": "GET",
"step_name": "read_faq",
"input": {"query": "refund policy"}
},
"context": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"risk_classification": "minimal",
"agent_allowed_tools": ["read_faq", "run_script"]
}
}
POST http://127.0.0.1:8080/record
Content-Type: application/json
{
"step": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"action_id": "act-1",
"kind": "step.resource",
"verb": "GET",
"step_name": "read_faq",
"input": {"query": "refund policy"},
"output": {"answer": "Refunds within 30 days."}
}
}
A blocked step¶
Without a preceding gate, the policies block the code execution. The engine records the blocked step itself (section 5), so the agent does not act and does not record it.
intended = Behavior(agent_id=context.agent_id, task_id=task_id, kind=ActionKind.step_exec,
step_name="run_script", input={"code": "cleanup()"})
try:
runner.evaluate(intended, context)
except KyvvuBlockedError as blocked:
# The engine has recorded the blocked step itself: do not record it.
verdict = blocked.result
# The answer has "blocked": true and the step's "recorded_seq": do not /record it.
POST http://127.0.0.1:8080/evaluate
Content-Type: application/json
{
"intended": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"action_id": "act-2",
"kind": "step.exec",
"step_name": "run_script",
"input": {"code": "cleanup()"}
},
"context": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"risk_classification": "minimal",
"agent_allowed_tools": ["read_faq", "run_script"]
}
}
End the task¶
Record the task.end, then end the task, which sends its records to the
configured sinks.
runner.record(Behavior(agent_id=context.agent_id, task_id=task_id,
kind=ActionKind.task_end, step_name="task.end"))
runner.end_task(task_id)
POST http://127.0.0.1:8080/record
Content-Type: application/json
{
"step": {
"agent_id": "agent_3f9c2a7b1d4e6f80",
"task_id": "task-0001",
"kind": "task.end",
"step_name": "task.end"
}
}
###
POST http://127.0.0.1:8080/end_task
Content-Type: application/json
{"agent_id": "agent_3f9c2a7b1d4e6f80", "task_id": "task-0001"}
The same, offline¶
From a policy file the runner needs no account and no network, and the
agent_id is your own. The task runs exactly as above, with the same verdicts.
kyvvu serve --policy-file owasp.yaml serves the same routes offline.
run_task is the task from "Open a task" to "End the task" above as one
function (walkthrough.py in the documentation samples).
runner = KyvvuRunner(policy_file="owasp.yaml", log_location="none")
context = EvalContext(agent_id="support-agent", risk_classification="minimal", agent_allowed_tools=TOOLS)
offline_run = run_task(runner, context)
4. Where policies come from¶
Sources and precedence¶
A runner loads its policy set from one source:
- the platform:
api_url,api_keyandagent_key(KV_API_URL,KV_API_KEY,KV_AGENT_KEY); - a policy file: a manifest YAML file,
policy_file(KV_POLICY_FILE); - inline: a list of policies,
policies=[...]; - the disk cache: the last set an earlier platform fetch wrote,
policy_cache_path(KV_POLICY_CACHE_PATH).
The first source that is fully configured takes priority, in that order: the
platform, then a policy file or an inline list, then the disk cache. One
exception keeps an offline run offline: a policy file or inline list passed as
a keyword argument wins over a platform configured only through the
environment. When both are passed as arguments, or both come from the
environment, the platform wins, so a stray manifest cannot replace an
organisation's policy set. A runner with no source at all raises
KyvvuConfigError when it is built.
import os
from kyvvu_baseline import load_baseline_policies
from kyvvu_engine import KyvvuRunner
# The platform, keeping the last good set in a disk cache.
platform = KyvvuRunner(api_url="https://platform.kyvvu.com", api_key=os.environ["KV_API_KEY"],
agent_key="support-agent", policy_cache_path="policy-cache.json")
# A policy file, or an inline list: no account and no network.
from_file = KyvvuRunner(policy_file="owasp.yaml", log_location="none")
inline = KyvvuRunner(policies=load_baseline_policies(), log_location="none")
# Only the disk cache an earlier platform fetch wrote.
from_cache = KyvvuRunner(policy_cache_path="policy-cache.json", log_location="none")
Fetching from the platform¶
- The runner sends
GET /api/v1/policieswith itsagent_key, over HTTPS, with the agent API key asAuthorization: Bearer KvKey-.... - The platform answers with the agent's complete policy set and names the
agent it was served for in
X-Kyvvu-Agent-Id. The runner, andkyvvu serve, govern that agent only. - With a disk cache configured, the response's
ETagis cached with the set, and the next fetch sends it asIf-None-Match. A304keeps the cached set. - A platform whose floor the client is below (
MIN_ENGINE_VERSION,MIN_POLICY_SCHEMA) answers426 client_upgrade_required, raised asKyvvuClientUpgradeRequired; the runner keeps the set it already has. - Every policy is validated, from any source, against the schema of its
rule's parameters. Loading is all-or-nothing: an unknown
rule_type, a failed schema validation, a duplicate policy name or a regex that does not compile raisesPolicyLoadErrornaming the policy, and nothing from that set is loaded. - After each successful fetch the set is written to the disk cache, if one is configured. When a cold start's fetch fails, the runner loads the disk cache instead.
Refresh, stale sets and the fail mode¶
The runner refreshes a platform set every policy_ttl_seconds
(KV_POLICY_TTL_SECONDS, default 300). When a refresh fails or is refused, it
keeps enforcing the last good set, and reports it as stale. A disk cache that
is the configured source is also stale once it is older than
policy_cache_max_age_seconds (KV_POLICY_CACHE_MAX_AGE_SECONDS, default
86400).
With no usable set at all, the fail mode decides: fail_mode
(KV_POLICY_FAIL_MODE) is closed by default, which blocks every step.*
behaviour, and open allows them. Task boundaries are allowed either way, and
a source that answers with no policies is not a failure.
Load states¶
policy_status().load_state says why the set in effect looks the way it does:
enforcing: at least one policy is loaded and enforced;none_configured: the source answered and has no policies, so nothing is enforced;unavailable: nothing is loaded, because no load has been attempted yet or the fetch failed with no cached set to fall back on;stale_cache: policies are enforced, but the set is not known to match its source, because the last fetch failed or the configured disk cache is too old.
Whenever nothing is enforced, the runner says so once on stderr, naming the state.
for name, runner in [("platform", platform), ("policy file", from_file),
("inline", inline), ("disk cache", from_cache)]:
status = runner.ensure_policies()
print(f"{name}: {status.load_state.value}, {status.policy_count} policies")
runner.stop()
With the platform up, each runner from the sources above prints:
platform: enforcing, 15 policies
policy file: enforcing, 8 policies
inline: enforcing, 15 policies
disk cache: enforcing, 15 policies
With the platform down, the platform runner keeps enforcing its disk cache, and both report it as stale:
platform: stale_cache, 15 policies
policy file: enforcing, 8 policies
inline: enforcing, 15 policies
disk cache: stale_cache, 15 policies
5. What evaluation returns¶
PolicyEngine evaluates an intended Behavior with an EvalContext against
the loaded policy set and the ordered history for that task.
PolicyEngine.evaluate() and explain() read history without recording
anything; callers record completed steps separately. Each applicable policy
produces a PolicyResult, the per-policy outcome: whether it was violated, its
severity and the details of the violation. The configured aggregator combines
those results into a risk score and an allow, warn or block verdict.
The default aggregator takes the highest severity among violations. An invalid
policy set is refused as a whole at load time, and a rule exception counts as a
violation of that policy.
The result is an EvalResult:
action: the verdict,allow,warnorblock;risk_score: the normalised violation severity, a finite number from0.0(no violations) to1.0;policies: onePolicyResultper evaluated policy, in evaluation order;action_id: the identifier aKyvvuRunnerevaluation used or generated for astep.*behaviour;recorded_seq: theseqof the step when the engine recorded it itself, elseNone.
When KyvvuRunner.evaluate() blocks a step.* behaviour, the runner records
the blocked step itself before raising KyvvuBlockedError: under the
evaluation's action_id, with meta["kyvvu.eval"], inside the same task lock,
in the one shape kyvvu_engine.blocked_step() builds (output.status:
"blocked", output.error, and output.decision: "deny" on a step.gate).
EvalResult.recorded_seq (on exc.result) is the seq it was given.
PolicyEngine.evaluate_and_record() records a block the same way, and
kyvvu serve's /evaluate returns the recorded_seq. A caller must not
record a blocked step: a record() of that action_id is refused as a
duplicate. A blocked step is ordinary rule history (rules opt in to counting
attempts with count_attempts). If recording fails, the block still stands and
recorded_seq is None.
A task boundary (task.*) is not given an action_id by the runner, and a
blocked one is not recorded by the engine. How a blocked boundary is
represented may change in a 1.x release.
6. Guarantees¶
6.1 Determinism¶
PolicyEngine's verdict and per-policy outcomes are a function of the
Behavior being evaluated, including its timestamp, the task history, the
ordered policy set and the aggregator (with the agent identity in
EvalContext). The same inputs give the same verdict, on any host and at any
time.
Time-based rules such as working_hours_only evaluate against the behaviour's
timestamp, which the client records when it forms the intent. PolicyEngine
reads no wall clock, and EvalContext has no evaluation-time field. So the
time a policy was checked against is the time the trace already carries, and a
preflight is judged at the time the intent was formed, not when the action
later executes, even when it waits behind a slow human gate. A registration has
no behaviour and therefore no evaluation time, and no registration rule reads
one.
To replay a decision, evaluate the recorded behaviours, with their recorded
timestamps, against a PolicyEngine loaded with the same policy set: no clock
needs injecting. Construct a Behavior once or supply its timestamp
explicitly, since its default timestamp reads the clock at construction.
History and policy changes between calls change the inputs. Custom rule
functions or aggregators that read external state can also break this
guarantee.
The guarantee covers engine evaluation, not the KyvvuRunner wrapper. The
runner fetches policies, logs results, and may generate a random action_id
for an intended action. It is also a live entrance, so it bounds clock skew
Kerberos-style: KyvvuRunner.evaluate() (and /evaluate on kyvvu serve,
with 422) refuses a behaviour whose timestamp is more than
max_clock_skew_seconds (KV_MAX_CLOCK_SKEW_SECONDS, default 300) from the
host's UTC clock, in either direction. The skew bound applies only there:
record(), restore_task(), restore_intent() and PolicyEngine replay
history and accept any recorded time. A replay should compare engine verdicts
and policy outcomes with fixed inputs, not runner-generated identifiers or I/O
effects.
6.2 Task lifecycle¶
A task is opened by recording its task.start. KyvvuRunner refuses
(TaskStateError) every other write (evaluate, record, restore_intent)
into a task whose task.start it has not recorded, and a second task.start.
A fresh runner rebuilding a task from a history tail that lacks its
task.start calls KyvvuRunner.restore_task() first. Once a task.end or
task.error is recorded the task is closing: any later write raises
TaskClosedError, and only end_task() may follow. PolicyEngine applies the
same closing rule and refuses a second task.start, refuses writes and intent
registration after end_task(), but does not require a task.start first.
Within one runner (and one PolicyEngine), a task_id that was ended or
evicted is not reopened, for its most recent 100,000 closed tasks; no claim is
made across processes or beyond that bound, and a task_id must still never be
reused. The stale-task sweeper's eviction is local to its runner: it ends the
evicted buffer with a task.idle whose properties.task.reason is
"expired", which is not a terminal boundary on the wire.
The order of seq is per agent, not per task. Several agents logging under one
task_id each get their own 1, 2, 3 …, and one task order exists only when one
client logs the whole task. This is a disclosed difference from AAG §3.4.
6.3 What happens to the events you send¶
Before anything is recorded, the engine checks every event. An event is refused, and nothing is stored, if:
- a payload field (properties, input, output, meta) is not I-JSON (RFC 7493);
- its timestamp has no time zone;
- an identifier breaks the identifier grammar;
- its action_id was already used in the same task, except to pair an intended step with its own completion;
- it sets a meta key starting with kyvvu.;
- it writes into a task that is not open.
The engine records:
- every event you record;
- every step.* it blocks. It records a blocked step itself, under the evaluation's action_id (callers must not record it again);
- intended steps that are abandoned when their task ends.
Within one agent and one task, seq strictly increases.
Delivery to sinks is at most once and is observable. The engine sends recorded events to the sinks you configure (KV_LOG_LOCATION, KV_INCIDENT_LOCATION) through one bounded queue (KV_REPORT_QUEUE_SIZE, default 1000). KyvvuRunner.report_status() counts every report as queued, delivered, failed, or dropped because the queue was full. When the process exits, the engine waits up to KV_REPORT_EXIT_FLUSH_SECONDS (default 2) for queued reports. Events can therefore fail to reach a sink in three ways:
- the queue is full;
- the sink fails;
- the process ends first.
In each case, either a counter records it or the process has ended. A failed platform batch is not sent again: the report is counted as failed and the engine discards its events.
What the platform stores, it has acknowledged in full. For the platform sink, the engine numbers each task's exported events seq 1, 2, 3 … with no gaps, and hashes each with the v1 chain: SHA-256 over agent_id, task_id, seq, input and the previous hash. The platform:
- recomputes every hash;
- refuses a batch that does not extend the chain it has stored for that task;
- stores the whole batch in one transaction before it answers.
A batch the platform refuses or does not acknowledge is counted as failed. Nothing from it is stored.
What the v1 chain proves, and what it does not. It shows that the covered fields of stored events were not edited without the chain being recomputed. It does not cover kind, verb, timestamp, output, properties or meta. It is unsigned, so anyone who can write to the store can recompute it. It cannot show that events were deleted from the end of a task.
What this means for you. The engine and the platform together guarantee that a stored record is an event the agent sent, in the order it sent it, with its covered fields unchanged since then. They do not guarantee that every event the agent produced was stored. If you need completeness, monitor report_status(), or use a sink that you make durable yourself.
7. Threat model and limits¶
The engine checks an agent's proposed action against policies chosen by its caller, including rules that inspect the agent's earlier steps. It treats the action data as untrusted and reports policy violations before the caller executes the action. The caller must actually submit each action for evaluation and honor a block; actions the integration does not submit, and side effects it does not report, are not governed. It also cannot prove that reported input, output, or history reflects what happened outside the process. Policy authors, custom rule code, the configured aggregator, and the policy delivery path are trusted inputs; this library is not a sandbox for malicious policy code.
Policy sets are unsigned in 1.0. The engine enforces the policy set it is delivered without verifying who wrote it. A TLS-inspecting proxy, a compromised edge, or anyone who can write the on-disk policy cache can change the enforced set undetected.
8. Stability and versioning¶
What the 1.0 interface promises, and what a 1.x release may add, is the interface stability and versioning policy.