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:
PlatformClientandAgentProfile: agent registration, policy fetch and trace and incident export now live in the engine, shared by the Python client,kyvvu serveand kyvvu-claude. - Added: value capture moved from the Python client into the engine:
to_ijson_capture,capture_dict,capture_step_name,compliant_task_idandcompliant_action_id. -
Added:
python -m kyvvu_engine.infoprints the interpreter's installed Kyvvu distributions and template resources as JSON, described byproject_probe.schema.json;kyvvu doctorandkyvvu template showuse it to inspect a project's own interpreter. -
Breaking: behaviour templates are no longer part of the engine. They are the new
kyvvu-templatesdistribution:kyvvu_engine.templatesis removed, andBehaviorTemplate,load_template,deep_mergeanddiscover_templatesare no longer inkyvvu_engine.__all__. Import them, andTemplateError, fromkyvvu_templates; there are no deprecated re-exports. The engine does not depend onkyvvu-templates.KyvvuSettings.template_location(KV_TEMPLATE_LOCATION) and thepython -m kyvvu_engine.infoprobe, which reads thekyvvu.templatesentry points without importing template code, stay in the engine. -
Breaking:
HttpExporternow takes aPlatformClientinstead of an API key, instance ID and timeout.PlatformClient.send_steps()andsend_incident()own report authentication and transport; the runner shares its client with both sinks. The unusedKyvvuSettings.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. ClassifiedDevelopment 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_hourmust lie in 0–23 andend_hourin 0–24 (a negative hour never matches, a start of 24 or more never occurs).end_houris exclusive, so24stays valid and means until midnight, exactly as0does;25and above are refused.execution_max_steps.max_steps,max_consecutive_same_type.max_consecutiveandusage_budget.budgetmust be at least 0.0is 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_classificationstakes onlyminimal,limited,high,unacceptableandunclassified, the classes an agent can carry. The rule compares exactly, soHIGHorcriticalnever matched.field_in_list.valuesstays open.- A sub-condition of
all_of,any_ofornotholdsrule_type(a string, required) andparams(a mapping, optional) and nothing else; an unknown key, which evaluation ignored, is refused, astrigger_conditionrefuses 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
enumin itsparams_schema(onitemsfor a list), so the snapshot records which params those are;_KIND_PARAM_KEYS/_VERB_PARAM_KEYSstill 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 asdefault:working_hours_only.timezone(UTC),step_requires_gate.gate_decision(pass) andpath_within_root.field(properties.target.host); each rule reads the same constant, so behaviour is unchanged.PolicyRule.check_paramsenforcesmaximumand checks every element of an array param that declaresitems. The contract classifier labels an addeditemsorpropertiesconstraint breaking and a removed one additive. Regeneratedrule_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/propertiesamong 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 asfilelock>= 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 intests/io/test_client_hash_chain.py),chain_steps()andChainHead. The algorithm is the one the platform used, so stored rows validate unchanged; the platform now imports it instead of keeping a copy.HttpExportersendsseqandhashon every step:seqruns 1, 2, 3, ... per (agent, task) across all of a task's batches, contiguous over exactly the steps exported (forKyvvuRunnerevery recorded step, blocked steps and abandoned intents included), and each hash is computed over the step as sent, afterlog_payloadsredaction. The head advances only when the API answers 201, so a failed batch is resent identically; ahash_tailthat differs from the head is logged as a WARNING. NewHttpExporter.chain_head()andexport_task(..., chain_from=, chained=)for a caller that keeps the head itself. A continuation the exporter holds no head for (first stepseq> 1, nochain_from: another process exported the task's earlier steps) is sent withouthash, logged at INFO, and the platform chains it as before; so is everything a runner exports for a task opened withrestore_task(). The wire version stays 1. An API older than this release refuses the newhashfield (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_verbsis 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 raisesPolicyLoadErrornaming each bad entry and the valid verbs. A verb param may hold one verb or a list, and every entry is checked.target_verbis unchanged, and the shippedclaude-code-safetymanifest still loads. - Breaking:
conditional_successor_required.trigger_conditiondeclares its structure:field(string, full step path),op(one ofbelow,above,equals,contains) andvalue, all required, and no other key. An unknownop(such as==), an unknown key (such asoperator), a missing key, atrigger_conditionthat is not a mapping, orbelow/abovewith avaluethat is not a number (a numeric string or a bool included) raisesPolicyLoadError. 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_paramsapplies the top-level checks (required, unknown,type,enum,minimum) to the keys of any object param that declaresproperties, recursively, reporting a dotted path such astrigger_condition.op. Anenumrefusal now also names the value it got. - Added: the built-in rules' manifest contract is frozen for 1.0.
tests/rules/rule_contract.v1.jsonrecords every rule's name, enforcement points and params (type,required,enum,minimum,path, and anydefault,itemsorproperties; descriptions andexample_paramsexcluded), 25 rules.tests/rules/test_rule_contract.pyfails 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 withuv run python scripts/update-rule-contract.pyand 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()takesserved_agent_id=, the platformagent_ida 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 otheragent_idwithAgentBindingError; a runner already bound to another id is rebound, with a warning.None, the default, changes nothing. An id outside the identifier grammar raisesValueErrorand loads nothing.AgentBindingErroris 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.nowis removed, soEvalContext(now=…)is a validation error and/evaluateonkyvvu serveanswers acontext.nowwith 422.RuleContexttakesnow(andhour) from the evaluated behaviour, normalised to UTC, and never reads the wall clock;PolicyEnginereads no clock at all, so a replay of the recorded behaviours reaches the same verdict with no clock to inject. Atagent_registrationthere is no behaviour,RuleContext.nowandhourareNone, and no registration rule reads the time;working_hours_onlygiven 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/evaluateonkyvvu serve, with 422) refuses a behaviour whosetimestampis more thanmax_clock_skew_secondsfrom the host's UTC clock, in either direction, with aValueErrornaming both times and the setting. New settingmax_clock_skew_seconds(KV_MAX_CLOCK_SKEW_SECONDS, default 300); it must be positive, and0or a negative value is refused withKyvvuConfigErrorwhen the settings are built.record(),restore_task(),restore_intent()andPolicyEngineare not bounded: they replay recorded history (kyvvu-claude across processes). A caller that built aBehaviorlong before evaluating it, or evaluated a recorded trace through the runner, must build it when the intent is formed, or replay it throughPolicyEngine. - Docs: the evaluation documentation restates the verdict as a function of the
Behavior(including itstimestamp), the task history and the ordered policy set, and places the skew bound at the runner only. - Breaking:
conditional_successor_requiredandstep_directly_preceded_byread "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 requiresstep.gate, only a gate whoseoutput.decisionispasscounts:conditional_successor_requiredlooks past gates that did not pass, keeping the trigger as the previous step until one does, while forstep_directly_preceded_byno 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 optionalrequired_kind. The shared definitions live inrules/_helpers.py(_happened,_gate_decision,_gate_passed, and_counts_forfor the successor rule);step_requires_gateuses 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>, orstill enforcing the N policies in the on-disk policy cachewhen no fetch time is known), a configured inline set, or nothing.upgrade_message()takes a newsource=(aPolicyStatus.sourcevalue) and the runner passes it; a disk-cache set read without a timestamp was called "configured inline policies". A set loaded withstale_cause=client_upgrade_requiredbut 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"; newcompat.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 setunavailable(orstale_cache) exactly as an unreachable platform does. - Breaking:
field_not_emptycounts an empty list, tuple, set, frozenset or mapping and a whitespace-only string as empty, alongsideNone, an absent path and"";Falseand0are 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
UserWarningraised throughkyvvu_langchainorkyvvu_crewainames the caller's line (theinvoke()orlistener.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.
KyvvuRunnerholds the registrationevaluate_registration()last evaluated and the digest of theagent_registrationpolicies it was evaluated against (newPolicyEngine.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 whichevaluate()blocks everystep.*behaviour with a result naming the registration policies, recorded like any other block. Task boundaries are not refused. A block atevaluate_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. NewPolicyStatus.registration_blocked_byandregistration_blocked. - Breaking: the engine records blocked steps. When
KyvvuRunner.evaluate()blocks astep.*behaviour it records the blocked step itself, inside the task lock and before raisingKyvvuBlockedError: under the evaluation'saction_id, withmeta["kyvvu.eval"], in the shape of the new public builderblocked_step(intended, result)(output: {"status": "blocked", "error": <message>}, plus"decision": "deny"on astep.gate). Fail-closed blocks are recorded the same way.PolicyEngine.evaluate_and_record()now records a blocked step too (and attacheskyvvu.evalto what it records);kyvvu serve's/evaluaterecords through the runner. Callers must not record a blocked step: arecord()of the sameaction_idis refused as a duplicate (ValueError; 422 from the sidecar).PolicyEngine.evaluate()andexplain()stay read-only. A blocked task boundary is not recorded; its representation is not part of the 1.0 contract. - Added:
EvalResult.recorded_seq(theseqof a step the engine recorded itself, elseNone) andrecorded_seqon the sidecar's/evaluateresponse. - Breaking: task lifecycle.
KyvvuRunnerrefuses (TaskStateError) anyevaluate()/record()/restore_intent()into a task whosetask.startit has not recorded, and a secondtask.start. Oncetask.endortask.erroris recorded the task is closing: every later write raisesTaskClosedError, and onlyend_task()may follow.PolicyEnginerefuses a secondtask.start, refuses writes after a recordedtask.end/task.error, and now remembers ids closed byend_task()(the most recent 100,000), refusing laterrecord()andregister_intent()for them; newPolicyEngine.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 atask.start, for a fresh runner that rebuilds a task from a bounded history tail (kyvvu-claude's per-hook replay). New publicTaskStateError;TaskClosedError(now a subclass of it) is public too. Both live inkyvvu_engine.errorsand are still importable fromkyvvu_engine.io.exceptions, as areKyvvuConfigErrorandAgentBindingError. - Changed: the stale-task sweeper ends an evicted buffer with
task.idle(properties.task.reason: "expired"), which is not terminal on the wire, instead oftask.error; the runner still refuses later writes locally. A task whosetask.end/task.errorwas already recorded gets no marker. - Fixed:
ReportStatus.deliveredno longer counts a job whose exporter returnedFalse; it is counted infailed.ReportStatusandUnresolvedPathare frozen keyword-only dataclasses. - Fixed (I-JSON):
KyvvuRunner.evaluate()andrestore_intent()re-check payloads (with_checked_payloads()) and the reservedkyvvu.meta namespace, so an intent carryingNaNcan no longer wedge a task. Policyname,rule_type,severity,description,manifest_name,agent_id,risk_classificationand the integer ids must be I-JSON at load (PolicyLoadError).EvalResult.risk_scoremust be finite.PolicyResult.violation_detailsmust 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 failedrecord()can be retried withkyvvu.evalintact. - Fixed: every exporter (
HttpExporter, file, stdout, incident sinks) andKyvvuRunner.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_payloadsredaction like step incidents (store_metadata: falsestill drops the details). - Breaking:
build_step_dicts()refuses a mapping (a persisted step) withoutkind,step_name,seqortimestampinstead of inventingstep.unknown,"unknown",0or the current time.Behaviorinputs are unchanged. - Added: the HTTP incident exporter sends
User-Agent: kyvvu-engine/<version>andX-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'stask.idle,kyvvu.manifestsas a snapshot at task start, and a boundary'saction_idmay be absent. -
Breaking: identifiers have a grammar.
Behavior.task_id(andmeta["parent_task_id"]ontask.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_nameis 1-255 characters of I-JSON text with no control characters (U+0000-U+001F, U+007F-U+009F). A violation is aValueErrornaming the field and the rule;record()re-checks steps built withmodel_copy.kyvvu-serveapplies the same rules tocontextand/end_task(422). Persisted steps without atask_idare exported with the placeholderunknown(the wire step carries notask_id). -
Docs:
PathTracker: recorded steps (completed, failed, blocked) receive aseqthat is strictly increasing per task and may have gaps; open intents do not. -
Breaking:
Behavior.input,output,propertiesandmetaaccept only I-JSON (RFC 7493) values:NaN/Infinity, integers beyond ±(2^53 − 1), tuples, sets, bytes, datetimes,Decimaland other objects are refused with aValueErrornaming the path.PolicyEngine.record()andbuild_step_dicts()re-check steps built withmodel_copy(update=...). Step exporters write strict JSON (allow_nan=False, nodefault=str), andkyvvu-serveanswers aNaNin a request body with a 422 instead of a 500. - Fixed: outside
log_payloads=full, payload-derived values inmeta["kyvvu.eval"]violation_detailsare redacted like the payload (metadata_onlydrops them,sha256digests them); the keys naming the failed rule and field stay. - Changed: the
sha256redaction 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
timestampis UTC with fixed microseconds and aZsuffix (2026-10-14T10:00:01.000000Z). - Added:
HttpExportersendsUser-Agent: kyvvu-engine/<version>andX-Kyvvu-Wire-Version: 1. - Breaking:
KyvvuRunnernever reopens atask_idit has ended or evicted: any laterevaluate()/record()raisesKyvvuConfigError(bounded memory of 100,000 closed ids). Newis_task_closed()/is_task_open(). - Breaking:
action_idis unique per (agent_id,task_id); a duplicate intent or outcome raisesValueError(pairing an intent with its completion is the one reuse). Pending decision records are keyed by (task_id,action_id). NewPolicyEngine.action_id_in_use(). - Breaking:
metakeys starting withkyvvu.are reserved;evaluate()/record()refuse a caller-supplied one.evaluate_registration()refusesagent_datathat is not I-JSON.KyvvuSettingsis keyword-only. - Breaking:
kyvvu-serveanswers 409 to a write into a task whosetask.startwas not recorded, to a secondtask.start, and to/end_taskfor an unopened task; a duplicateaction_idor reserved meta key is 422. - Fixed: recording
task.end/task.error(andend_task()) records still-open intents as abandoned before the boundary; new publicabandon_open_intents(). A naive timestamp set viamodel_copyis refused at record/export. Step incidents followlog_payloadsredaction, carryaction_id, use the trace'sZtimestamp; incident exporters serialise strictly.HttpExportersplits a task into batches of at most 1000 steps. Policy fetch pages through sets larger than 1000 (refusing a set that changed mid-paging);PolicyStoreholds the set sorted byid, thenname. -
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_gatereadsproperties.kind(humanorguardrail) and a completedoutput.decision(passordeny). Its params aregate_kindandgate_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 raisesPolicyLoadErroron 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/recordresponse ({"seq": N, "task_id": ...}). A behaviour that carriesstepis rejected with an error namingseq. The/recordrequest still nests the Behavior under itsstepkey. -
Breaking: The behaviour field is
kindand its enum isActionKind; the retired field and enum names are rejected. Policy kind parameters aretarget_kinds,trigger_kinds,forbidden_kind,forbidden_kinds,required_kind,taint_kind, andforbidden_predecessor_kinds; old parameter names are rejected. A step policy'sfieldneeds a complete path such asinput.recipientorproperties.message.recipient(that is,input.<name>orproperties.<name>); bare data field names are rejected. -
Breaking:
step.modelcarries no verb. Model calls withPOSTnow fail validation; policies that matchedPOSTwithout a step-type filter must selectstep.modelexplicitly. HistoricalPOSTlog 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 unknownrule_type,paramsthat do not match the rule'sparams_schema(a required key missing, an unknown key, a wrong type),paramsthat is not a mapping, a duplicate name within the set, or a regex that does not compile raises out ofPolicyEngine.load_policies, the manifest parser, the runner and the SDK'spolicy_file, naming every problem. The load is all or nothing. Removed:DroppedPolicy,PolicyStatus.dropped,PolicyLoadReport.dropped,PolicyEngine.dropped,PolicyStore.dropped,KyvvuRunner.dropped_policiesandPolicyLoadState.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 reportsstale_cache; a cold start raises to the caller. - The
scopealias forenforcement_pointis removed.PolicyDefinitionand manifests that still sayscope:now raisePolicyLoadErrornamingenforcement_point. (The 0.10.0 entry below records when the alias was introduced; it no longer applies.) domain_allowlistrequirestarget_step_types(security-relevant). Only steps of the listed types are evaluated, and an in-scope step whoseproperties.target.hostis 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 localstep.resourcewith no network target (aread_faqtool) is blocked by an allowlist scoped tostep.resource: declareproperties={"target": {"host": ...}}or keep the step type out of the rule. Existing policies withouttarget_step_typesnow fail to load.- Regexes use
re.search.field_matches_regexand a template'sname_patternpreviously usedre.match(start-anchored); they now match anywhere in the value, aspii_in_requestalways did. Anchor with^and$. A pattern written for the old start-anchored behaviour can now match more. Template name patterns are case-insensitive. risk_classificationon a policy means this class or above. Orderedminimal < 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 usekyvvu_engine.risk.cross_execution_rate_limitis refused at load. No host supplies cross-execution counts, so the rule raised nothing and never fired. It now declaresrequires=["cross_execution_counts"]and any policy using it, including insideall_of/any_of/not, raisesPolicyLoadError.- Template
behavior/matchvalidation is strict at load. Every rule needs a uniqueid; a rule has onlyid,description,match,behavior;behavioronlystep_type,step_name,verb,properties;conditionsandoutputare rejected with a hint; a literalstep_type/verbmust be in the vocabulary;name_patternmust compile. A missingmatchis an error (writematch: {}for a catch-all).function_name_patternis removed in favour ofname_pattern, and a missingnamenever matches (.*included). KV_ENVIRONMENTdefaults toproductioneverywhere.- A block never ends the task.
step_requires_gateandstep_requires_predecessorignore a blocked step, andstep_directly_preceded_bylooks past blocked attempts.
Added¶
PolicyLoadError(exported fromkyvvu_engine, aValueErrorwith.errors).PolicyRule.register(requires=[...])andPolicyEngine(capabilities=...): a rule can require something from the host;GET /policies/ruleslistsrequires.kyvvu_engine.riskholds the one risk ordering.usage_budgetapplies 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. Anatexithook waits up toKV_REPORT_EXIT_FLUSH_SECONDS(default 2) for the queue. Readrunner.report_status(), callrunner.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_taskreturnsboolinstead ofNone.Truemeans the steps reached their destination,Falsemeans 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. TheStepTypemembers are renamed to match (StepType.task_start, …), so stale code fails at import withAttributeErrorrather 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.
PolicyDefinitionnow rejects aparamsstep type outside the vocabulary. Rule functions compare params step types againstBehavior.step_type.valueas 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.PolicyStoredrops just that policy with a warning and records it indropped; the rest of the set still loads, because the policy set is the enforcement set.
Changed¶
TASK_BOUNDARY_STEP_TYPESis unchanged in meaning and remains the sanctioned way to test for a boundary. It is now also the set thetask.prefix projects to, and a test asserts the two agree so the hand-maintained frozenset cannot drift.Behaviorrejects agranularity=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. TheStepTypemembers 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/Granularityenum is removed outright, along withBehavior.scopeand its short-lived successorBehavior.granularity. With one namespace the value is the same for every behavior, and a constant is not a field.Behaviorrejects bothgranularity=andscope=with an error naming the replacement. A serializedBehaviornow round-trips unconditionally — there is no computed field in the dump to feed back. TASK_BOUNDARY_STEP_TYPESis added (exported fromkyvvu_engine.schemasand re-exported by the SDK) for callers that need to tell the four task-boundary types from action steps. It replaces thestartswith("task.")idiom the namespace split used to license.VALID_COMBINATIONSholds(step_type, verb)pairs instead of(step_type, scope, verb)triples. Granularity was a constant function ofstep_type, so no combination was lost.PolicyDefinition.scoperenamed toPolicyDefinition.enforcement_point. The historicalscopekey is still accepted on input so policy sets cached by an earlier release keep loading.PolicyDefinitionnow rejects an unrecognisedenforcement_point. Previously any string was accepted, so a policy with the removedtask_executionvalue loaded and was indexed but never evaluated — an installed policy that enforced nothing.@PolicyRule.register(scopes=[...])is nowenforcement_points=[...], and the rule metadata key isenforcement_points.RuleContext.scoperenamed toRuleContext.enforcement_point.PolicyStore.get_applicable(scope=...)is nowget_applicable(enforcement_point=...)— a public keyword argument.- The rule-data dict key
scopeis removed; there is no granularity to carry. properties.auth.scoperenamed toproperties.auth.access_level.- Wire format. Incident payloads posted by
KyvvuRunnernow carryenforcement_pointinstead ofscope, and the OTLP span attributekyvvu.incident.scopeis nowkyvvu.incident.enforcement_point. Any dashboard, alert, or log query keyed on the old attribute needs updating. The API accepts the oldscopekey 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_pointis 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¶
PolicyEnginewithevaluate(),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 forfield_matches_regexandpii_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/blockoutcomes. - Payload redaction mode (
metadata_only). stdoutsink for local debugging (both log and incident endpoints).sweep_stale_tasks()for abandoned-task cleanup.
Standalone server (kyvvu-serve)¶
kyvvu-serveCLI with--host,--port,--agent-key,--api-url,--api-keyflags.- 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.nowuses UTC (datetime.now(timezone.utc)).- Error messages reference correct
KV_*env var names. setup_logging()protected bythreading.Lock.PathTracker.record()rejects emptytask_id.- Rule exceptions logged with rule name.
- Serve app version imported from
__version__.py. - Compound rule docstrings document non-short-circuit behaviour.