All notable changes to this project are documented here. The format follows Keep a Changelog, and this project follows Semantic Versioning.
Public API names are frozen in docs/SPEC-v0.1.md §8. Before 1.0 they may still change, and
any change to one appears here.
ctrlrun mcp-operator --stdio: the operator console for the client that launched it. Desktop assistants, Cursor and the editors launch an MCP server as a subprocess and speak to it on stdin and stdout; until now this server spoke only HTTP on loopback, so the registry listing that went live at 0.12.2 described a server none of those clients could start.--stdioopens no socket at all, which is stricter than the loopback rule and not a loosening of it, and every message still goes through the same parser, the same refusals and the same store calls as a POST body does. The approver is the account the process runs as, read from the real uid and from nothing the client sends or sets: notgetpass.getuser(), which believes the environment, notclientInfo, notSUDO_USER. What that does and does not promise is stated rather than implied: a client that can subvert the process can already open the store as that account, so nothing is added to what it can do, only a name on what it did. Root is an account and not a person, so under uid 0 every write is refused and reads still answer. The login carries no roles and no expiry, and says so: a control naming anapprover_rolerefuses over stdio, and the client process holds approve, deny and resolve under the login for as long as it runs, which theinitializeinstructions and the startup block both say, because the confirmation the client shows before a write is then the only human step. Every flag that names a header is refused with it, by name, and--max-body-bytesnow has a floor on both transports.SPEC-mcp-operator.md§2.3 and §3.1 carry the design, and §10's exclusion of stdio is struck through with the reasoning that replaced it rather than deleted. The registry manifest now describes this:uvx ctrlrun mcp-operator --stdiowithCTRLRUN_CONFIGpointing at your policy, which is a config block a desktop client can install.
A patch release with no change to the enforcement path. Two things sat on main with no way for
anyone to install them: the operator's tool and argument descriptions, which are what an MCP
client puts in front of a model, and the mcp-name: marker the official MCP registry reads out
of this package's long description to verify the namespace. The second one can only work from a
published release, because the description it reads is the README of a distribution on PyPI.
- Every operator tool argument now describes itself, and the read tools say which to reach
for. An input schema could say
controlwas a string; it could not say that it filters rather than selects, thatsincetakes24has readily as a timestamp, or that afailedresolution is what unblocks a retry.list_pending_approvalsnow says it is where the request ids come from, andlimitsays it bounds the response and not the scan, so a store holding many answered requests still walks them. A caller that has to infer any of this from an argument's name is guessing, and this server exists so that nobody guesses.
server.json, the manifest for the official MCP registry, namingio.github.CTRLRun/ctrlrun-mcp-operatorand the PyPI distribution that carries it, with the matchingmcp-name:marker in the README. The namespace carries the owning organisation's own case because the registry matches it case-sensitively against the OIDC token'srepository_owner, and the two are pinned to each other, and both versions topyproject.toml, by tests.- The registry listing publishes itself on a kernel tag. A job in
publish.ymlthat runs after the PyPI upload, authenticates with a GitHub OIDC token rather than a stored credential, installs the publisher binary pinned by version and checksum, and leaves alone a version the registry already has. glama.json, naming the maintainer of the Glama listing.
- The README's MCP row pointed an approver at the server and not at their own page. It now links the page written for the person answering the request.
0.12.0 was tagged at the release merge, and these three landed on main shortly after, so the
published wheel carried none of them. Nothing here touches the enforcement path: the whole of it
is verify/scenarios.py, the self-check tool. It under-reported rather than over-claimed, which
is the safe direction, but the under-report was severe enough to read as a tool that checks
nothing. On the document below, 0.12.0 grades 1 of 1 and calls thirty-one not applicable;
this release grades 22 of 22.
ctrlrun verifysized every vector for eighteen spends, and a grant with an ordinary count budget graded nothing.selectfitted each candidate to the grant's budgets withPROCESSES * 2 + 2of room, G4's nine landings doubled, for every guarantee. A document whose grant said twelve refunds an hour, which is what a payments pack writes for a support agent, reported twenty-three guarantees not applicable, "every action reaching this decision exceeds a budget on the grant that covers it", about a budget that admitted the action twelve times. The room is now the scenario's own:DEFAULT_SPENDS, four, unless the scenario says otherwise, and G4 saysPROCESSES + 2.- The synthesizer stopped at the first rule of a decision. A grant's budget can refuse the
first rule's vector for a reason the second rule's does not share: a document whose first
approverule iscounterparty_new_eq: trueyields a vector with noamount, which a budget onamountcannot measure, while the nextapproverule is the amount band the budget was written for.selectnow tries every candidate of a decision, rule by rule, before it concludes nothing binds. - A boolean condition was negated with a string.
X_neqoncounterparty_new_eq: trueproduced"ctrlrun-verify", neither answer; the vector landed in the next rule by accident ofeqand carried a value no document could mean. A boolean is negated with the other boolean. adopt-site/was a committed build cache. Two files,.vite/deps/package.jsonand an empty_metadata.json, swept into the repository root by #138 and referenced by nothing for four milestones. Removed, and.vite/andnode_modules/are in.gitignoreso it cannot recur. It shipped in no distribution; this is the repository root a reader lands on.
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.
-
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 foundstate -> 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.pyare 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, sofrom 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_identityandrevocationsharing 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
-41002relayedstr(ApprovalRequired)verbatim, and that exception carries the decorator's wording: "runctrlrun 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.
-
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 row
nalso 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 readingpyproject.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, andrelease.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.
-
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.
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.
-
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'sreceiptsandstatstools 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 called
json.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 be
verifygrading 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 own
anchorstable 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 oneSPEC-v0.11.md§2.1 assumes an attacker rewrites, and deciding--throughfrom it meant oneUPDATEturned 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 leavemissingandlink_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, and
SPEC-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 named
anchor_brokenat the anchoredseq.What an anchor proves, and what it does not. It freezes a prefix: anything at or below an anchored
seqcan 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 requires both reports to stay clean.No keys. The anchor consumes a timestamp and issues nothing: no key generation, no rotation, no revocation, no signing. Signing stays off the roadmap for the reason
SPEC-v0.6.md§11 gives, and a test greps this module's own source to keep that true. -
ctrlrun.anchor:AnchorProvider(a four-call protocol you implement, because ctrlrun ships no timestamp client and a network client does not belong in this wheel),verify_anchors,AnchorReport,ANCHOR_BREAKS, andanchor=onControl. -
ANCHOR_BREAKSis its own closed set andCHAIN_BREAKSdoes not change.anchor_broken,anchor_missing,anchor_repudiated. Putting them inCHAIN_BREAKSwould failG11's control withcontrol failedon every anchoring deployment, because that control reads the wholeChainReport.anchor_unavailableis in neither set: an unreachable provider is a transport failure, and grading it as tampering would make a network blip indistinguishable from a truncation. -
ctrlrun anchor, with--verify, and migration0008_anchor_checkpoint_hold. -
G28, a truncation past an anchor fails, whose positive control is the attack itself run against a real store. -
One chain, five receipt schema versions, walked end to end (
SPEC-v0.11.md§6). A store kept since v0.6 holds five:v3(0.6),v4(0.7),v5(0.8),v6(0.9),v7(0.10). No new field:schemahas existed sinceSPEC-v0.3.md§12.2. What is new is the proof thatverify_chainwalks such a chain hash by hash, each row hashed by the rule its own version wrote. v0.10's release pass proved thev6/v7boundary against the released 0.9.0 and stopped there.scripts/five_schema_chain.pybuilds the chain from the released wheels rather than from fixtures: five environments,pip install ctrlrun==0.6.1,0.7.0,0.8.0,0.9.0,0.10.0, one store, then this build verifies across the whole thing. A fixture is this build's opinion of what 0.6 wrote; the wheel is what it wrote.And a receipt whose schema label this binary does not know is named, not reported as a break:
SPEC-v0.6.md§3.2's distinction, and the difference between this evidence is from a future version and this evidence is tampered with. Relabelling a stored row without rehashing it is stillcontent_altered, because that is somebody editing evidence. -
G31, five receipt schemas verify, andctrlrun.guarantees/v6becomesv7, moved once.G28toG30andG32are not in the catalogue yet:SPEC-v0.11.md§8 assigns ids in item order so that splitting the milestone renumbers nothing, and a row whose check does not exist would report something before it could.
StateStore.receipts()returnstuple[Receipt | UnreadableReceipt, ...], amendingSPEC-v0.6.md§9.2's frozen protocol. Before, it raised. Both backends change, and a second backend could not implement §5 without it. A store with no bad row is unaffected: every row still reads back as aReceipt.- A receipt's position now comes from the
seqcolumn, which is whatverify_chain's docstring has claimed since v0.6 and what was not true as shipped. Both stores selectedjson, hashand ordered by a column they never read, so everyReceipt.seqcame out ofdocument.get("seq"), the one field a tamperer controls. Rewriting one document'sseqfrom 2 to 99 reportedmissing 2,content_altered 99,missing 100andlink_broken 3: four breaks at three positions, two of them rows that do not exist. The same tamper now reportscontent_alteredonce, at 2. ctrlrun statsreportsunreadable receiptsand thectrlrun.stats/v1document carriesunreadable_receipts, omitted entirely where there is none, onledger_rows' precedent. A total that silently dropped a row nobody could read would beSPEC-v0.4.md§3.8's false green.
One question: when one agent hands work to another, what does the second one hold?
-
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 samecontained_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.evaluatepassed 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.executeandControl.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.
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.
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.
-
docs/SPEC-v0.10.md, the v0.10 "Multi-agent" contract: authority propagation across agent hops, upstream identity pinning, and one ordered list of checks for both enforcement modes, as a delta over v0.1 to v0.9. Documentation only. It answers one question: when one agent hands work to another, what does the second one hold?Almost nothing in it is a new mechanism. A hop is
SPEC-v0.3.md§5's delegation over a boundary the kernel does not control, checked by the samecontained_dimensionwith a third caller, identified by thedelegation_idthat already exists, and charged bySPEC-v0.9.md§2.7's walk to the root. One rule is new, and §2.3 is the whole of it: which grant decides. A probe against the tree at the 0.9.0 tag settled that:Authority.evaluatepasses on any matching grant, so a receiving agent holding a grant of its own is authorised by that one, the hop is never consulted, and the issuer's budget is charged nothing. An action proposed under a hop is therefore evaluated against that hop's grant alone, with no fallback.Four rules the milestone is measured against. A hop narrows or it is refused, with no widening at a boundary and no dimension inherited by omission. The issuing agent's budget is what a hop spends, so a hop never creates a second root. A hop is evidence, not a side channel: both sides' receipts carry the same hop id. And nothing infers who the peer is: the receiving agent's identity is resolved by the
IdentityProviderand never read off the payload.The four open questions are answered in the specification rather than left to an item. ctrlrun defines no wire format and consumes none; what crosses a hop is a reference of two strings. A hop is a record in the store and not a claim in a token, which the budget rule forces and which costs cross-store propagation, refused fail-closed and by name. Depth across hops is
max_delegation_depth, unchanged. A receipt records the hop it ran under and nothing derivable from it.It also pays
SPEC-v0.9.md§4.2.1b's named debt, the two enforcement paths whose checks run in different orders, and takes up §6.3.2's handover: the task and the hop are stamped ontoEXECUTION_STARTEDso a resumed leg is evaluated on both dimensions instead of skipping them.
- The DCO check refused every Dependabot pull request. Dependabot signs its commits off as
support@github.comwhile authoring from its noreply address, so the trailer never matched the author and thedcojob was red on every update it opened, the weekly lock updates included. A sign-off under another address is now accepted on exactly one fact that is not a string anyone can set: GitHub's own signature on the commit, read back through the API. There is still no exemption keyed on a name or an email.
- CI installs from hashed locks. Every
pip installin a workflow now reads arequirements/*.txtthatscripts/lock.shwrites withuv pip compile --universal --generate-hashes, under--require-hashes, and then installs the checkout itself with--no-deps --no-build-isolationagainst the setuptools the same lock carries;python -m buildruns with--no-isolationfor the same reason. What a job resolves, the build backend included, is what somebody generated and reviewed, not what PyPI served that morning. The version floors inpyproject.tomlare unchanged: they are what a user may install against, and the locks are what CI does. Dependabot moves the locks weekly, grouped. - A Scorecard gate on every pull request.
scorecard-gate.ymlruns OpenSSF Scorecard's file-based checks against the pull request's tree and fails it if any would come back below whatmainpublishes, so an unpinned install, a widened token or a vulnerable pin is red before the merge rather than a lower badge after it.tests/test_repository_signals.pyasserts the same rule forpip install, so the suite catches it first.
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.
-
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 putAUTHORITY_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 on
Actionwould 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 reaches
FAILED.Ambiguity is not a refund. An
AMBIGUOUSeffect keeps its consumption until a human or areconcilehook 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_effectandconsume_approval_and_reserve, andconsumptions()to read the ledger back. Migration0007_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 a
SELECT ... 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 ametric, 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 with
limit <=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.yamlalready 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.readoncustomer:*, 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 inspectgains--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 to
ctrlrun 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 unchanged, and T436 asserts that rather than assuming it.
-
ctrlrun.receipt/v6carriesscope_hashbesidetask, andctrlrun.guarantees/v5carries G23 beside G24. -
ctrlrun.policy/v7,ctrlrun.receipt/v6andctrlrun.guarantees/v5.tasks:andbudgets:on a grant are refused in av6document rather than ignored, because an older reader would grant the action on every task and against no limit.DIMENSIONSgrows from six entries to eight,tasksandbudgets, and it is exported and iterated byverify's G9, so a--jsonconsumer counting dimensions sees eight. -
The shipped
examples/authority/payments.yamlbinds itshead-of-supportgrant torefund-run:*and gives it a daily budget, so the milestone's own guarantees are notN/Aon what this repository ships. The authority badge moves fromverified 19/19toverified 22/22, G22, G23 and G24. -
docs/SPEC-v0.9.md, the v0.9 "Envelope" contract: consequence budgets, scope providers and task-bound authority, as a delta over v0.1 to v0.8. Documentation only. It specifies the quantitative half of authority, whichVISION.md§5 has had no code under it: a grant saysamount_lte: 5000and is silent about the thousand actions that each pass it.Four rules the milestone is measured against, recorded here because each one is a decision that could have gone the other way. A budget is consumed on reserve, inside the reservation's 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
AMBIGUOUSeffect holds its consumption until a human or a hook resolves it, because otherwise an agent that can generate ambiguity can generate authority. A budget names a metric, not a consequence, so nothing here ranks, scores or classifies an operator's actions. And a scope provider answers a question rather than detecting a change, which is what separates it from the precondition fingerprint ofSPEC-v0.7.md§6.The specification amends one frozen surface:
StateStore, frozen sinceSPEC-v0.6.md§9.2, gainscharges=on the two methods that reserve. §3.3 argues it against that section's stated bar.
-
A 0.8.0 binary refuses a store 0.9.0 has opened. Migration
0007_budget_ledgeradds the ledger table, and an older binary opening the migrated database refuses at open, naming the migration it does not know. Before: there was no0007. This isSPEC-v0.6.md§3.5's rule and it makes the upgrade one-way per store: a rollback to 0.8.0 needs the database it had, because a migration that only runs forwards turns a rollback into silent corruption. -
A third-party
StateStoremust implement three more things.charges=onreserve_effectandconsume_approval_and_reserve, and aconsumptions()read. Before:StateStorewas frozen atSPEC-v0.6.md§9.2 and a backend implementing every declared method was complete. A backend that implementscharges=and not the read satisfies the protocol and breaksctrlrun inspectandctrlrun verify, which is whySPEC-v0.9.md§3.3 argues the read as part of the amendment rather than leaving it implicit. -
DIMENSIONSchanged value, from six entries to eight. It is exported andverify's G9 iterates it and prints its length, so a--jsonconsumer counting dimensions sees eight. Before: six.tasksandbudgetsare the two. -
tasks:andbudgets:are refused in actrlrun.policy/v6document, rather than ignored as an unknown key would be. Before: neither key existed. An older reader that ignored them would grant the action on every task and against no limit, which is the fail-open this refusal closes. -
A grant carrying a budget refuses an action that resolves no effect key. Before: an action with no
effect:template was permitted, and it still is on any grant without a budget. With one, it is refused: there is nothing to charge against, so an agent proposing such actions would spend nothing against every budget on the chain for ever.SPEC-v0.9.md§2.4.1 records the two probes that moved this out of the loader. -
A metric value that is negative, missing, or not an integer is refused, with
ACTION_DENIEDand adeniedreceipt. Before: no metric existed. A negative amount would reduce the rolling sum and refill the budget, which is the compensationSPEC-v0.9.md§12 forbids; a missing one counted as zero would turn the absence of a field into unlimited authority. -
ctrlrun verifysizes its own action vector to a grant's budgets. Before: it synthesized a vector to land in a rule and reported a budget refusing that action as an internal error, exit 3, on guarantees with nothing to do with budgets. Where no value fits a band, the guarantee is nowN/Awith a reason that names the action and the grant.
-
resolve_effectreleased no budget hold. It does not go through_transition, so a human resolving anAMBIGUOUSeffectFAILEDheld its charge for ever: the one act meant to free a budget was the one path that did not. Fixed in all three backends, inside the same transaction as the record's own write. -
A refused receipt claimed a charge it never made.
budget_chargeswas stamped where the charges were computed, so a refusal raised later in the same loop reached the receipt with them set, and adeniedreceipt asserted the action charged the very grant it was refused from spending against. A receipt asserting a spend that never happened is the one thing an evidence trail may not do. -
An oversized stored window crashed every evaluation in the deployment.
Authority.evaluatereads every delegation row on every evaluation, and an unreadable window raisedOverflowErrorout of it, so one corrupt row denied nothing and crashed everything, for every principal and every action, with no event and no receipt to find it by. It isauthority_unreadablenow.
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.
-
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 in
stagingand 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 declare
ctrlrun.policy.changeunderctrlrun.policy/v6; nothing else may name it in aresource: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.pyused 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 againstPrincipal.agent.agentis whateveragent_claimnames, which a deployment may set toclient_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 no
action_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 a
Controlthat 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'scontrols:; a deployment that names no approver identity cannot open one at all. An assertion typed at a shell is exactly what break-glass must not accept.What it is afterwards: recorded, with
created_via: break-glass; expiring, and bounded bymax_ttlfrom the moment it was opened; revocable, and revoking it stops everything beneath it; attenuable, obeyingchild ⊆ parenton every dimension. A setting has none of those.Receipt.authority_grant_idnow names the grant that decided the action, for every action decided by authority and not only under break-glass. A field exercised only on the rare path is one nobody notices breaking.created_viagains its third value, which is a public change rather than an addition: the vocabulary is a closed set and a record carrying an unknown value is unreadable, which answersauthority_unreadablefor every action in the deployment. It moves with every reader in one commit.No guarantee id. The roadmap assigned five to v0.8 and G22 to G24 to v0.9, so inventing a sixth would collide or renumber, and a renumber is the maintainer's change. Its evidence is its tests, and one of them greps the shipped package for sixteen names a flag would be spelled as.
-
M-of-N approvals (
docs/SPEC-v0.8.md§4). An action may require more than one yes, and what the threshold counts is distinct verified principals: a second answer from a principal that already answered is recorded, moves that entry'sgranted_at, and does not move the count.schema: ctrlrun.policy/v6 actions: payments.refund: decision: approve approvals_required: 2
The count is decided where the row is written, on all three stores, and never by a read followed by a write: SQLite counts inside its
BEGIN IMMEDIATEtransaction, Postgres compare-and-sets on the approver list it read and retries, and the in-memory store holds its lock. Two processes answering at the same instant produce two approvers or one, never a threshold reached twice.A yes that cannot be attributed does not count.
approvals_requiredabove 1 in a deployment that names no approver identity is a denial, not a silent downgrade to one approval: the kernel cannot tell two anonymous yeses apart, so it refuses rather than counting them.ctrlrun approverecords no verified approver and therefore never counts toward a threshold, which the CLI says at the moment it is used rather than leaving to be discovered.ApprovalStore.grant_approvalnow returnsApproval | None, whereNonemeans recorded and still short of N. Nothing is granted, noAPPROVAL_GRANTEDevent is written, and a consume attempted below the threshold is refused aspendingwith nothing reserved.Needs
ctrlrun.policy/v6.ctrlrun verifygrades G19 underctrlrun.guarantees/v4,N/Awhere every action in the document takes one approval. -
Entitlement from the control registry (
docs/SPEC-v0.8.md§3). A control may now name the role that answers for it, and an approval whose recorded entitlement does not cover the roles the request pinned is refused, with the control named in the message, the exception and theAPPROVAL_INVALIDATEDevent.schema: ctrlrun.policy/v6 controls: card-data-handling: title: Cardholder data changes are approved by a named owner approver_role: payments-owner
ctrlrun does not interpret the role. It does not know what
payments-ownermeans, does not check that such a role exists anywhere, and makes no compliance claim on the strength of one, exactly as it does not interpretsource:. What changed aboutSPEC-v0.6.md§7.3's "attribution, not prevention" is one sentence: a control still decides no action, and now decides who may answer an approval the decision already required.Omission is not entitlement, and a control naming no role gates nobody. Two sentences that mean opposite things: a principal whose claims lack the role is not entitled, because a missing claim is a statement about a person and the kernel refuses to invent one; a control with no
approver_rolegates nobody, because a missing role is a statement about the operator's document and inventing one there would refuse every approval in every deployment that has controls and has not heard of v0.8.Roles are matched byte for byte, in both claim shapes. No case folding, no trimming, no prefix matching and no pattern grammar: a wildcard in a role would be an entitlement nobody wrote. Where an evaluation cites several controls, every required role must be held, because any-of lets the weakest control in the set decide who may answer.
ClaimValuegains a tuple of strings, amendingSPEC-v0.3.md§2.1. A roles claim is a JSON array at every issuer anybody deploys, and the old rule meant such a claim arrived absent, so its holder was silently unentitled.JWTIdentityProvidercarries an array-of-strings claim now instead of dropping it, and says so at WARNING rather than DEBUG when it drops anything else it was asked to carry. Safe for hashes:v0.3 §2.2keeps claims out of an action's canonical form.The check is bounded and the bound is stated. What the kernel refuses is an approval whose recorded entitlement does not cover the role; what entitled it was decided where the credential was verified, which is the operator MCP server (
ctrlrun mcp-operator --approver-roles-claim) and an embedding application.docs/SPEC-mcp-operator.md§4.3 and §10 are amended to say that, and §4.3 now carries a three-row table instead of one sentence, because the sentence covered two unconfigured cases that behave in opposite ways: a control naming no role admits any verified human, and a control naming a role in a deployment with no claim to read roles from refuses everyone. The server warns about the second at startup rather than at the first refusal.Needs
ctrlrun.policy/v6.ctrlrun verifygrades G17 underctrlrun.guarantees/v4,N/Awith a reason that is true of a document naming no approver role. -
The approver is a principal (
docs/SPEC-v0.8.md§2, §4.1).Approval.approveris a string whose only check is that it is not empty, andadapter.pyhas always conceded what that string often is: a channel, wherever the framework's primitive does not identify a person. A deployment may now name anApproverIdentity, and where one is named an approval is consumable only if the store holds aVerifiedApproverfor it: a principal the granting surface resolved, recorded on the approval row, and carried onto the receipt.Opt in, then fail closed, which is
SPEC-v0.3.md§1.2's rule for authority applied to the approver. AControlbuilt without one behaves exactly as 0.7.0 did, asserted field by field on the whole approve-and-execute path. One built with it gets no partial mode: an approval whose row carries no verified approver is refused withapprover_unverified, including one granted before the provider was configured, including one granted through a surface that cannot resolve, and including one held by a store that ignores the column.ctrlrun verifygrades seventeen guarantees now, G18 among them underctrlrun.guarantees/v4: an approval granted by the principal that requested the action is refused, compared on the resolved principal and never on the string. Verify supplies the approver identity it grades against, so what it reports is the kernel's refusal and never whether an operator configured anything, which is a fact about their application and not about their document.Which surfaces can produce a verified approver, stated plainly because it is narrower than the feature's name suggests. The operator MCP server can, and now does: it has resolved a principal for every request since it shipped and then discarded it into
mcp-operator:<user>. An embedding application can.ctrlrun approve, the webhook and the adapters cannot, and the approvals they grant are refused wherever an approver identity is configured. A deployment whose approvals arrive through one of those three turns its approval path off by configuring this, which is the rule working rather than a defect, and §2.6's table is the thing to read before configuring.Observe mode now records a mismatch's own reason where it recorded one constant for all of them.
would_have.blocked_reasonsaidapproval_mismatchfor everyApprovalMismatch, so a moved precondition and an approver who may not answer were one word in a report. Recording the specific reason for the approver refusals alone would have left a vocabulary nobody can explain, so every mismatch records its own. This reaches refusals that have nothing to do with v0.8, and it is listed here rather than left for an operator to notice in a diff.Needs
ctrlrun.receipt/v5, which addsapproversandauthority_grant_id, and migration0006_verified_approver. Every reader upgrades before any writer switches (SPEC-v0.3.md§12.2). -
ctrlrun revoke --created-by PRINCIPALand--under ID(docs/SPEC-v0.8.md§7). During an incident the operation an operator reaches for is everything this principal issued or everything under this grant, and until now that was a script over the events file, written under pressure. Both are queries over rows that already exist: no newStateStoremethod, no bulk statement, and no transaction over the set. Each match is revoked exactly as one id is, one at a time, so a run that stops halfway leaves the rows it reached revoked and the rest untouched, and a second run finishes.--created-bytakesAGENTorAGENT/USER, splitting on the first/asctrlrun delegate --asdoes;--underreaches the subtree at every depth and is strictly beneath, so it leaves the id it names alone. A selector that matches nothing exits non-zero and says what it searched for, because during an incident a mistyped name that exits 0 reads as a finished job. Still nounrevoke, in any costume.--byis unchanged and still means who performed the revocation. The roadmap called the new selector--by <principal>, which is the opposite meaning on an option that already exists, so the selector is--created-byand every script written against 0.7.0 keeps working.What a killed run can leave, stated because the tests bound it rather than assume it: a revoked row whose
DELEGATION_REVOKEDevent was never written.Control.revokewrites the row and then appends the event, with no transaction over the pair, so aSIGKILLbetween them leaves one row unaccounted for in the log. That is 0.3 behaviour for a singlectrlrun revoketoo; a selector only makes the window easy to land in.
-
docs/SPEC-v0.8.md: the v0.8 "Oversight" contract, a delta over v0.1 to v0.7. No code lands with it. It asks one question: who may say yes, and can the kernel tell? Seven milestones have verified the principal that acts, and nothing has ever been asked of the principal that permits:approveris a non-empty string,ctrlrun delegate --asis an assertion typed at a shell, and the operator server authenticates who answered without checking that they were entitled to. Seven items answer that: revocation by selector, the approver resolved as a principal, entitlement from the control registry, M-of-N on distinct verified principals, break-glass as a recorded expiring grant rather than a flag, credential revocation consumed from Shared Signals and CAEP events, and a policy change as a protected action with a diff replay beside it. Tests come from §10 (T272 onward); public names are frozen in §11; guarantees G17 to G21 joinctrlrun.guarantees/v4, andctrlrun.receipt/v5andctrlrun.policy/v6each move once.The rule the whole document is built on is opt in, then fail closed, which is
SPEC-v0.3.md§1.2's rule for authority applied to the approver: a deployment that names no approver identity behaves exactly as 0.7.0, and one that names one gets no partial mode, no fallback to the string, and no setting that turns a check off.Reading the code changed nine things the plan had assumed, and §1.4 lists them: five from the drafting and four from the independent review, which found the first draft unbuildable in four places and is recorded rather than quietly fixed. Five matter beyond this document.
Controlnever grants an approval, so the check that matters lives at the consumption and not at the grant.Control._recheckreturns early on the default path, so a check added after it would have been dead there, green, and invisible to a mutation table. APrincipalcould not carry a list, and every issuer's roles claim is one, soClaimValuegains a tuple of strings. The Postgres grant's compare-and-set was on a status that does not change at N-1, which is a lost update and one principal filling two slots. And a reserved action name no document may declare is a name every proposal is denied for, so the policy-change action is reserved and declarable.Smaller, and worth knowing before anyone scripts against it:
ctrlrun revoke --byalready means who performed the revocation, so the new selector is--created-byand every script written against 0.7.0 keeps working.What it does not close is in §1.1, before anything else: a persuaded approver gives a valid approval and the receipt records it as one, and an administrator with write access to the policy file, the store or the code is outside every guard here.
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.
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 recordedFAILED, 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 recordsAMBIGUOUSand 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 the
CONNECTline with the sameConnectError, and a writtenCONNECTline is a written byte. At 0.6.1 the forwarder mapped everyConnectErrortoNEVER_CONNECTED, therefore to afailedreceipt and-41011. Whereurllib.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_requestandprecondition_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 ownv1orv2label 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, and
ActionDeniednamesattempt_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, everyAF_UNIXpath and every datagram send are refused.
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. Under
max_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 withattempt_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, and
Controlmarks a register on every send throughctrlrun.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_idalone, so attempt 1's write, arriving after its lease lapsed and after the sameActionwas retried, can read attempt 2's record, find its own id in a state it expects, and land there. A latefail_effectis the sharp case: it writesFAILEDover an executing attempt 2, andFAILEDis the state that permits a renewal. Amark_ambiguousreaches the same place in one more step, through areconcilehook that is asked about the effect key and not about the attempt, so it needs no person.ctrlrun resolvecarries no attempt number either, so someone who inspected attempt 1 can resolve attempt 2's ambiguity. The fix inside the frozenStateStoreprotocol is a store-side memo of the attempt each reservation wrote, which is a schema change of its own; §12.3a states the argument so the next milestone inherits it. - An approval granted and presented inside
request()is spent beforeControlknows the request exists (§6.4). What would take that away is a store call recording a request and its fingerprint in one write, andStateStoreis frozen. - A malformed value of a key a receipt schema declares still raises out of
from_dict. OneUPDATEputting a float among a receipt'scontrolsblindsctrlrun receipts,receipts --verify-chain,inspect,statsand G11 together, where the schema-level and added-key cases are each reported at theirseqand leave every other row readable. 0.6.1 behaves the same and v0.7 neither introduces nor widens it. Fixing it needs a new name inCHAIN_BREAKS, a closed set on av0.6 §6.5surface, or a reader that walks raw rows; it is a named item on the roadmap before v1.0.
-
ctrlrun.transport, theNotExecutedclassifier, in core (SPEC-v0.7 §2, build-list item 2).v0.1 §5.5leaves the one decision the product exists to get right,FAILEDorAMBIGUOUS, to the executor, and until now the correct rule was reachable only throughctrlrun[gateway].ctrlrun.transport.urlopen,HTTPConnectionandHTTPSConnectionareurllibandhttp.clientwith a counter: they raiseNotExecuted, chained from the original exception, only where the connection they opened fresh failed before a single request byte was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every other failure is the original exception, which the kernel recordsAMBIGUOUS: a reset or a timeout after the request was offered, asendallthat raised part way, a reused connection, a socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel after itsCONNECTline was sent. The count is taken from evidence, never from an exception's type, and above TLS. No redirect is followed, no HTTP status is everNotExecuted, and no parameter, attribute or environment variable changes a classification. The module is stdlib only and is not imported byimport ctrlrun.The rule itself,
ctrlrun.transport.effect_state, is the one implementation: the gateway'sTransportis now the core one, andgateway/outcome.pyasks the core rule rather than keeping a copy.ctrlrun.gateway.transport.requestoffers the gateway's httpx mapping to an executor that uses httpx, on a client built for the one call. The gateway's ownNotExecuted, for an upstream it never reached, is now chained from the httpx exception and its receipt names it.ctrlrun verifygains G12, "a byte written is ambiguous", underctrlrun.guarantees/v3, with the refused connection as its positive control. G12 needs a loopback peer, so verify's rule becomes no connection except to the store--store-urlnames and to loopback listeners verify bound itself, and the test suite's network guard admits exactly that: IPv4 on the127.0.0.1literal, to a port the process bound through a stream socket that is still open, and nothing else.The claim is about the executor run, not about one connection. An independent review showed that every false
NotExecutedit could produce came from two connections in one effect: the first delivered the request, the second was refused, and a per-connection classifier judged the second alone.xmlrpc.client's retry,FancyURLopenerfollowing a303, an opener whose handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that pair.Controlnow opens a register around each executor call; every send throughctrlrun.transportorctrlrun.gateway.transport.requestmarks it before the first byte, and a claim needs it unmarked as well as the connection's own evidence. Outside an executor run nothing is claimed. The limit is stated in the module, the class and the specification: the register sees only this library's own sends, so an executor that sends part of the effect through another transport and then uses the classifier can be handed a claim that is true of these connections and false of the effect. A send through this library on a thread that did not copy the executor's context is seen: it belongs to no register, so it marks every register open in the process, which costs claims in unrelated concurrent runs and never safety.A continuation leg never records
FAILED, and 0.6.1 did. A continuation exists only because the remote answered and is holding the exchange, so nothing on that leg can say the remote did nothing.Control.resumenow runs with the register already marked, and the gateway refuses to recordFAILEDfor anything a continuation meets: a refused connection, a pre-dispatch JSON-RPC code, the401rule ofv0.2 §6.8, and a tool error under an operator'snot_executed_on_error: true, which asserts that that tool reports errors before acting and cannot speak for a call it did not answer. At 0.6.1 each of those recordedFAILEDand, for a connection never established, answered the client-41011"not executed", which permitted a retry of an effect the upstream may have been part-way through. The upstream's own response is still relayed unchanged; what changes is the record, which is nowAMBIGUOUSand needsctrlrun resolve.Behind a proxy the gateway is stricter than 0.6.1. httpx reports an unreachable proxy and a TLS failure with the target after the proxy answered the
CONNECTline with the sameConnectError, andctrlrun.transportcounts a writtenCONNECTline as a byte. Where the environment names a proxy,ConnectErrorandProxyErrorare now an unknown outcome: an intercepted call that would have been recordedFAILEDwith-41011is recordedAMBIGUOUSwith-41010, and needsctrlrun resolve. With no proxy configured nothing changes. -
Clock-skew detection (SPEC-v0.7 §3, item 1).
PostgresStateStoremeasures its server's clock against the application's at open, and again when an expired lease is declaredAMBIGUOUS(at most once perDEFAULT_LEASE), in one round trip whose half is the measurement's bound, so latency alone is never reported as skew. It keeps its latest measurement as the optional, read-onlyclock_skewattribute, actrlrun.state.ClockSkew;Controlreads it at the start of everyexecuteandresumeand after anAmbiguousEffect, and appends one new event type,CLOCK_SKEW_DETECTED, for a measurement pastclock_skew_threshold(default one second, at mostDEFAULT_LEASE, and no value switches it off). It observes and reports, and changes no decision: every lease is still evaluated against the application clock exactly as at 0.6.1, no reservation outcome changes, and a measurement that fails is logged and changes nothing. Verify gains G13, graded against a Postgres--store-urlandN/Aon SQLite, and the catalogue moves toctrlrun.guarantees/v3; the store conformance suite gains aclockcase,not_applicableon SQLite and the in-memory store because neither has a clock of its own. -
The attempt ceiling,
max_attempts(SPEC-v0.7 §5, item 4, and the amendment todocs/SPEC-v0.1.md§5.4). A new action-entry policy key, an integer of at least 1, bounding the attempts that may execute on one effect key, the first included:max_attempts: 3is the first attempt and two renewals. It needsschema: ctrlrun.policy/v5, a new schema version that is a superset ofv4asv4is ofv3;0, a negative, abool, a float, a string and a mapping are each aPolicyErrorat load, naming the key, the action and the line. The ceiling is inside the policy hash, so a receipt records which one refused an attempt. An attempt, not an executor invocation: 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 adds none. The decision is taken on the attempt number the store assigned, after the reservation and before the executor, because two callers that both read attempt N-1 would both pass a read taken before reserving. Above the ceiling the executor is not called, the record is released asFAILEDwith an error naming the ceiling,EFFECT_RESERVATION_REFUSEDcarriesreason: "attempt_ceiling"with the attempt and the ceiling, ablockedreceipt is written, andActionDenied(reason="attempt_ceiling")is raised. The refused attempt number is spent: raisingmax_attemptsfrom 2 to 4 after a refusal buys one further dispatch, not two. A read of the record before the approval gate refuses the ordinary sequential case earlier, writing nothing, spending no presented approval and creating no approval request; it refuses only aFAILEDrecord and is never the guarantee. On any other route the approval gate comes first, so on anAPPROVEaction a human can be asked, and answer, for an attempt that is then refused: a wasted answer, never an execution, anddocs/SPEC-v0.7.md§5.2 and §5.5 say so rather than closing it. In observe mode the refusal is recorded aswould_have.blocked_reason: "attempt_ceiling"and the action runs. Verify gains G15, and G5 and G14 now select only an action whose ceiling permits a renewal, reportingN/Awhere the ceiling is the only reason they cannot, because each one's control is a renewal andmax_attempts: 1would otherwise report a correct kernel as a failure. No new error type, no new event type, no newStateStoremethod, no newControlmethod, and no CLI change. -
The provider idempotency token (SPEC-v0.7 §4, item 3).
ctrlrun.idempotency_token(), a new zero-argument accessor re-exported at package import, answers inside an executor with the token of the attempt it is running:ctrlrun.effect.idempotency_token_for(effect_key, attempt), a SHA-256 over the canonical form of(effect_key, attempt)under the domain tagctrlrun.idempotency/v1, rendered as a 36-character UUID of version 8. Send it to a provider as its idempotency key. Derived from the attempt and not from the effect key alone: the effect key is stable acrossSPEC-v0.1.md§5.4's renewal, so a provider given it would answer the one retry the kernel permits, permitted because the executor proved nothing happened, with the cached failure of the attempt that failed. It is stable within one attempt, including across aControl.resumeof a suspended one, and different after a renewal. What it is for is reconciliation: a deterministic handle to ask a provider what became of an attempt whose outcome is unknown, by a key the provider already indexes. It does not make a retry safe, and after anAMBIGUOUSoutcome the kernel still refuses one. Nothing is stored: the token is a pure function of two fields every receipt of an attempt that ran already carries, so a receipt re-derives it and areconcilehook reads the attempt off the record. The executor signature is unchanged, and an executor that never calls the accessor runs exactly as it did at 0.6.1. Outside an executor, for an action with no effect key, for an observe-mode attempt whose reservation was refused, and on a thread started without a copy of the executor's context, it raisesInvalidArgument. Verify gains G14, with a note beneath the table: a token is unique only as far as the operator's effect keys are, and a kernel that sees one store cannot check that two stores sharing a provider account never produce one effect-key string for two different effects. -
Precondition fingerprints (
docs/SPEC-v0.7.md§6, §7).@protect(..., preconditions=provider)andControl.execute(..., preconditions=provider), where the provider takes theActionand returns a mapping of the state an approval depends on. A precondition fingerprint narrows the window between a human's approval and the action's execution; it does not close it. UnderAPPROVEthe provider is called when the approval is requested, and the result is kept only as asha256:fingerprint on the request (ApprovalRequest.precondition_fingerprint, stored in the newapprovals.precondition_fingerprintcolumn). On the presenting pass it is called again, strictly before the store call that consumes the approval, and the action is refused withApprovalMismatchand a reason of its own:precondition_changedwhere the two fingerprints differ,precondition_missingwhere only one side has one (a store that lost the column, or the gateway and the ACS hook, which name no provider), andprecondition_unavailablewhere the provider raises or returns something that is not a canonicalizable mapping. Every refusal reserves nothing and leaves the approval granted. On the request pass a provider that fails refuses the action withActionDenied(reason="precondition_unavailable")before any human is asked, and a fingerprint that is computed and then not recorded (a store without the column, a third-partyApprovalProviderbuilding its own request) refuses withActionDenied(reason="precondition_missing")and withdraws the request it left behind where this call can reach it: denied while it is pending, spent where a grant landed inside the window, andnot_withdrawn:<status>in the evidence where neither write was possible (the provider raised after recording, or the store refused). §6.4 states that bound and its residual. A withdrawal is adeny_approval, sofind_denied_requestreturns it and the gateway's "no is an answer" pre-check refuses every call for that action hash until the request expires, as though a human had said no: fail-closed, bounded by the TTL, and traceable through the approverctrlrun:precondition-not-recorded.ALLOW,DENYandControl.resumenever call the provider; observe mode compares, records and runs. The comparison 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: T261b opens that window and asserts exactly that. Raw provider output reaches no receipt, event, log line or table, and a provider's exception is recorded by its type name only. Noskip_preconditions, and no timeout parameter: a provider that hangs holds the call and reserves nothing. -
Migration
0005_precondition_fingerprintaddsapprovals.precondition_fingerprint,NULLon every existing row, on SQLite and Postgres. A database built by 0.6.1's own code migrates keeping every row, and 0.6.1 refuses the migrated database 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=, and nothing in the new process can see the old one. -
G16 in
ctrlrun verify, "a moved fingerprint is refused" before the reservation, underctrlrun.guarantees/v3. Verify supplies its own provider, because a provider is named in code that verify does not read, and the report says so beneath the table;not applicableonly where no action requires approval. The store conformance suite gains aprecondition-fingerprintcase and a broken-store fixture that fails it by name. -
Python 3.13 and 3.14 are tested and declared. CI's
checkjob runs the full suite on 3.11, 3.12, 3.13 and 3.14, and the package classifiers name all four. The floor is unchanged:requires-pythonstays>=3.11, and mypy and ruff still check against 3.11. No library code changed; the one test fix is below.
-
An action entry may declare
max_attempts, and a renewal overFAILEDcan now be bounded. This is stricter than 0.6.1 only where an operator asks for it: an action that declares nomax_attemptsrenews without bound, exactly as before, and every document that loaded at 0.6.1 loads unchanged. There is no default ceiling, and no value of the key means "unlimited". -
ctrlrun.receipt/v4, withprecondition_at_requestandprecondition_at_recheck, and the first receipt-schema bump that does not report older receipts as altered. A receipt read from a store is now hashed as the document it was read from (docs/SPEC-v0.7.md§6.11, amendingSPEC-v0.6.md§6.4's last bullet), so everyv3receipt a released 0.6 wrote still rehashes to its stored hash and a chain spanningv3andv4verifies end to end. A key added to a stored receipt, a relabelledschema, a removed one or an unknown one iscontent_alteredat itsseq, and no longer something a reader could miss. Visible:to_dict(),ctrlrun receipts --jsonandctrlrun inspectrender each receipt under its own schema, so a pre-v0.6 receipt shows its ownv1orv2label and keys where 0.6.1 showedv3. Upgrade every reader before any writer: av4JSONL line handed to 0.6.1 rehashes wrongly. -
ctrlrun verifyprints each distinct note once, where it printed only the first note in the report, which would have dropped G16's beneath G3's. CI'sverifyjob expectsverified 13/13with two not applicable, andverified 7/7with eight, measured from a run of the merged catalogue rather than carried over from either branch. -
A receipt chain reader no longer stops at a row it cannot hash. A stored document holding a value with no canonical form (a float, a lone surrogate) made
verify_chainraise, so one tampered row ended the walk:ctrlrun receipts --verify-chainexited with no report and a forged field at anotherseqwent unnamed. Such a row iscontent_alteredat itsseq, named by the refusal's type and never its message. -
APPROVAL_CONSUMEDcarries what the presenting pass compared, where a precondition was compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the comparison its first leg made.
-
PostgresStateStore.events()read a missingaction_idback as the string"None". An event about no action (the threeDELEGATION_*types, and nowCLOCK_SKEW_DETECTEDreported at open) named a proposal called "None" on Postgres alone; SQLite and the in-memory store returnedNone. It now returnsNoneon all three. -
The migration tests' release fixtures could not build a venv on some interpreters.
venv.createcopies the interpreter by default, and a copied binary from a shared-libpython build (uv's CPython 3.14 on macOS) aborted insideensurepip, so all five release fixtures errored before a release was installed. The fixture now symlinks, aspython -m venvdoes on POSIX. -
The Postgres store could hand out one attempt number twice (
docs/SPEC-v0.7.md§5.6). 0.6.1's renewal afterFAILEDread the record with a plainSELECTand then updated it oneffect_keyandstate = 'failed'alone. So a renewal planned against attempt k could land after another process had renewed to k+1, run and failed, and write k+1 a second time: two dispatches, and two receipts, under one attempt number. TheUPDATEis now also conditioned on the attempt it was planned from, with the row count checked, and a stale renewal is refused withDuplicateEffect. SQLite carries the same condition, where it was already unreachable becauseBEGIN IMMEDIATEholds the read and the write together. -
A stale Postgres write could put an older attempt number back (
docs/SPEC-v0.7.md§5.6). 0.6.1's compare-and-set underresolve_effect,extend_lease,hold_continuation, the keptAMBIGUOUSwrite and the four transitions matchedeffect_key,action_idandstate, and wrote back the attempt number it had read. A caller that retries oneActionreuses itsaction_id, so the sameaction_idand state could come round again at a newer attempt between the read and the write. A human'sctrlrun resolvedecided on attempt 1 then wroteFAILEDat 1 over attempt 2's unknown outcome, and the next renewal handed out attempt 2 a second time. On Postgres, every write to an effect record is now also conditioned on the attempt it read, and a write whose record moved is re-read rather than landed (SQLite's writes are already inside theBEGIN IMMEDIATEthat holds their read, and are unchanged). The fix for the renewal above depends on this one: it holds only because the number can no longer move backwards. What this closes is the race between a write's own read and its write. The window a human stands in is longer, becausectrlrun resolvecarries no attempt number: someone who inspected attempt 1 can still resolve attempt 2's ambiguity. An attempt argument on the CLI would close that, and it is not in this change. -
A Postgres write that carried an outcome could be refused and drop it. With the condition above in place, a
commit_effectormark_ambiguouswhose record moved under it wrote nothing and raised, so the effect record, which is what gates the next renewal, said nothing about an attempt that may have acted: a review measured a renewal to a third attempt with a committed refund recorded on no record at all. Both are now re-issued once against the re-read, so the outcome lands on the record as it stands.begin_executionandfail_effectare still refused there, becauseFAILEDasserts that nothing happened and the newer attempt may be running. -
A refused write on a moved record claimed the wrong thing. All of these were
DuplicateEffect(state="in_progress"), which means another attempt holds a live reservation, and after a staleresolve_effectthe record isAMBIGUOUSat a newer attempt, which is nobody's reservation. The refusal now takes its type from what the re-read found,AmbiguousEffectorDuplicateEffectwithcommittedorin_progress, and its message names the move. -
An unknown outcome could vanish when the store refused to record it, on both backends and since before 0.6.
Controlcaught two store refusals around its outcome writes, and a record a human resolvedFAILEDwhile the attempt was still running answers a third: an executor that raisedTimeoutErrorthen produced no receipt and noEXECUTION_AMBIGUOUSevent, and the caller was handed a store error about its own effect key instead of its executor's exception.Controlnow catches everyCTRLRunErrorfrom an outcome write, writes the receipt and the event whatever the store answered, names the refusal in both, and re-raises the caller's own exception. Nothing is reconciled on a record the attempt could not mark. -
Two bounds on the Postgres store's re-issues reset each other. The stale re-issue and the lost-commit re-issue of
docs/SPEC-v0.6.md§4.3.2 each carry a flag that permits one attempt, and neither passed the other's flag on, so a lostCOMMITinside a re-issue and a re-issue inside a lostCOMMITalternated without end: a review drove both halves at once and reachedRecursionError, which is outside this library's closed set of errors, so no caller can classify it and the record is left stranded. Both flags now travel through both re-issues. -
A receipt for an outcome the store refused did not say what the executor had done. An executor that returned normally and one that raised
NotExecutedproduced identical receipts, naming the store's refusal and nothing else. For the first the remote very likely acted and for the second it very likely did not, and with the effect record carrying neither, that is the one fact whoever runsctrlrun resolvehas to go on. The receipt now carries both. -
A lapsed Postgres approval could be marked expired over a consumption.
_expirewrotestatus = expiredonapproval_idalone, from a read that sawgrantedpastexpires_at, so a consumption committing in between was overwritten and an approval that authorised a real effect readexpired. It is now conditioned on the status it read, as every other write on that table already was. -
After a lost
COMMIT, a Postgres reservation could return an attempt number it did not write. Where a reservation'sCOMMITwas lost and the re-read found the write absent, 0.6.1 re-issued it and then returned the reservation it had first planned, discarding the re-issue's. If another process had renewed, or inserted, and failed in between, the caller and its receipt held attempt k+1 while the record held k+2, and k+1 was a number another dispatch had already been handed. The reservation methods now return what the re-issue wrote. -
A lost
COMMITon a Postgres renewal could take another process's reservation for its own. 0.6.1's re-read accepted anyRESERVEDrecord carrying the renewal'saction_idas proof the commit had landed.docs/SPEC-v0.6.md§4.3.3 had already ruled that out on the insert path, becauseaction_idis caller-supplyable, and a caller that rebuilt the sameActionrenews under the same one. Two processes then held one attempt. The renewal's re-read now applies the same whole-row identity check, and a row that is not its own write is refused.
-
docs/SPEC-v0.7.md: the v0.7 "Execution boundary" contract, a delta over v0.1 to v0.6. No code lands with it. It asks one question: does it hold 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; and it does not know whether the world still looks the way it did when a human said yes. Five items answer those edges: theNotExecutedclassifier promoted from the gateway into core asctrlrun.transport, clock-skew detection, a provider idempotency token derived from(effect_key, attempt), an operator-set ceiling on renewal afterFAILED(max_attempts, written as an amendment toSPEC-v0.1.md§5.4), and precondition fingerprints, which narrow the window between a human's approval and the action's execution and do not close it. Tests come from §8 (T209 to T271, because T182 to T208 already belong toSPEC-mcp-operator.mdandSPEC-scan.md); public names are frozen in §9; guarantees G12 to G16 joinctrlrun.guarantees/v3.Reading the code changed nine things the plan had assumed, and §1.4 lists them. Four matter beyond this document. On Postgres a renewal could reuse an attempt number, and a lost
COMMIT's re-issue could return a number other than the one it wrote; v0.7 makes the number load-bearing, so both are fixed first, in a new item 3a, "attempt numbers never repeat", its own pull request stacked before item 3 and independently reviewed because it changes a store. Bumping the receipt schema the ordinary way would have reported every receipt a released 0.6 wrote as altered, so a receipt now renders under the schema it was written with. In enforce mode one granted approval buys one dispatch, not unlimited ones, because every renewal of an approved action needs a new granted approval; "granted" is not always a human (a scripted provider, an automatedwait=Trueloop and approvals granted ahead of a gateway all count), observe mode needs none, and an adapter can still put a human in front of an attempt the ceiling will refuse, which §5.5 records. The unbounded case the ceiling exists for is the action the policy allows outright. Andmax_attemptsneedsctrlrun.policy/v5.An independent review of the draft found seven blocking defects and nine smaller ones, and every one became an edit: among them the two store defects above, a G15 that passed with the ceiling's own check deleted, a G5 that would have failed a correct kernel under
max_attempts: 1, a receipt rule that let a fabricated field verify, three falseN/Areasons, and a verify network rule that was already untrue under--store-url. -
README.mdnow says whatctrlrun.transportis for beside the sentence that namesNotExecuted, says thatctrlrun verifybinds loopback listeners of its own rather than claiming it opens no socket at all, and says that a precondition fingerprint narrows the window between a human's answer and the execution. -
The documentation site,
CTRLRun/ctrlrun-docs, carries the generated pages for this release: everyctrlrun.transportname in the Python API reference,ctrlrun.policy/v5andctrlrun.receipt/v4in the schema references, G12 to G16 inverify.mdand in the readiness block, the classifier and the residual precondition window inTHREAT_MODEL.md, andCLAIMS.mdregenerated so every cited line number resolves.ROADMAP.mdmarks v0.7 shipped and carries two items named before v1.0: a malformed value of a schema-declared key blinding every receipt reader, and thestate -> receipt -> policy -> authority -> stateimport cycle, which contradictsARCHITECTURE.md§6.
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.
@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--helpdocuments 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.
WebhookApprovalProvideradvertisesPOST /ctrlrun/approvals/<id>asrespond_toin everyAPPROVAL_REQUESTEDnotification, andGateway.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 aCTRLRunError— 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
only
close()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_loadresolves 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 malformed
Content-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 sendsAccept-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 aMissingDependencythat could not be raised. SPEC-v0.5 §12.1 reversed that extra andpyproject.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.
- 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. Its
titleis 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.
0.6.0 - 2026-09-07 — Durable runtime
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/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/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.
-
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 frozenv0.1 §5.3protocol, extended by nothing, withUNIQUE(effect_key)plusINSERT … 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_consumableandcheck_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 optionalsource. 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, onresearch/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/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 store does not do for you.
-
ctrlrun initwrites actrlrun.policy/v2starter, with aneffect:on the refund and on the namespace delete. The starter was a v1 document headed "(v0.1)" with a comment about a feature that "arrives later", six releases on; the first file a new user reads should not be the oldest one in the repository. The actions and decisions are unchanged, so a policy written from the old starter evaluates the same way, andctrlrun verifyagainst the new one exercises the effect guarantees instead of reporting them not applicable. -
Observe mode no longer spends a presented approval, and that is a change to shipped v0.3 behaviour.
_observe_securerouted a presented approval through the same consuming path enforce mode uses, so an operator evaluating a policy in observe mode was silently burning their humans' single-use answers on actions observe mode was never going to gate. It now checks the grant — with the same pure predicate every store applies, so the refusals it records are the ones enforce mode would have raised — and writes nothing.The reservation is still taken, and the asymmetry is deliberate: in observe mode the action genuinely executes, so the effect record has to exist or the duplicate refusal has nothing to refuse with. Observe mode suppresses ctrlrun's decisions; it does not suppress the record of an effect that really happened. The
APPROVAL_CONSUMEDevent on that path is gone rather than renamed — v0.6 adds no event type, and an event naming a write that did not happen is worse than no event. -
A denial now names the approval that was presented, on the
ACTION_DENIEDevent and on the receipt. Where a policy denies an action a human had already approved, the approval staysgrantedand unspent — that is deliberate, andSPEC-v0.6.md§7.2.1 argues it — but nothing previously connected the live grant to the refusal it met. -
data_scopeis now refused as an argument name, at every place an argument is named: adata:key, aneffect:orresource:template placeholder, and a@protect-ed function's parameter. §7.4 said it always was; no such check existed. A document or a decorated function using that name stops loading, with the reason. -
data_scope_eq:anddata_scope_neq:compare the set and not its order. The derived value is sorted, and list equality is order-sensitive, sodata_scope_eq: [phi, internal]silently never matched while[internal, phi]did — an operator writing the labels in their own declaration order got a rule that never fired, and where that rule was thedenyor theapprove, that is fail-open. Narrowed to derived subjects: an ordinary list argument still means that exact list. -
policy_hashcovers what §7.1 said it covered. Four things were missing and each is now in: an authority document loaded separately from the policy (the gateway's shape, andverify --authority's — two deployments with different grants produced byte-identical provenance on every receipt); an action'sdata:labels; per-action and per-rulecontrols:citations; and thecontrols:registry itself, whose titles are what a receipt's control ids mean. The effective environment is hashed rather than the document's, since$CTRLRUN_ENVIRONMENTandControl(environment=...)outrank it. -
A control's citations are recorded in registry order, not in the order the action and the matched rule cite them, so two receipts citing the same set list it the same way whichever rule matched.
-
A relaxed policy now closes the approval it made unnecessary, and this is a change to shipped behaviour. A policy relaxed between a human granting an approval and an agent presenting it left that approval
grantedfor its full TTL, bound to a hash a later edit could makeAPPROVE-requiring again — a live bearer token for an action a human already answered, andv0.1 §4.1calls a request id a bearer token in as many words. The approval is now spent, in a write of its own after the reservation, so that an allowed action's success never depends on the approval store. -
Every entry point re-checks the policy in force at execution, and the receipt says which policy that was. Where the policy changed between a grant and its consumption,
SPEC-v0.6.md§7.2's table decides by observation: a policy that now denies leaves the approval granted (the action is refused on the policy axis, and spending the approval would destroy the evidence a human answered); a policy that now allows invalidates rather than consumes it.
- A store failure closing an unneeded approval no longer refuses the action. Where a policy
had been relaxed to
allow, a locked database or a dropped connection while closing the now-unnecessary approval propagated to the caller — after the effect was reserved and before execution began. No receipt was written at all, and the effect key was leftRESERVEDuntil its lease lapsed: an ambiguity manufactured by the permissive decision path. - Concurrent store opens no longer fail. Switching a database to WAL takes a brief exclusive
lock that SQLite's busy handler does not cover, and
busy_timeoutwas being set after the switch — so simultaneous opens raced and lost. It was survivable while opening a store was a read; v0.6 makes every open a potential write, which is the fleet restarting after an upgrade. Measured at 38 of 60 concurrent opens succeeding before, 120 of 120 after.
-
The browser page is a playground.
/try-itruns one protected refund on the released wheel in the tab: an amount, a payment id, a lose the reply switch, a Refund button and an Approve button that is the human. Every line it prints isctrlrun's own — the page's Python is a module over the public API, read out of the JavaScript by the same regex the Node harness uses, andtests/test_docs_travelling.pyruns it natively through the six steps the page suggests on every commit. The harness records what it ran indocs/assets/browser-demo.verified.json, and the page's quoted versions are held to it. -
The front door leads somewhere. The docs home shows
@protectand a policy before it shows anything else, the capability grid shows the six guarantees and folds the other twenty, the quickstart is titled for what it takes and opens withpip install, the cookbook sidebar is grouped the way its index is, and the README puts Protect your first action ahead of the problem statement and the release notes, with the long policy and the verify transcripts collapsed. The capability One effect, once now reads "happens at most once", which is the hero's phrase and the one the limitations section had been contradicting. The policy reference states the version rule: declare the lowest schema that has every key you use. -
docs/SPEC-v0.6.md— the v0.6 "Durable runtime" contract, a delta over v0.1–v0.5. No code lands with it. It asks one question: does it still hold when the process dies, the host goes away, and the database is somewhere else? Every guarantee shipped so far is a guarantee about one process holding one SQLite file, andBEGIN IMMEDIATEis a whole-database write lock on a local file; take the file away and the promise has to be re-earned with a different mechanism. Tests come from §8 (T140–T181); public names are frozen in §9.An independent review in a session that did not write it found twenty-one defects, four of them blockers, and every one became an edit. Three of the four were invisible from the diff and visible only from the shipped code: a re-read that concluded "it carries our
action_id, so we hold it" — which another process's live reservation satisfies, giving a double execution through the storage layer; a claim that a failed receipt write is "logged, not raised", which isv0.1 §6.1's rule about the JSONL file and not about the store, and would have turned the evidence-integrity section into silent evidence loss; and a migration that adopted a pre-v0.6 database without running its baseline DDL, which would have left every v0.1 and v0.2 database with nocontinuationsand nodelegationstable.docs/SPEC-v0.6.md§9.6.1 records all twenty-one with where each landed. -
docs/docs/ROADMAP.md's v0.6 bullet said "receipt integrity (hash chain / signatures)", and the slash was the problem. A chain detects alteration; a signature proves origin, and proving origin brings key generation, rotation and revocation with it — which is issuing, and this project verifies what it is handed. Signing is out of scope for v0.6 (SPEC-v0.6.md§11). Corrected in the same commit as the specification, on the ruleSPEC-v0.4.md§9.4 set. -
docs/docs/THREAT_MODEL.md's "Receipts are not signed; a database admin can alter history (v0.6)" promised something v0.6 does not deliver. Rewritten to say which half v0.6 closes — the partial tamper: anUPDATEon one row, aDELETEfrom the middle, a reordering — and which half it does not: truncation at the end, authorship, an adversary who can rewrite every row including the chain head, and the question of whether every action wrote a receipt at all. Two drafts of that line claimed truncation, and a review measured it: deleting a suffix and rewinding the head is two statements, undetected.
Two subcommands landed after v0.6's code was complete, and ride along in this release. They
are recorded under their own heading rather than mixed into the milestone, because v0.6's claim
is that the surface did not grow — and it did not. These grew it, afterwards, each under its
own specification, and neither gated the release nor was gated by it. Both are subcommands of
the ctrlrun distribution rather than separate ones, so unlike an adapter neither carries a
version line of its own.
docs/SPEC-scan.md is the contract; it was written first and its §8 tests were red before any
of it existed. scan reads a Python tree and a policy document and reports the consequential
call sites and policy entries ctrlrun is not covering — the gap between installed and in the
path, which until now had no command.
- The honest half is the load-bearing half. A scanner reports what it found where it looked, and a clean result is not a verdict. §4 enumerates what it misses by construction — dynamic dispatch, reachability, anything outside the tree, and a deployment whose protection is entirely the gateway — and the report says so on every run, including the run with no findings.
- There is no score, no percentage and no badge (§10). A number that improves when the vocabulary is shortened is a number that will be.
- It adds no entry point at all (§9.2), stated as a rule rather than as a fact about the
first implementation: the tempting version of this tool builds an
Actionfor each call site and asks the policy what would happen to it, which would be a principal invented by a tool from a source file. - Exit codes:
0nothing found,1a finding or a call whose name could not be resolved,2the scan could not run.
Five sections of the specification carry a paragraph beginning Found by, recording what
writing the tests changed about the design: the plural rule separating stripe.refunds.create
from refunds_report; execute dropped from the vocabulary, because cursor.execute was 90 of
208 findings against this repository's own src/; a policy action whose decorator supplies its
own effect template no longer reported as missing one; a call on an expression matched rather
than filed as undetermined, which took that list from 216 entries to 10; and undetermined
removed from the finding kinds it was listed among and contradicted by.
docs/SPEC-mcp-operator.md is the contract. It authenticates who answered and records it; it
does not check that they were entitled to, which is separation of duties and is still not built.
Added
ctrlrun mcp-operator— an MCP server exposing the operator's own commands as tools, so the person who has to answer an approval can answer it from the assistant they are already talking to. Read toolslist_pending_approvals,inspect_action,receipts,effectsandstats; write toolsapprove,denyandresolve. Ships inctrlrun[gateway], imports nothing from an extra, andimport ctrlrunimports none of it (T192).ctrlrun.reporting— core and stdlib. Thectrlrun.inspection/v2andctrlrun.stats/v1document builders, moved out ofctrlrun/cli/main.pyunchanged so that the CLI and the operator server have one producer each. T193 asserts the two agree by equality rather than by shape, which is the only version of that claim worth having.- Two entry-point rows in
docs/SPEC-v0.3.md§4.3.1, written before the code, as that section requires of every new way in. - Two JSON-RPC codes in
v0.2 §6.10's reserved-410xxrange:-41013 ctrlrun.not_a_humanand-41014 ctrlrun.principal_expired, neither reachable from the gateway.
What it deliberately does not do
- It is not a second approval path.
approveanddenyare the two store callsctrlrun approveandctrlrun denymake, against the same record, with the same hash binding, single use and expiry. There is one approval record and one place its state changes. - It cannot make an agent act. No tool proposes, executes or resumes; no
Controlmethod butstore,policyandenvironmentis referenced, and T189 asserts that against the source rather than against behaviour. - It has no
--allow-remoteand no--principal. Its read tools answer without a credential, so it binds loopback and there is no flag that changes that; and a static principal would attribute every approval to one name whoever gave it. Both absences are asserted by name (T183, T185), so adding either fails a test rather than a review. - It does not authorize the approver, only authenticate them. Any human whose credential
the provider verifies can answer any pending request, exactly as any human who can run
ctrlrun approvecan.docs/SPEC-mcp-operator.md§10 says so in the place a reader would otherwise assume otherwise; separation of duties is still not built.
What the independent review changed
An authorization surface gets a review in a session that did not write it. Ten findings, all
ten accepted. Four changed the contract; docs/SPEC-mcp-operator.md §9.5 has the table.
- The
--identity-jwt-*validation was missing, with threeasserts in its place. A server could start with an unpinned issuer, audience, algorithm ortyp— and underpython -Othe asserts vanish. An unpinnedtypaccepts an ID token, so an OIDC login would have approved a payment. The gateway'scheck_jwt_flagsis now shared rather than copied, every check is anInvalidArgument, and one test runs under-O. - "Every refusal leaves the store byte-identical" was false. Answering a lapsed request
moves it
pending → expiredand commits before refusing — the kernel's own rule, a lapsed approval is evidence — and the test table had omitted that row, so the claim was asserted nowhere. The spec carves it out and the test asserts the delta. - The composes-nothing test checked 81% of the file. It split the source on a marker
comment; the excluded fifth was the part that handles the socket, and a
Control.executeinsidedo_POSTpassed it. It scans the whole file now. ::1was accepted and could not bind.ThreadingHTTPServerinheritsAF_INET, so--listen ::1:8901exited with a traceback while the test asserted only that the string had been stored. The socket family follows the host,[::1]is accepted, and the test binds.
Also: --otel was inert and is gone, a Content-Length of -1 reached an unbounded read, the
repeated-identity-header check lived only in the stdlib handler, the startup block omitted the
store, and serve_operator leaked a connection under --store-url.
Changed
ctrlrun.cli.mainno longer definesINSPECTION_SCHEMA,STATS_SCHEMA,_stats_documentor_since; they arectrlrun.reporting's.ctrlrun inspect --jsonandctrlrun stats --jsonemit byte-identical documents to before, and a--sincethat does not parse is still a usage error with exit code 2.
0.5.0 - 2026-09-05 — Adapter contract
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.
-
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, andControl.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 callsctrlrun 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/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 the
approval-mutationcolumn carefully:executed_oncethere does not mean the scenario went well.
ctrlrun demo --helpsaid four scenarios and ran five, stale since v0.3 added the authority escalation.
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 nestedNotExecutedmasked 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.
-
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 the
SPEC-v0.3.md§6.5 banner once perControl, whichctrlrun.adapter.bannerdoes so no two adapters word it differently. The conformance kit refuses an observingControl— 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.principalhas 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, andControl.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_approvalandbanner. 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 andSPEC-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.
-
docs/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.
0.4.0 - 2026-09-04
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.
-
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 §10andv0.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 guarantee
failwithreason: "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 against
tests/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/docs/verify.md(SPEC-v0.4 §5).action.ymlat the repository root is a composite action: it installsctrlrun, runsctrlrun 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 hascontinue-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 need
contents: 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/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/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/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 first line, before any table, says what it is not: not a compliance claim, not a conformance claim, not a certification, and not a statement that ctrlrun covers the Top 10.Two tables, and the second is what makes the first credible.
ASI04supply chain,ASI05code execution andASI06memory and context poisoning are not ctrlrun's subject — nothing here inspects a package, sandboxes an interpreter or reads a model's memory — andASI07inter-agent communication waits on v0.7.ASI01agent goal hijack andASI09human-agent trust exploitation appear in both tables, because ctrlrun constrains what a hijacked agent can do without detecting the hijack, and binds an approval to one action without authenticating the approver or noticing that they were misled.The document records how its codes and titles were derived, because the published PDF sits behind a download form and could not be retrieved: they come from the OWASP-owned
OWASP/secure-agent-playbookrepository, corroborated against two independent summaries, and the four places where a third summary disagreed are named. That correction is what SPEC-v0.4 §6.2 marked its own provisional list as needing. -
research/framework-probe/(SPEC-v0.4 §7) — a research harness that drives the double-refund and approval-mutation scenarios through third-party agent frameworks against a fake remote, and emits a table. It lives outsidesrc/, is never imported byctrlrun, and its per-framework dependencies are never installed byctrlrunor by any of its extras.Its README's first paragraph says what the table is: behaviour, not quality. A framework that retries a lost response is doing what its documentation says it does; the finding is about what an agent stack does without an effect-level guard.
One fake remote for every framework, with three behaviours — commit-then-drop, commit-then-timeout, capture-what-was-approved — counting effects by identity, not by request, so "executed twice" means two effects and not two HTTP calls. Every outcome is derived from what the remote saw and never from anything an adapter reports about itself.
Two stub frameworks run by default and disagree: one retries and reports
executed_twice, one does not and reportsexecuted_once. Without the pair, a harness hard-coded to sayexecuted_twicewould pass its own tests and say the same thing about every real framework it ever ran.No results are checked in, and a test asserts it. The runs are made and published by the maintainer; a commit carrying findings about other projects that nobody had reviewed is not one this repository makes.
-
The badge this repository shows is published, by a
badgejob scoped as narrowly as the thing it does:contents: writeat job level (the workflow iscontents: read, so nothing else in it can write), running on a push tomainand on nothing else — a pull request from a fork must not be able to write the badge, and a read-only fork token is a default rather than a refusal — and publishing the badge theverifyjob already produced, downloaded as an artifact rather than regenerated, so §5.1's one-run rule holds across the job boundary.docs/docs/verify.mdshows the job and names the permission it costs. -
docs/SPEC-v0.4.mdgains a §12, recording the readings the implementation had to take where the specification could not be satisfied as written. A specification that disagrees with the code it describes is worse than one that admits a gap: G6 drives a a guarantee's invariant is the behaviour and not one reason string — G6 asserts that an unlisted action never executes, against theControlthe operator's configuration actually composes, accepting any reason that configuration can produce and reporting which one fired, because under anauthority:section the refusal correctly arrives from the authority axis before policy is reached; G7 isN/Awhere no action in the policy can run, because §2.2 said "never" and §1.3 requires a control that such a policy cannot supply; G8 gains a fourth N/A reason for a layered document; G9's control names the delegation only where the parent's subject does not also match it; and G4's children are subprocesses rather thanmultiprocessing, which would re-import the caller's__main__in every child.
-
docs/SPEC-v0.3.md§10 T85 is amended, as SPEC-v0.4 §9.4 item 2 requires and in the commit that made it true:ctrlrun verifyexits 2 undermode: observewith a message naming the mode, and runs undermode: enforce. The banner assertions for every other command are untouched. A frozen test whose subject was explicitly temporary is amended rather than deleted. -
docs/SPEC-v0.3.md§4.3.1 gains an informational row forctrlrun.verify.run(SPEC-v0.4 §3.9, §9.4 item 3). Verify is not a new entry point: it proposes no action of its own and drives the rows already there. The row exists because a reader will look for one. -
docs/docs/ARCHITECTURE.md§6's module map gainsverify/, abovecontrol.pyand besidecli/. -
docs/SPEC-v0.4.md— the v0.4 contract, a delta over v0.1, v0.2 and v0.3. v0.4 answers the question the first three releases could not: does it hold in my setup? Everything ctrlrun guarantees is proven today by this repository's tests against this repository's configurations, which is the right place to start and the wrong place to stop.ctrlrun verifyruns those failure scenarios against the operator's own policy, grants and store type, and reports what passed, what failed, and what could not be tested at all.Three rules govern it. Not applicable is not a pass: a configuration with no
approverule cannot exercise the approval-binding guarantees, so they are reportedN/Awith the reason, excluded from the denominator, and listed separately —3/3 (5 not applicable), never8/8. Verify never touches the operator's store: every scenario runs against a scratch store created and destroyed with the run, and.ctrlrun/state.dbis byte-identical before and after. The badge means "declared guarantees pass" — that phrase, on the badge's link target, and never "secure" or "compliant".Nothing is implemented yet. The specification is the contract the seven build-list items are written against.
0.3.0rc1 is this section, published to TestPyPI only, so the wheel and the sdist can be
installed from a real index before a version number that can never be reused is spent on
PyPI. The date lands when 0.3.0 is cut.
Who is acting, and what are they entitled to? v0.1 built the kernel and v0.2 put it in the network path; both could see the action and nothing else. This release adds a second axis — identity, grants and delegation — that policy never learns to read, plus the mode you roll it out in and the command that tells you what it would have cost.
Three sentences govern everything below.
Authority is opt-in, then fail-closed. No authority: section is v0.2 behaviour, exactly.
An authority: section means every principal needs a grant and no grant means denied. There is
no half-way and no flag that makes a missing grant permissive.
Attenuation is structural. A delegated grant is valid only if it is provably a subset of its parent, on every dimension, at creation and at every evaluation. Omission never means unlimited: a child that drops a dimension its parent constrains is rejected.
Identity is consumed, not invented. ctrlrun verifies tokens it is handed and maps verified
claims onto a Principal. It issues nothing and defines no identity format. Claims are receipt
data rather than action identity — they are not in the canonical form, so an approval survives
a token rotation.
-
A fifth demo scenario,
ctrlrun demo— authority escalation (build-list item 6, SPEC-v0.3 §1.2). The first four ask whether an action is safe to run; this one asks whether a principal is entitled to propose it. A human's €100,000 delegable grant narrows to a finance agent's €25,000 and then to a support agent's €2,000, and the two ways out of the chain fail at two different moments: asking for €50,000 is refused at evaluation (authority_constraint), and minting €50,000 under a €25,000 parent is refused at creation (containment, naming the dimension). Asserting one would hide the other. The scenario ends with an amount the grant does permit, which the policy still stops to ask a human about — the two axes, combining as the stricter of the pair, in one line. -
examples/authority-escalation/— the same story as a standalone script, with theelse: raise SystemExit(...)guard on every refusal. A demonstration that quietly starts succeeding is worse than none, because it keeps printing the line that says the guard worked. -
examples/authority/— a payments delegation chain and a DevOps chain, as complete documents to read rather than run, with a README that says in its first paragraph that every principal in them is invented. -
docs/docs/authority.md— grants, delegation and the omission rule in plain language, including the two things it is worth knowing before you need them: anAuthorityis built at load time and is not hot-reloaded, and there is no way to list delegations, so cutting a chain of unknown width meansdelegable: falseon the root and a restart. -
docs/docs/THREAT_MODEL.mdgains v0.3's boundary. In scope: delegation escalation, omission as widening, expired and revoked authority, token forgery, cross-JWT confusion, and signing keys fetched from somewhere else. Out of scope, and stated rather than implied: a compromised identity provider, aHeaderIdentityProviderbehind a proxy that does not overwrite, a revoked token before itsexp, a tenant-templated issuer, and authority across an agent-to-agent hop. -
MANIFEST.inshipsexamples/**/README.md, and a test now keeps it honest. The file has claimed for two releases that a test namedtest_the_sdist_carries_everything_the_tests_needkeeps it in step, and there was no such test — so the first README it forgot was found by the CI job that builds an sdist and runs its tests, one push after it could have been found locally. That is v0.2's four.gitignored policy files in a different costume: setuptools resolvesMANIFEST.inagainst the working tree, so a local run is green either way. The test now checks every git-tracked file underexamples/anddocs/against the manifest's include patterns, and it foundexamples/acs/README.mdhad been missing since v0.2 as well. -
Fixed a test whose fuse was the suite's own duration. T27's parametrize list built two timestamps at collection time, so the future-dated one was 400 seconds ahead of collection and only
400 - <however long the suite had been running>seconds ahead by the time the test executed. Past the replay window's 300 seconds it landed inside the window and the refusal under test stopped happening. It had been latent since v0.2 and fired the first time an item made the suite slower. The timestamps are built when the test runs, and the fix is verified against aconftestthat stalls 120 seconds between collection and the body — the condition that failed CI. -
The nine sector templates stay on v0.1 and now say why. They gain no
authority:section: a grant names a real principal in a real organization, and a template that shipped plausible ones would invite an operator to adopt them. -
JWTIdentityProvider(build-list item 5, SPEC-v0.3 §3.4), inctrlrun[identity](pyjwt[crypto]), imported by naming it. It reads a bearer token from a header, verifies it against a JWKS or a static key, and maps the verified claims onto aPrincipal. It issues nothing: no OAuth flow, no refresh, no token exchange, no introspection, no dynamic client registration. An absent header is a decline; a present and invalid one is a refusal, andControlnever backfills from one. -
Configuration is refused before any token is seen. More or fewer than one key source; an
HS*algorithm withjwks_urlorpublic_key; an asymmetric algorithm withsecret; an emptyalgorithms;nonein any casing;token_typenot passed at all. TheHS*-with-a-public-key case is key confusion in its plainest form — RS256→HS256 is literally "HMAC the token using the PEM of the public key as the secret" — and it is refused at the configuration end, which is the end that can be refused. -
token_typeis required, and it is the whole cross-JWT defence. Without it an OIDC ID token from the same issuer, signed with the same key, carrying the configuredaud, passes every other check (RFC 8725 §2). PassingNoneexplicitly is permitted and warns at construction naming exactly what it gives up. -
audby membership on either wire shape, never a rawin: on the string shape that is substring matching, and it acceptshttps://ctrlrun.examplefor a configuredctrl.expis required — a credential with no expiry cannot be revoked by waiting, and v0.3 has no revocation channel — andexp/nbfare compared against the provider's injected clock, so an expiry boundary is tested exactly rather than raced. -
JWKS handling that cannot be turned into a load generator. An unknown
kidtriggers at most one refresh and then a refusal; a second such token insidejwks_min_refresh_intervalis refused with no fetch at all. A set with two entries sharing akidis refused rather than resolved by first match; a key whoseuseis notsigor whosekey_opsexcludes verification is ignored; a key carrying its ownalgconstrains itself. A failed fetch is a refusal that discards nothing, and nothing is followed: the fetch refuses a redirect outright and re-checks the scheme of the URL that actually answered.urllib'sbuild_openerkeeps itsHTTPRedirectHandlerunless an argument subclasses it, so an opener built the obvious way follows a 302 — including one to plaintexthttp://on another host. An open redirect on the issuer's domain would otherwise have this process fetch its signing keys in cleartext from wherever it pointed, cache them for the life of the process, and verify every token the attacker then signed. -
Two limits stated rather than configured around.
issueris an exact string, so a tenant-templated issuer cannot be configured correctly here — pointing it at a multi-tenant endpoint without pinning the tenant makes every tenant on that platform a valid issuer. And there is no revocation channel: short token lifetimes are the whole of the story. -
Gateway identity selection (§8.2).
--principaland--principal-headerare understood as constructors now —StaticIdentityProviderandHeaderIdentityProvider— and--identity-jwtis a third, with its own flags. Exactly one is required, still with no default; every--identity-jwt-*flag is an error without--identity-jwt, because a flag that cannot take effect is a flag the operator believes took effect — and the test for that reads the flag list off the command itself rather than off the check, because a list copied from the code under test cannot fail for the flag the code forgot. The shared secret is read from a file, never from a flag value: a secret on a command line is in every process listing on the host.--identity-jwt-http-timeoutbounds the JWKS fetch and is its own flag: the fetch runs on the request thread before any decision is made, so borrowing--upstream-timeoutwould have coupled two unrelated knobs in the fail-slow direction. -
ctrlrun gateway --authority PATH(§8.3), because the person who writes grants and the person who writes per-action autonomy are often not the same person. That document is not a policy document — its top-level key set is closed atschemaandauthority, soactions:ormode:in it is a load error naming the file and the key. Declaringauthority:in both the policy and--authorityrefuses to start, naming both paths. -
-41012ctrlrun.unauthorized(§8.4). A tool call outside the principal's grant returns it, HTTP 403, upstream untouched;v0.2 §6.10's-41001now means a policy denial. The gateway catchesAuthorityDeniedbeforeActionDenied, which it subclasses — the other order makes the new code unreachable while every test asserting only "it was refused" stays green, so both halves are pinned by name. -
No refusal reaches a client as a dropped connection.
do_POSThas no top-level handler, and an escaping exception makessocketserverclose the socket with nothing written — which a client reads as a transport failure, and a transport failure is the one signal it retries on. Three v0.3 paths landed there and each now answers: an authority denial or an expired credential on a continuation (-41012/-41007), an expired principal inControl.execute(-41007, andIdentityErroris deliberately not anActionDenied, so it needs its own clause), and an identity provider raising anything at all, which §3.2 makes anIdentityErrorin-process and now does at the gateway too. The expired-principal path is routine rather than adversarial: the JWT provider admits a token up toleewaypast itsexp, so every token in that 60-second window was affected. -
A startup block that says what is in force (§8.4): the environment, the identity provider by name and the header it trusts, whether an
authority:section is loaded and how many grants it holds — or the single lineno authority: section — every principal is unrestricted, so an operator who believes they configured authority finds out on the line that starts the process. -
Observe mode (build-list item 4, SPEC-v0.3 §6). A top-level
mode: observeruns every real decision against real traffic and records what would have been blocked, without blocking anything: noActionDenied, noAuthorityDenied, noApprovalRequired, noDuplicateEffect.Control.executereturns a receipt whoseresultisobserved, whoseexecutionis what the executor actually did, and whosewould_havesays what enforce mode would have reached and what it would have done with it. It is the rollout path, and it is not a dry run: it executes, effects land at remotes, and the records of them are real. -
One switch, and it governs the process.
mode:is top level and nothing else — inside an action, a rule, a grant or theauthority:section it is a load error naming the rule, and so is a value that is not exactlyobserveorenforce. A partially-enforced configuration is the failure mode that rule exists to prevent. Absent meansenforce;mode:needsschema: ctrlrun.policy/v3, because a reader that ignored it would enforce a configuration that was deployed to observe. -
Observe mode still refuses what it cannot describe. A missing principal, a provider that raises, an unresolvable effect key, an argument an Action cannot represent, and a delegation that would escalate are refused in both modes: the first four are wiring bugs that would run an action ctrlrun could not describe, and the fifth is an act of authority rather than a decision about an action. It asks no human either — a policy reaching
approverecordsapproval_requiredand runs, creating no request and appending noAPPROVAL_REQUESTED— and it never calls thereconcilehook, whose"committed"answer would move a record a human may still be adjudicating. -
A refused reservation writes no effect record. The record belongs to the attempt that holds the key, and observe mode does not make a second attempt its owner: a record already
AMBIGUOUSstaysAMBIGUOUS. The refusal is on the receipt instead, aswould_have.blocked_reason. The gateway's v0.2 §6.10 pre-check is skipped in observe mode for the same reason — it refuses calls beforeControl.executeis reached, and a gateway that kept it would enforce three rows in the one mode that enforces none of them. -
ctrlrun stats, counting from the local store and nothing else — no network, no aggregation service, no upload. Would-have-denied broken down by reason, would-have-needed- approval, would-have-been-blocked broken down by duplicate and ambiguous, and ambiguous outcomes.--sincetakes an ISO-8601 timestamp with an offset or30m/24h/7dand comparesfinished_atinclusively;--jsonemits the same numbers underschema: ctrlrun.stats/v1. In enforce mode it reports what actually happened and reports less, saying so in its footer rather than printing a line the receipts cannot substantiate. -
The observe banner.
OBSERVE MODE — nothing is enforced, to stderr, before anything else, on every invocation of every command that loads the operator's policy —gateway,stats,delegate,revoke,verify. To stderr so a--jsonstdout stays parseable. The evidence commands (receipts,effects,inspect,resolve,approve,deny) do not print it and do not load a policy at all: reading evidence must not depend on the configuration that produced it. -
ctrlrun verifyas a stub that prints to stderr that verification lands in 0.4 and exits 2. It runs nothing, checks nothing, and claims nothing. It exists because observe mode's whole purpose is to lead somewhere, and the command an operator reaches for next should not be aNo such commanderror that suggests they mistyped. -
Delegation with attenuation (build-list item 3, SPEC-v0.3 §5). A principal who holds a
delegablegrant can create a narrower one at runtime —Control.delegate,ctrlrun delegate— and revoke it —Control.revoke,ctrlrun revoke. A delegated grant is valid only if it is provably a subset of its parent on every dimension, checked at creation and again at every evaluation: a check performed only at creation would leave every delegation exactly as wide as the file used to be, which is the shape of every stale-permission incident there has ever been. -
Omission never means unlimited. A child that drops a dimension its parent constrains is rejected, not treated as inheriting the parent's limit and certainly not as unconstrained — including a child subject that carries a wildcard, omits
agent, or drops the parent'suser, each of which hands the grant to a wider population rather than a narrower one. -
Revocation is transitive by structure. Nothing is rewritten and no children are visited: a chain of any depth is cut by one write, because every evaluation walks to the root. Chain depth is recomputed, never read from the stored column, the walk is bounded and refuses a chain that revisits an id, and a stored delegation that cannot be read denies the action outright rather than being skipped in favour of a broader grant that happens to match.
-
A
delegationstable in both stores, andDelegationRecordwith it. A new table, soCREATE TABLE IF NOT EXISTSadds it to a database that already exists and v0.3 still needs no migration story; no existing table gains a column.put_delegationis anINSERTand never an upsert — an upsert on an existing id would clearrevoked_at, which isunrevokeby another door in a release that says there is no such thing. -
Three delegation event types,
DELEGATION_CREATED,DELEGATION_REVOKEDandDELEGATION_REJECTED. They are about an authority record rather than an action, soEvent.action_idbecomesstr | Noneand they carrynull;OTelEventSinkemits a standalone span for each rather than dropping them, because the highest-privilege operations in the release must not be the only ones missing from the export path. -
An expired credential mints nothing.
Control.delegaterefuses abywhose credential has lapsed, before it looks at the parent at all — a delegation is the most durable thing a principal can create, and it is the last place a stale credential should still work. -
The
authority:section: grants, patterns and evaluation (build-list item 2, SPEC-v0.3 §4). A second axis, and it is opt-in and then fail-closed: a document with noauthority:key behaves exactly as v0.2, and the moment one exists every principal needs a grant — including for actions the policy allows outright, including reads, including actions with no effect key. There is nodefault: allowand no flag that makes a missing grant permissive.Authority,Grant,SubjectandAuthorityResultare public;Control(authority=...)takes one, andControl.from_filereads it from the same document. -
Authority is evaluated before policy, and the two combine as the stricter of the pair. A denial there appends
AUTHORITY_DENIEDand neverPOLICY_EVALUATED, so it cannot leave a pending approval request behind for an action that could never run. Neither axis loosens the other: authority cannot make a denied action allowed, and policy cannot make an unauthorized one permitted. A grant carries nodecision:— how much autonomy an action has stays the same for every principal, and what differs is whether they may propose it at all. -
A pattern grammar small enough that containment is decidable (§4.4, §5.5): a literal, a
prefix*that cannot cross a separator, and a final**whose preceding segments are all literals.stripe.*matchesstripe.refundand notstripe.refund.partial; there is no?, no character class, and no short spelling for "everything" — granting the whole surface of a system is spelled**, one token, greppable, and impossible to write by accident. -
Two new event types and one new error.
AUTHORITY_RESOLVEDis appended for every action that passes authority, not only a delegated one, so a deployment with a permissive grant is distinguishable from one with no section at all.AuthorityDeniedsubclassesActionDenied— an authority denial is the action being denied — and carries a reason from §4.3's closed set, never a grant id: a grant may legally be namedno_authority, and evidence that can be spoofed by naming a grant is not evidence. -
Extended
Principal,IdentityProvider, and the two core providers (build-list item 1, SPEC-v0.3 §2, §3).Principalgainsclaims,issuerandexpires_at, validated the way arguments are — nofloat, no containers, a timezone-aware expiry — and none of the three is in the action hash, so an approval survives a token rotation.StaticIdentityProviderandHeaderIdentityProvidership in core; the JWT one lands with item 5. A provider's answer wins overcontext(), aNoneis a decline that leaves the v0.1 path intact, and a provider that raises is never backfilled — falling back there would turn a rejected credential into a successful action. -
An expired principal is refused before authority and before policy, with a
deniedreceipt and no approval request.Control.evaluatereturnsdeny/principal_expiredinstead, because it may not write. -
ctrlrun inspectshows the issuer, the expiry and the claim names; the values reach--json. The OTel sink exports the issuer and the names only — a span goes to a third party by default, a receipt is evidence meant to be read. -
docs/SPEC-v0.3.md— the v0.3 contract, a delta over v0.1 and v0.2. Seven deliverables: an extendedPrincipaland theIdentityProviderprotocol; theauthority:section with grants, patterns and constraints; delegation with structural attenuation; observe mode andctrlrun stats; a JWT identity provider and the gateway wiring for both; examples and docs; the release. Tests come from §10 (T60–T93). -
The
identityextra, empty until build-list item 5 adds the JWT verifier and thepyjwtline it needs.pip install ctrlrunstill installs nothing butpyyamlandclick.
-
BREAKING: a condition naming
claims,issuerorexpires_atis refused at load, in a document of every schema version (§4.5, §12.1). The reservation lives in the condition-key splitter, which runs for every condition in every document, and gating it onv3would leave the same name meaning two things in two files. Av1policy whose protected function takes an argument calledclaimsstops loading until the argument is renamed; the load error says so. -
BREAKING:
AcsControlHooktakes anidentityprovider, and ignores the envelope'sagent_idandenvironmentwhen it has one (§8.4). Under v0.2 both came off the wire, on exactly the argumentv0.2 §6.5made forclientInfo: a policy could not address the principal. §4 ends that, and §8.1 removes--principal-from-client-infoover the same sentence — the ACS hook was that flag in a different module. A hook built against aControlholding anAuthoritywith no provider raisesInvalidArgumentat construction. With a provider,agent_idis ignored — not merged, not a fallback, not compared — and a provider that names nobody is a denial withreason_codes: ["no_principal"], never a fall back to the envelope.handle()gains an optionalheaders=for the transport's own headers, which is what a provider reads.docs/docs/ACS.md's mapping table is amended in the same change. -
All three v0.2 call sites now use the combined decision (§8.3): the gateway's
tools/callpath,ctrlrun.acs's request hook, andControl.resume. Left asPolicy.evaluate, an action a grant forbids outright would still have its approval flow run, and a human would be paged about a call that could never happen. -
Control.evaluatereturns the combined decision, not the policy axis alone. Its signature andEvaluation's two fields are unchanged, and it still writes nothing — it would simply stop answering "what will happen to this action" if it reported one axis whileControl.executeacted on both. -
Control.resumerecords the authority axis on its receipt (§5.6.1). Evaluated and recorded, not re-decided: the reservation is held and the remote may already be acting on it. A lease extension is the other way round — refused where authority no longer covers the action, so the lease lapses and the record becomesAMBIGUOUSby the ordinary path. -
policy._Conditionis nowpolicy.Condition, andpolicy.parse_conditionsis public. A grant'sconstraints:is in exactly a rule'swhen:syntax and is parsed by the same code: a second condition evaluator would be a second place forTrueto start comparing equal to1. The top-level policy key set gainsauthority, which needsctrlrun.policy/v3. -
BREAKING:
context(environment=...)is removed — already recorded below, and item 1 is where it happens.Control(environment=...)replaces it. -
BREAKING:
ctrlrun gateway --principal-from-client-infois removed. It exits non-zero naming--principal-header, rather than starting with a principal the operator did not ask for.--environmentnow defaults to unset so$CTRLRUN_ENVIRONMENTis not silently outranked, and--user-headerwithout--principal-headeris an error rather than a flag that cannot take effect. -
A repeated identity header is refused —
-41007, HTTP 403, upstream untouched. AMappingholds one value per name, so something had to decide what two become, and under authority that decision picks the principal. -
BREAKING for a reader:
ReceiptResultgainsobserved.Receipt.from_dictparsesresultinto a closedStrEnumandSQLiteStateStorereads every stored receipt through it, so a ctrlrun ≤ 0.2 process runningctrlrun receiptsorctrlrun inspectagainst a store an 0.3 observe-mode process wrote raises on the unknown value. Two processes sharing one store is the intended deployment: upgrade every reader before switching any writer tomode: observe. An enforce-mode 0.3 writer emits noobservedreceipt and is safe to mix. An external reader that keys onresultmust readexecutiontoo, or it counts every observed execution as if nothing ran. -
The demo's scenario 5 shares the store and the sinks of the other four. A second
Controlthat quietly dropped the JSONL sink left the demo printing a receipt count from the store that the file it points the reader at did not match — a false green, in a transcript people paste into issues. -
ctrlrun.receipt/v2andctrlrun.inspection/v2. The receipt carries the whole principal and reservesexecutionandwould_haveasnulluntil observe mode fills them, so the shape a reader parses settles once rather than changing twice under one version string. The stored Action carries the whole principal too — without it every receipt written byControl.resumereported no claims and no expiry, on the only receipt an MCP multi-round-trip action ever gets. -
Four edits claimed by the previous change were never in the file. A batch that wrote only at the end discarded every successful replacement before the one that raised, and only the failing one was re-run — so
authority_unreadablewas referenced by §4.6, three rows of §9 and T77d while §4.3's closed reason set never defined it; the precedence order was never reordered; thepyyaml>=6.0rationale was missing from §4.2; andauthority_grantstill had no route into evidence. A later edit then deleted §4.2'senvironmentsparagraph believing it a duplicate, when it was the only copy. All five are restored, and the tooling now writes after each edit so a later failure cannot undo an earlier success. -
BREAKING:
context(environment=...)is removed;Controltakesenvironment=instead. v0.3 makes the environment an authorization input — a grant may scope toenvironments: ["staging"]— and a dimension the subject sets is not one. On the decorator path the value came from the agent's own call site, so a grant scoped to staging was a real restriction through the gateway and decoration in-process.It is now set once per
Control, from itsenvironment=argument, else$CTRLRUN_ENVIRONMENT, else the policy document's top-levelenvironment:, else"production". The gateway's--environmentand the ACS hook's configuration are unchanged — they were already this rule. A deployment that ran several environments from one process runs oneControleach.SPEC-v0.1.md§8's frozen signature is amended in the same change, as the rule for a frozen name requires. -
docs/SPEC-v0.3.mdamended against an independent review of §4 and §5. The contract was read by two reviewers who had not written it, and they found two authorization holes the author's own pass had missed.An expired credential could mint permanent authority.
Control.delegate's checks matched the creating principal onagentanduser, which do not stop being equal when a token stops being valid — and §2.3's expiry refusal is scoped toControl.execute, which a delegation is not. A principal whose every action was being refused could still write an unexpiring, re-delegable grant for an agent of its choosing. §5.3 gains a rule 0.The ACS hook read its principal off the wire.
ctrlrun.acstakesagent_idandenvironmentfromparams.metadataon the inbound envelope. Under v0.2 that was survivable on the argumentv0.2 §6.5makes forclientInfo; §4 ends it, and §8.1 removes--principal-from-client-infoover exactly this. Removing the flag while the same pattern lived in another module would have made the removal a gesture. §8.4 gives the hook anIdentityProviderand refuses to construct one without it against aControlholding anAuthority.Also: an unreadable stored delegation is now
authority_unreadableand denies outright rather than being skipped in favour of a broader grant that happens to match;Control.resumejoins the list of call sites that must use the combined decision, and §5.6.1 says what authority does across a lease extension, a commit and a resume; adelegablegrant must declareexpires_at, which is the only thing bounding the grantee population a compromised holder can reach; andpyyaml>=6.0becomes a floor, because PyYAML 5 returns naive datetimes and would reject the specification's own example.
Recorded here because each is a decision a reader of the code would otherwise have to reconstruct:
- Authority is opt-in as a section and fail-closed once present. A file with no
authority:key behaves exactly as v0.2. With one, every principal needs a grant; no grant means DENY. - Claims are receipt data, not action identity. The canonical form of an Action is unchanged, so an approval survives a token rotation.
- Omission never means unlimited. A delegated grant that drops a dimension its parent constrains is rejected, not treated as unconstrained.
- Observe mode is top-level only. A partially-enforced configuration is the failure mode that rule exists to prevent.
- Policy still cannot see the principal, and a grant carries no decision. Authority is a second, independent axis that permits or denies; the two results combine as the stricter of the pair. How much autonomy an action has stays the same for every principal.
- A delegation may change who acts, not how many. Its subject must name a concrete agent — present, no wildcard — and a user its parent's pattern admits wherever the parent names one. Otherwise one delegation hands the grant to every agent, or strips the human it was bound to, and omitting the key reaches the same place as an asterisk.
- Revocation and expiry are live; an edit to the document is not. Grants are read when the
document is loaded.
ctrlrun revokeis the runtime kill switch and it covers delegations only; there is no hot reload and no runtime revoke for a root grant.
- Nothing new.
--principal-from-client-info, deprecated in 0.2.0, is removed in 0.3.0 by build-list item 1; SPEC-v0.3 §8.1 has the replacement.
-
ruff formatreformatted Python code blocks inside the specs;ruff checknever looked at them. Whether a spec's examples were rewritten therefore turned on whether they happened to parse:SPEC-v0.1.mdandSPEC-v0.2.mdkeep their aligned comments only because theirs carry placeholders like<generated>, andSPEC-v0.3.md's parse, so a CI check that had never fired before went red on them and the alignment was flattened to make it green.A specification's code blocks are illustration rather than code — they elide, they annotate, they line comments up so a reader can compare down the column — and the formatter has no way to know that.
[tool.ruff.format] excludenow tells it,force-excludemakes the exclusion mean the same thing however ruff is invoked, and SPEC-v0.3's alignment is restored.test_the_formatter_leaves_markdown_alonekeeps it that way, with a control test that fails if the probe it relies on is not actually unformatted. -
The gateway compared mirrored header values by re-parsing them, and Python's parser is lenient.
Mcp-Param-{Name}validation ran the header throughint(), so a body carryingamount: 2000was certified as agreeing with headers spelling it2_000,+2000,02000,2000,2000, and the Arabic-indic and fullwidth digit forms; booleans were matched case-insensitively, soTRUE,TrueandtRuEall agreed withtrue. None of those is what a JSON serializer writes, and each is read differently — or refused — by another parser:parseInt("2_000")is 2 in JavaScript andstrconv.Atoierrors in Go.That is the exact hazard SPEC-v0.2 §6.4 exists to prevent. The gateway's job there is to certify that a routing intermediary and ctrlrun are looking at the same value, and it was certifying agreement that held only under Python's rules. ctrlrun's own decisions were never affected — the action is built from the body, and the headers are only checked — so this costs an intermediary's correctness rather than an approval binding.
A header value must now be the body value's canonical rendering, compared as text, with no parser in the comparison — and only a string, an integer or a boolean has one. The revision permits
x-mcp-headeron those three types alone and omits the header for anull, so a header naming an argument of any other type is refused rather than compared against a rendering ctrlrun invented for it. This also declines the revision's SHOULD that servers compare integers numerically (42.0equals42): v0.1 §2.3 refuses a float in the body outright, so the leniency has no legitimate case here. SPEC-v0.2 §6.4 states both rules and the reasoning.
0.2.0 - 2026-09-03
Everything below ships. pip install ctrlrun still installs nothing but pyyaml and
click; the gateway, the ACS hook and the OpenTelemetry sink live in extras.
- MCP gateway —
ctrlrun gateway --upstream <url> --alias <name>, inctrlrun[gateway]. An existing MCP tool server gets ctrlrun semantics with no agent changes:tools/callbecomes an Action, everything else is relayed unchanged. The request forwarded upstream is built from the action's canonical arguments, so what was hashed, reserved and recorded is byte-for-byte what the tool receives. Serves2026-07-28and2025-03-26–2025-11-25in passthrough. A fresh connection per intercepted call, because "the connection was never established" is the only observation that proves non-execution. - Reconciliation hook —
@protect(..., reconcile=...). The second authority permitted to move a record out ofAMBIGUOUS, and only where its answer points. An exception, a nonsense return value and a hook that is never called all mean"unknown", which changes nothing. SuspendedandControl.resume— an executor may say "the remote asked for something before it will finish". The record staysEXECUTING, the lease is extended, the continuation is held, no receipt is written, and the signal reaches the caller. Built for MCP elicitation; used by the ACS adapter for the same reason.EventSink— a protocol receiving everyEventandReceipt, called after the authoritative store write.JSONLEventSinkis the v0.1 file writer under that interface. A sink that raises is logged and skipped; it can never change a decision or an outcome.OTelEventSink— inctrlrun[otel]. One OpenTelemetry span per action, one span event per step,ctrlrun.*attributes. Argument values are withheld unless asked for.WebhookApprovalProvider— core, over stdliburllib.request. One signed POST onAPPROVAL_REQUESTED; the gateway serves the signed inbound grant/deny atPOST /ctrlrun/approvals/<request_id>. An undelivered notification is not an approval.ctrlrun inspect <action_id>— one action's whole history: proposal, decision, approvals, effect, receipt and the event timeline.--jsonemitsctrlrun.inspection/v1.- Policy
schema: ctrlrun.policy/v2— per-actioneffect:,resource:andmcp:templates, needed because a gateway call has no decorator to carry them. Where a decorator and the policy disagree, the decorator wins and the mismatch is warned about once. - ACS control hook —
ctrlrun.acs.AcsControlHook, inctrlrun[gateway]. Answers the OWASP Agent Control Standard'ssteps/toolCallRequestandsteps/toolCallResult. Seedocs/docs/ACS.mdfor the mapping and for the four places ACS is silent. No compliance claim: at the commit read there is no ACS reference implementation and no conformance suite, so there is nothing to be conformant with. examples/— four standalone failure scenarios, an ACS integration example, and nine sector policy templates underexamples/policies/.
StateStore.append_eventreturns the event as stored, where it returnedNone. Sinks must be called with theevent_idthe store assigned, and the store is the only thing that knows it. Callers that ignore the return value are unaffected. Recorded inSPEC-v0.1.md§8.SQLiteStateStoreno longer writes JSONL.Controldoes, throughJSONLEventSink, andControl.from_file()installs one by default — so the two files land exactly where v0.1 put them and existing evidence directories are unchanged.docs/SPEC-v0.2.md§9 amended: it forbade an ACS adapter on the reading that ACS had no stable interface. The v0.1.0 schemas say otherwise, so the adapter ships. The no-claim rule is untouched.
ctrlrun gateway --principal-from-client-info— removed in 0.3. Use--principal-header. It takes the agent's name from_meta["io.modelcontextprotocol/ clientInfo"], which the MCP revision says is self-reported and "SHOULD NOT" be relied on for security decisions. It is offerable in 0.2 only because of a fact that stops being true: a v0.1 policy cannot address the principal at all (SPEC-v0.1.md§3.2 refusesagent_eqand every other reserved name at load), so an unauthenticated principal misattributes evidence and cannot widen an outcome. v0.3's authority model makes the principal an authorization input, at which point a self-reported name cannot be one. The flag warns at startup and its--helpsays so.
SQLiteStateStore.journal, and theEventLogclass behind it.JSONLEventSinkis that class under the sink interface, and it isControl's now.
.gitignoreignoredctrlrun.yamlunanchored, so it matched at any depth and silently kept every example's policy file out of the repository. Anchored to/ctrlrun.yaml.
- A v0.1
ctrlrun.yamlloads unchanged.ctrlrun.policy/v2is opt-in and additive; a document declaringv1that uses a v2 key is a load-timePolicyErrornaming the key and the schema it needs, because a v0.1 reader would ignore the template and execute with no duplicate protection at all. - The receipt schema is unchanged —
ctrlrun.receipt/v1still describes every receipt v0.2 writes.EFFECT_RESOLVEDgainsdata.resolved_by, and four event types join the set:RECONCILIATION_STARTED,RECONCILIATION_RESOLVED,EXECUTION_SUSPENDED,EXECUTION_RESUMED. - A database written by v0.1 is read by v0.2 without migration: the one new table
(
continuations) is created on open.
- The spec is written against MCP revision 2026-07-28, which removed the
initializehandshake, protocol-level sessions andMcp-Session-Id, and madeMcp-Method/Mcp-Namerequired request headers that servers must validate against the body. The gateway will also serve2025-03-26through2025-11-25in passthrough mode, relaying session ids,GETSSE streams andDELETEwithout interpreting them; header–body validation applies only where the headers exist. Decisions come from the parsed body on every revision, so header trust is never the guarantee. The deprecated2024-11-05HTTP+SSE transport is not served. - An MCP tool call held open across a multi round-trip elicitation keeps its effect reservation
in
EXECUTINGwith an extended lease, so concurrent duplicates stay blocked for the whole exchange and only the final result is mapped to an outcome. This needs the upstream to supply arequestState, the protocol's only correlator and an optional one; without it the first leg isAMBIGUOUS, because the alternative would let any client walk past duplicate protection by inventing aninputResponsesfield. - A policy file using the new
effect:/resource:/mcp:keys must declareschema: ctrlrun.policy/v2.ctrlrun.policy/v1files keep loading unchanged; av2file will not load on 0.1.0, which is the point — 0.1.0 would ignore the effect template and execute with no duplicate protection. - MCP tool arguments that ctrlrun cannot canonicalize — any JSON number with a fraction — will be refused by the gateway, never rounded or coerced. Tools that move money through the gateway need integer minor units or decimal strings in their schema.
0.1.0 — 2026-09-03
First packaged release. The v0.1 kernel is complete: every acceptance test in
docs/SPEC-v0.1.md §7 passes, including the multi-process concurrency test.
- Action — canonical form,
action_hash, deep-frozen arguments.floatis rejected in arguments:0.1and0.10are the same money and different hashes. - Policy — YAML loader with
ALLOW/APPROVE/DENYand fail-closed defaults. An unknown action is denied; there is no default-allow. See the config-breaking rule below for how condition keys are validated. @ctrlrun.protect()— binds a function call to an Action, evaluates it, and executes from the action's canonical arguments rather than the caller's objects.- Approval binding — approvals carry the
action_hashof what a human saw, and are single-use and expiring. A mutated action cannot present an approval granted for another. - Effect key and reservation — template-resolved effect identity, reserved atomically
across processes via
BEGIN IMMEDIATEand a unique constraint oneffect_key. - Effect state machine —
NEW → RESERVED → EXECUTING → COMMITTED | FAILED | AMBIGUOUS. Only an executor raisingNotExecutedproducesFAILED; every other exception, timeouts included, producesAMBIGUOUS, and only a human resolves it. - Receipts and events — portable JSONL evidence for every action.
- CLI —
init,demo,approve,deny,receipts,effects,resolve. ctrlrun demo— four failure scenarios, in process, no network.SECURITY.mdanddocs/docs/CLAIMS.md, which maps every README claim to its code and test.
Rules that reject a policy file which an earlier build of this kernel would have loaded.
A ctrlrun.yaml written before this release may need an edit; the process refuses to start
until it gets one, which is the point.
- A condition key naming an
Actionfield is now a load-timePolicyError. The reserved names areaction_id,agent,environment,principal,resourceanduser.when: { environment_eq: production }reads exactly like it scopes a rule to production, and matched nothing at all — conditions address an action's arguments, and those are not arguments. Combined with a catch-alldecision: allowbeneath it, a rule that looked restrictive silently permitted everything. If a protected function genuinely takes an argument by one of those names, rename the argument (SPEC-v0.1 §3.2). Only the whole name is reserved:resource_id_eqis unaffected. - A condition on an argument the action does not carry now logs a warning. The decision
is unchanged — still false, still never an error, per SPEC-v0.1 §3.2 — but a typo such as
amont_lteno longer disappears in silence. Nothing to edit; expect new log output.
- Requires Python ≥ 3.11. Runtime dependencies are
pyyamlandclick. - Single-host only: reservation is atomic across processes on one machine via SQLite. Multi-host needs the Postgres store planned for v0.6.
- Receipts are not signed. A database administrator can alter history. This line read "(v0.6)" until v0.6 was built, and that was a promise v0.6 does not keep: v0.6 adds a hash chain, which detects alteration and is not evidence of authorship, and it does not stop an administrator who can rewrite every row including the chain head. Signing is out of scope (
SPEC-v0.6.md§11). - Approver identity is free text and is not authenticated (v0.3).
- Generated ids (
act_,apr_,ctr_) are 128 bits. An approval id is not a bearer token in v0.1 — consuming one needs write access to the store — but it becomes one with the webhook provider in v0.2, and an id format cannot be widened after records exist. - Effect key templates do not escape placeholder values, so a crafted argument can make two
distinct effects share one key. The result is a refusal rather than a double execution;
docs/docs/THREAT_MODEL.mdstates the limit and the workaround. - Policy conditions address an action's arguments only. Scoping a rule by environment, resource or principal arrives with the authority model in v0.3.