ctrlrun 0.6.0
The soak criterion was amended on 2026-09-07, and it was amended downwards. It read a soak
of at least one week with no unexplained AMBIGUOUS; the week was removed rather than waited
out, and the criterion is now a published run with no unattributed AMBIGUOUS and a positive
control that fired — the two things a harness is allowed to decide about itself. SPEC-v0.6.md
§8.1 carries the reasoning, what it costs and what did not change; docs/ROADMAP.md records it
in the milestone's own reconciliation. The short version: elapsed hours were a proxy for a
question the injection ledger already answers, and what a week would actually have bought —
whether anything accumulates over days — is unestablished by anything in this repository and
is now claimed by nothing rather than owed by a gate.
The published run is twenty minutes, 889,735 actions, 133,393 ambiguous outcomes all attributed,
0 unattributed, positive control fired. The duration is printed on every surface that quotes
the run so a reader can discount it: the README's readiness block, the docs home, the
production index, and docs/production/soak.mdx, which now recomputes the criterion from the
published counts instead of reading exit_criterion_met out of the same file.
v0.5 asked can somebody else implement this? v0.6 asks: does it still hold when the process
dies, the host goes away, and the database is somewhere else?
Every guarantee shipped so far was a guarantee about one process holding one SQLite file.
BEGIN IMMEDIATE is a whole-database write lock on a local file; take the file away, put the
store on another host, and E1 — at most one caller per effect key — has to be re-earned with a
different mechanism. That is the milestone.
The suite was written before the backend it grades. ctrlrun.conformance.store runs this
repository's own acceptance tests — the cases of v0.1 §7, v0.2 §10 and v0.3 §10 that are
statements about StateStore rather than about Control — against any backend, and it landed
two items before Postgres existed. A backend measured against a suite written for it has marked
its own homework. The ordering paid for itself on the first three runs, which found three real
bugs in the Postgres store before a single test in its own file existed.
And the distinction that made it tractable: the store is reconcilable by re-reading; the remote
is not. A Postgres transaction is atomic, so an ambiguous store write has exactly one truth
and the store can go and look at it. An ambiguous remote effect has no such move, which is why
AMBIGUOUS is terminal there. Two ambiguities, one word, different remedies — and an
implementation that collapsed them would look correct while either refusing work one query could
have recovered or retrying work nothing can.
Added
-
ctrlrun.conformance.store— the store conformance suite (v0.6 item 1). This repository's
own acceptance tests, runnable against anyStateStore, with fourteen deliberately-broken
stores proving the suite can fail. It found thatevents()andreceipts()were never
declared on theStateStoreprotocol while both shipped stores implement them and four
callers depend on them; both are now declared. -
Schema version and forward-only migrations (v0.6 item 2). A
schema_versiontable records
applied migration ids — recorded, never inferred — and a store refuses a database it does not
recognise in both directions. Six places across v0.2–v0.5 said "there is still no
migration story — that is v0.6." -
SchemaMismatch, exported fromctrlrun. Raised at open when a store meets a database it
does not recognise. Its own type because "your database is from the future" and "your lease
is negative" have entirely different remedies. -
PostgresStateStore—ctrlrun[postgres], lazily imported (v0.6 item 3). The frozen
v0.1 §5.3protocol, extended by nothing, withUNIQUE(effect_key)plus
INSERT … ON CONFLICT DO NOTHINGunderREAD COMMITTEDwhere SQLite hadBEGIN IMMEDIATE.
The guarantee is the unique index and not the isolation level, which the store does not set.
Every later transition is a compare-and-set with the row count checked. It passes item 1's
suite 23/23, with no N/A.import ctrlrunimports nopsycopgmodule.The decisions did not move:
plan_reservation,plan_lease_extension,check_consumableand
check_answerablestay pure functions, and all three backends decide with them and then only
write — sov0.1 §5.4's retry table has one implementation rather than three, and a backend
cannot drift into permitting something SQLite refuses. -
--store-urlaccepts apostgresql://URL, and is now on every command that reads or
resolves the operator's own store —receipts,effects,inspect,resolve,approve,
deny— readingCTRLRUN_STORE_URL. CTRLRun's own?ctrlrun_schema=parameter selects the
schema and is peeled off before the URL reaches the driver.It creates nothing and migrates nothing. A review found the first version doing both: a
ctrlrun effectsagainst an empty schema printed "no effects yet", exited 0 and left eight
tables behind, and against a database one migration short, actrlrun receiptsapplied it.
On the milestone that first shares a store across hosts, that is one reader altering a table
every other process is still running against. A schema that is missing, behind or ahead is now
refused with an instruction. -
ctrlrun receipts --control ID— shows only the receipts citing that control. A filter
and not a lookup: it does not consult the policy, so an id no document defines matches
nothing rather than erroring, which is the right answer for a reader running against a store
whose policy has since changed. A dangling citation is still a load error, in the place that
can see the registry. -
ctrlrun receipts --verify-chain— reads the chain in the operator's own store and reports
every break byseqand by name:content_altered,hash_missing,link_broken,missing,
head_mismatch,unchained. Six names rather than one boolean, because "receipt 41 was
edited" and "the last nine were deleted" are different incidents. -
Receipts carry
seq,prev_hashandhash(v0.6 item 6). One chain per store — not one
per effect key, which would not detect the deletion of every receipt for one key, and not one
per process, which is not a chain.seqis inside the hashed content, so two adjacent
receipts swapped with theirseqvalues change both documents;hashis a column, because a
document cannot contain its own hash.put_receipttakes the head row's lock first and
advances it in the same transaction. -
policy_hash,policy_versionandcontrolson every receipt (v0.6 item 7). A receipt
from six months ago says what the rules were, not what they are now.policy_hashis over the
parsed decision inputs — schema, actions and rules in document order,mode,environment,
the authority grants — and not the file's bytes, so a comment or a reordering of keys does not
change it.version:is a free string the operator chooses, recorded and never
authoritative: two documents sharing aversion:and differing in content are two different
policies, and the hash is what says so. -
ctrlrun.policy.PolicyControl— a registry entry: an id, a title, and an optional
source. NamedPolicyControland notControl, because a secondControlin a package
whose central object isControlis a collision every call site would have to disambiguate,
and one this milestone would have frozen for a long time. -
ctrlrun.policy/v4, with three new top-level keys and a closed key set, so a typo is still
a load error:controls:— a registry of ids, each with atitleand an optionalsource. An action
cites some, a rule may narrow or add, and the receipt carries the union of the action's and
the matched rule's in registry order. CTRLRun does not interpret a control:source:
is a string the operator wrote and the registry records and never enforces. It maps to no
standard, and citing one is not a claim about it.data:— an action declares which of its arguments carry which class of data.
data_scopeis the set of labels present in the arguments actually supplied, not the
whole declared map: an action that carries no PHI is not a PHI action because some other call
of it would be.data_scope_in: [phi]reuses the membership_inalready expresses and adds
no operator, deliberately —_OPERATORSis shared with authorityconstraints:, so an
operator added here would become available to grants.version:— see above.
-
resolved_byon every effect record (v0.6 item 5). Out ofAMBIGUOUSthere are exactly
two authorities — a human and a reconcile hook — and the record now says which one acted.
ctrlrun effectsprints it, and printsexecuting (lease expired)for a lease that lapsed and
was never contended, because nothing sweeps and the state alone hid it. -
research/soak/(v0.6 item 8) — a soak harness, outsidesrc/and packaged nowhere, on
research/framework-probe/'s precedent. It defines unexplained before the run starts —
anAMBIGUOUScaused by an injected failure is explained, one with no corresponding injection
is not — records every injection before causing it, and carries a positive control that
runs in its own store: a deliberately unrecorded ambiguity that the table must report. A soak
with no unexplainedAMBIGUOUSis a result; a soak whose harness could not have detected one
is not. -
docs/postgres.md— the operator's page: connection strings, what to grant, what happens
on failover, the one row every receipt write serializes on, and what the store does not do
for you.
Changed
-
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. TheAPPROVAL_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 stays
grantedand 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: a
data: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 the
approve, 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, and
verify --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 approvalgrantedfor its full TTL, bound to a hash a later edit could make
APPROVE-requiring again — a live bearer token for an action a human already answered, and
v0.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.
Fixed
- A store failure closing an unneeded approval no longer refuses the action. Where a policy
had been relaxed toallow, 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 left
RESERVEDuntil 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, andbusy_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.
Documentation
-
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 in
docs/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 ouraction_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/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/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.
Shipped in this release, and not part of the v0.6 milestone
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.
ctrlrun scan — the command that says what is not covered
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 anActionfor 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,2
the 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.
ctrlrun mcp-operator — answer an approval from the assistant you already use
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,effectsand
stats; 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/v1
document 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_human
and-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 calls
ctrlrun 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 under
python -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 itpending → 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 aControl.execute
insidedo_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_document
or_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.