Releases: CTRLRun/ctrlrun
Release list
ctrlrun 0.12.0
One question: is the thing this project says about itself checkable?
No new capability. Three claims that were prose became tests, and each of the three was false or
incomplete when the test was written, which is the milestone's whole argument.
Changed
-
The module map is acyclic, and
ARCHITECTURE.md§6's rule is now a test. §6 has said
dependencies point downward only since v0.1, and from v0.7 it was false: a review found
state -> receipt -> policy -> authority -> stateand the sentence was amended to record the
cycle rather than fix it. Nothing broke at run time, because the two edges out ofpolicy.py
are function-level, soimport ctrlrunresolved in one order and the suite passed for five
milestones.Broken in two places.
DecisionandPOLICY_UNAPPROVEDmoved toctrlrun.decision, which
imports nothing from the package: that was the whole of what a receipt needed from the decider,
and an evidence type reaching up into it is the edge that most contradicts §6's table. Then
the policy document grammar -- schemas, the strict YAML loader, the condition parser and
evaluator, type-strict equality -- moved toctrlrun.grammar, soauthority.pyno longer
importspolicy.pyat all.SPEC-v0.3.md§4.5 requires the two axes to share one condition
evaluator, and that is better served than before: the one evaluator is owned by neither axis.No public name moved.
policy.pyre-exports all thirty-four, so
from ctrlrun.policy import Decision, Condition, parse_conditionsresolves to the same objects
andSPEC-v0.1.md§8's frozen__init__block is unchanged.tests/test_module_graph.pywalks every module's AST and separates two questions §6 kept
conflating: the import-order graph, which is module-level imports, and the layering
graph, which counts deferred imports and is what the table describes. The recorded cycle is
invisible to the first, so a guard built only on module-level imports passes on 0.11.0's
tree. Writing it found a second cycle nobody had recorded,jwt_identityandrevocation
sharing a security-critical redirect handler through a deferred import;_NoRedirectsmoved
down torevocation.pyand keeps logging underctrlrun.policy's logger name so no operator's
handler is re-routed. -
The gateway told MCP clients to call a Python API. A
-41002relayed
str(ApprovalRequired)verbatim, and that exception carries the decorator's wording: "run
ctrlrun approve …, then retry insidectrlrun.with_approval(…)". On the one path where the
caller may be in any language, and is often a model reading the error as text, it pointed at a
context manager the caller cannot reach. It now says what the gateway's own documentation
already said: a human approves and this same call runs.
Added
-
Property tests over generated inputs (
tests/test_properties.py,hypothesis). The
invariant that matters is the one v0.11 item 1 violated: every break a tamper reports names a
row the store actually holds. That defect reportedcontent_altered 99andmissing 100on
an eight-row chain, because position came from the document rather than theseqcolumn.
Reinstating it fails these tests immediately.The first version of that property was one tamper is one break, and hypothesis falsified it on
its second example: altering rownalso breaks the link atn + 1. Two is correct and the
expectation was wrong, which is the same mistake the file exists to catch.derandomize=True,
so a counterexample found in CI reproduces locally by construction. -
A CycloneDX SBOM of the wheel, measured from the wheel (
scripts/sbom.sh). Generated by
installing the built wheel into an empty environment and recording what resolves, not by reading
pyproject.toml: a manifest-derived SBOM is the project's opinion of its own dependencies. The
answer is two,PyYAMLandclick. CI generates and checks it on every pull request, and
release.ymlwrites it intodist/before the attestation step, so it is signed with the
distributions and attached to the release.The seed packages are removed before the scan, and that is not cosmetic:
python -m venvadds
pip, and on 3.11 setuptools too, and a scanner cannot tell "ctrlrun needs this" from "the venv
came with this". The first version removed only pip, passed on 3.12 and went red in CI on 3.11
with['PyYAML', 'click', 'setuptools']. The assertion compares by equality, which is why it
caught a document that would have overstated what a consumer takes on.
Fixed
-
A shared-directory race in the cookbook tests. Two tests ran the same recipe in
examples/cookbook/<name>/on different xdist workers; one'srm -f verify-report.jsonlanded
between the other's write and read, andbash -euo pipefailturned it into a failure with
nothing wrong in the library. Measured at four concurrent runs: shared directory 2 of 4 fail,
one copy per run 4 of 4 pass. Each test now runs in its own copy, and the suite stops writing
into the working tree. -
Both adapter READMEs failed on copy-paste. Their examples show
identity=...as an
ellipsis, and dropping it leaves the first call refused withno principal is available. Both
now state the requirement and say why it is fail-closed. -
adapters/PUBLISHED.tomlhad gone stale in the other direction. 0.11.0 published both
adapters at 1.2.0 with<0.12and never updated the record, so the file spent a release
claiming 1.1.0 /<0.11. Both adapters go to 1.3.0 withctrlrun>=0.5,<0.13, because the
published 1.2.0 excludes this kernel, andRECORDEDnow freezes 1.2.0's range too.
ctrlrun 0.11.0
One question: can the record be trusted after the fact, and kept?
Every milestone so far added something the receipt records. None asked whether the receipt is
still worth reading a year later, on a database an administrator can write to, after somebody
pruned it. This is the first milestone whose subject is the evidence itself rather than the
decision, and the first that opens by admitting a defect in the thing it is about: the chain has
never detected truncation or append, both reachable in two SQL statements, and both written down
since SPEC-v0.6.md §6.4.
Added
-
docs/SPEC-v0.11.md, the v0.11 "Evidence" contract. Documentation only; the version bump is
the release item's. It answers one question, can the record be trusted after the fact and kept,
and it opens by demonstrating the defect it exists to close: a truncation and a forged append both
verify as intact after two SQL statements, because the head that would catch them is a row in the
same database. Transcribed from a real store rather than argued. -
A reader that names a bad row and blinds nothing else (
SPEC-v0.11.md§5, rule 3). A single
malformed value of a declared key raised out ofReceipt.from_dict, and because both stores
build every row before any caller sees one, that oneUPDATEstoppedctrlrun receipts,
receipts --verify-chain,ctrlrun inspect,ctrlrun statsand the operator MCP server's
receiptsandstatstools together.inspecton an action the tamper never touched is what
the blast radius really was: not "this receipt is unreadable" but "this store is unreadable".
SPEC-v0.7.md§12.5 recorded it and deferred it twice.One tampered row now costs one row.
ctrlrun.receipt.UnreadableReceiptis what a store hands
back for a row it cannot construct, carrying the row'sseq, itsreceipt_idwhere that field
alone is readable, and the type of what refused it, never the message.CHAIN_BREAKSdid not
change: §12.5 offered a new break name as one of two candidates andSPEC-v0.11.md§5.1 declines
it, becausecontent_alteredalready names a document that cannot be canonicalized and a second
name for one fact would be two names for one break. -
A row that does not parse is one row too. The first implementation of the reader above
calledjson.loadsin the generator expression that fed it, outside the guard, so a row
whose storedjsonis not JSON at all raised through every reader exactly as before v0.11, and
worse:JSONDecodeErroris not aCTRLRunError, so the CLI's handler did not catch it either
andctrlrun receiptsprinted a traceback. OneUPDATE receipts SET json = 'not json'was
enough. Parsing now happens inside the refusal's own guard, and a row that parses to something
that is not an object (3,"a receipt",[1,2,3],null) is refused as one row rather than
trusted. Found by review; the tests that missed it all tampered with a row's content, and
{}and a float among the controls are both valid JSON. -
Enforcement coverage: what this deployment has never exercised (
SPEC-v0.11.md§7).
ctrlrun scan --coveragereads a store and reports the policy entries, gateway tools and
@protectactions that no receipt in it names.From what is already written: no new event type and no new column. The action name lives on
the receipt rather than on the event, and every action that reached a decision leaves one, a
denial included — so an action that is always denied counts as exercised, because the deny
rule firing is the action being enforced rather than ignored.It is a list and not a score. No percentage, no ratio, no badge, and it does not move the
exit code: a number that ranked a deployment would beverifygrading an operator's document
in a new costume, whichSPEC-v0.4.md§3.9 forbids. Every entry carries a reason that states
what was not found, and the report says in every rendering, empty or not, that a policy entry
nothing exercised may be correctly unused — a quarterly job, a deny rule that exists so the
action is refused rather than unknown, a tool nobody has needed yet. -
Retention: a prune that leaves the chain verifiable across the gap, a checkpoint, and a
hold (SPEC-v0.11.md§4 and rule 2). There has been no retention policy until now, and
../ctrlrun-docs/docs/postgres.mdsaid so in the same breath as the reason one is hard:
deleting receipts from the middle or the end of the chain is detected as a break by design.A prune removes a prefix, never a suffix and never a middle, and leaves a checkpoint
the chain reader seeds from.ctrlrun prune --through --older-than --provider --by --reason.It refuses rather than warns, and there is no
--force, no--allow-gapand no setting
that admits a break the prune caused. Refused: a prune that would leave a(kind, seq)pair
the store did not already report; one through the chain's head; one moving the checkpoint
backwards; one overlapping a held range; and one that would delete a ledger row whose charge
is still held, or aCOMMITTEDrow insideSPEC-v0.9.md§7.3's window, because pruning that
hands back authority nobody granted.Rule 2 is a delta, not "the chain verifies afterwards."
unchainedis a pre-existing
condition on any store migrated from v0.1 to v0.5 and can never be inside a prefix, so the
absolute version would make retention permanently impossible on the oldest and largest stores,
which are the ones it is for.The prune anchors its checkpoint before it deletes anything. An attacker who erases a
prefix and writes a checkpoint to explain it must also anchor it, through the provider, which
is outside the store, so a prune stays visible in the anchor history even though the receipts
are gone. An anchor at or below an anchored checkpoint is then superseded, not broken:
without that, every anchor older than the retention window would be permanently
anchor_brokenand an anchoring deployment would have to choose between pruning and a
permanent tamper signal."Anchored" means the provider says so, at the pair the checkpoint claims. The first
implementation took the union of what the provider returned and what the store's ownanchors
table held, so oneINSERTbeside a forged checkpoint row bought supersession and the row's
hash was never compared to anything. Supersession now comes fromprovider.since()alone, and
the anchor's(seq, hash)must be the pair the checkpoint asserts. A local row the provider
does not confirm buys nothing. Found by the independent review the build order required for
this item, which also gaveSPEC-v0.11.md§4.6 the sentence that says which reading is meant.A prune leaves two receipts, and they are distinguishable. The first records the request,
--through,--older-thanand--reason, stagedproposed; the second records what became
of it,completedorrefused. They were byte-identical at first, and--older-thanwas in
neither, which made the record of a refusal worth nothing.The bound comes from the receipts, not from
receipt_chain. That row is the one
SPEC-v0.11.md§2.1 assumes an attacker rewrites, and deciding--throughfrom it meant one
UPDATEturned a prefix prune into a full-chain delete that both readers called clean.The prune's lock is held across the validation and the delete on both backends. SQLite's
pruning()openedBEGIN IMMEDIATEand then everyput_anchorwent throughwith connection:
and committed it, so the prune held the lock for one statement; a failed prune could leave
missingandlink_brokenon a chain that was intact when it started. The defect had been
found on Postgres during the item and fixed only there, and SQLite is the default backend.A checkpoint is a row, not a receipt field. A receipt naming itself a checkpoint is a
string in a document, andSPEC-v0.3.md§4.3.1 settled that shape. A prune writes a receipt
for a human; the row is what the walk reads. -
ctrlrun hold place/release/list. A hold names a range and refuses to prune it.
No expiry: a hold that lapsed on a timer would release evidence on a schedule nobody
reviewed, which isSPEC-v0.9.md§4's rule about a budget hold applied unchanged. -
G29,G30andG32.G32grades the interaction: an honestly pruned chain leaves a
clean anchor report.G28grades a truncation against an anchor andG29grades a prune
against the chain, and the pair was graded by neither. -
An anchor: the chain's head, recorded where the store's writer cannot reach it
(SPEC-v0.11.md§2, §3). The receipt chain detects alteration. It does not detect
truncation, because the head that would catch it is a row in the same database. Measured on
a six-receipt chain, in two statements:DELETE FROM receipts WHERE seq > 3 UPDATE receipt_chain SET seq = ?, hash = ? -> ok=True verified=3 breaks=[]Three receipts erased, and the chain reports itself intact. An anchor records the pair the head
holds outside the database, at an interval the operator chooses, and the same two statements are
then namedanchor_brokenat the anchoredseq.What an anchor proves, and what it does not. It freezes a prefix: anything at or below
an anchoredseqcan no longer be removed or altered without the anchored pair failing to
reproduce. An append is not detected, because it lands above every anchoredseq; nor are
receipts created and destroyed between two anchors; nor who wrote any of it. The window you are
exposed to is(last anchored seq, current head], and its size is your choice of interval.
That is the number to quote rather than any sentence about tamper-evidence, and there is a test
that runs a forged append and requir...
ctrlrun 0.10.0
One question: when one agent hands work to another, what does the second one hold?
Added
-
A hop: authority that crosses an agent boundary. A hop is
SPEC-v0.3.md§5's delegation over
a boundary the kernel does not control, and almost nothing about it is new machinery. The same
contained_dimensiondecides it, over all eight dimensions, at creation and again at every
evaluation. The samedelegation_idnames it.SPEC-v0.9.md§2.7's walk charges it, so a
consumption under a hop costs the issuer and every ancestor to the root.One rule is new, and it is the whole of what v0.10 adds: an action proposed under a hop is
evaluated against that hop's grant alone, with no fallback. Before it,Authority.evaluate
passed on any matching grant, so a receiving agent holding a grant of its own was authorised by
that one, the hop was never consulted, and the issuer's budget paid nothing. Which way it went
turned on how two identifiers sorted. -
Control.hop(parent_id, grant, *, by, action_id=None),hop=on@protect,Control.execute
andControl.evaluate, andhopin the metadata the gateway and the ACS hook already carry. -
ctrlrun.receipt/v7addshop: the hop an action ran under, never the one it created. -
ctrlrun.policy/v8addsupstream:on an action entry, pinning the server an action
authorises itself against by certificate or by the hash of its advertised tool schema. -
ctrlrun.guarantees/v6: G25a hop narrows or it is refused, G26a hop is named on both sides, G27a swapped upstream is denied. -
ctrlrun inspect --hopemitsctrlrun.hop/v1: who issued a hop, and which dimensions each
link narrowed, which is the question an operator paged at 3am actually has. -
ctrlrun scannames the principals holding a grant no hop bounds. It reports and does not score. -
task=andhop=onctrlrun.adapter.needs_approval, the pre-invocation predicate a
framework asks before it invokes a tool. Without them the predicate evaluated against the
receiver's whole candidate set whileexecuteevaluates against the hop alone, so it answered
"a human is needed" for a callexecutethen refuses: the framework surfaced an approval item, a
human said yes, and the call failed anyway. Never a wider grant, becauseControl.executeis the
enforcement point and refuses either way; what it cost was the framework's own approval item and a
receipt nobody could explain.SPEC-v0.10.md§9 froze the name and §9.4 records that it shipped
after this section was first written, which is why it is here and not above.
Changed
Every behaviour below is stricter than 0.9.0, with what it did before.
- An action presented with a hop is decided against that hop alone. Before: every grant the
principal held was a candidate and the narrowest-sorting one decided. An action presented with
no hop is decided exactly as 0.9.0 decided it, which is why every existing deployment
upgrades untouched. - A resumed leg is evaluated on the task and the hop. Before (
v0.9 §6.3.2), the task
dimension was not evaluated on a resumed leg at all, because the rehydrated action carried none;
EXECUTION_STARTEDnow carries both and_resumed_contextreads them back. A leg suspended by
0.9.0 carries neither and is evaluated exactly as 0.9.0 evaluated it, or every action in
flight across the upgrade would be denied. - A lease extension is decided against the hop the first leg held, on every round. Before, and
briefly during this milestone, a receiver holding a grant of its own kept its reservation across
a round trip after the hop was cut. - Observe mode reports the refusal enforce mode would raise. Before (
v0.9 §4.2.1b), it
reported whichever refusal it reached first, which was not always the same one. - A policy that pins an upstream refuses an action the process has not verified one for.
Before, no such key existed. In-process there is no upstream to observe, so a pinned action
refuses on every call; the ACS hook refuses such a policy at construction.
The upgrade, and the one irreversible thing
0007_budget_ledger is still the last migration: receipts and events are stored as whole JSON
documents and created_via is already TEXT, so v0.10 needs no schema change.
The irreversible step is creating the first hop, not installing 0.10.0. CreatedVia is a closed
vocabulary and Authority._candidates reads every delegation row before filtering any of them, so a
0.9.x binary meeting one created_via='hop' row answers authority_unreadable for every action in
the deployment, not just that delegation. Fail-closed, and a deployment that stops. A deployment
that installs 0.10.0 and creates no hop can still roll back.
ctrlrun 0.9.0
Undated until the tag.
Every guarantee before this one answers whether. A grant says amount_lte: 5000, and is silent
about the thousand actions that each pass it: the authority model bounds one action and has never
bounded an aggregate, so an agent acting entirely within its permissions can still empty an account
one permitted refund at a time. v0.9 answers the other half: how much, over which records, for
which task?
Three dimensions, one rule each.
Consequence budgets. A grant may carry budgets:, a metric with a limit over a rolling window,
consumed on reserve, inside the reservation's own transaction, because a check on one line and a
consumption on another is a race two processes win together. Ambiguity is not a refund: an
AMBIGUOUS effect holds its consumption until a human or a hook resolves it, because otherwise an
agent that can manufacture ambiguity can manufacture authority. A budget names a metric, not a
consequence: nothing here ranks, scores or classifies an operator's actions.
Scope providers. scope= answers "is this record this principal's?", strictly before the
reservation, which is the bite on an identifier an attacker chose. A grant permits records.read on
customer:*, and until now nothing had an opinion about whose record customer:90210 is.
Task-bound authority. tasks: narrows a grant to a unit of work, by the same child ⊆ parent
rule as every other dimension. It limits blast radius; it does not detect a hijack.
What a budget is not. It cannot recall an action already in flight: a rolling window changes
what the next reserve may do and nothing about what is already reserved, so a reservation taken a
second before the window rolls commits regardless. It counts a metric an operator named, an argument
on the action, and is not a consequence model: nothing ranks, scores or classifies what an action
means. It is per store, so two deployments sharing a provider account and not a store each enforce
their own. And it is fail-closed against its own principal: an agent able to manufacture ambiguity
can pin a budget it cannot spend, which is a denial of service against the operator's own agents and
is the deliberate side of the trade against an agent that manufactures authority.
Added
-
Task-bound authority (SPEC-v0.9 §6). A grant may carry
tasks:, a unit-of-work dimension
attenuated by the samechild ⊆ parentrule as actions, resources and environments.
Control.execute(task=...)andControl.evaluate(task=...)take the resolved task id;
@protect(task=...)takes a template over the call's arguments, likeeffect=andresource=.
G24 grades it: a task-bound grant refused off its task, by reason and not by type.A grant that names no
tasks:authorises any task, so every existing grant upgrades
untouched.SPEC-v0.3.md§5.4 settled that asymmetry in writing: a root grant's omissions are
an operator's decision, a delegation's are what an attacker would write.Two paths deliberately do not evaluate the dimension:
Control.resumeand a lease
extension. Both rehydrate an action that carries no task, and evaluating it there would put
AUTHORITY_DENIEDon what is the only receipt an MCP multi round-trip ever gets. A resumed leg
is therefore unbound by task, which is stated rather than hidden.The task reaches the authority decision and the receipt, and never the action hash: a field
onActionwould move every hash in existence and invalidate every stored approval. -
Consequence budgets, enforced (SPEC-v0.9 §4). G22. A budgeted grant charges every
ancestor on reserve, inside the reservation's own transaction, and the ledger is released
exactly when the effect reachesFAILED.Ambiguity is not a refund. An
AMBIGUOUSeffect keeps its consumption until a human or a
reconcilehook resolves it, because otherwise an agent that can generate ambiguity can
generate authority, and generating ambiguity is free for any flaky integration. This is the
correctness hole that kept budgets out of four milestones.The refusal is
ActionDenied(reason="budget_exhausted"), naming the grant, the metric and the
window, and never the remaining balance: refused actions cost nothing, so a refusal that
reported the balance is an oracle an attacker binary-searches.ctrlrun verifyreports 22/22 on the shipped examples, with G22, G23 and G24 all graded
against positive controls. -
The budget ledger, and one amendment to a frozen protocol (SPEC-v0.9 §3).
StateStorehas
been frozen since v0.6 and gains exactly two things:charges=onreserve_effectand
consume_approval_and_reserve, andconsumptions()to read the ledger back. Migration
0007_budget_ledger, additive and forward-only.The charge lands inside the transaction that writes the reservation, on all three backends.
Anything else is a check-then-act race: two processes read the same remaining amount and both
spend. On Postgres that needs aSELECT ... FOR UPDATEon a per-grant anchor row before the sum,
because READ COMMITTED does not serialise a sum and an insert. Measured, not chosen: without it,
twenty-four processes racing a budget that permits ten spent 2400 against a limit of 1000,
with zero refusals.Nothing spends this yet. The consumption, the holds and the releases are the next item.
-
Consequence budgets, in the document (SPEC-v0.9 §2). A grant may carry
budgets:, each a
metric, alimitand awindow. They load, validate, render into the policy hash, and
attenuate down a delegation chain. Nothing counts yet: the ledger and the spending are
separate items, so this release note describes a contract and not an enforcement.The window axis reads backwards, and it is worth stating plainly. Over the same limit a
shorter window is a higher rate: a child of 100,000 per hour under a parent of 100,000 per
day is 24 times the parent's authority, and is rejected. A child of 100,000 per week is one
seventh the rate, and is accepted. Containment is existential: for every parent budget there
must exist a child budget on the same metric withlimit <=andwindow >=, so one child
budget may discharge several of its parent's.A metric names an action argument, or
count. Its value must be a non-negative integer that
is not abool, so money is budgeted in minor units, asexamples/authority/payments.yaml
already does for every constraint. The kernel does not know what any metric means: there is no
branch on a metric name anywhere. -
Scope providers (SPEC-v0.9 §5).
Control.execute(scope=...)and@protect(scope=...)take
a callable that answers what the calling principal's assigned scope is; the kernel matches
this action's resource into it, with the relation a grant'sresources:already uses. It runs
strictly before the reservation and before the precondition recheck, so a provider that
hangs leaves nothing reserved and nothing executed. G23 grades it.This is the bite on an identifier an attacker chose: a grant permits
records.readon
customer:*, and until now nothing had an opinion about whose recordcustomer:90210is.Two distinct refusals, never one:
scope_unavailablewhen the provider raises, answers with the
wrong shape, or answers something the canonicalizer refuses;out_of_scopewhen it answered and
the resource is not covered. A non-callablescope=isInvalidArgument, at decoration time
under@protect.Only the hash of what the provider returned reaches the receipt, under its own domain tag so
it can never equal a precondition fingerprint over the same mapping. A scope is a list of what a
principal may touch, and an evidence store is not the place to keep a second copy of it.It amends
SPEC-v0.7.md§6.9, which said v0.9's scope providers would configure the
precondition hook rather than add a second one.SPEC-v0.9.md§5.2.1 records the amendment and
the three mechanical differences that justify it. -
The operator surfaces for a budget (SPEC-v0.9 §7). No new command.
ctrlrun inspect
gains--grant GRANT_ID, which reports each of that grant's budgets as three numbers:
consumed, the un-released sum over the rolling window, which is the number that decides;
held, the part of it whose effects have not committed; and why, the effect holding each
part and the state it is in.The third is the deliverable. A budget that refuses while it looks nowhere near its limit is
almost always one unresolved effect, and without the third column an operator cannot get from
the refusal toctrlrun resolve. The view prints that command with the effect key already in
it, because an operator retyping the key from the line above is one transcription away from
resolving a different effect.ctrlrun effectssays what each effect is holding, so--state ambiguousanswers "what is
pinning this grant". It says spent for a committed effect and holds for every other,
because §7.2 defines held as the part that has not committed and one word for two numbers would
make the two commands disagree.ctrlrun statsreports the ledger's row count, so growth is observable before it is a problem.
The ledger only grows: the kernel deletes no row, ships no retention command and has no policy
key that expires evidence. What §7.3 owes instead is the invariant that makes somebody else's
archiving safe, and it states it: rows older than the longest window on any budget of a grant
cannot affect any future decision.ctrlrun.budget/v1is its own document rather than a key insidectrlrun.inspection/v2,
because that one answers about an action and this answers about a grant: a reader handed one
would have to know which of two shapes it got. Every existing--jsonshape is u...
ctrlrun 0.8.0
Every guarantee shipped before this one verifies the principal that acts. G7 refuses an action
whose requester cannot be resolved; nothing whatever was asked of the principal that permits it.
approver was a non-empty string, ctrlrun delegate --as was an assertion typed at a shell, and
the operator MCP server authenticated who answered without checking they were entitled to. v0.8
asks the question all seven put only to the acting side: who may say yes, and can the kernel
tell?
Five guarantees answer it — G17 an unentitled approver, G18 the requester cannot approve, G19 one
principal counts once, G20 a credential revoked before its exp, G21 an unapproved policy decides
nothing — and one thing that is not a guarantee: break-glass, which is a grant and not a flag.
Opt in, then fail closed. A deployment that names no approver identity behaves exactly as
0.7.0 did, and a test drives the whole approve-and-execute path to prove it. One that names one has
no partial mode, no "resolve if you can", and no setting that puts the string back. There is no
skip_entitlement, no trust_approver, no allow_self_approval, no break_glass=True, no
ignore_revocations — and that sentence is a test, not a claim: the shipped package is grepped for
sixteen spellings a flag would take, and the control plants one and finds it.
What v0.8 does not close, in one place. A persuaded approver gives a valid approval and the
receipt records it as one. An entitlement check is against what the granting surface recorded,
not a re-derivation from a credential that no longer exists. A revoked credential leaves a log line
and no receipt. A feed is worth what its source is worth. And a policy change that no verified
principal other than the proposer approved decides nothing — which is not the same as saying a
policy cannot be changed by whoever holds the file.
Added
-
A policy change is a protected action (
docs/SPEC-v0.8.md§8). The policy is the one file
that decides every other decision, and until now it was changed by editing it. v0.6 made the
change evidenced: every receipt records the hash of the policy that decided it. v0.8 makes it
approved: a policy nobody approved decides nothing.ctrlrun policy propose --file new.yaml ctrlrun approve <request> # there is no `ctrlrun policy approve`Control(policy, store, require_approved_policy=True)
An ordinary action, which is why §8 adds no event type.
ctrlrun.policy.changehas an
ordinary action hash, an ordinary effect key (policy:<hash>), ordinary events and an ordinary
receipt, so §2, §3 and §4 apply with no second path to keep correct: an unverifiable approver is
refused, an unentitled one is refused, a proposer approving their own change is refused, and
M-of-N counts. A committed receipt for that action is the approval of that hash.The approval is per deployment, and that is not obvious. The hash folds in the effective
authority and the effective environment, so the same file instagingand inprodis two
hashes and needs two approvals — which is what an operator wants and what nothing else would say.
Comments, key order and whitespace do not move it.The name is reserved and declarable, and a first draft had that backwards. A document may
declarectrlrun.policy.changeunderctrlrun.policy/v6; nothing else may name it in a
resource:oreffect:template. Underrequire_approved_policythe policy in force must
declare it withdecision: approve— a policy that declares itallow, or omits it, decides
nothing, with the refusal naming the key. That is the rule that closes the obvious escape:
installing such a policy still needs an approval under the old one, and the moment it is
installed the deployment stops deciding anything.ctrlrun policy replay --file new.yaml --last Nreports which recorded decisions change
under a proposed policy. It writes nothing, executes nothing and reserves nothing, and it reports
what changes — never safer, riskier, too permissive, a score or a grade. A receipt whose action
cannot be rebuilt is named and skipped, never counted as unchanged.What it does not close, in full. An administrator with write access to the policy file can
still widen who may approve the next change. What they cannot manufacture is the approving
principal: the approver's credential is verified by the provider configured in code, and §4.1
refuses their own. So the property is exactly "a policy change that no verified principal other
than the proposer approved decides nothing", and not "a policy cannot be changed by whoever
holds the file". An approval also binds a hash and not an ordering, so any hash ever approved
stays approved and a superseded policy can be restored with nothing in the evidence saying so.ctrlrun verifygrades G21 with the flag set by verify, under a note rather than anN/A. -
A credential revoked before its
expis refused (docs/SPEC-v0.8.md§6).jwt_identity.py
used to say, in as many words, that a verified token is valid until itsexpand that nothing
polls. Both sentences are gone.from ctrlrun.revocation import FileRevocationFeed JWTIdentityProvider(..., revocations=FileRevocationFeed(path, issuers=[ISSUER]))
Security Event Tokens are consumed, from a file the operator's own transmitter writes or by RFC
8936 poll delivery. Nothing subscribes and nothing introspects: a subscription needs an
endpoint this project serves and an introspection call is a question it asks nobody. Consuming an
event is reading it.The match is against the token's own
iss,subandjti, never against
Principal.agent.agentis whateveragent_claimnames, which a deployment may set to
client_id, so matching aniss_subidentifier against it would compare two different things
and admit exactly the deployment the feature was bought for. The check runs inside the provider,
where the raw verified claims are still in hand, and nothing new is stored onPrincipal.Two things this closes less than it sounds, both stated wherever the feature is described. A
revoked credential leaves a log line and no receipt: resolution happens before an action
exists, so there is noaction_idto attribute a refusal to, where an expired credential
leaves a receipt. And a feed is worth what its source is worth: whoever can write the file can
refuse the operator's own agents, which is a denial of service against them and is fail-closed.
They cannot admit a principal the issuer revoked, because the feed is only ever consulted to
refuse. That asymmetry is the security property.max_stalenessis the operator's call. Unset means no bound, which is 0.7.0's availability.
Set, and every principal of a covered issuer is refused past it withrevocation_feed_stale,
because "has this been revoked" is exactly the question a stale feed cannot answer. Configuring
it makes the feed's availability part of the deployment's, and a kernel choosing that for an
operator would be choosing their outage budget.Behind
ctrlrun[identity], beside the provider it serves.import ctrlrunimports no part of
it.ctrlrun verifygrades G20 against a feed verify supplies, with a note saying so rather
than anN/Aclaiming something about a document that is silent on the subject.No standards claim. RFC 8935, RFC 8936, RFC 9493 and CAEP are consumed as code, and the words
compatible, conformant, aligned and certified appear nowhere. -
Break-glass is a grant, and there is no flag (
docs/SPEC-v0.8.md§5). An incident needs
authority nobody was granted in advance. The wrong answer is a setting: a setting leaves no
record, expires never, cannot be revoked and cannot be narrowed.authority.pyalready has
grants that are all five, so break-glass is a delegation beneath an envelope the policy
declared in advance.authority: break_glass: incident-payments: subject: {agent: "oncall-*"} # who a grant opened here may be FOR actions: ["payments.*"] constraints: {amount_lte: 50000} max_ttl: PT4H # the longest expiry a grant beneath it may carry controls: [incident-response] # whose approver_role gates who may OPEN it
There is no CLI command for it in 0.8.0. One was built and withdrawn before the release:
the CLI builds aControlthat wires no approver identity, and there is no configuration key
for one, soctrlrun break-glasscould not succeed in any configuration the CLI can load. It
failed closed, which is the right direction and not a reason to ship it — a command that cannot
work is a claim the CLI makes that the code does not honour. Opening an envelope in 0.8.0 is
reached from an application that built its ownControl; the shell surface returns in the
milestone that gives the CLI a way to verify an approver.docs/SPEC-v0.8.md§14.5 records the
two alternatives and why each was worse.The envelope decides nothing, by construction. It lives in
Authority.envelopes, a mapping
separate fromgrants, because the candidate set is every entry ofgrantsunconditionally: an
envelope living there would decide actions, which is the opposite of what it is for. The test
asserts it is absent from the candidate set rather than merely unmatched.It is covered by the policy hash,
max_ttlincluded. The argument for declaring the widest
authority an incident can reach in a file is that somebody reviewed it before the incident, and
that argument is only true if widening it moves every receipt.There is no
--as. Whoever opens one is the principal the deployment's approver identity
resolves, gated by the envelope's `...
ctrlrun 0.7.0
Every milestone before this one asked what holds inside CTRLRun. v0.7 asks whether it holds at
the edges the kernel does not control. The kernel does not decide whether the remote acted, an
executor does. It does not own the clock its leases are measured against, once the store is on
another host. It does not know whether the world still looks the way it did when a human said
yes. Six items answer those edges:
| What it adds | Where |
|---|---|
ctrlrun.transport, the NotExecuted classifier, in core and stdlib only. One rule, promoted out of ctrlrun[gateway] rather than copied, reachable from @protect. |
§2 |
Clock-skew detection. PostgresStateStore measures its server's clock against this host's and names divergence with a new event. It observes and reports, and changes no decision. |
§3 |
| Attempt numbers that never repeat. Three Postgres defects that could hand one attempt number out twice, or move it backwards, fixed before v0.7 made the number load-bearing. | §5.6 |
The provider idempotency token, ctrlrun.idempotency_token(), derived from (effect_key, attempt): a deterministic handle for reconciliation, and one that changes on a renewal. |
§4 |
The attempt ceiling, max_attempts, a policy key bounding renewal after FAILED. Needs ctrlrun.policy/v5. |
§5 |
Precondition fingerprints, an approval bound to the resource state it was granted against and rechecked strictly before the reservation. Needs ctrlrun.receipt/v4. |
§6, §7 |
Section numbers are docs/SPEC-v0.7.md, which is the contract; §9 freezes every public name
added here. ctrlrun verify now grades sixteen guarantees under ctrlrun.guarantees/v3, G12 to
G16 being new, each with a positive control and each N/A only for a reason that is true of the
document it was handed. pip install ctrlrun still installs pyyaml and click and nothing
else, import ctrlrun still imports no module from an extra, and ctrlrun demo still runs every
scenario in under a minute with no network.
Stricter than 0.6.1, with what 0.6.1 did
Everything here can refuse, or record as unknown, something 0.6.1 accepted or recorded as
settled. Nothing here is a flag, and no setting relaxes any of it.
- A continuation leg never records
FAILED. A continuation exists only because the remote
answered and is holding the exchange, so nothing on that leg can say the remote did nothing.
At 0.6.1 a refused connection on a continuation, a pre-dispatch JSON-RPC code, the401rule
ofv0.2 §6.8and a tool error under an operator'snot_executed_on_error: trueeach recorded
FAILED, and the gateway answered the client-41011"not executed", which permitted a
retry of an effect the upstream may have been part-way through. The gateway now records
AMBIGUOUSand answers-41010, and the effect needsctrlrun resolve. The upstream's own
response is relayed unchanged, tool error included. - Behind a proxy the gateway claims nothing. httpx reports an unreachable proxy and a TLS
failure with the target after the proxy answered theCONNECTline with the same
ConnectError, and a writtenCONNECTline is a written byte. At 0.6.1 the forwarder mapped
everyConnectErrortoNEVER_CONNECTED, therefore to afailedreceipt and-41011. Where
urllib.request.getproxies()names a proxy,ConnectErrorandProxyErrorare now an unknown
outcome:AMBIGUOUS,-41010, and actrlrun resolve.NO_PROXY=*is honoured and a
narrowerNO_PROXYis not consulted, so a bypassed host is judged as if it were proxied, which
costs a claim and never makes a false one. With no proxy configured nothing changes. ctrlrun.policy/v5. A document that declaresmax_attemptsmust declarev5; 0.6.1's
newest schema wasv4, and av4document naming the key is aPolicyErrorat load, with the
key, the action and the line. Every document that loaded at 0.6.1 loads unchanged and renews
without bound, because there is no default ceiling and no value of the key means "unlimited".
An 0.6.1 reader refuses av5document, as it should.ctrlrun.receipt/v4. Two new fields,precondition_at_requestand
precondition_at_recheck. Upgrade every reader before any writer: av4JSONL line handed
to 0.6.1 rehashes wrongly and reads as altered. Rendering is stricter in the other direction
too, and visibly:to_dict(),ctrlrun receipts --jsonandctrlrun inspectrender each
receipt under the schema it was written with, so a pre-v0.6 receipt shows its ownv1orv2
label and keys where 0.6.1 showedv3. A key added to a stored receipt, a relabelledschema,
a removed one or an unknown one iscontent_alteredat itsseq, where a reader could have
missed it before.- Migration
0005_precondition_fingerprint, and a store no 0.6 process may still hold. A
database built by 0.6.1's own code migrates keeping every row, and 0.6.1 then refuses it at
open, naming0005. Stop every 0.6 process before any 0.7 process opens the store: a store
checks migrations only at open, so a 0.6.1 process already running would consume a fingerprinted
approval with no comparison, and would rehash everyv4receipt underv3's keys and report
a correct chain as altered. The trigger is the first receipt a 0.7 process writes, not the first
caller that passespreconditions=. - A precondition, once one exists, is never skipped. An approval that carries a fingerprint,
presented by a call that names no provider, is refused rather than consumed: that includes the
gateway and the ACS hook, which name none. A provider that raises, hangs or returns something
with no canonical form refuses the action and reserves nothing. There is no
skip_preconditionsand no timeout parameter. - The attempt ceiling is stricter only where an operator asks for it, and then it is
absolute: above the ceiling the executor is not called, on any route, andActionDeniednames
attempt_ceiling. ctrlrun verifyopens loopback sockets it bound itself.v0.4 §3.7's "no scenario opens a
socket" becomes no connection except to the store--store-urlnames and to loopback
listeners verify bound itself, because G12 needs a peer that can receive a byte. The old
sentence was already untrue under--store-url postgresql://remote-host/…. The test suite's
network guard admits exactly that and no more: IPv4 to the127.0.0.1literal, at a port this
process bound through a stream socket that is still open.localhost,::1,0.0.0.0, every
AF_UNIXpath and every datagram send are refused.
What this release does not close
Stated here, and not only in the specification, because each one is a limit somebody operating
this will meet.
- A precondition fingerprint narrows the window between a human's approval and the action's
execution, and does not close it. The recheck is a network call, so it runs outside the atomic
reservation write, and a change that lands after the comparison and before the reservation is
not refused. It takes the exposure from minutes of human deliberation down to milliseconds,
which is worth having and is not prevention. T261b opens that residual window and asserts
exactly that. - On the reconcile route a doomed attempt still costs a human answer and three provider
calls. Undermax_attempts: 1on anapproveaction whose retry carries areconcilehook,
the approval gate runs before the ceiling's check: a new approval request can be created, a
human can grant it, and the reservation that consumes it is then refused with
attempt_ceiling. One wasted answer, never an execution. On that same route the precondition
provider is called three times, once on the request pass and twice on the retry, and a
provider that is down there makes the refusalApprovalMismatch(reason="precondition_unavailable")
rather thanattempt_ceiling, writes no effect record and never runs thereconcilehook, so
the operator is told the wrong reason for an attempt that could never have run. The ordinary
sequential route calls the provider zero times and refuses before any human is asked. Closing
either needs a seam that would make the ceiling's check unreachable from any public route, and
a guarantee that could not have failed is not a pass. max_attemptsbounds attempts, not executor invocations. ASuspendedexecutor holds its
reservation and everyControl.resumeruns on that same attempt, so an elicitation loop is one
dispatch however many rounds it takes. The gateway bounds those withmax_elicitation_rounds;
a directControl.resumecaller has no bound, and this release adds none.- A refused attempt number is spent. Raising
max_attemptsfrom 2 to 4 after a refusal buys
one further dispatch, not two. - The classifier's register sees only this library's own sends. A claim is about the executor
run, not about one connection, andControlmarks a register on every send through
ctrlrun.transportorctrlrun.gateway.transport.request. An executor that sends part of the
effect throughrequests, through httpx directly, or on a raw socket, and then uses the
classifier, can be handed aNotExecutedthat is true of these connections and false of the
effect. So can one that raises a claim while a sibling thread's request is still in flight. The
claim holds where every request of the effect goes through the classifier on the executor's
context, and that sentence is in the module docstring, the class docstring and §2.3. - A reused
action_idstill leaves the attempt a late write is about undecided, on every
backend. The attempt number is now monotonic, so no two reservations of one key carry the same
number; what is not closed is attempt identity. A transition names its holder byaction_id
alone, so attempt 1's write, arriving after ...
ctrlrun 0.6.1
Everything found after v0.6.0 was tagged: twenty-nine defects from an audit of the shipped
code, the gateway's transport behaviour, and the documentation's first screen. No public API
name changes and no schema change. Two behaviours become stricter and could refuse input
that 0.6.0 accepted silently — @protect on an async def, and a duplicated mapping key in a
policy or authority document — and both are listed below with what they did before.
Fixed
@protecton anasync defrecorded a consequential action as done that never happened.
The wrapper is synchronous, so "the return value" was an un-awaited coroutine: the effect was
committed and acommittedreceipt written before the body ran, and the legitimate retry was
then refused withDuplicateEffectfor ever. Async, generator and async-generator functions
are refused at decoration time.- One human approval could authorise two effects on Postgres. A lost
COMMITon a
renewal — a reservation over aFAILEDrecord, v0.1 §5.4's one automatic retry — re-issued
the approval without consuming it, sofind_granted_approvalwould hand the same "yes" out
again for a different effect key.v0.1 §4.2 A2requires single use consumed atomically with
the reservation. SQLite has no lost-commit resolution, so this was also a backend-switch
regression. ctrlrun verifyreported false N/A reasons, which is a false green. N/A is excluded from
the denominator, so a run that could exercise one guarantee reported "1/1 declared guarantees
pass" beside ten reasons that were each untrue of the operator's document. A miss on the
authority axis is now distinguished from a miss on the policy axis and named. Verify also
crashed, exit 1, on aneffect:template containing{resource}— a code path--help
documents as "a guarantee FAILED" — because it invented an argument for a placeholder that
names the action'sresourcefield. Every shipped example underexamples/is verified in
CI now.- Webhook approvals were never routed.
WebhookApprovalProvideradvertises
POST /ctrlrun/approvals/<id>asrespond_toin everyAPPROVAL_REQUESTEDnotification, and
Gateway.handle_approvalhad no caller: the approver's system posted its answer to that URL,
read an HTML 404, and the approval sat pending until it expired. - A lone surrogate in an executor's exception message stranded the effect.
mark_ambiguousraisedUnicodeEncodeErrorfrom inside the transaction — not a
CTRLRunError— and left the recordEXECUTING, which is neither outcome and blocks the
retry until the lease expires. Executor text is escaped withbackslashreplacewhere it
enters, so the row, the event and the receipt carry the same value and the chain hashes. - The SQLite store leaked two file descriptors per thread. Connections were pinned in a set
onlyclose()emptied, so a host whose threads come and go eventually failed every store
access with "unable to open database file". Measured on the gateway: 200 connections, 410
descriptors; now flat. - A duplicated mapping key in a policy or authority document failed open.
yaml.safe_load
resolves one to the last silently, so a grant withactions:written twice during a narrowing
edit loaded as("**",). Refused now, naming the key and the line, by the one loader policy
and authority share. ctrlrun delegateandctrlrun revokeignored the store. Both calledControl.from_file(),
which always opens.ctrlrun/state.dbbeside the policy, so on Postgres a delegation went
into a local file no agent reads andrevokereported success while the delegation stayed
live. Both take--store-urlnow.- Read commands created the store they were reporting on.
ctrlrun receiptsin a directory
without actrlrun.yamlcreated and migrated.ctrlrun/state.dband answered "no receipts
yet" — telling an operator looking for evidence that there was none, from a store the command
had just made. Five commands also reached the terminal as tracebacks where the identical input
printed one clean line elsewhere. - The gateway dropped a client's connection with no reply, which an agent reads as a
transport error and retries blind: a 401 or 403 whose body is not a JSON object (an RFC 6750
bearer challenge, or a CDN's HTML), a malformedContent-Length— where-1bypassed
--max-body-bytesentirely — andhttpx.DecodingErrorandhttpx.InvalidURL, which inherit
fromRequestErrorand so matched neitherexcept. - The gateway relayed
Content-Encoding: gzipwith the decompressed bytes. httpx sends
Accept-Encoding: gzipby default, so an upstream doing nothing but honouring content
negotiation made the gateway unusable withDecodingError: incorrect header check. ctrlrun scanfailed the policyctrlrun inithad just written, exit 1, on the two
actions the starter's own comment says need no effect. The rule asked "does the policy permit
this?" where it meant "does this have a consequence to reserve?".data_scope_eq: [[phi]]raisedTypeErroron every evaluation of that action rather than
aCTRLRunError, so an application catching the kernel's errors did not catch it. Refused at
load.- The gateway's startup block never reached a pipe. Python block-buffers a non-tty stdout,
so SPEC-v0.3 §8.4's block — which identity provider, which store, which environment — was
still buffered when the process was signalled. Visible only at an interactive terminal, which
is the one place nobody runs a server. - Both adapters pinned
ctrlrun>=0.5,<0.6beside a 0.6.0 kernel, sopip install ctrlrun-langgrapheither refused to resolve or silently downgradedctrlrun. The framework
range was checked against the version CI installed; the kernel range was checked against
nothing. - Two reference pages promised a
ctrlrun[conformance]extra that does not exist and a
MissingDependencythat could not be raised. SPEC-v0.5 §12.1 reversed that extra and
pyproject.tomlnever declared it, so both halves of the sentence were false. - Validate upstream JSON-RPC response IDs before changing effect state. Missing, mismatched,
and malformed responses remain ambiguous and cannot make an executed action retryable. - Forward MCP SSE progress incrementally, record the matching final response, and keep
interrupted streams ambiguous. Client cancellation closes the upstream connection. - Relay empty HTTP acknowledgements and MCP GET/DELETE requests, including session headers
and standalone streams. Recognize successful responses from accepted legacy revisions. - Preserve the original request ID in synthesized gateway errors and support IPv6 listeners.
- Restore consumed approval attribution and original attempt timing on resumed receipts,
including across database reopenings and multiple suspension rounds.
Changed
- The README's first integration example runs end to end. It stopped at
ApprovalRequiredand
left the approval and the resumption in prose, so no reader could reach a completed protected
action by copying it; it now covers the policy, the decorator,ctrlrun approvefrom the
shell,with_approval, a mutated €5,000 refused and three receipts, in one domain throughout. - The README states each guarantee once. It stated the same six of them six times — a table
after the first example, the problem table, the pipeline steps, the generated matrix, the
bullet list and the readiness block. Prose is down from 3,205 words to 2,662, with the demo
transcript, the guarantee matrix, both receipt-chain disclaimers and the whole It can't
section untouched. - The documentation home page leads with what CTRLRun is rather than with its own name, and
says the promise once instead of twice above the fold. Itstitleis the category line and
itsdescriptionthe tagline, which is whatdocs/IA.mdassigns to each; the browser tab no
longer reads CTRLRun - CTRLRun. try-itputs its controls above its explanation, in a wide column, with the policy below
them rather than between the reader and the button.- Four badges: CodeQL, the documentation site, Ruff and
mypy --strict. Downloads and stars
are deliberately absent —docs/STYLE.mdforbids social proof that does not exist, and a
count published four days after the first release measures mirrors.
ctrlrun 0.6.0
The soak criterion was amended on 2026-09-07, and it was amended downwards. It read a soak
of at least one week with no unexplained AMBIGUOUS; the week was removed rather than waited
out, and the criterion is now a published run with no unattributed AMBIGUOUS and a positive
control that fired — the two things a harness is allowed to decide about itself. SPEC-v0.6.md
§8.1 carries the reasoning, what it costs and what did not change; docs/ROADMAP.md records it
in the milestone's own reconciliation. The short version: elapsed hours were a proxy for a
question the injection ledger already answers, and what a week would actually have bought —
whether anything accumulates over days — is unestablished by anything in this repository and
is now claimed by nothing rather than owed by a gate.
The published run is twenty minutes, 889,735 actions, 133,393 ambiguous outcomes all attributed,
0 unattributed, positive control fired. The duration is printed on every surface that quotes
the run so a reader can discount it: the README's readiness block, the docs home, the
production index, and docs/production/soak.mdx, which now recomputes the criterion from the
published counts instead of reading exit_criterion_met out of the same file.
v0.5 asked can somebody else implement this? v0.6 asks: does it still hold when the process
dies, the host goes away, and the database is somewhere else?
Every guarantee shipped so far was a guarantee about one process holding one SQLite file.
BEGIN IMMEDIATE is a whole-database write lock on a local file; take the file away, put the
store on another host, and E1 — at most one caller per effect key — has to be re-earned with a
different mechanism. That is the milestone.
The suite was written before the backend it grades. ctrlrun.conformance.store runs this
repository's own acceptance tests — the cases of v0.1 §7, v0.2 §10 and v0.3 §10 that are
statements about StateStore rather than about Control — against any backend, and it landed
two items before Postgres existed. A backend measured against a suite written for it has marked
its own homework. The ordering paid for itself on the first three runs, which found three real
bugs in the Postgres store before a single test in its own file existed.
And the distinction that made it tractable: the store is reconcilable by re-reading; the remote
is not. A Postgres transaction is atomic, so an ambiguous store write has exactly one truth
and the store can go and look at it. An ambiguous remote effect has no such move, which is why
AMBIGUOUS is terminal there. Two ambiguities, one word, different remedies — and an
implementation that collapsed them would look correct while either refusing work one query could
have recovered or retrying work nothing can.
Added
-
ctrlrun.conformance.store— the store conformance suite (v0.6 item 1). This repository's
own acceptance tests, runnable against anyStateStore, with fourteen deliberately-broken
stores proving the suite can fail. It found thatevents()andreceipts()were never
declared on theStateStoreprotocol while both shipped stores implement them and four
callers depend on them; both are now declared. -
Schema version and forward-only migrations (v0.6 item 2). A
schema_versiontable records
applied migration ids — recorded, never inferred — and a store refuses a database it does not
recognise in both directions. Six places across v0.2–v0.5 said "there is still no
migration story — that is v0.6." -
SchemaMismatch, exported fromctrlrun. Raised at open when a store meets a database it
does not recognise. Its own type because "your database is from the future" and "your lease
is negative" have entirely different remedies. -
PostgresStateStore—ctrlrun[postgres], lazily imported (v0.6 item 3). The frozen
v0.1 §5.3protocol, extended by nothing, withUNIQUE(effect_key)plus
INSERT … ON CONFLICT DO NOTHINGunderREAD COMMITTEDwhere SQLite hadBEGIN IMMEDIATE.
The guarantee is the unique index and not the isolation level, which the store does not set.
Every later transition is a compare-and-set with the row count checked. It passes item 1's
suite 23/23, with no N/A.import ctrlrunimports nopsycopgmodule.The decisions did not move:
plan_reservation,plan_lease_extension,check_consumableand
check_answerablestay pure functions, and all three backends decide with them and then only
write — sov0.1 §5.4's retry table has one implementation rather than three, and a backend
cannot drift into permitting something SQLite refuses. -
--store-urlaccepts apostgresql://URL, and is now on every command that reads or
resolves the operator's own store —receipts,effects,inspect,resolve,approve,
deny— readingCTRLRUN_STORE_URL. CTRLRun's own?ctrlrun_schema=parameter selects the
schema and is peeled off before the URL reaches the driver.It creates nothing and migrates nothing. A review found the first version doing both: a
ctrlrun effectsagainst an empty schema printed "no effects yet", exited 0 and left eight
tables behind, and against a database one migration short, actrlrun receiptsapplied it.
On the milestone that first shares a store across hosts, that is one reader altering a table
every other process is still running against. A schema that is missing, behind or ahead is now
refused with an instruction. -
ctrlrun receipts --control ID— shows only the receipts citing that control. A filter
and not a lookup: it does not consult the policy, so an id no document defines matches
nothing rather than erroring, which is the right answer for a reader running against a store
whose policy has since changed. A dangling citation is still a load error, in the place that
can see the registry. -
ctrlrun receipts --verify-chain— reads the chain in the operator's own store and reports
every break byseqand by name:content_altered,hash_missing,link_broken,missing,
head_mismatch,unchained. Six names rather than one boolean, because "receipt 41 was
edited" and "the last nine were deleted" are different incidents. -
Receipts carry
seq,prev_hashandhash(v0.6 item 6). One chain per store — not one
per effect key, which would not detect the deletion of every receipt for one key, and not one
per process, which is not a chain.seqis inside the hashed content, so two adjacent
receipts swapped with theirseqvalues change both documents;hashis a column, because a
document cannot contain its own hash.put_receipttakes the head row's lock first and
advances it in the same transaction. -
policy_hash,policy_versionandcontrolson every receipt (v0.6 item 7). A receipt
from six months ago says what the rules were, not what they are now.policy_hashis over the
parsed decision inputs — schema, actions and rules in document order,mode,environment,
the authority grants — and not the file's bytes, so a comment or a reordering of keys does not
change it.version:is a free string the operator chooses, recorded and never
authoritative: two documents sharing aversion:and differing in content are two different
policies, and the hash is what says so. -
ctrlrun.policy.PolicyControl— a registry entry: an id, a title, and an optional
source. NamedPolicyControland notControl, because a secondControlin a package
whose central object isControlis a collision every call site would have to disambiguate,
and one this milestone would have frozen for a long time. -
ctrlrun.policy/v4, with three new top-level keys and a closed key set, so a typo is still
a load error:controls:— a registry of ids, each with atitleand an optionalsource. An action
cites some, a rule may narrow or add, and the receipt carries the union of the action's and
the matched rule's in registry order. CTRLRun does not interpret a control:source:
is a string the operator wrote and the registry records and never enforces. It maps to no
standard, and citing one is not a claim about it.data:— an action declares which of its arguments carry which class of data.
data_scopeis the set of labels present in the arguments actually supplied, not the
whole declared map: an action that carries no PHI is not a PHI action because some other call
of it would be.data_scope_in: [phi]reuses the membership_inalready expresses and adds
no operator, deliberately —_OPERATORSis shared with authorityconstraints:, so an
operator added here would become available to grants.version:— see above.
-
resolved_byon every effect record (v0.6 item 5). Out ofAMBIGUOUSthere are exactly
two authorities — a human and a reconcile hook — and the record now says which one acted.
ctrlrun effectsprints it, and printsexecuting (lease expired)for a lease that lapsed and
was never contended, because nothing sweeps and the state alone hid it. -
research/soak/(v0.6 item 8) — a soak harness, outsidesrc/and packaged nowhere, on
research/framework-probe/'s precedent. It defines unexplained before the run starts —
anAMBIGUOUScaused by an injected failure is explained, one with no corresponding injection
is not — records every injection before causing it, and carries a positive control that
runs in its own store: a deliberately unrecorded ambiguity that the table must report. A soak
with no unexplainedAMBIGUOUSis a result; a soak whose harness could not have detected one
is not. -
docs/postgres.md— the operator's page: connection strings, what to grant, what happens
on failover, the one row every receipt write serializes on, and what the s...
ctrlrun 0.5.0
v0.4 asked does it hold in my setup? v0.5 asks a narrower and harder question: can somebody
else implement this? The adapter contract is one of the six things v1.0 freezes, so it is
written to be lived with rather than revised once somebody tries it.
The milestone's own answer is yes, with caveats. A session that could read the five
specifications and nothing else — no kernel source, no reference adapter, no test — wrote a third
adapter against the contract and returned fourteen questions it could not answer. Every one
became an edit, and one of them was a defect in a shipping adapter that no test caught. That
exercise, not the two adapters, is what v0.5 is for.
Added
-
Two reference adapters, on their own version line and in their own distributions:
ctrlrun-langgraph(adapters-langgraph-1.0) reusesinterrupt(),Command(resume=...)and
the checkpointer;ctrlrun-openai-agents(adapters-openai-agents-1.0) reuses the SDK's
tool-approval interruption. Neither is in thectrlrunwheel or sdist (T136). LangGraph
passes the conformance kit 6/6; the Agents SDK 4/4 with two suitesnot_applicable, each with
its reason on the report and in the README.Prevention or attribution, in that word, is the sentence each README leads with.
LangGraph's resumption carries the arguments a human answered against and core re-checks them;
the Agents SDK records that a call was approved and not what its arguments were. -
ctrlrun.adapter— the surface:FrameworkInterrupt,PendingApproval,
ApprovalAnswer,InterruptApprovalProvider,needs_approval,banner, and
Control.resolve_principalpromoted from private. Core, stdlib, in the action path. An
adapter returns an answer and one core provider writes the grant, through the same calls
ctrlrun approvemakes — which is what makes "never a second approval path" structural. -
ctrlrun.conformance— this repository's ownv0.1 §7andv0.3 §10acceptance tests,
runnable against any adapter, in core. It is not a certification and passing it is not a
claim about quality: it answers one question, does an action driven through this adapter get
the same refusals as one driven through@protect? Fifteen deliberately broken fixtures were
written first, and each fails the suite named for it and no other. -
docs/adapters.md, and a README section that opens by saying when you do not need an
adapter (T139) —@protectcovers anything in this process and the gateway anything over MCP. -
The framework probe was run against LangGraph 1.2.11 and openai-agents 0.22.0, five
repetitions each, and the results are published. Read theapproval-mutationcolumn carefully:
executed_oncethere does not mean the scenario went well.
Changed
ctrlrun demo --helpsaid four scenarios and ran five, stale since v0.3 added the
authority escalation.
Security
Three independent reviews and item 6 found five authorization defects in
ctrlrun-openai-agents before it shipped. All are fixed, mutation-tested, and recorded here
because the pattern matters more than any one of them: the SDK's approval record is keyed to a
tool call, and a CTRLRun grant binds to an action hash, so every defect was the same shape —
reading a coarser answer as though it answered a finer question.
interrupt()returnedgranted=Trueunconditionally.- It then accepted a sticky per-tool decision, so
always_approve=Trueanswered for later
calls no human saw. - It then answered for every action raised under one tool call: a human approving a $5 refund
authorized a $1,000,000 wire raised beside it, with a receipt naming the channel as approver. unwrap()returned the firstCTRLRunErroranywhere in the chain, so a nestedNotExecuted
masked an AMBIGUOUS refund — safe to retry reported for an effect that may have landed.- Observe mode interrupted and blocked the action. Found by item 6 without reading the
adapter. §3.6's rule followed for one framework shape and had to be required of the other;
a deployment evaluating CTRLRun in the mode built for evaluating it would have had its agent
halted.
Added
-
docs/SPEC-v0.5.md— the v0.5 contract, a delta over v0.1, v0.2, v0.3 and v0.4. It fixes
the adapter surface (§2), the approval round trip (§3), theSPEC-v0.3.md§4.3.1 rows an
adapter adds (§4), the conformance kit (§5), packaging and versioning (§6), what an adapter
must document (§7), the acceptance tests T126–T139 (§8) and the public names v1.0 will freeze
(§9). No implementation lands with it.Five decisions are written into it with their arguments, because a decision whose
reasoning lives only in a build note is one the next session re-litigates:
ApprovalRequired+with_approvalrather thanSuspended+Control.resume; a Protocol
rather than a base class; the conformance kit in this repository rather than a third distribution;
one repository with separate distributions; and an adapter that sees the principal and
never supplies one.A sixth was open and is settled in §3.6: an adapter never interrupts in observe mode,
never prints — it is inside somebody else's loop and may have nowhere to print — and
logs theSPEC-v0.3.md§6.5 banner once perControl, whichctrlrun.adapter.banner
does so no two adapters word it differently. The conformance kit refuses an observing
Control— a refused report with no suites, which is not a success — rather than producing
an all-not_applicablereport with a zero denominator, because0/0reported as a pass is
the false greenSPEC-v0.4.md§3.8 refuses by name.An independent review in a session that did not write the document found five defects that
would each have produced an insecure or unimplementable adapter, and §9.1 records them. The
worst was--principal-from-client-info's third costume: §3.5 told an adapter to answer its
framework's pre-invocation predicate withControl.evaluate(action), andAction.principal
has no default — so the only way to obey was to build a principal from the framework's
session.ctrlrun.adapter.needs_approvalis core's because of that, and
Control.resolve_principalis promoted from private for it: the identity seam an adapter may
read and may not supply. -
ctrlrun.adapter— the adapter surface: theFrameworkInterruptProtocol,
PendingApproval,ApprovalAnswer,InterruptApprovalProvider,needs_approvaland
banner. Core and stdlib, re-exported fromctrlrun, andControl.resolve_principalis
promoted from private for it — the identity seam an adapter may read and may not supply. -
ctrlrun.conformance— the adapter conformance kit: theSPEC-v0.1.md§7 and
SPEC-v0.3.md§10 acceptance suites runnable against any adapter through that surface, and
eleven adapters broken in one named way each, written before the reference adapters because
"two adapters pass the suites" means nothing until the suite can fail.It is core and stdlib-only, not the extra
SPEC-v0.5.md§5.1 originally specified. The
premise there was that a kit needspytest; building it showed otherwise, and an extra with
no dependency behind it is an install line that installs nothing. §12.1 records the change.
dependenciesis unchanged:pyyamlandclick.
Changed
-
docs/ROADMAP.md's v0.5 bullet was wrong and is corrected here, not silently. It said
the reference adapters map their frameworks' interrupts ontoSuspended/Control.resume,
"which v0.2 already ships for exactly this shape". It does not:Suspendedexists for the
remote asking a question mid-execution, where the reservation is already taken and must
stay taken, and an approval gate has none to hold — v0.1 consumes the approval in the same
transaction as the reservation, so a human deliberating for an hour pins nothing.
SPEC-v0.5.md§3.1 argues it in full. This is the treatmentSPEC-v0.4.md§9.4 gave the
threat model's sentence about a check verify could not deliver. -
Version is
0.5.0.dev0.
v0.4.0 — Verification
Does it hold in your setup?
pip install ctrlrun
ctrlrun verifyEverything CTRLRun guarantees was proven, until now, by this repository's tests against this repository's configurations. That is the right place to start and the wrong place to stop: what you deploy is your policy, your grants and your store, and a guarantee that has never been exercised against those is a guarantee nobody has checked.
ctrlrun verify runs the kernel's own failure scenarios against the configuration in front of it — in a scratch store, with fake executors, reaching no network — and reports what passed, what failed, and what could not be tested at all.
G1 mutated approval refused PASS stripe.refund
G2 replayed approval refused PASS stripe.refund
G3 duplicate effect refused PASS stripe.refund
G4 one winner under concurrency PASS stripe.refund (8 processes)
G5 ambiguous blocks a blind retry PASS stripe.refund
G6 unknown action refused PASS
G7 no principal refused PASS stripe.refund
G8 expired authority refused PASS head-of-support
G9 delegation cannot escalate PASS head-of-support (6 of 6 dimensions)
G10 unknown exception is ambiguous PASS stripe.refund
10/10 declared guarantees pass. 0 not applicable.
Not applicable is not a pass. A policy with no approve rule cannot exercise the approval-binding guarantees, so they are reported N/A with the reason, excluded from the denominator and listed separately — 5/5 (5 not applicable), never 10/10. There is no flag that folds one into the count.
Verify never touches your store. .ctrlrun/state.db is byte-identical before and after, and is never created where it did not exist.
Every guarantee carries a positive control. A refusal asserted against a scenario in which nothing ran passes on a kernel with the guard deleted, so each scenario establishes the observable would have been visible had the guard not fired. A control that misbehaves is fail with reason: "control failed" — never a pass, never an N/A.
The badge means "declared guarantees pass" — that phrase, and no other. Not secure, not safe, not compliant, not certified, not audited. docs/verify.md says on the same screen what verify cannot see: your executors, your reconcile hooks, where you put the decorator, your deployment, and whether your policy is the right policy.
pip install ctrlrun still installs nothing but pyyaml and click — including all of verify, because a verification tool behind an extra is one half the deployments never run.
Note on versions: 0.3.0 was never published; 0.3.0rc1 was its last tag. This release goes from 0.2.0 to 0.4.0 on PyPI.
Does it hold in your setup? Everything CTRLRun guarantees was proven, until now, by this
repository's tests against this repository's configurations. That is the right place to start
and the wrong place to stop: what an operator deploys is their policy, their grants and
their store, and a guarantee that has never been exercised against those is a guarantee
nobody has checked.
ctrlrun verify runs the failure scenarios of v0.1 §7, v0.2 §10 and v0.3 §10 against the
configuration in front of it and reports what passed, what failed, and — the part that makes
the number mean anything — what could not be tested at all.
Three rules govern it, and each has a test that would go red if it stopped holding. Not
applicable is not a pass. Verify never touches the operator's store. The badge means
"declared guarantees pass", and nothing else. A fourth keeps verify honest about itself:
every guarantee carries a positive control, because a refusal asserted against a scenario
in which nothing ran passes on a kernel with the guard deleted.
No schema changes: ctrlrun.policy/v3, ctrlrun.receipt/v2, ctrlrun.action/v1 and
ctrlrun.inspection/v2 are untouched, and no store gains a table or a column — verify
writes only to a scratch store it created. Three new schema strings belong to documents rather
than to storage: ctrlrun.verify/v1, ctrlrun.guarantees/v1 and ctrlrun.framework-probe/v1.
Added
-
ctrlrun verify— the guarantee catalogue, the scenario engine and all ten guarantees
(SPEC-v0.4 §2, §3).ctrlrun.verifyis core: stdlib,pyyamlandclick, because a
verification tool that needed an extra installed is one half the deployments never run. It is
not re-exported fromctrlrunandimport ctrlrundoes not import it.It reads the operator's policy document, and the authority document beside it where
--authoritynames one, derives concrete actions, principals and delegations the
configuration actually admits, and runs the failure scenarios ofv0.1 §7,v0.2 §10and
v0.3 §10against them — in a scratch store, with in-process fake executors, reaching no
network.G1mutated approval refused ·G2replayed approval refused ·G3duplicate
effect refused ·G4one winner under concurrency, across real OS processes ·G5ambiguous
blocks a blind retry ·G6unknown action refused ·G7no principal refused ·G8expired
authority refused ·G9delegation cannot escalate, on every dimension the parent constrains
including the omission case ·G10unknown exception is ambiguous, never failed.Every guarantee carries a positive control. A refusal is satisfied just as well by a
scenario in which nothing ever ran, and that scenario passes against a kernel with the guard
deleted — so each scenario runs a companion establishing that the observable would have been
visible had the guard not fired. A control that does not behave as specified makes the
guaranteefailwithreason: "control failed": never a pass, and never an N/A.There is no randomness anywhere — not seeded randomness, none. Selection is sorted by
codepoint, values come from a fixed table, the candidate search is bounded at 64, and two runs
against one document produce byte-identical JSON once the timestamps are removed. -
ctrlrun verify [--authority PATH] [--json] [--junit PATH] [--only G1,G3] [--store-url URL],
replacing the v0.3 stub. Exit codes: 0 every applicable guarantee passed and at least one was
applicable, 1 a guarantee failed, 2 the configuration was refused or is unusable, 3 an
internal error in verify itself. -
Reporting (SPEC-v0.4 §4). The human report is one line per guarantee in catalogue order,
every N/A carrying the reason that made it one, with the summary as the last line so a
tail -1is meaningful.--jsonemits onectrlrun.verify/v1document carrying the SHA-256
of both documents verify read — a report and a policy that do not hash the same are a report
about something else — and acounterexampleonly on afail, because a counterexample
on a pass would be evidence of a failure that did not happen.--junit PATHwrites a JUnit
XML file in which an N/A is<skipped>and never a pass, which is the same rule as
everywhere else expressed in the vocabulary a CI dashboard already has.JUnit XML has no normative schema, and the report says so rather than implying one: T115
validates againsttests/data/junit-10.xsd, a checked-in copy of the de-facto Windy Road
schema with its provenance and Apache-2.0 licence recorded beside it, and asserts the
document structurally as well — a permissive schema is not a check.xmlschemajoins the
dev extra for that test and for nothing else. -
The GitHub Action, the badge and
docs/verify.md(SPEC-v0.4 §5).action.ymlat the
repository root is a composite action: it installsctrlrun, runs
ctrlrun verify --json --junit, renders the job summary and the badge from that report
rather than from a second run — so the badge, the summary and the uploaded artifact can never
disagree about what happened — and uploads the three files as one artifact.It fails the job when a guarantee failed and when the configuration was refused, and succeeds
when guarantees are N/A. There is no input that makes a failure not fail the job: a
continue-on-error-shaped flag here would be a flag that makes a consequential thing
permissive by default, and a workflow that wants to tolerate a failure has
continue-on-erroron the step already, where it is visible.The badge is a Shields endpoint JSON the action writes and never publishes. Committing it
would needcontents: writein every consumer's workflow, and asking for write access to a
repository as the price of a verification badge is a bad trade for a tool whose subject is
least privilege;docs/verify.mdshows the one-job publishing pattern once, with its cost
visible. Rendered, it reads exactlyCTRLRun verified N/M, whereMis applicable
guarantees and never the catalogue size. A partial run and a run that exited 2 or 3 write no
badge at all.The badge means "declared guarantees pass" — that phrase, on the badge's link target, and
no other. Not secure, not safe, not compliant, not certified, not audited.
docs/verify.md#what-the-badge-meanssays it in its first sentence and, on the same screen,
what verify cannot see: the operator's executors, theirreconcilehooks, where they put the
decorator, their deployment, and whether the policy is the right policy.This repository's CI runs the action against
examples/authority/payments.yaml(10/10) and
againstexamples/policies/payments.yaml(5/5, 5 not applicable), asserting both shapes —
so a change that made verify silently count N/As as passes is caught in CI rather than in a
badge. -
docs/OWASP-AGENTIC-TOP10.md(SPEC-v0.4 §6) — a reading of the OWASP Top 10 for Agentic
Applications (2026 edition, announced 2025-12-09) against the ten guarantees. Its...