Skip to content

Releases: CTRLRun/ctrlrun

ctrlrun 0.12.0

Choose a tag to compare

@github-actions github-actions released this 15 Sep 01:27
ed6b2ec

One question: is the thing this project says about itself checkable?

No new capability. Three claims that were prose became tests, and each of the three was false or
incomplete when the test was written, which is the milestone's whole argument.

Changed

  • The module map is acyclic, and ARCHITECTURE.md §6's rule is now a test. §6 has said
    dependencies point downward only since v0.1, and from v0.7 it was false: a review found
    state -> receipt -> policy -> authority -> state and the sentence was amended to record the
    cycle rather than fix it. Nothing broke at run time, because the two edges out of policy.py
    are function-level, so import ctrlrun resolved in one order and the suite passed for five
    milestones.

    Broken in two places. Decision and POLICY_UNAPPROVED moved to ctrlrun.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 to ctrlrun.grammar, so authority.py no longer
    imports policy.py at 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.py re-exports all thirty-four, so
    from ctrlrun.policy import Decision, Condition, parse_conditions resolves to the same objects
    and SPEC-v0.1.md §8's frozen __init__ block is unchanged.

    tests/test_module_graph.py walks 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_identity and revocation
    sharing a security-critical redirect handler through a deferred import; _NoRedirects moved
    down to revocation.py and keeps logging under ctrlrun.policy's logger name so no operator's
    handler is re-routed.

  • The gateway told MCP clients to call a Python API. A -41002 relayed
    str(ApprovalRequired) verbatim, and that exception carries the decorator's wording: "run
    ctrlrun approve …, then retry inside ctrlrun.with_approval(…)"
    . On the one path where the
    caller may be in any language, and is often a model reading the error as text, it pointed at a
    context manager the caller cannot reach. It now says what the gateway's own documentation
    already said: a human approves and this same call runs.

Added

  • Property tests over generated inputs (tests/test_properties.py, hypothesis). The
    invariant that matters is the one v0.11 item 1 violated: every break a tamper reports names a
    row the store actually holds.
    That defect reported content_altered 99 and missing 100 on
    an eight-row chain, because position came from the document rather than the seq column.
    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 n also breaks the link at n + 1. Two is correct and the
    expectation was wrong, which is the same mistake the file exists to catch. derandomize=True,
    so a counterexample found in CI reproduces locally by construction.

  • A CycloneDX SBOM of the wheel, measured from the wheel (scripts/sbom.sh). Generated by
    installing the built wheel into an empty environment and recording what resolves, not by reading
    pyproject.toml: a manifest-derived SBOM is the project's opinion of its own dependencies. The
    answer is two, PyYAML and click. CI generates and checks it on every pull request, and
    release.yml writes it into dist/ 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 venv adds
    pip, and on 3.11 setuptools too, and a scanner cannot tell "ctrlrun needs this" from "the venv
    came with this"
    . The first version removed only pip, passed on 3.12 and went red in CI on 3.11
    with ['PyYAML', 'click', 'setuptools']. The assertion compares by equality, which is why it
    caught a document that would have overstated what a consumer takes on.

Fixed

  • A shared-directory race in the cookbook tests. Two tests ran the same recipe in
    examples/cookbook/<name>/ on different xdist workers; one's rm -f verify-report.json landed
    between the other's write and read, and bash -euo pipefail turned 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 with no principal is available. Both
    now state the requirement and say why it is fail-closed.

  • adapters/PUBLISHED.toml had gone stale in the other direction. 0.11.0 published both
    adapters at 1.2.0 with <0.12 and never updated the record, so the file spent a release
    claiming 1.1.0 / <0.11. Both adapters go to 1.3.0 with ctrlrun>=0.5,<0.13, because the
    published 1.2.0 excludes this kernel, and RECORDED now freezes 1.2.0's range too.

ctrlrun 0.11.0

Choose a tag to compare

@github-actions github-actions released this 14 Sep 21:19
8a81289

One question: can the record be trusted after the fact, and kept?

Every milestone so far added something the receipt records. None asked whether the receipt is
still worth reading a year later, on a database an administrator can write to, after somebody
pruned it. This is the first milestone whose subject is the evidence itself rather than the
decision, and the first that opens by admitting a defect in the thing it is about: the chain has
never detected truncation or append, both reachable in two SQL statements, and both written down
since SPEC-v0.6.md §6.4.

Added

  • docs/SPEC-v0.11.md, the v0.11 "Evidence" contract. Documentation only; the version bump is
    the release item's. It answers one question, can the record be trusted after the fact and kept,
    and it opens by demonstrating the defect it exists to close: a truncation and a forged append both
    verify as intact after two SQL statements, because the head that would catch them is a row in the
    same database. Transcribed from a real store rather than argued.

  • A reader that names a bad row and blinds nothing else (SPEC-v0.11.md §5, rule 3). A single
    malformed value of a declared key raised out of Receipt.from_dict, and because both stores
    build every row before any caller sees one, that one UPDATE stopped ctrlrun receipts,
    receipts --verify-chain, ctrlrun inspect, ctrlrun stats and the operator MCP server's
    receipts and stats tools together. inspect on 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.UnreadableReceipt is what a store hands
    back for a row it cannot construct, carrying the row's seq, its receipt_id where that field
    alone is readable, and the type of what refused it, never the message. CHAIN_BREAKS did not
    change: §12.5 offered a new break name as one of two candidates and SPEC-v0.11.md §5.1 declines
    it, because content_altered already 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.loads in the generator expression that fed it, outside the guard, so a row
    whose stored json is not JSON at all raised through every reader exactly as before v0.11, and
    worse: JSONDecodeError is not a CTRLRunError, so the CLI's handler did not catch it either
    and ctrlrun receipts printed a traceback. One UPDATE 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 --coverage reads a store and reports the policy entries, gateway tools and
    @protect actions 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 verify grading an operator's document
    in a new costume, which SPEC-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.md said 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-gap and 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 a COMMITTED row inside SPEC-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." unchained is 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_broken and 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 anchors
    table held, so one INSERT beside a forged checkpoint row bought supersession and the row's
    hash was never compared to anything. Supersession now comes from provider.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 gave SPEC-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-than and --reason, staged proposed; the second records what became
    of it, completed or refused. They were byte-identical at first, and --older-than was in
    neither, which made the record of a refusal worth nothing.

    The bound comes from the receipts, not from receipt_chain. That row is the one
    SPEC-v0.11.md §2.1 assumes an attacker rewrites, and deciding --through from it meant one
    UPDATE turned 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() opened BEGIN IMMEDIATE and then every put_anchor went through with connection:
    and committed it, so the prune held the lock for one statement; a failed prune could leave
    missing and link_broken on 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 is SPEC-v0.9.md §4's rule about a budget hold applied unchanged.

  • G29, G30 and G32. G32 grades the interaction: an honestly pruned chain leaves a
    clean anchor report. G28 grades a truncation against an anchor and G29 grades 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_broken at the anchored seq.

    What an anchor proves, and what it does not. It freezes a prefix: anything at or below
    an anchored seq can no longer be removed or altered without the anchored pair failing to
    reproduce. An append is not detected, because it lands above every anchored seq; nor are
    receipts created and destroyed between two anchors; nor who wrote any of it. The window you are
    exposed to is (last anchored seq, current head], and its size is your choice of interval.
    That is the number to quote rather than any sentence about tamper-evidence, and there is a test
    that runs a forged append and requir...

Read more

ctrlrun 0.10.0

Choose a tag to compare

@github-actions github-actions released this 14 Sep 12:31
a01bfb4

One question: when one agent hands work to another, what does the second one hold?

Added

  • A hop: authority that crosses an agent boundary. A hop is SPEC-v0.3.md §5's delegation over
    a boundary the kernel does not control, and almost nothing about it is new machinery. The same
    contained_dimension decides it, over all eight dimensions, at creation and again at every
    evaluation. The same delegation_id names it. SPEC-v0.9.md §2.7's walk charges it, so a
    consumption under a hop costs the issuer and every ancestor to the root.

    One rule is new, and it is the whole of what v0.10 adds: an action proposed under a hop is
    evaluated against that hop's grant alone, with no fallback.
    Before it, Authority.evaluate
    passed on any matching grant, so a receiving agent holding a grant of its own was authorised by
    that one, the hop was never consulted, and the issuer's budget paid nothing. Which way it went
    turned on how two identifiers sorted.

  • Control.hop(parent_id, grant, *, by, action_id=None), hop= on @protect, Control.execute
    and Control.evaluate, and hop in the metadata the gateway and the ACS hook already carry.

  • ctrlrun.receipt/v7 adds hop: the hop an action ran under, never the one it created.

  • ctrlrun.policy/v8 adds upstream: 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: G25 a hop narrows or it is refused, G26 a hop is named on both sides, G27 a swapped upstream is denied.

  • ctrlrun inspect --hop emits ctrlrun.hop/v1: who issued a hop, and which dimensions each
    link narrowed
    , which is the question an operator paged at 3am actually has.

  • ctrlrun scan names the principals holding a grant no hop bounds. It reports and does not score.

  • task= and hop= on ctrlrun.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 while execute evaluates against the hop alone, so it answered
    "a human is needed" for a call execute then refuses: the framework surfaced an approval item, a
    human said yes, and the call failed anyway. Never a wider grant, because Control.execute is the
    enforcement point and refuses either way; what it cost was the framework's own approval item and a
    receipt nobody could explain. SPEC-v0.10.md §9 froze the name and §9.4 records that it shipped
    after this section was first written, which is why it is here and not above.

Changed

Every behaviour below is stricter than 0.9.0, with what it did before.

  • An action presented with a hop is decided against that hop alone. Before: every grant the
    principal held was a candidate and the narrowest-sorting one decided. An action presented with
    no hop is decided exactly as 0.9.0 decided it, which is why every existing deployment
    upgrades untouched.
  • A resumed leg is evaluated on the task and the hop. Before (v0.9 §6.3.2), the task
    dimension was not evaluated on a resumed leg at all, because the rehydrated action carried none;
    EXECUTION_STARTED now carries both and _resumed_context reads them back. A leg suspended by
    0.9.0 carries neither and is evaluated exactly as 0.9.0 evaluated it, or every action in
    flight across the upgrade would be denied.
  • A lease extension is decided against the hop the first leg held, on every round. Before, and
    briefly during this milestone, a receiver holding a grant of its own kept its reservation across
    a round trip after the hop was cut.
  • Observe mode reports the refusal enforce mode would raise. Before (v0.9 §4.2.1b), it
    reported whichever refusal it reached first, which was not always the same one.
  • A policy that pins an upstream refuses an action the process has not verified one for.
    Before, no such key existed. In-process there is no upstream to observe, so a pinned action
    refuses on every call; the ACS hook refuses such a policy at construction.

The upgrade, and the one irreversible thing

0007_budget_ledger is still the last migration: receipts and events are stored as whole JSON
documents and created_via is already TEXT, so v0.10 needs no schema change.

The irreversible step is creating the first hop, not installing 0.10.0. CreatedVia is a closed
vocabulary and Authority._candidates reads every delegation row before filtering any of them, so a
0.9.x binary meeting one created_via='hop' row answers authority_unreadable for every action in
the deployment
, not just that delegation. Fail-closed, and a deployment that stops. A deployment
that installs 0.10.0 and creates no hop can still roll back.

ctrlrun 0.9.0

Choose a tag to compare

@github-actions github-actions released this 13 Sep 12:04
22c9948

Undated until the tag.

Every guarantee before this one answers whether. A grant says amount_lte: 5000, and is silent
about the thousand actions that each pass it: the authority model bounds one action and has never
bounded an aggregate, so an agent acting entirely within its permissions can still empty an account
one permitted refund at a time. v0.9 answers the other half: how much, over which records, for
which task?

Three dimensions, one rule each.

Consequence budgets. A grant may carry budgets:, a metric with a limit over a rolling window,
consumed on reserve, inside the reservation's own transaction, because a check on one line and a
consumption on another is a race two processes win together. Ambiguity is not a refund: an
AMBIGUOUS effect holds its consumption until a human or a hook resolves it, because otherwise an
agent that can manufacture ambiguity can manufacture authority. A budget names a metric, not a
consequence: nothing here ranks, scores or classifies an operator's actions.

Scope providers. scope= answers "is this record this principal's?", strictly before the
reservation, which is the bite on an identifier an attacker chose. A grant permits records.read on
customer:*, and until now nothing had an opinion about whose record customer:90210 is.

Task-bound authority. tasks: narrows a grant to a unit of work, by the same child ⊆ parent
rule as every other dimension. It limits blast radius; it does not detect a hijack.

What a budget is not. It cannot recall an action already in flight: a rolling window changes
what the next reserve may do and nothing about what is already reserved, so a reservation taken a
second before the window rolls commits regardless. It counts a metric an operator named, an argument
on the action, and is not a consequence model: nothing ranks, scores or classifies what an action
means. It is per store, so two deployments sharing a provider account and not a store each enforce
their own. And it is fail-closed against its own principal: an agent able to manufacture ambiguity
can pin a budget it cannot spend, which is a denial of service against the operator's own agents and
is the deliberate side of the trade against an agent that manufactures authority.

Added

  • Task-bound authority (SPEC-v0.9 §6). A grant may carry tasks:, a unit-of-work dimension
    attenuated by the same child ⊆ parent rule as actions, resources and environments.
    Control.execute(task=...) and Control.evaluate(task=...) take the resolved task id;
    @protect(task=...) takes a template over the call's arguments, like effect= and resource=.
    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.resume and a lease
    extension. Both rehydrate an action that carries no task, and evaluating it there would put
    AUTHORITY_DENIED on 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 Action would 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 AMBIGUOUS effect keeps its consumption until a human or a
    reconcile hook 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 verify reports 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). StateStore has
    been frozen since v0.6 and gains exactly two things: charges= on reserve_effect and
    consume_approval_and_reserve, and consumptions() to read the ledger back. Migration
    0007_budget_ledger, additive and forward-only.

    The charge lands inside the transaction that writes the reservation, on all three backends.
    Anything else is a check-then-act race: two processes read the same remaining amount and both
    spend. On Postgres that needs a SELECT ... FOR UPDATE on a per-grant anchor row before the sum,
    because READ COMMITTED does not serialise a sum and an insert. Measured, not chosen: without it,
    twenty-four processes racing a budget that permits ten spent 2400 against a limit of 1000,
    with zero refusals.

    Nothing spends this yet. The consumption, the holds and the releases are the next item.

  • Consequence budgets, in the document (SPEC-v0.9 §2). A grant may carry budgets:, each a
    metric, a limit and a window. 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 <= and window >=, 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 a bool
    , so money is budgeted in minor units, as examples/authority/payments.yaml
    already does for every constraint. The kernel does not know what any metric means: there is no
    branch on a metric name anywhere.

  • Scope providers (SPEC-v0.9 §5). Control.execute(scope=...) and @protect(scope=...) take
    a callable that answers what the calling principal's assigned scope is; the kernel matches
    this action's resource into it, with the relation a grant's resources: 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.read on
    customer:*, and until now nothing had an opinion about whose record customer:90210 is.

    Two distinct refusals, never one: scope_unavailable when the provider raises, answers with the
    wrong shape, or answers something the canonicalizer refuses; out_of_scope when it answered and
    the resource is not covered. A non-callable scope= is InvalidArgument, at decoration time
    under @protect.

    Only the hash of what the provider returned reaches the receipt, under its own domain tag so
    it can never equal a precondition fingerprint over the same mapping. A scope is a list of what a
    principal may touch, and an evidence store is not the place to keep a second copy of it.

    It amends SPEC-v0.7.md §6.9, which said v0.9's scope providers would configure the
    precondition hook rather than add a second one. SPEC-v0.9.md §5.2.1 records the amendment and
    the three mechanical differences that justify it.

  • The operator surfaces for a budget (SPEC-v0.9 §7). No new command. ctrlrun inspect
    gains --grant GRANT_ID, which reports each of that grant's budgets as three numbers:
    consumed, the un-released sum over the rolling window, which is the number that decides;
    held, the part of it whose effects have not committed; and why, the effect holding each
    part and the state it is in.

    The third is the deliverable. A budget that refuses while it looks nowhere near its limit is
    almost always one unresolved effect, and without the third column an operator cannot get from
    the refusal 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 effects says what each effect is holding, so --state ambiguous answers "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 stats reports 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/v1 is its own document rather than a key inside ctrlrun.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 --json shape is u...

Read more

ctrlrun 0.8.0

Choose a tag to compare

@github-actions github-actions released this 12 Sep 19:20
acf45c0

Every guarantee shipped before this one verifies the principal that acts. G7 refuses an action
whose requester cannot be resolved; nothing whatever was asked of the principal that permits it.
approver was a non-empty string, ctrlrun delegate --as was an assertion typed at a shell, and
the operator MCP server authenticated who answered without checking they were entitled to. v0.8
asks the question all seven put only to the acting side: who may say yes, and can the kernel
tell?

Five guarantees answer it — G17 an unentitled approver, G18 the requester cannot approve, G19 one
principal counts once, G20 a credential revoked before its exp, G21 an unapproved policy decides
nothing — and one thing that is not a guarantee: break-glass, which is a grant and not a flag.

Opt in, then fail closed. A deployment that names no approver identity behaves exactly as
0.7.0 did, and a test drives the whole approve-and-execute path to prove it. One that names one has
no partial mode, no "resolve if you can", and no setting that puts the string back. There is no
skip_entitlement, no trust_approver, no allow_self_approval, no break_glass=True, no
ignore_revocations — and that sentence is a test, not a claim: the shipped package is grepped for
sixteen spellings a flag would take, and the control plants one and finds it.

What v0.8 does not close, in one place. A persuaded approver gives a valid approval and the
receipt records it as one. An entitlement check is against what the granting surface recorded,
not a re-derivation from a credential that no longer exists. A revoked credential leaves a log line
and no receipt. A feed is worth what its source is worth. And a policy change that no verified
principal other than the proposer approved decides nothing — which is not the same as saying a
policy cannot be changed by whoever holds the file.

Added

  • A policy change is a protected action (docs/SPEC-v0.8.md §8). The policy is the one file
    that decides every other decision, and until now it was changed by editing it. v0.6 made the
    change evidenced: every receipt records the hash of the policy that decided it. v0.8 makes it
    approved: a policy nobody approved decides nothing.

    ctrlrun policy propose --file new.yaml
    ctrlrun approve <request>              # there is no `ctrlrun policy approve`
    
    Control(policy, store, require_approved_policy=True)

    An ordinary action, which is why §8 adds no event type. ctrlrun.policy.change has 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 staging and in prod is 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.change under ctrlrun.policy/v6; nothing else may name it in a
    resource: or effect: template. Under require_approved_policy the policy in force must
    declare it with decision: approve — a policy that declares it allow, 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 N reports 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 verify grades G21 with the flag set by verify, under a note rather than an N/A.

  • A credential revoked before its exp is refused (docs/SPEC-v0.8.md §6). jwt_identity.py
    used to say, in as many words, that a verified token is valid until its exp and 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, sub and jti, never against
    Principal.agent.
    agent is whatever agent_claim names, which a deployment may set to
    client_id, so matching an iss_sub identifier 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 on Principal.

    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_id to 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_staleness is 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 with revocation_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 ctrlrun imports no part of
    it. ctrlrun verify grades G20 against a feed verify supplies, with a note saying so rather
    than an N/A claiming 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.py already 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 Control that wires no approver identity, and there is no configuration key
    for one, so ctrlrun break-glass could 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 own Control; 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 from grants, because the candidate set is every entry of grants unconditionally: 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_ttl included. The argument for declaring the widest
    authority an incident can reach in a file is that somebody reviewed it before the incident, and
    that argument is only true if widening it moves every receipt.

    There is no --as. Whoever opens one is the principal the deployment's approver identity
    resolves, gated by the envelope's `...

Read more

ctrlrun 0.7.0

Choose a tag to compare

@github-actions github-actions released this 11 Sep 23:38
de509a5

Every milestone before this one asked what holds inside CTRLRun. v0.7 asks whether it holds at
the edges the kernel does not control. The kernel does not decide whether the remote acted, an
executor does. It does not own the clock its leases are measured against, once the store is on
another host. It does not know whether the world still looks the way it did when a human said
yes. Six items answer those edges:

What it adds Where
ctrlrun.transport, the NotExecuted classifier, in core and stdlib only. One rule, promoted out of ctrlrun[gateway] rather than copied, reachable from @protect. §2
Clock-skew detection. PostgresStateStore measures its server's clock against this host's and names divergence with a new event. It observes and reports, and changes no decision. §3
Attempt numbers that never repeat. Three Postgres defects that could hand one attempt number out twice, or move it backwards, fixed before v0.7 made the number load-bearing. §5.6
The provider idempotency token, ctrlrun.idempotency_token(), derived from (effect_key, attempt): a deterministic handle for reconciliation, and one that changes on a renewal. §4
The attempt ceiling, max_attempts, a policy key bounding renewal after FAILED. Needs ctrlrun.policy/v5. §5
Precondition fingerprints, an approval bound to the resource state it was granted against and rechecked strictly before the reservation. Needs ctrlrun.receipt/v4. §6, §7

Section numbers are docs/SPEC-v0.7.md, which is the contract; §9 freezes every public name
added here. ctrlrun verify now grades sixteen guarantees under ctrlrun.guarantees/v3, G12 to
G16 being new, each with a positive control and each N/A only for a reason that is true of the
document it was handed. pip install ctrlrun still installs pyyaml and click and nothing
else, import ctrlrun still imports no module from an extra, and ctrlrun demo still runs every
scenario in under a minute with no network.

Stricter than 0.6.1, with what 0.6.1 did

Everything here can refuse, or record as unknown, something 0.6.1 accepted or recorded as
settled. Nothing here is a flag, and no setting relaxes any of it.

  • A continuation leg never records FAILED. A continuation exists only because the remote
    answered and is holding the exchange, so nothing on that leg can say the remote did nothing.
    At 0.6.1 a refused connection on a continuation, a pre-dispatch JSON-RPC code, the 401 rule
    of v0.2 §6.8 and a tool error under an operator's not_executed_on_error: true each recorded
    FAILED, and the gateway answered the client -41011 "not executed", which permitted a
    retry
    of an effect the upstream may have been part-way through. The gateway now records
    AMBIGUOUS and answers -41010, and the effect needs ctrlrun 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 CONNECT line with the same
    ConnectError, and a written CONNECT line is a written byte. At 0.6.1 the forwarder mapped
    every ConnectError to NEVER_CONNECTED, therefore to a failed receipt and -41011. Where
    urllib.request.getproxies() names a proxy, ConnectError and ProxyError are now an unknown
    outcome: AMBIGUOUS, -41010, and a ctrlrun resolve. NO_PROXY=* is honoured and a
    narrower NO_PROXY is 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 declares max_attempts must declare v5; 0.6.1's
    newest schema was v4, and a v4 document naming the key is a PolicyError at 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 a v5 document, as it should.
  • ctrlrun.receipt/v4. Two new fields, precondition_at_request and
    precondition_at_recheck. Upgrade every reader before any writer: a v4 JSONL 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 --json and ctrlrun inspect render each
    receipt under the schema it was written with, so a pre-v0.6 receipt shows its own v1 or v2
    label and keys where 0.6.1 showed v3. A key added to a stored receipt, a relabelled schema,
    a removed one or an unknown one is content_altered at its seq, 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, naming 0005. 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 every v4 receipt under v3'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 passes preconditions=.
  • 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_preconditions and 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 ActionDenied names
    attempt_ceiling.
  • ctrlrun verify opens loopback sockets it bound itself. v0.4 §3.7's "no scenario opens a
    socket" becomes no connection except to the store --store-url names 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 the 127.0.0.1 literal, at a port this
    process bound through a stream socket that is still open. localhost, ::1, 0.0.0.0, every
    AF_UNIX path and every datagram send are refused.

What this release does not close

Stated here, and not only in the specification, because each one is a limit somebody operating
this will meet.

  • A precondition fingerprint narrows the window between a human's approval and the action's
    execution, and does not close it.
    The recheck is a network call, so it runs outside the atomic
    reservation write, and a change that lands after the comparison and before the reservation is
    not refused. It takes the exposure from minutes of human deliberation down to milliseconds,
    which is worth having and is not prevention. T261b opens that residual window and asserts
    exactly that.
  • On the reconcile route a doomed attempt still costs a human answer and three provider
    calls.
    Under max_attempts: 1 on an approve action whose retry carries a reconcile hook,
    the approval gate runs before the ceiling's check: a new approval request can be created, a
    human can grant it, and the reservation that consumes it is then refused with
    attempt_ceiling. One wasted answer, never an execution. On that same route the precondition
    provider is called three times, once on the request pass and twice on the retry, and a
    provider that is down there makes the refusal ApprovalMismatch(reason="precondition_unavailable")
    rather than attempt_ceiling, writes no effect record and never runs the reconcile hook, 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_attempts bounds attempts, not executor invocations. A Suspended executor holds its
    reservation and every Control.resume runs on that same attempt, so an elicitation loop is one
    dispatch however many rounds it takes. The gateway bounds those with max_elicitation_rounds;
    a direct Control.resume caller has no bound, and this release adds none.
  • A refused attempt number is spent. Raising max_attempts from 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 Control marks a register on every send through
    ctrlrun.transport or ctrlrun.gateway.transport.request. An executor that sends part of the
    effect through requests, through httpx directly, or on a raw socket, and then uses the
    classifier, can be handed a NotExecuted that 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_id still 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 by action_id
    alone, so attempt 1's write, arriving after ...
Read more

ctrlrun 0.6.1

Choose a tag to compare

@github-actions github-actions released this 07 Sep 11:31
da2a033

Everything found after v0.6.0 was tagged: twenty-nine defects from an audit of the shipped
code, the gateway's transport behaviour, and the documentation's first screen. No public API
name changes and no schema change. Two behaviours become stricter and could refuse input
that 0.6.0 accepted silently — @protect on an async def, and a duplicated mapping key in a
policy or authority document — and both are listed below with what they did before.

Fixed

  • @protect on an async def recorded 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 a committed receipt written before the body ran, and the legitimate retry was
    then refused with DuplicateEffect for ever. Async, generator and async-generator functions
    are refused at decoration time.
  • One human approval could authorise two effects on Postgres. A lost COMMIT on a
    renewal — a reservation over a FAILED record, v0.1 §5.4's one automatic retry — re-issued
    the approval without consuming it, so find_granted_approval would hand the same "yes" out
    again for a different effect key. v0.1 §4.2 A2 requires single use consumed atomically with
    the reservation. SQLite has no lost-commit resolution, so this was also a backend-switch
    regression.
  • ctrlrun verify reported 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 an effect: template containing {resource} — a code path --help
    documents as "a guarantee FAILED" — because it invented an argument for a placeholder that
    names the action's resource field. Every shipped example under examples/ is verified in
    CI now.
  • Webhook approvals were never routed. WebhookApprovalProvider advertises
    POST /ctrlrun/approvals/<id> as respond_to in every APPROVAL_REQUESTED notification, and
    Gateway.handle_approval had 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_ambiguous raised UnicodeEncodeError from inside the transaction — not a
    CTRLRunError — and left the record EXECUTING, which is neither outcome and blocks the
    retry until the lease expires. Executor text is escaped with backslashreplace where 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_load
    resolves one to the last silently, so a grant with actions: 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 delegate and ctrlrun revoke ignored the store. Both called Control.from_file(),
    which always opens .ctrlrun/state.db beside the policy, so on Postgres a delegation went
    into a local file no agent reads and revoke reported success while the delegation stayed
    live. Both take --store-url now.
  • Read commands created the store they were reporting on. ctrlrun receipts in a directory
    without a ctrlrun.yaml created and migrated .ctrlrun/state.db and 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 -1 bypassed
    --max-body-bytes entirely — and httpx.DecodingError and httpx.InvalidURL, which inherit
    from RequestError and so matched neither except.
  • The gateway relayed Content-Encoding: gzip with the decompressed bytes. httpx sends
    Accept-Encoding: gzip by default, so an upstream doing nothing but honouring content
    negotiation made the gateway unusable with DecodingError: incorrect header check.
  • ctrlrun scan failed the policy ctrlrun init had 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]] raised TypeError on every evaluation of that action rather than
    a CTRLRunError, 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.6 beside a 0.6.0 kernel, so pip install ctrlrun-langgraph either refused to resolve or silently downgraded ctrlrun. The framework
    range was checked against the version CI installed; the kernel range was checked against
    nothing.
  • Two reference pages promised a ctrlrun[conformance] extra that does not exist and a
    MissingDependency that could not be raised. SPEC-v0.5 §12.1 reversed that extra and
    pyproject.toml never declared it, so both halves of the sentence were false.
  • Validate upstream JSON-RPC response IDs before changing effect state. Missing, mismatched,
    and malformed responses remain ambiguous and cannot make an executed action retryable.
  • Forward MCP SSE progress incrementally, record the matching final response, and keep
    interrupted streams ambiguous. Client cancellation closes the upstream connection.
  • Relay empty HTTP acknowledgements and MCP GET/DELETE requests, including session headers
    and standalone streams. Recognize successful responses from accepted legacy revisions.
  • Preserve the original request ID in synthesized gateway errors and support IPv6 listeners.
  • Restore consumed approval attribution and original attempt timing on resumed receipts,
    including across database reopenings and multiple suspension rounds.

Changed

  • The README's first integration example runs end to end. It stopped at ApprovalRequired and
    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 approve from 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 title is the category line and
    its description the tagline, which is what docs/IA.md assigns to each; the browser tab no
    longer reads CTRLRun - CTRLRun.
  • try-it puts 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.md forbids social proof that does not exist, and a
    count published four days after the first release measures mirrors.

ctrlrun 0.6.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 23:22
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 s...

Read more

ctrlrun 0.5.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 07:02
86e64fe

v0.4 asked does it hold in my setup? v0.5 asks a narrower and harder question: can somebody
else implement this?
The adapter contract is one of the six things v1.0 freezes, so it is
written to be lived with rather than revised once somebody tries it.

The milestone's own answer is yes, with caveats. A session that could read the five
specifications and nothing else — no kernel source, no reference adapter, no test — wrote a third
adapter against the contract and returned fourteen questions it could not answer. Every one
became an edit, and one of them was a defect in a shipping adapter that no test caught. That
exercise, not the two adapters, is what v0.5 is for.

Added

  • Two reference adapters, on their own version line and in their own distributions:
    ctrlrun-langgraph (adapters-langgraph-1.0) reuses interrupt(), Command(resume=...) and
    the checkpointer; ctrlrun-openai-agents (adapters-openai-agents-1.0) reuses the SDK's
    tool-approval interruption. Neither is in the ctrlrun wheel or sdist (T136). LangGraph
    passes the conformance kit 6/6; the Agents SDK 4/4 with two suites not_applicable, each with
    its reason on the report and in the README.

    Prevention or attribution, in that word, is the sentence each README leads with.
    LangGraph's resumption carries the arguments a human answered against and core re-checks them;
    the Agents SDK records that a call was approved and not what its arguments were.

  • ctrlrun.adapter — the surface: FrameworkInterrupt, PendingApproval,
    ApprovalAnswer, InterruptApprovalProvider, needs_approval, banner, and
    Control.resolve_principal promoted from private. Core, stdlib, in the action path. An
    adapter returns an answer and one core provider writes the grant, through the same calls
    ctrlrun approve makes — which is what makes "never a second approval path" structural.

  • ctrlrun.conformance — this repository's own v0.1 §7 and v0.3 §10 acceptance tests,
    runnable against any adapter, in core. It is not a certification and passing it is not a
    claim about quality: it answers one question, does an action driven through this adapter get
    the same refusals as one driven through @protect?
    Fifteen deliberately broken fixtures were
    written first, and each fails the suite named for it and no other.

  • docs/adapters.md, and a README section that opens by saying when you do not need an
    adapter (T139) — @protect covers 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-mutation column carefully:
    executed_once there does not mean the scenario went well.

Changed

  • ctrlrun demo --help said four scenarios and ran five, stale since v0.3 added the
    authority escalation.

Security

Three independent reviews and item 6 found five authorization defects in
ctrlrun-openai-agents before it shipped. All are fixed, mutation-tested, and recorded here
because the pattern matters more than any one of them: the SDK's approval record is keyed to a
tool call, and a CTRLRun grant binds to an action hash
, so every defect was the same shape —
reading a coarser answer as though it answered a finer question.

  • interrupt() returned granted=True unconditionally.
  • It then accepted a sticky per-tool decision, so always_approve=True answered 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 first CTRLRunError anywhere in the chain, so a nested NotExecuted
    masked an AMBIGUOUS refund — safe to retry reported for an effect that may have landed.
  • Observe mode interrupted and blocked the action. Found by item 6 without reading the
    adapter. §3.6's rule followed for one framework shape and had to be required of the other;
    a deployment evaluating CTRLRun in the mode built for evaluating it would have had its agent
    halted.

Added

  • docs/SPEC-v0.5.md — the v0.5 contract, a delta over v0.1, v0.2, v0.3 and v0.4. It fixes
    the adapter surface (§2), the approval round trip (§3), the SPEC-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_approval rather than Suspended + 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 per Control, which ctrlrun.adapter.banner
    does so no two adapters word it differently. The conformance kit refuses an observing
    Control — a refused report with no suites, which is not a success — rather than producing
    an all-not_applicable report with a zero denominator, because 0/0 reported as a pass is
    the false green SPEC-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 with Control.evaluate(action), and Action.principal
    has no default — so the only way to obey was to build a principal from the framework's
    session. ctrlrun.adapter.needs_approval is core's because of that, and
    Control.resolve_principal is promoted from private for it: the identity seam an adapter may
    read and may not supply.

  • ctrlrun.adapter — the adapter surface: the FrameworkInterrupt Protocol,
    PendingApproval, ApprovalAnswer, InterruptApprovalProvider, needs_approval and
    banner. Core and stdlib, re-exported from ctrlrun, and Control.resolve_principal is
    promoted from private for it — the identity seam an adapter may read and may not supply.

  • ctrlrun.conformance — the adapter conformance kit: the SPEC-v0.1.md §7 and
    SPEC-v0.3.md §10 acceptance suites runnable against any adapter through that surface, and
    eleven adapters broken in one named way each, written before the reference adapters because
    "two adapters pass the suites" means nothing until the suite can fail.

    It is core and stdlib-only, not the extra SPEC-v0.5.md §5.1 originally specified. The
    premise there was that a kit needs pytest; building it showed otherwise, and an extra with
    no dependency behind it is an install line that installs nothing. §12.1 records the change.
    dependencies is unchanged: pyyaml and click.

Changed

  • docs/ROADMAP.md's v0.5 bullet was wrong and is corrected here, not silently. It said
    the reference adapters map their frameworks' interrupts onto Suspended / Control.resume,
    "which v0.2 already ships for exactly this shape". It does not: Suspended exists 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 treatment SPEC-v0.4.md §9.4 gave the
    threat model's sentence about a check verify could not deliver.

  • Version is 0.5.0.dev0.

v0.4.0 — Verification

Choose a tag to compare

@arpanghoshal arpanghoshal released this 04 Sep 17:03
2763010

Does it hold in your setup?

pip install ctrlrun
ctrlrun verify

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 you deploy is your policy, your grants and your store, and a guarantee that has never been exercised against those is a guarantee nobody has checked.

ctrlrun verify runs the kernel's own failure scenarios against the configuration in front of it — in a scratch store, with fake executors, reaching no network — and reports what passed, what failed, and what could not be tested at all.

G1   mutated approval refused         PASS  stripe.refund
G2   replayed approval refused        PASS  stripe.refund
G3   duplicate effect refused         PASS  stripe.refund
G4   one winner under concurrency     PASS  stripe.refund (8 processes)
G5   ambiguous blocks a blind retry   PASS  stripe.refund
G6   unknown action refused           PASS
G7   no principal refused             PASS  stripe.refund
G8   expired authority refused        PASS  head-of-support
G9   delegation cannot escalate       PASS  head-of-support (6 of 6 dimensions)
G10  unknown exception is ambiguous   PASS  stripe.refund

10/10 declared guarantees pass. 0 not applicable.

Not applicable is not a pass. A policy with no approve rule cannot exercise the approval-binding guarantees, so they are reported N/A with the reason, excluded from the denominator and listed separately — 5/5 (5 not applicable), never 10/10. There is no flag that folds one into the count.

Verify never touches your store. .ctrlrun/state.db is byte-identical before and after, and is never created where it did not exist.

Every guarantee carries a positive control. A refusal asserted against a scenario in which nothing ran passes on a kernel with the guard deleted, so each scenario establishes the observable would have been visible had the guard not fired. A control that misbehaves is fail with reason: "control failed" — never a pass, never an N/A.

The badge means "declared guarantees pass" — that phrase, and no other. Not secure, not safe, not compliant, not certified, not audited. docs/verify.md says on the same screen what verify cannot see: your executors, your reconcile hooks, where you put the decorator, your deployment, and whether your policy is the right policy.

pip install ctrlrun still installs nothing but pyyaml and clickincluding all of verify, because a verification tool behind an extra is one half the deployments never run.

Note on versions: 0.3.0 was never published; 0.3.0rc1 was its last tag. This release goes from 0.2.0 to 0.4.0 on PyPI.


Does it hold in your setup? Everything CTRLRun guarantees was proven, until now, by this
repository's tests against this repository's configurations. That is the right place to start
and the wrong place to stop: what an operator deploys is their policy, their grants and
their store, and a guarantee that has never been exercised against those is a guarantee
nobody has checked.

ctrlrun verify runs the failure scenarios of v0.1 §7, v0.2 §10 and v0.3 §10 against the
configuration in front of it and reports what passed, what failed, and — the part that makes
the number mean anything — what could not be tested at all.

Three rules govern it, and each has a test that would go red if it stopped holding. Not
applicable is not a pass.
Verify never touches the operator's store. The badge means
"declared guarantees pass"
, and nothing else. A fourth keeps verify honest about itself:
every guarantee carries a positive control, because a refusal asserted against a scenario
in which nothing ran passes on a kernel with the guard deleted.

No schema changes: ctrlrun.policy/v3, ctrlrun.receipt/v2, ctrlrun.action/v1 and
ctrlrun.inspection/v2 are untouched, and no store gains a table or a column — verify
writes only to a scratch store it created. Three new schema strings belong to documents rather
than to storage: ctrlrun.verify/v1, ctrlrun.guarantees/v1 and ctrlrun.framework-probe/v1.

Added

  • ctrlrun verify — the guarantee catalogue, the scenario engine and all ten guarantees
    (SPEC-v0.4 §2, §3). ctrlrun.verify is core: stdlib, pyyaml and click, because a
    verification tool that needed an extra installed is one half the deployments never run. It is
    not re-exported from ctrlrun and import ctrlrun does not import it.

    It reads the operator's policy document, and the authority document beside it where
    --authority names one, derives concrete actions, principals and delegations the
    configuration actually admits, and runs the failure scenarios of v0.1 §7, v0.2 §10 and
    v0.3 §10 against them — in a scratch store, with in-process fake executors, reaching no
    network. G1 mutated approval refused · G2 replayed approval refused · G3 duplicate
    effect refused · G4 one winner under concurrency, across real OS processes · G5 ambiguous
    blocks a blind retry · G6 unknown action refused · G7 no principal refused · G8 expired
    authority refused · G9 delegation cannot escalate, on every dimension the parent constrains
    including the omission case · G10 unknown 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 fail with reason: "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 -1 is meaningful. --json emits one ctrlrun.verify/v1 document 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 a counterexample only on a fail, because a counterexample
    on a pass would be evidence of a failure that did not happen. --junit PATH writes 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. xmlschema joins the
    dev extra for that test and for nothing else.

  • The GitHub Action, the badge and docs/verify.md (SPEC-v0.4 §5). action.yml at the
    repository root is a composite action: it installs ctrlrun, runs
    ctrlrun verify --json --junit, renders the job summary and the badge from that report
    rather than from a second run — so the badge, the summary and the uploaded artifact can never
    disagree about what happened — and uploads the three files as one artifact.

    It fails the job when a guarantee failed and when the configuration was refused, and succeeds
    when guarantees are N/A. There is no input that makes a failure not fail the job: a
    continue-on-error-shaped flag here would be a flag that makes a consequential thing
    permissive by default, and a workflow that wants to tolerate a failure has
    continue-on-error on 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: write in every consumer's workflow, and asking for write access to a
    repository as the price of a verification badge is a bad trade for a tool whose subject is
    least privilege; docs/verify.md shows the one-job publishing pattern once, with its cost
    visible. Rendered, it reads exactly CTRLRun verified N/M, where M is applicable
    guarantees and never the catalogue size. A partial run and a run that exited 2 or 3 write no
    badge at all.

    The badge means "declared guarantees pass" — that phrase, on the badge's link target, and
    no other. Not secure, not safe, not compliant, not certified, not audited.
    docs/verify.md#what-the-badge-means says it in its first sentence and, on the same screen,
    what verify cannot see: the operator's executors, their reconcile hooks, 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
    against examples/policies/payments.yaml (5/5, 5 not applicable), asserting both shapes
    so a change that made verify silently count N/As as passes is caught in CI rather than in a
    badge.

  • docs/OWASP-AGENTIC-TOP10.md (SPEC-v0.4 §6) — a reading of the OWASP Top 10 for Agentic
    Applications (2026 edition, announced 2025-12-09) against the ten guarantees. Its...

Read more