Skip to content

ctrlrun 0.6.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 23:22
· 389 commits to main since this release
016104a

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 any StateStore, with fourteen deliberately-broken
    stores proving the suite can fail. It found that events() and receipts() were never
    declared on the StateStore protocol
    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_version table 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 from ctrlrun. 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.

  • PostgresStateStorectrlrun[postgres], lazily imported (v0.6 item 3). The frozen
    v0.1 §5.3 protocol, extended by nothing, with UNIQUE(effect_key) plus
    INSERT … ON CONFLICT DO NOTHING under READ COMMITTED where SQLite had BEGIN 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 ctrlrun imports no psycopg module.

    The decisions did not move: plan_reservation, plan_lease_extension, check_consumable and
    check_answerable stay pure functions, and all three backends decide with them and then only
    write — so v0.1 §5.4's retry table has one implementation rather than three, and a backend
    cannot drift into permitting something SQLite refuses.

  • --store-url accepts a postgresql:// URL, and is now on every command that reads or
    resolves the operator's own store — receipts, effects, inspect, resolve, approve,
    deny — reading CTRLRUN_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 effects against an empty schema printed "no effects yet", exited 0 and left eight
    tables behind, and against a database one migration short, a ctrlrun receipts applied 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 by seq and 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_hash and hash (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. seq is inside the hashed content, so two adjacent
    receipts swapped with their seq values change both documents; hash is a column, because a
    document cannot contain its own hash. put_receipt takes the head row's lock first and
    advances it in the same transaction.

  • policy_hash, policy_version and controls on every receipt (v0.6 item 7). A receipt
    from six months ago says what the rules were, not what they are now. policy_hash is 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 a version: 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. Named PolicyControl and not Control, because a second Control in a package
    whose central object is Control is 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 a title and an optional source. 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_scope is 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 _in already expresses and adds
      no operator, deliberately — _OPERATORS is shared with authority constraints:, so an
      operator added here would become available to grants.
    • version: — see above.
  • resolved_by on every effect record (v0.6 item 5). Out of AMBIGUOUS there are exactly
    two authorities — a human and a reconcile hook — and the record now says which one acted.
    ctrlrun effects prints it, and prints executing (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, outside src/ and packaged nowhere, on
    research/framework-probe/'s precedent. It defines unexplained before the run starts —
    an AMBIGUOUS caused 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 unexplained AMBIGUOUS is 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 init writes a ctrlrun.policy/v2 starter, with an effect: 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, and ctrlrun verify against 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_secure routed 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_CONSUMED event 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_DENIED event and on the
    receipt. Where a policy denies an action a human had already approved, the approval stays
    granted and unspent — that is deliberate, and SPEC-v0.6.md §7.2.1 argues it — but nothing
    previously connected the live grant to the refusal it met.

  • data_scope is now refused as an argument name, at every place an argument is named: a
    data: key, an effect: or resource: 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: and data_scope_neq: compare the set and not its order. The derived value
    is sorted, and list equality is order-sensitive, so data_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 the deny or the
    approve, that is fail-open. Narrowed to derived subjects: an ordinary list argument still
    means that exact list.

  • policy_hash covers 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's data: labels; per-action and per-rule controls:
    citations; and the controls: registry itself, whose titles are what a receipt's control ids
    mean. The effective environment is hashed rather than the document's, since
    $CTRLRUN_ENVIRONMENT and Control(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 granted for 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.1 calls 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 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 left
    RESERVED until 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_timeout was 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-it runs 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 is ctrlrun'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, and tests/test_docs_travelling.py runs 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 @protect and 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 with pip 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, and BEGIN IMMEDIATE is 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
    is v0.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 no continuations and no delegations table. 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 rule SPEC-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: an UPDATE on one row, a DELETE from 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 an Action for 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: 0 nothing found, 1 a 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 tools list_pending_approvals, inspect_action, receipts, effects and
    stats; write tools approve, deny and resolve. Ships in ctrlrun[gateway], imports
    nothing from an extra, and import ctrlrun imports none of it (T192).
  • ctrlrun.reporting — core and stdlib. The ctrlrun.inspection/v2 and ctrlrun.stats/v1
    document builders, moved out of ctrlrun/cli/main.py unchanged 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 -410xx range: -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. approve and deny are the two store calls
    ctrlrun approve and ctrlrun deny make, 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 Control method
    but store, policy and environment is referenced, and T189 asserts that against the
    source rather than against behaviour.
  • It has no --allow-remote and 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 approve can. 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 three asserts in its place. A
    server could start with an unpinned issuer, audience, algorithm or typ — and under
    python -O the asserts vanish. An unpinned typ accepts an ID token, so an OIDC login
    would have approved a payment. The gateway's check_jwt_flags is now shared rather than
    copied, every check is an InvalidArgument, and one test runs under -O.
  • "Every refusal leaves the store byte-identical" was false. Answering a lapsed request
    moves it pending → expired and 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.execute
    inside do_POST passed it. It scans the whole file now.
  • ::1 was accepted and could not bind. ThreadingHTTPServer inherits AF_INET, so
    --listen ::1:8901 exited 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.main no longer defines INSPECTION_SCHEMA, STATS_SCHEMA, _stats_document
    or _since; they are ctrlrun.reporting's. ctrlrun inspect --json and ctrlrun stats --json emit byte-identical documents to before, and a --since that does not parse is
    still a usage error with exit code 2.