Skip to content

kyvvu-engine changelog

All notable changes to kyvvu-engine are documented in this file.

Versions before 0.2.0 were internal development builds. The changelog starts from the first published release.


[1.0.0] - 2026-10-28

  • Added: PlatformClient and AgentProfile: agent registration, policy fetch and trace and incident export now live in the engine, shared by the Python client, kyvvu serve and kyvvu-claude.
  • Added: value capture moved from the Python client into the engine: to_ijson_capture, capture_dict, capture_step_name, compliant_task_id and compliant_action_id.
  • Added: python -m kyvvu_engine.info prints the interpreter's installed Kyvvu distributions and template resources as JSON, described by project_probe.schema.json; kyvvu doctor and kyvvu template show use it to inspect a project's own interpreter.

  • Breaking: behaviour templates are no longer part of the engine. They are the new kyvvu-templates distribution: kyvvu_engine.templates is removed, and BehaviorTemplate, load_template, deep_merge and discover_templates are no longer in kyvvu_engine.__all__. Import them, and TemplateError, from kyvvu_templates; there are no deprecated re-exports. The engine does not depend on kyvvu-templates. KyvvuSettings.template_location (KV_TEMPLATE_LOCATION) and the python -m kyvvu_engine.info probe, which reads the kyvvu.templates entry points without importing template code, stay in the engine.

  • Breaking: HttpExporter now takes a PlatformClient instead of an API key, instance ID and timeout. PlatformClient.send_steps() and send_incident() own report authentication and transport; the runner shares its client with both sinks. The unused KyvvuSettings.auth_headers() helper is removed.

  • Added: public manifest_schema() returns an independent editor JSON Schema snapshot from the installed engine's rule and manifest declarations. Native parsing and policy loading remain authoritative.

  • 1.0: the rule contract (tests/rules/rule_contract.v1.json) is frozen: within 1.x a renamed rule or param is a breaking change that needs 2.0. Classified Development Status :: 5 - Production/Stable.

  • Breaking: the remaining rule-contract gaps are closed before the 1.0 freeze. Values that could never have matched now fail to load with PolicyLoadError; no policy that was valid before changes meaning, and every shipped manifest still loads. Stored policies with such values are refused at load, which fails closed.

  • working_hours_only.start_hour must lie in 0–23 and end_hour in 0–24 (a negative hour never matches, a start of 24 or more never occurs). end_hour is exclusive, so 24 stays valid and means until midnight, exactly as 0 does; 25 and above are refused.
  • execution_max_steps.max_steps, max_consecutive_same_type.max_consecutive and usage_budget.budget must be at least 0. 0 is kept because it is meaningful: the intended step counts, so a limit of 0 forbids every matching step, and a budget of 0 admits only steps that spend nothing. A negative value behaved as one of those, spelt in a way no reader would guess.
  • step_forbidden_for_classification.agent_risk_classifications takes only minimal, limited, high, unacceptable and unclassified, the classes an agent can carry. The rule compares exactly, so HIGH or critical never matched. field_in_list.values stays open.
  • A sub-condition of all_of, any_of or not holds rule_type (a string, required) and params (a mapping, optional) and nothing else; an unknown key, which evaluation ignored, is refused, as trigger_condition refuses one. A malformed condition is reported once, with its path (conditions[1].paramz, condition.rule_type).
  • A list param naming action kinds or verbs must hold strings: a non-string entry was skipped by the vocabulary check and could never equal a kind or verb.
  • Changed: the contract snapshot now freezes these. Each param that names an action kind or a verb declares the vocabulary as an enum in its params_schema (on items for a list), so the snapshot records which params those are; _KIND_PARAM_KEYS / _VERB_PARAM_KEYS still drive the load-time check and its messages, and a test keeps the two in agreement. The defaults that lived only in code are declared as default: working_hours_only.timezone (UTC), step_requires_gate.gate_decision (pass) and path_within_root.field (properties.target.host); each rule reads the same constant, so behaviour is unchanged. PolicyRule.check_params enforces maximum and checks every element of an array param that declares items. The contract classifier labels an added items or properties constraint breaking and a removed one additive. Regenerated rule_contract.v1.json: 55 changes, all breaking, accepted before 1.0.
  • Docs: the rules reference states the bounds, the risk classes, the shape of a sub-condition and the vocabulary enums; stability policy lists items / properties among the breaking and additive changes.
  • Breaking: requires Python 3.11 or newer (requires-python = ">=3.11"); 3.10 reaches end of life in October 2026 and dependencies such as filelock >= 4.2.1 already need 3.11. The 3.10 classifier is gone, and CI's floor check runs 3.11. On 3.10, pip keeps resolving the newest 0.x release.

  • Added: the engine computes the platform's hash chain (v1). New public chain_entry_hash() (the v1 entry hash: sha256(json.dumps({agent_id, task_id, seq, input_data, previous_hash?}, sort_keys=True, separators=(",", ":"))), input_data {} when absent; known-answer vectors in tests/io/test_client_hash_chain.py), chain_steps() and ChainHead. The algorithm is the one the platform used, so stored rows validate unchanged; the platform now imports it instead of keeping a copy. HttpExporter sends seq and hash on every step: seq runs 1, 2, 3, ... per (agent, task) across all of a task's batches, contiguous over exactly the steps exported (for KyvvuRunner every recorded step, blocked steps and abandoned intents included), and each hash is computed over the step as sent, after log_payloads redaction. The head advances only when the API answers 201, so a failed batch is resent identically; a hash_tail that differs from the head is logged as a WARNING. New HttpExporter.chain_head() and export_task(..., chain_from=, chained=) for a caller that keeps the head itself. A continuation the exporter holds no head for (first step seq > 1, no chain_from: another process exported the task's earlier steps) is sent without hash, logged at INFO, and the platform chains it as before; so is everything a runner exports for a task opened with restore_task(). The wire version stays 1. An API older than this release refuses the new hash field (422): deploy the platform first, as usual.

  • Docs: the audit-trail documentation and the README say the engine computes the chain and the platform verifies it, and what it still does not prove.

  • Fixed: path_within_root.target_verbs is validated at load like every other verb param. A misspelt entry (["POST", "Patch", "DELET"]) used to load and match nothing, so a PATCH or DELETE outside the project root was allowed; it now raises PolicyLoadError naming each bad entry and the valid verbs. A verb param may hold one verb or a list, and every entry is checked. target_verb is unchanged, and the shipped claude-code-safety manifest still loads.

  • Breaking: conditional_successor_required.trigger_condition declares its structure: field (string, full step path), op (one of below, above, equals, contains) and value, all required, and no other key. An unknown op (such as ==), an unknown key (such as operator), a missing key, a trigger_condition that is not a mapping, or below/above with a value that is not a number (a numeric string or a bool included) raises PolicyLoadError. Each of these used to load and then never fire. Stored policies of this shape are refused at load, which fails closed; the shipped manifests are unaffected. The rule contract snapshot changes for this one param (its first deliberate regeneration). The classifier marks it breaking, which is accepted before 1.0. At runtime a type mismatch with the step's data still counts as the condition not being met.
  • Changed: PolicyRule.check_params applies the top-level checks (required, unknown, type, enum, minimum) to the keys of any object param that declares properties, recursively, reporting a dotted path such as trigger_condition.op. An enum refusal now also names the value it got.
  • Added: the built-in rules' manifest contract is frozen for 1.0. tests/rules/rule_contract.v1.json records every rule's name, enforcement points and params (type, required, enum, minimum, path, and any default, items or properties; descriptions and example_params excluded), 25 rules. tests/rules/test_rule_contract.py fails on any difference from the registry and labels each one breaking (needs a major version from 1.0) or additive (allowed in 1.x, but the snapshot must be regenerated with uv run python scripts/update-rule-contract.py and reviewed). No rule or param changes.
  • Docs: stability policy states what is frozen, what is not, and which changes are breaking or additive; the rules reference and the compatibility page link to it.
  • Added: KyvvuRunner.load_policies() takes served_agent_id=, the platform agent_id a caller-supplied set was served for (kyvvu-claude's hooks read it from their policy cache). The runner binds to it exactly as its own fetch would and refuses any other agent_id with AgentBindingError; a runner already bound to another id is rebound, with a warning. None, the default, changes nothing. An id outside the identifier grammar raises ValueError and loads nothing. AgentBindingError is promoted to the public interface (kyvvu_engine.AgentBindingError).
  • Breaking: time-based rules evaluate against the behaviour's own timestamp, recorded by the client when the intent is formed. EvalContext.now is removed, so EvalContext(now=…) is a validation error and /evaluate on kyvvu serve answers a context.now with 422. RuleContext takes now (and hour) from the evaluated behaviour, normalised to UTC, and never reads the wall clock; PolicyEngine reads no clock at all, so a replay of the recorded behaviours reaches the same verdict with no clock to inject. At agent_registration there is no behaviour, RuleContext.now and hour are None, and no registration rule reads the time; working_hours_only given a context without a time raises, which counts as a violation. A preflight is judged at the time the intent was formed, not when the action executes, including behind a slow human gate.
  • Breaking: KyvvuRunner.evaluate() (and /evaluate on kyvvu serve, with 422) refuses a behaviour whose timestamp is more than max_clock_skew_seconds from the host's UTC clock, in either direction, with a ValueError naming both times and the setting. New setting max_clock_skew_seconds (KV_MAX_CLOCK_SKEW_SECONDS, default 300); it must be positive, and 0 or a negative value is refused with KyvvuConfigError when the settings are built. record(), restore_task(), restore_intent() and PolicyEngine are not bounded: they replay recorded history (kyvvu-claude across processes). A caller that built a Behavior long before evaluating it, or evaluated a recorded trace through the runner, must build it when the intent is formed, or replay it through PolicyEngine.
  • Docs: the evaluation documentation restates the verdict as a function of the Behavior (including its timestamp), the task history and the ordered policy set, and places the skew bound at the runner only.
  • Breaking: conditional_successor_required and step_directly_preceded_by read "previous" as the most recent step that happened: open intents and blocked, failed or abandoned outcomes are looked past, so a trigger counts only once it has completed. Where either requires step.gate, only a gate whose output.decision is pass counts: conditional_successor_required looks past gates that did not pass, keeping the trigger as the previous step until one does, while for step_directly_preceded_by no gate is looked past: a gate still awaiting its decision (an open gate intent), a denied gate and a decision-less gate are each the immediate predecessor and block the target ("pass, deny, target" and "pass, pending gate, target" block; "deny, pass, target" allows). Open intents that are not gates are still looked past. RuleContext.get_previous_step() now does what its docstring said (it returned the last history entry) and takes an optional required_kind. The shared definitions live in rules/_helpers.py (_happened, _gate_decision, _gate_passed, and _counts_for for the successor rule); step_requires_gate uses the same gate decision. Stored policies get the stricter meaning.

[0.13.0] - 2026-10-17

  • Fixed: the too-old upgrade line names what is really still in effect, and only that: the platform's set (still enforcing the policies fetched at <ts>, or still enforcing the N policies in the on-disk policy cache when no fetch time is known), a configured inline set, or nothing. upgrade_message() takes a new source= (a PolicyStatus.source value) and the runner passes it; a disk-cache set read without a timestamp was called "configured inline policies". A set loaded with stale_cause=client_upgrade_required but no refusal of the runner's own (a Claude Code hook reading its cache) now gets the upgrade notice rather than a generic "policies could not be loaded"; new compat.recorded_refusal().
  • Fixed: a policy fetch the platform answered with an HTTP error is logged with its status (Policy fetch failed (HTTP 401: <api_url> rejected the API key); also 403, 404 and 5xx), not as (network), which is now only a fetch that got no answer. The state is unchanged: a refused fetch leaves the set unavailable (or stale_cache) exactly as an unreachable platform does.
  • Breaking: field_not_empty counts an empty list, tuple, set, frozenset or mapping and a whitespace-only string as empty, alongside None, an absent path and ""; False and 0 are still values. declared_tools=[] therefore violates "Agent must declare a tool allowlist" at registration, matching the step allowlist, which already failed closed on it. The rule's registered description says so.
  • Fixed: a policy UserWarning raised through kyvvu_langchain or kyvvu_crewai names the caller's line (the invoke() or listener.kickoff()) instead of a line in the adapter. The adapters were not on the list of internal modules the warning skips.
  • Added: a manifest assigned to a running agent applies without re-registration. KyvvuRunner holds the registration evaluate_registration() last evaluated and the digest of the agent_registration policies it was evaluated against (new PolicyEngine.registration_policy_digest(): id, name, rule type, params, severity, enabled and applicability of each). After a load whose digest differs, the held registration is evaluated again: violations are reported as a registration incident, and a block puts the runner in the registration-blocked state, in which evaluate() blocks every step.* behaviour with a result naming the registration policies, recorded like any other block. Task boundaries are not refused. A block at evaluate_registration() sets the state too (it still raises); an evaluation that does not block clears it. Nothing is re-evaluated on the first load, on an unchanged set, or without a held registration, and a failed re-evaluation is logged and never leaves the refresh loop. New PolicyStatus.registration_blocked_by and registration_blocked.
  • Breaking: the engine records blocked steps. When KyvvuRunner.evaluate() blocks a step.* behaviour it records the blocked step itself, inside the task lock and before raising KyvvuBlockedError: under the evaluation's action_id, with meta["kyvvu.eval"], in the shape of the new public builder blocked_step(intended, result) (output: {"status": "blocked", "error": <message>}, plus "decision": "deny" on a step.gate). Fail-closed blocks are recorded the same way. PolicyEngine.evaluate_and_record() now records a blocked step too (and attaches kyvvu.eval to what it records); kyvvu serve's /evaluate records through the runner. Callers must not record a blocked step: a record() of the same action_id is refused as a duplicate (ValueError; 422 from the sidecar). PolicyEngine.evaluate() and explain() stay read-only. A blocked task boundary is not recorded; its representation is not part of the 1.0 contract.
  • Added: EvalResult.recorded_seq (the seq of a step the engine recorded itself, else None) and recorded_seq on the sidecar's /evaluate response.
  • Breaking: task lifecycle. KyvvuRunner refuses (TaskStateError) any evaluate()/record()/restore_intent() into a task whose task.start it has not recorded, and a second task.start. Once task.end or task.error is recorded the task is closing: every later write raises TaskClosedError, and only end_task() may follow. PolicyEngine refuses a second task.start, refuses writes after a recorded task.end/task.error, and now remembers ids closed by end_task() (the most recent 100,000), refusing later record() and register_intent() for them; new PolicyEngine.is_task_closed(). KyvvuRunner.is_task_closed() includes closing tasks. restore_intent() refuses task boundaries.
  • Added: KyvvuRunner.restore_task(task_id, agent_id) marks a task open without recording a task.start, for a fresh runner that rebuilds a task from a bounded history tail (kyvvu-claude's per-hook replay). New public TaskStateError; TaskClosedError (now a subclass of it) is public too. Both live in kyvvu_engine.errors and are still importable from kyvvu_engine.io.exceptions, as are KyvvuConfigError and AgentBindingError.
  • Changed: the stale-task sweeper ends an evicted buffer with task.idle (properties.task.reason: "expired"), which is not terminal on the wire, instead of task.error; the runner still refuses later writes locally. A task whose task.end/task.error was already recorded gets no marker.
  • Fixed: ReportStatus.delivered no longer counts a job whose exporter returned False; it is counted in failed. ReportStatus and UnresolvedPath are frozen keyword-only dataclasses.
  • Fixed (I-JSON): KyvvuRunner.evaluate() and restore_intent() re-check payloads (with_checked_payloads()) and the reserved kyvvu. meta namespace, so an intent carrying NaN can no longer wedge a task. Policy name, rule_type, severity, description, manifest_name, agent_id, risk_classification and the integer ids must be I-JSON at load (PolicyLoadError). EvalResult.risk_score must be finite. PolicyResult.violation_details must be I-JSON; a rule that returns details that are not is still a violation, with an error record as its details. A pending decision is released only after its step is recorded, so a failed record() can be retried with kyvvu.eval intact.
  • Fixed: every exporter (HttpExporter, file, stdout, incident sinks) and KyvvuRunner.report_incident() run the I-JSON walker before encoding, so tuples, integers beyond 2^53 - 1 and lone surrogates are dropped (logged, reported as not delivered) instead of written. The file exporter encodes a whole batch before writing any of it.
  • Fixed: registration incidents follow log_payloads redaction like step incidents (store_metadata: false still drops the details).
  • Breaking: build_step_dicts() refuses a mapping (a persisted step) without kind, step_name, seq or timestamp instead of inventing step.unknown, "unknown", 0 or the current time. Behavior inputs are unchanged.
  • Added: the HTTP incident exporter sends User-Agent: kyvvu-engine/<version> and X-Kyvvu-Wire-Version: 1, like the batch exporter.
  • Docs: The lifecycle documentation records the lifecycle, narrows "a runner never reopens a task_id" to "within one runner, for its most recent 100,000 closed tasks; no claim across processes", and adds "What a 1.x release may add" (consumers must ignore unknown output fields). README: blocked steps, restore_task, the sweeper's task.idle, kyvvu.manifests as a snapshot at task start, and a boundary's action_id may be absent.

  • Breaking: identifiers have a grammar. Behavior.task_id (and meta["parent_task_id"] on task.start) must match ^[A-Za-z0-9._:-]{1,100}$, action_id ^[A-Za-z0-9._:-]{1,255}$, agent_id ^[A-Za-z0-9._:-]{0,255}$, aag_version ^[A-Za-z0-9.+-]{1,64}$; step_name is 1-255 characters of I-JSON text with no control characters (U+0000-U+001F, U+007F-U+009F). A violation is a ValueError naming the field and the rule; record() re-checks steps built with model_copy. kyvvu-serve applies the same rules to context and /end_task (422). Persisted steps without a task_id are exported with the placeholder unknown (the wire step carries no task_id).

  • Docs: PathTracker: recorded steps (completed, failed, blocked) receive a seq that is strictly increasing per task and may have gaps; open intents do not.

  • Breaking: Behavior.input, output, properties and meta accept only I-JSON (RFC 7493) values: NaN/Infinity, integers beyond ±(2^53 − 1), tuples, sets, bytes, datetimes, Decimal and other objects are refused with a ValueError naming the path. PolicyEngine.record() and build_step_dicts() re-check steps built with model_copy(update=...). Step exporters write strict JSON (allow_nan=False, no default=str), and kyvvu-serve answers a NaN in a request body with a 422 instead of a 500.

  • Fixed: outside log_payloads=full, payload-derived values in meta["kyvvu.eval"] violation_details are redacted like the payload (metadata_only drops them, sha256 digests them); the keys naming the failed rule and field stay.
  • Changed: the sha256 redaction digest is SHA-256 over RFC 8785 (JCS) canonical JSON (new dependency: rfc8785, Apache-2.0). Digests of payloads containing floats or non-BMP keys differ from earlier releases.
  • Changed: exported timestamp is UTC with fixed microseconds and a Z suffix (2026-10-14T10:00:01.000000Z).
  • Added: HttpExporter sends User-Agent: kyvvu-engine/<version> and X-Kyvvu-Wire-Version: 1.
  • Breaking: KyvvuRunner never reopens a task_id it has ended or evicted: any later evaluate()/record() raises KyvvuConfigError (bounded memory of 100,000 closed ids). New is_task_closed() / is_task_open().
  • Breaking: action_id is unique per (agent_id, task_id); a duplicate intent or outcome raises ValueError (pairing an intent with its completion is the one reuse). Pending decision records are keyed by (task_id, action_id). New PolicyEngine.action_id_in_use().
  • Breaking: meta keys starting with kyvvu. are reserved; evaluate()/record() refuse a caller-supplied one. evaluate_registration() refuses agent_data that is not I-JSON. KyvvuSettings is keyword-only.
  • Breaking: kyvvu-serve answers 409 to a write into a task whose task.start was not recorded, to a second task.start, and to /end_task for an unopened task; a duplicate action_id or reserved meta key is 422.
  • Fixed: recording task.end/task.error (and end_task()) records still-open intents as abandoned before the boundary; new public abandon_open_intents(). A naive timestamp set via model_copy is refused at record/export. Step incidents follow log_payloads redaction, carry action_id, use the trace's Z timestamp; incident exporters serialise strictly. HttpExporter splits a task into batches of at most 1000 steps. Policy fetch pages through sets larger than 1000 (refusing a set that changed mid-paging); PolicyStore holds the set sorted by id, then name.
  • Docs: contract wording for seq, batched delivery, the platform's v1 hash chain, JSONL records, and the audit trail being out of 1.0 scope (planned 1.1).

  • Removed: HMAC policy signing and KyvvuSettings.policy_hmac_secret. Policy fetch relies on TLS and API key authentication until Ed25519 signing is designed.

  • Breaking: step_requires_gate reads properties.kind (human or guardrail) and a completed output.decision (pass or deny). Its params are gate_kind and gate_decision; the old names are rejected at load. Ship the engine and SDK before the platform serves rewritten stored policies or the updated manifests: an older engine raises PolicyLoadError on them.

  • Removed: EvalContext.user_settings, EvalContext.cross_execution_counts, the cross-execution rate-limit rule, and the host capability mechanism. The retired rule is now rejected as unknown.

  • Breaking: The per-task ordinal is seq, as in the AAG: Behavior.seq, the exported step dicts, and the /record response ({"seq": N, "task_id": ...}). A behaviour that carries step is rejected with an error naming seq. The /record request still nests the Behavior under its step key.

  • Breaking: The behaviour field is kind and its enum is ActionKind; the retired field and enum names are rejected. Policy kind parameters are target_kinds, trigger_kinds, forbidden_kind, forbidden_kinds, required_kind, taint_kind, and forbidden_predecessor_kinds; old parameter names are rejected. A step policy's field needs a complete path such as input.recipient or properties.message.recipient (that is, input.<name> or properties.<name>); bare data field names are rejected.

  • Breaking: step.model carries no verb. Model calls with POST now fail validation; policies that matched POST without a step-type filter must select step.model explicitly. Historical POST log rows remain.

[0.12.0] - 2026-10-01

Breaking. A policy that cannot be loaded now raises instead of being dropped; regexes search instead of matching from the start; domain_allowlist requires a step-type list. Ship this release (and the SDK's) before the platform serves a rule an older engine does not know: an agent on the previous engine refuses to start on a policy it cannot load.

Breaking changes

  • Every policy load failure raises PolicyLoadError; there is no dropped-policy state. A policy with an unknown rule_type, params that do not match the rule's params_schema (a required key missing, an unknown key, a wrong type), params that is not a mapping, a duplicate name within the set, or a regex that does not compile raises out of PolicyEngine.load_policies, the manifest parser, the runner and the SDK's policy_file, naming every problem. The load is all or nothing. Removed: DroppedPolicy, PolicyStatus.dropped, PolicyLoadReport.dropped, PolicyEngine.dropped, PolicyStore.dropped, KyvvuRunner.dropped_policies and PolicyLoadState.all_dropped. Four load states remain: enforcing, none_configured, unavailable, stale_cache. The one tolerated path is a background refresh that raises: it keeps the last good set and reports stale_cache; a cold start raises to the caller.
  • The scope alias for enforcement_point is removed. PolicyDefinition and manifests that still say scope: now raise PolicyLoadError naming enforcement_point. (The 0.10.0 entry below records when the alias was introduced; it no longer applies.)
  • domain_allowlist requires target_step_types (security-relevant). Only steps of the listed types are evaluated, and an in-scope step whose properties.target.host is absent, empty or blank is a violation, so a destination passed in a parameter a template does not read can no longer slip past the allowlist. A value containing :// is reduced to its lower-cased hostname. A local step.resource with no network target (a read_faq tool) is blocked by an allowlist scoped to step.resource: declare properties={"target": {"host": ...}} or keep the step type out of the rule. Existing policies without target_step_types now fail to load.
  • Regexes use re.search. field_matches_regex and a template's name_pattern previously used re.match (start-anchored); they now match anywhere in the value, as pii_in_request always did. Anchor with ^ and $. A pattern written for the old start-anchored behaviour can now match more. Template name patterns are case-insensitive.
  • risk_classification on a policy means this class or above. Ordered minimal < limited < high; None, or a class the order does not know, applies to every agent. The engine's policy index used to match the exact class while the API's assignment filter read "at or above", so a policy could be assigned and never run. Both now use kyvvu_engine.risk.
  • cross_execution_rate_limit is refused at load. No host supplies cross-execution counts, so the rule raised nothing and never fired. It now declares requires=["cross_execution_counts"] and any policy using it, including inside all_of/any_of/not, raises PolicyLoadError.
  • Template behavior/match validation is strict at load. Every rule needs a unique id; a rule has only id, description, match, behavior; behavior only step_type, step_name, verb, properties; conditions and output are rejected with a hint; a literal step_type/verb must be in the vocabulary; name_pattern must compile. A missing match is an error (write match: {} for a catch-all). function_name_pattern is removed in favour of name_pattern, and a missing name never matches (.* included).
  • KV_ENVIRONMENT defaults to production everywhere.
  • A block never ends the task. step_requires_gate and step_requires_predecessor ignore a blocked step, and step_directly_preceded_by looks past blocked attempts.

Added

  • PolicyLoadError (exported from kyvvu_engine, a ValueError with .errors).
  • PolicyRule.register(requires=[...]) and PolicyEngine(capabilities=...): a rule can require something from the host; GET /policies/rules lists requires. kyvvu_engine.risk holds the one risk ordering.
  • usage_budget applies only to steps of the budgeted type and counts the intended step's own declared value, so the step that would cross the budget is the one refused. A cost reported only after a call can overshoot by one step.
  • The template filter {{ path | omit_empty }} drops a key whose value is missing or empty.
  • Incident reports and trace flushes are asynchronous. They go through a bounded queue (KV_REPORT_QUEUE_SIZE, default 1000) drained by one worker thread; overflow drops the newest report and counts it. An atexit hook waits up to KV_REPORT_EXIT_FLUSH_SECONDS (default 2) for the queue. Read runner.report_status(), call runner.flush(timeout). The stdout, file and callback sinks stay inline. A hard kill can lose reports still queued.

Fixed

  • The task-end summary counts every step recorded from the tracker, not the exporter buffer.

[0.11.0] - 2026-09-09

Exporter.export_task now reports whether the steps arrived.

Changed

  • Exporter.export_task returns bool instead of None. True means the steps reached their destination, False means they did not.

Exporters do not raise on a failed export -- a telemetry sink taking its caller down is worse than losing telemetry -- so HttpExporter has always caught HTTPError and URLError, logged, and returned. That left callers unable to tell an accepted batch from one that vanished into an unreachable API, which is fine for telemetry and not fine for an audit trail: a caller holding the only copy of a task's steps would discard it on a silent failure, exactly when the API was down.

KyvvuRunner._flush_to_exporters returns the same signal, and treats one refusal as a failed flush rather than reporting partial success.

This is source-compatible for exporter authors. A third-party exporter written against the old contract returns None, and callers treat None as success -- only an explicit False counts as loss. Return False to opt in.

OtlpExporter is the honest exception: its spans go to a batching processor that ships them on another thread, so delivery has not happened when export_task returns. It reports whether the spans were built and says so in its docstring; do not rely on it alone before discarding a local copy.


[0.10.0] - 2026-08-27

Breaking. Restores the task.* action-type namespace that 0.9.0 folded into step.*. The four task-lifecycle types are renamed back, and the StepType members follow.

Breaking changes

  • The four task-lifecycle step types return to the task.* namespace: step.task_start → task.start, step.task_end → task.end, step.task_error → task.error, step.task_idle → task.idle. The StepType members are renamed to match (StepType.task_start, …), so stale code fails at import with AttributeError rather than matching nothing.

0.9.0 argued that the two namespaces named a distinction the engine never made, on the grounds that both are emitted on one path and evaluated at one enforcement point. That is true of the routing and false of the grammar: a task-boundary marker carries no verb and neither input nor output, while a behavioral step carries the full {properties, input, output} triplet. That asymmetry is what lets a consumer read an absent output on a step as an intended action instead of waiting for a result that never arrives, and the namespace is what named it. Once everything moved under step., the prose had to start saying "behavioral steps" wherever it had said step.* — reintroducing the distinction in words because the names no longer carried it.

  • PolicyDefinition now rejects a params step type outside the vocabulary. Rule functions compare params step types against Behavior.step_type.value as plain strings with no coercion, so a policy naming a misspelled or retired type used to load cleanly, index, evaluate on every step, and match nothing — an installed policy that enforces nothing. PolicyStore drops just that policy with a warning and records it in dropped; the rest of the set still loads, because the policy set is the enforcement set.

Changed

  • TASK_BOUNDARY_STEP_TYPES is unchanged in meaning and remains the sanctioned way to test for a boundary. It is now also the set the task. prefix projects to, and a test asserts the two agree so the hand-maintained frozenset cannot drift.
  • Behavior rejects a granularity= key with a message saying it is derived from the step_type prefix and never a field, rather than "retired" — the AAG spec declares it a derived axis again.

[0.9.0] - 2026-08-24

Breaking. Retires "scope" as a technical term (it previously meant four unrelated things) and collapses the task.* / step.* namespace split that sat underneath it. Policy enforcement-point values are unchanged; the four task-lifecycle step types are renamed.

Breaking changes

  • The four task-lifecycle step types move into the step.* namespace: task.start → step.task_start, task.end → step.task_end, task.error → step.task_error, task.idle → step.task_idle. The StepType members are renamed to match (StepType.step_task_start, …). These types were always emitted on the same path, evaluated at the same enforcement point, and matched the same way as any other step; the second namespace only implied a distinction the engine never made.
  • The Scope / Granularity enum is removed outright, along with Behavior.scope and its short-lived successor Behavior.granularity. With one namespace the value is the same for every behavior, and a constant is not a field. Behavior rejects both granularity= and scope= with an error naming the replacement. A serialized Behavior now round-trips unconditionally — there is no computed field in the dump to feed back.
  • TASK_BOUNDARY_STEP_TYPES is added (exported from kyvvu_engine.schemas and re-exported by the SDK) for callers that need to tell the four task-boundary types from action steps. It replaces the startswith("task.") idiom the namespace split used to license.
  • VALID_COMBINATIONS holds (step_type, verb) pairs instead of (step_type, scope, verb) triples. Granularity was a constant function of step_type, so no combination was lost.
  • PolicyDefinition.scope renamed to PolicyDefinition.enforcement_point. The historical scope key is still accepted on input so policy sets cached by an earlier release keep loading.
  • PolicyDefinition now rejects an unrecognised enforcement_point. Previously any string was accepted, so a policy with the removed task_execution value loaded and was indexed but never evaluated — an installed policy that enforced nothing.
  • @PolicyRule.register(scopes=[...]) is now enforcement_points=[...], and the rule metadata key is enforcement_points.
  • RuleContext.scope renamed to RuleContext.enforcement_point.
  • PolicyStore.get_applicable(scope=...) is now get_applicable(enforcement_point=...) — a public keyword argument.
  • The rule-data dict key scope is removed; there is no granularity to carry.
  • properties.auth.scope renamed to properties.auth.access_level.
  • Wire format. Incident payloads posted by KyvvuRunner now carry enforcement_point instead of scope, and the OTLP span attribute kyvvu.incident.scope is now kyvvu.incident.enforcement_point. Any dashboard, alert, or log query keyed on the old attribute needs updating. The API accepts the old scope key on incident ingest so a version-skewed fleet keeps reporting, but the engine only emits the new one.

Fixed

  • PolicyStore.load() now drops an individual unloadable policy with a WARNING instead of failing the whole batch. Rejecting the entire set over one bad entry would stop enforcing everything — the policy set is the enforcement set.
  • An unrecognised enforcement_point is rejected at load rather than silently indexed and never evaluated.

[0.2.0] - 2026-04-28

First published release. Establishes the public API surface for PolicyEngine, KyvvuRunner, and the 26 built-in rule functions.

Core engine

  • PolicyEngine with evaluate(), record(), end_task(), evaluate_registration(), evaluate_and_record(), explain().
  • PathTracker — per-task in-memory step history with thread-safe access.
  • PolicyStore — scope + risk-classification index for sub-ms policy lookup. Pre-compiled regex patterns for field_matches_regex and pii_in_request.
  • Three aggregation strategies: aggregate_max (default), aggregate_mean, aggregate_weighted_sum.

Rule functions (26)

  • Field (3): field_not_empty, field_in_list, field_matches_regex.
  • Path (9): step_directly_preceded_by, step_requires_predecessor, step_preceded_by_without_intervening, step_requires_dedicated_predecessor, step_requires_gate, sequence_forbidden, step_not_after, history_contains, current_is.
  • Count (4): execution_max_steps, max_consecutive_same_type, cross_execution_rate_limit, usage_budget.
  • Classification (3): step_forbidden_for_classification, working_hours_only, step_name_in_allowlist.
  • Content (2): pii_in_request, domain_allowlist.
  • Flow (5): conditional_successor_required, tainted_path_block, all_of, any_of, not.

I/O layer (KyvvuRunner)

  • Policy fetch from Kyvvu platform API with configurable TTL.
  • Background policy refresh thread.
  • Step log buffering with batch flush on end_task().
  • Incident webhook on warn / block outcomes.
  • Payload redaction mode (metadata_only).
  • stdout sink for local debugging (both log and incident endpoints).
  • sweep_stale_tasks() for abandoned-task cleanup.

Standalone server (kyvvu-serve)

  • kyvvu-serve CLI with --host, --port, --agent-key, --api-url, --api-key flags.
  • Five HTTP endpoints: /health, /evaluate, /register_agent, /record, /end_task.
  • Typed Pydantic response models for all endpoints.

Observability

  • setup_logging() with plain-text and JSON modes.
  • explain() for human-readable per-policy evaluation traces.
  • DEBUG-level per-policy pass/fail logging.
  • Task-end INFO summary with step-type breakdown.
  • Policy violation INFO logging with risk score and action.

Hardening (applied in this release)

  • RuleContext.now uses UTC (datetime.now(timezone.utc)).
  • Error messages reference correct KV_* env var names.
  • setup_logging() protected by threading.Lock.
  • PathTracker.record() rejects empty task_id.
  • Rule exceptions logged with rule name.
  • Serve app version imported from __version__.py.
  • Compound rule docstrings document non-short-circuit behaviour.