Skip to content

ctrlrun 0.7.0

Choose a tag to compare

@github-actions github-actions released this 11 Sep 23:38
· 268 commits to main since this release
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 its lease lapsed and after the same Action was
    retried, can read attempt 2's record, find its own id in a state it expects, and land there. A
    late fail_effect is the sharp case: it writes FAILED over an executing attempt 2, and
    FAILED is the state that permits a renewal. A mark_ambiguous reaches the same place in one
    more step, through a reconcile hook that is asked about the effect key and not about the
    attempt, so it needs no person. ctrlrun resolve carries no attempt number either, so someone
    who inspected attempt 1 can resolve attempt 2's ambiguity. The fix inside the frozen
    StateStore protocol is a store-side memo of the attempt each reservation wrote, which is a
    schema change of its own; §12.3a states the argument so the next milestone inherits it.
  • An approval granted and presented inside request() is spent before Control knows the
    request exists
    (§6.4). What would take that away is a store call recording a request and its
    fingerprint in one write, and StateStore is frozen.
  • A malformed value of a key a receipt schema declares still raises out of from_dict. One
    UPDATE putting a float among a receipt's controls blinds ctrlrun receipts,
    receipts --verify-chain, inspect, stats and G11 together, where the schema-level and
    added-key cases are each reported at their seq and leave every other row readable. 0.6.1
    behaves the same and v0.7 neither introduces nor widens it. Fixing it needs a new name in
    CHAIN_BREAKS, a closed set on a v0.6 §6.5 surface, or a reader that walks raw rows; it is a
    named item on the roadmap before v1.0.

Added

  • ctrlrun.transport, the NotExecuted classifier, in core (SPEC-v0.7 §2, build-list item
    2). v0.1 §5.5 leaves the one decision the product exists to get right, FAILED or
    AMBIGUOUS, to the executor, and until now the correct rule was reachable only through
    ctrlrun[gateway]. ctrlrun.transport.urlopen, HTTPConnection and HTTPSConnection are
    urllib and http.client with a counter: they raise NotExecuted, chained from the original
    exception, only where the connection they opened fresh failed before a single request byte
    was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every
    other failure is the original exception, which the kernel records AMBIGUOUS: a reset or a
    timeout after the request was offered, a sendall that raised part way, a reused connection, a
    socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel
    after its CONNECT line was sent. The count is taken from evidence, never from an exception's
    type, and above TLS. No redirect is followed, no HTTP status is ever NotExecuted, and no
    parameter, attribute or environment variable changes a classification. The module is stdlib
    only and is not imported by import ctrlrun.

    The rule itself, ctrlrun.transport.effect_state, is the one implementation: the gateway's
    Transport is now the core one, and gateway/outcome.py asks the core rule rather than keeping
    a copy. ctrlrun.gateway.transport.request offers the gateway's httpx mapping to an executor
    that uses httpx, on a client built for the one call. The gateway's own NotExecuted, for an
    upstream it never reached, is now chained from the httpx exception and its receipt names it.
    ctrlrun verify gains G12, "a byte written is ambiguous", under
    ctrlrun.guarantees/v3, with the refused connection as its positive control. G12 needs a
    loopback peer, so verify's rule becomes no connection except to the store --store-url names
    and to loopback listeners verify bound itself
    , and the test suite's network guard admits
    exactly that: IPv4 on the 127.0.0.1 literal, to a port the process bound through a stream
    socket that is still open, and nothing else.

    The claim is about the executor run, not about one connection. An independent review showed
    that every false NotExecuted it could produce came from two connections in one effect: the
    first delivered the request, the second was refused, and a per-connection classifier judged the
    second alone. xmlrpc.client's retry, FancyURLopener following a 303, an opener whose
    handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that
    pair. Control now opens a register around each executor call; every send through
    ctrlrun.transport or ctrlrun.gateway.transport.request marks it before the first byte, and a
    claim needs it unmarked as well as the connection's own evidence. Outside an executor run
    nothing is claimed. The limit is stated in the module, the class and the specification: the
    register sees only this library's own sends, so an executor that sends part of the effect
    through another transport and then uses the classifier can be handed a claim that is true of
    these connections and false of the effect. A send through this library on a thread that did not
    copy the executor's context is seen: it belongs to no register, so it marks every register
    open in the process, which costs claims in unrelated concurrent runs and never safety.

    A continuation leg never records FAILED, and 0.6.1 did. A continuation exists only
    because the remote answered and is holding the exchange, so nothing on that leg can say the
    remote did nothing. Control.resume now runs with the register already marked, and the gateway
    refuses to record FAILED for anything a continuation meets: a refused connection, 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, which asserts that that tool reports errors before
    acting and cannot speak for a call it did not answer. At 0.6.1 each of those recorded
    FAILED and, for a connection never established, answered the client -41011 "not executed",
    which permitted a retry of an effect the upstream may have been part-way through. The upstream's
    own response is still relayed unchanged; what changes is the record, which is now AMBIGUOUS
    and needs ctrlrun resolve.

    Behind a proxy the gateway is stricter than 0.6.1. httpx reports an unreachable proxy and a
    TLS failure with the target after the proxy answered the CONNECT line with the same
    ConnectError, and ctrlrun.transport counts a written CONNECT line as a byte. Where the
    environment names a proxy, ConnectError and ProxyError are now an unknown outcome: an
    intercepted call that would have been recorded FAILED with -41011 is recorded AMBIGUOUS
    with -41010, and needs ctrlrun resolve. With no proxy configured nothing changes.

  • Clock-skew detection (SPEC-v0.7 §3, item 1). PostgresStateStore measures its server's
    clock against the application's at open, and again when an expired lease is declared
    AMBIGUOUS (at most once per DEFAULT_LEASE), in one round trip whose half is the
    measurement's bound, so latency alone is never reported as skew. It keeps its latest
    measurement as the optional, read-only clock_skew attribute, a ctrlrun.state.ClockSkew;
    Control reads it at the start of every execute and resume and after an AmbiguousEffect,
    and appends one new event type, CLOCK_SKEW_DETECTED, for a measurement past
    clock_skew_threshold (default one second, at most DEFAULT_LEASE, and no value switches it
    off). It observes and reports, and changes no decision: every lease is still evaluated
    against the application clock exactly as at 0.6.1, no reservation outcome changes, and a
    measurement that fails is logged and changes nothing. Verify gains G13, graded against a
    Postgres --store-url and N/A on SQLite, and the catalogue moves to
    ctrlrun.guarantees/v3; the store conformance suite gains a clock case, not_applicable
    on SQLite and the in-memory store because neither has a clock of its own.

  • The attempt ceiling, max_attempts (SPEC-v0.7 §5, item 4, and the amendment to
    docs/SPEC-v0.1.md §5.4). A new action-entry policy key, an integer of at least 1, bounding
    the attempts that may execute on one effect key, the first included: max_attempts: 3 is
    the first attempt and two renewals. It needs schema: ctrlrun.policy/v5, a new schema version
    that is a superset of v4 as v4 is of v3; 0, a negative, a bool, a float, a string and
    a mapping are each a PolicyError at load, naming the key, the action and the line. The ceiling
    is inside the policy hash, so a receipt records which one refused an attempt.
    An attempt, not an executor invocation: 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 adds none.
    The decision is taken on the attempt number the store assigned, after the reservation and
    before the executor, because two callers that both read attempt N-1 would both pass a read
    taken before reserving. Above the ceiling the executor is not called, the record is released as
    FAILED with an error naming the ceiling, EFFECT_RESERVATION_REFUSED carries
    reason: "attempt_ceiling" with the attempt and the ceiling, a blocked receipt is written,
    and ActionDenied(reason="attempt_ceiling") is raised. The refused attempt number is spent:
    raising max_attempts from 2 to 4 after a refusal buys one further dispatch, not two. A read of
    the record before the approval gate refuses the ordinary sequential case earlier, writing
    nothing, spending no presented approval and creating no approval request; it refuses only a
    FAILED record and is never the guarantee. On any other route the approval gate comes
    first
    , so on an APPROVE action a human can be asked, and answer, for an attempt that is then
    refused: a wasted answer, never an execution, and docs/SPEC-v0.7.md §5.2 and §5.5 say so
    rather than closing it. In observe mode the refusal is recorded as would_have.blocked_reason: "attempt_ceiling" and the action runs. Verify gains G15, and G5 and G14 now select only an
    action whose ceiling permits a renewal, reporting N/A where the ceiling is the only reason
    they cannot, because each one's control is a renewal and max_attempts: 1 would otherwise
    report a correct kernel as a failure. No new error type, no new event type, no new StateStore
    method, no new Control method, and no CLI change.

  • The provider idempotency token (SPEC-v0.7 §4, item 3). ctrlrun.idempotency_token(), a new
    zero-argument accessor re-exported at package import, answers inside an executor with the token
    of the attempt it is running: ctrlrun.effect.idempotency_token_for(effect_key, attempt), a
    SHA-256 over the canonical form of (effect_key, attempt) under the domain tag
    ctrlrun.idempotency/v1, rendered as a 36-character UUID of version 8. Send it to a provider as
    its idempotency key. Derived from the attempt and not from the effect key alone: the effect
    key is stable across SPEC-v0.1.md §5.4's renewal, so a provider given it would answer the one
    retry the kernel permits, permitted because the executor proved nothing happened, with the
    cached failure of the attempt that failed. It is stable within one attempt, including across a
    Control.resume of a suspended one, and different after a renewal. What it is for is
    reconciliation
    : a deterministic handle to ask a provider what became of an attempt whose
    outcome is unknown, by a key the provider already indexes. It does not make a retry safe, and
    after an AMBIGUOUS outcome the kernel still refuses one. Nothing is stored: the token is a pure
    function of two fields every receipt of an attempt that ran already carries, so a receipt
    re-derives it and a reconcile hook reads the attempt off the record. The executor signature is
    unchanged, and an executor that never calls the accessor runs exactly as it did at 0.6.1. Outside
    an executor, for an action with no effect key, for an observe-mode attempt whose reservation was
    refused, and on a thread started without a copy of the executor's context, it raises
    InvalidArgument. Verify gains G14, with a note beneath the table: a token is unique only as far
    as the operator's effect keys are, and a kernel that sees one store cannot check that two stores
    sharing a provider account never produce one effect-key string for two different effects.

  • Precondition fingerprints (docs/SPEC-v0.7.md §6, §7). @protect(..., preconditions=provider)
    and Control.execute(..., preconditions=provider), where the provider takes the Action and
    returns a mapping of the state an approval depends on. A precondition fingerprint narrows
    the window between a human's approval and the action's execution; it does not close it.
    Under APPROVE the provider is called when the approval is requested, and the result is kept only
    as a sha256: fingerprint on the request (ApprovalRequest.precondition_fingerprint, stored in the
    new approvals.precondition_fingerprint column). On the presenting pass it is called again,
    strictly before the store call that consumes the approval, and the action is refused with
    ApprovalMismatch and a reason of its own: precondition_changed where the two fingerprints
    differ, precondition_missing where only one side has one (a store that lost the column, or the
    gateway and the ACS hook, which name no provider), and precondition_unavailable where the
    provider raises or returns something that is not a canonicalizable mapping. Every refusal reserves
    nothing and leaves the approval granted. On the request pass a provider that fails refuses the
    action with ActionDenied(reason="precondition_unavailable") before any human is asked, and a
    fingerprint that is computed and then not recorded (a store without the column, a third-party
    ApprovalProvider building its own request) refuses with
    ActionDenied(reason="precondition_missing") and withdraws the request it left behind where
    this call can reach it: denied while it is pending, spent where a grant landed inside the window,
    and not_withdrawn:<status> in the evidence where neither write was possible (the provider raised
    after recording, or the store refused). §6.4 states that bound and its residual. A withdrawal is
    a deny_approval
    , so find_denied_request returns it and the gateway's "no is an answer"
    pre-check refuses every call for that action hash until the request expires, as though a human had
    said no: fail-closed, bounded by the TTL, and traceable through the approver
    ctrlrun:precondition-not-recorded. ALLOW, DENY and Control.resume never call the provider;
    observe mode compares, records and runs.
    The comparison is a network call, so it runs outside the atomic reservation write, and a change
    that lands after the comparison and before the reservation is not refused: T261b opens that
    window and asserts exactly that. Raw provider output reaches no receipt, event, log line or
    table, and a provider's exception is recorded by its type name only. No skip_preconditions,
    and no timeout parameter: a provider that hangs holds the call and reserves nothing.

  • Migration 0005_precondition_fingerprint adds approvals.precondition_fingerprint, NULL on
    every existing row, on SQLite and Postgres. A database built by 0.6.1's own code migrates keeping
    every row, and 0.6.1 refuses the migrated database at open 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=, and
    nothing in the new process can see the old one.

  • G16 in ctrlrun verify, "a moved fingerprint is refused" before the reservation, under
    ctrlrun.guarantees/v3. Verify supplies its own provider, because a provider is named in code
    that verify does not read, and the report says so beneath the table; not applicable only where
    no action requires approval. The store conformance suite gains a precondition-fingerprint case
    and a broken-store fixture that fails it by name.

  • Python 3.13 and 3.14 are tested and declared. CI's check job runs the full suite on
    3.11, 3.12, 3.13 and 3.14, and the package classifiers name all four. The floor is unchanged:
    requires-python stays >=3.11, and mypy and ruff still check against 3.11. No library code
    changed; the one test fix is below.

Changed

  • An action entry may declare max_attempts, and a renewal over FAILED can now be bounded.
    This is stricter than 0.6.1 only where an operator asks for it: an action that declares no
    max_attempts renews without bound, exactly as before, and every document that loaded at 0.6.1
    loads unchanged. There is no default ceiling, and no value of the key means "unlimited".

  • ctrlrun.receipt/v4, with precondition_at_request and precondition_at_recheck, and the
    first receipt-schema bump that does not report older receipts as altered. A receipt read from a
    store is now hashed as the document it was read from (docs/SPEC-v0.7.md §6.11, amending
    SPEC-v0.6.md §6.4's last bullet), so every v3 receipt a released 0.6 wrote still rehashes to its
    stored hash and a chain spanning v3 and v4 verifies end to end. A key added to a stored
    receipt, a relabelled schema, a removed one or an unknown one is content_altered at its seq,
    and no longer something a reader could miss. Visible: to_dict(), ctrlrun receipts --json
    and ctrlrun inspect render each receipt under its own schema, so a pre-v0.6 receipt shows its
    own v1 or v2 label and keys where 0.6.1 showed v3. Upgrade every reader before any writer:
    a v4 JSONL line handed to 0.6.1 rehashes wrongly.

  • ctrlrun verify prints each distinct note once, where it printed only the first note in the
    report, which would have dropped G16's beneath G3's. CI's verify job expects verified 13/13
    with two not applicable, and verified 7/7 with eight, measured from a run of the merged
    catalogue rather than carried over from either branch.

  • A receipt chain reader no longer stops at a row it cannot hash. A stored document holding a
    value with no canonical form (a float, a lone surrogate) made verify_chain raise, so one
    tampered row ended the walk: ctrlrun receipts --verify-chain exited with no report and a forged
    field at another seq went unnamed. Such a row is content_altered at its seq, named by the
    refusal's type and never its message.

  • APPROVAL_CONSUMED carries what the presenting pass compared, where a precondition was
    compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the
    comparison its first leg made.

Fixed

  • PostgresStateStore.events() read a missing action_id back as the string "None". An
    event about no action (the three DELEGATION_* types, and now CLOCK_SKEW_DETECTED reported at
    open) named a proposal called "None" on Postgres alone; SQLite and the in-memory store returned
    None. It now returns None on all three.

  • The migration tests' release fixtures could not build a venv on some interpreters.
    venv.create copies the interpreter by default, and a copied binary from a shared-libpython
    build (uv's CPython 3.14 on macOS) aborted inside ensurepip, so all five release fixtures
    errored before a release was installed. The fixture now symlinks, as python -m venv does on
    POSIX.

  • The Postgres store could hand out one attempt number twice (docs/SPEC-v0.7.md §5.6).
    0.6.1's renewal after FAILED read the record with a plain SELECT and then updated it on
    effect_key and state = 'failed' alone. So a renewal planned against attempt k could land
    after another process had renewed to k+1, run and failed, and write k+1 a second time: two
    dispatches, and two receipts, under one attempt number. The UPDATE is now also conditioned on
    the attempt it was planned from, with the row count checked, and a stale renewal is refused with
    DuplicateEffect. SQLite carries the same condition, where it was already unreachable because
    BEGIN IMMEDIATE holds the read and the write together.

  • A stale Postgres write could put an older attempt number back (docs/SPEC-v0.7.md §5.6).
    0.6.1's compare-and-set under resolve_effect, extend_lease, hold_continuation, the kept
    AMBIGUOUS write and the four transitions matched effect_key, action_id and state, and
    wrote back the attempt number it had read. A caller that retries one Action reuses its
    action_id, so the same action_id and state could come round again at a newer attempt between
    the read and the write. A human's ctrlrun resolve decided on attempt 1 then wrote FAILED at
    1 over attempt 2's unknown outcome, and the next renewal handed out attempt 2 a second time.
    On Postgres, every write to an effect record is now also conditioned on the attempt it read,
    and a write whose record moved is re-read rather than landed (SQLite's writes are already inside
    the BEGIN IMMEDIATE that holds their read, and are unchanged). The fix for the renewal above
    depends on this one: it holds only because the number can no longer move backwards. What this
    closes is the race between a write's own read and its write. The window a human stands in is
    longer, because ctrlrun resolve carries no attempt number: someone who inspected attempt 1 can
    still resolve attempt 2's ambiguity. An attempt argument on the CLI would close that, and it is
    not in this change.

  • A Postgres write that carried an outcome could be refused and drop it. With the condition
    above in place, a commit_effect or mark_ambiguous whose record moved under it wrote nothing
    and raised, so the effect record, which is what gates the next renewal, said nothing about an
    attempt that may have acted: a review measured a renewal to a third attempt with a committed
    refund recorded on no record at all. Both are now re-issued once against the re-read, so the
    outcome lands on the record as it stands. begin_execution and fail_effect are still refused
    there, because FAILED asserts that nothing happened and the newer attempt may be running.

  • A refused write on a moved record claimed the wrong thing. All of these were
    DuplicateEffect(state="in_progress"), which means another attempt holds a live reservation,
    and after a stale resolve_effect the record is AMBIGUOUS at a newer attempt, which is nobody's
    reservation. The refusal now takes its type from what the re-read found, AmbiguousEffect or
    DuplicateEffect with committed or in_progress, and its message names the move.

  • An unknown outcome could vanish when the store refused to record it, on both backends and
    since before 0.6. Control caught two store refusals around its outcome writes, and a record a
    human resolved FAILED while the attempt was still running answers a third: an executor that
    raised TimeoutError then produced no receipt and no EXECUTION_AMBIGUOUS event, and the caller
    was handed a store error about its own effect key instead of its executor's exception. Control
    now catches every CTRLRunError from an outcome write, writes the receipt and the event whatever
    the store answered, names the refusal in both, and re-raises the caller's own exception. Nothing
    is reconciled on a record the attempt could not mark.

  • Two bounds on the Postgres store's re-issues reset each other. The stale re-issue and the
    lost-commit re-issue of docs/SPEC-v0.6.md §4.3.2 each carry a flag that permits one attempt,
    and neither passed the other's flag on, so a lost COMMIT inside a re-issue and a re-issue
    inside a lost COMMIT alternated without end: a review drove both halves at once and reached
    RecursionError, which is outside this library's closed set of errors, so no caller can
    classify it and the record is left stranded. Both flags now travel through both re-issues.

  • A receipt for an outcome the store refused did not say what the executor had done. An
    executor that returned normally and one that raised NotExecuted produced identical receipts,
    naming the store's refusal and nothing else. For the first the remote very likely acted and for
    the second it very likely did not, and with the effect record carrying neither, that is the one
    fact whoever runs ctrlrun resolve has to go on. The receipt now carries both.

  • A lapsed Postgres approval could be marked expired over a consumption. _expire wrote
    status = expired on approval_id alone, from a read that saw granted past expires_at, so a
    consumption committing in between was overwritten and an approval that authorised a real effect
    read expired. It is now conditioned on the status it read, as every other write on that table
    already was.

  • After a lost COMMIT, a Postgres reservation could return an attempt number it did not
    write.
    Where a reservation's COMMIT was lost and the re-read found the write absent, 0.6.1
    re-issued it and then returned the reservation it had first planned, discarding the re-issue's.
    If another process had renewed, or inserted, and failed in between, the caller and its receipt
    held attempt k+1 while the record held k+2, and k+1 was a number another dispatch had
    already been handed. The reservation methods now return what the re-issue wrote.

  • A lost COMMIT on a Postgres renewal could take another process's reservation for its own.
    0.6.1's re-read accepted any RESERVED record carrying the renewal's action_id as proof the
    commit had landed. docs/SPEC-v0.6.md §4.3.3 had already ruled that out on the insert path,
    because action_id is caller-supplyable, and a caller that rebuilt the same Action renews
    under the same one. Two processes then held one attempt. The renewal's re-read now applies the
    same whole-row identity check, and a row that is not its own write is refused.

Documentation

  • docs/SPEC-v0.7.md: the v0.7 "Execution boundary" contract, a delta over v0.1 to v0.6. No
    code lands with it. It asks one question: does it hold at the edges the kernel does not
    control?
    The kernel does not decide whether the remote acted, an executor does; it does not own
    the clock its leases are measured against once the store is on another host; and it does not know
    whether the world still looks the way it did when a human said yes. Five items answer those
    edges: the NotExecuted classifier promoted from the gateway into core as ctrlrun.transport,
    clock-skew detection, a provider idempotency token derived from (effect_key, attempt), an
    operator-set ceiling on renewal after FAILED (max_attempts, written as an amendment to
    SPEC-v0.1.md §5.4), and precondition fingerprints, which narrow the window between a
    human's approval and the action's execution and do not close it. Tests come from §8
    (T209 to T271, because T182 to T208 already belong to SPEC-mcp-operator.md and SPEC-scan.md);
    public names are frozen in §9; guarantees G12 to G16 join ctrlrun.guarantees/v3.

    Reading the code changed nine things the plan had assumed, and §1.4 lists them. Four matter
    beyond this document. On Postgres a renewal could reuse an attempt number, and a lost COMMIT's
    re-issue could return a number other than the one it wrote; v0.7 makes the number load-bearing,
    so both are fixed first, in a new item 3a, "attempt numbers never repeat", its own pull
    request stacked before item 3 and independently reviewed because it changes a store. Bumping the
    receipt schema the ordinary way would have reported every receipt a released 0.6 wrote as
    altered, so a receipt now renders under the schema it was written with. In enforce mode one
    granted approval buys one dispatch, not unlimited ones, because every renewal of an approved
    action needs a new granted approval; "granted" is not always a human (a scripted provider, an
    automated wait=True loop and approvals granted ahead of a gateway all count), observe mode needs
    none, and an adapter can still put a human in front of an attempt the ceiling will refuse, which
    §5.5 records. The unbounded case the ceiling exists for is the action the policy allows outright.
    And max_attempts needs ctrlrun.policy/v5.

    An independent review of the draft found seven blocking defects and nine smaller ones, and
    every one became an edit: among them the two store defects above, a G15 that passed with the
    ceiling's own check deleted, a G5 that would have failed a correct kernel under max_attempts: 1,
    a receipt rule that let a fabricated field verify, three false N/A reasons, and a verify
    network rule that was already untrue under --store-url.

  • README.md now says what ctrlrun.transport is for beside the sentence that names
    NotExecuted, says that ctrlrun verify binds loopback listeners of its own rather than
    claiming it opens no socket at all, and says that a precondition fingerprint narrows the window
    between a human's answer and the execution.

  • The documentation site, CTRLRun/ctrlrun-docs, carries the generated pages for this
    release: every ctrlrun.transport name in the Python API reference, ctrlrun.policy/v5 and
    ctrlrun.receipt/v4 in the schema references, G12 to G16 in verify.md and in the readiness
    block, the classifier and the residual precondition window in THREAT_MODEL.md, and
    CLAIMS.md regenerated so every cited line number resolves. ROADMAP.md marks v0.7 shipped and
    carries two items named before v1.0: a malformed value of a schema-declared key blinding every
    receipt reader, and the state -> receipt -> policy -> authority -> state import cycle, which
    contradicts ARCHITECTURE.md §6.