ctrlrun 0.7.0
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, the401rule
ofv0.2 §6.8and a tool error under an operator'snot_executed_on_error: trueeach 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
AMBIGUOUSand answers-41010, and the effect needsctrlrun resolve. The upstream's own
response is relayed unchanged, tool error included. - Behind a proxy the gateway claims nothing. httpx reports an unreachable proxy and a TLS
failure with the target after the proxy answered theCONNECTline with the same
ConnectError, and a writtenCONNECTline is a written byte. At 0.6.1 the forwarder mapped
everyConnectErrortoNEVER_CONNECTED, therefore to afailedreceipt and-41011. Where
urllib.request.getproxies()names a proxy,ConnectErrorandProxyErrorare now an unknown
outcome:AMBIGUOUS,-41010, and actrlrun resolve.NO_PROXY=*is honoured and a
narrowerNO_PROXYis not consulted, so a bypassed host is judged as if it were proxied, which
costs a claim and never makes a false one. With no proxy configured nothing changes. ctrlrun.policy/v5. A document that declaresmax_attemptsmust declarev5; 0.6.1's
newest schema wasv4, and av4document naming the key is aPolicyErrorat load, with the
key, the action and the line. Every document that loaded at 0.6.1 loads unchanged and renews
without bound, because there is no default ceiling and no value of the key means "unlimited".
An 0.6.1 reader refuses av5document, as it should.ctrlrun.receipt/v4. Two new fields,precondition_at_requestand
precondition_at_recheck. Upgrade every reader before any writer: av4JSONL line handed
to 0.6.1 rehashes wrongly and reads as altered. Rendering is stricter in the other direction
too, and visibly:to_dict(),ctrlrun receipts --jsonandctrlrun inspectrender each
receipt under the schema it was written with, so a pre-v0.6 receipt shows its ownv1orv2
label and keys where 0.6.1 showedv3. A key added to a stored receipt, a relabelledschema,
a removed one or an unknown one iscontent_alteredat itsseq, where a reader could have
missed it before.- Migration
0005_precondition_fingerprint, and a store no 0.6 process may still hold. A
database built by 0.6.1's own code migrates keeping every row, and 0.6.1 then refuses it at
open, naming0005. Stop every 0.6 process before any 0.7 process opens the store: a store
checks migrations only at open, so a 0.6.1 process already running would consume a fingerprinted
approval with no comparison, and would rehash everyv4receipt underv3's keys and report
a correct chain as altered. The trigger is the first receipt a 0.7 process writes, not the first
caller that passespreconditions=. - A precondition, once one exists, is never skipped. An approval that carries a fingerprint,
presented by a call that names no provider, is refused rather than consumed: that includes the
gateway and the ACS hook, which name none. A provider that raises, hangs or returns something
with no canonical form refuses the action and reserves nothing. There is no
skip_preconditionsand no timeout parameter. - The attempt ceiling is stricter only where an operator asks for it, and then it is
absolute: above the ceiling the executor is not called, on any route, andActionDeniednames
attempt_ceiling. ctrlrun verifyopens loopback sockets it bound itself.v0.4 §3.7's "no scenario opens a
socket" becomes no connection except to the store--store-urlnames and to loopback
listeners verify bound itself, because G12 needs a peer that can receive a byte. The old
sentence was already untrue under--store-url postgresql://remote-host/…. The test suite's
network guard admits exactly that and no more: IPv4 to the127.0.0.1literal, at a port this
process bound through a stream socket that is still open.localhost,::1,0.0.0.0, every
AF_UNIXpath 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. Undermax_attempts: 1on anapproveaction whose retry carries areconcilehook,
the approval gate runs before the ceiling's check: a new approval request can be created, a
human can grant it, and the reservation that consumes it is then refused 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 refusalApprovalMismatch(reason="precondition_unavailable")
rather thanattempt_ceiling, writes no effect record and never runs thereconcilehook, so
the operator is told the wrong reason for an attempt that could never have run. The ordinary
sequential route calls the provider zero times and refuses before any human is asked. Closing
either needs a seam that would make the ceiling's check unreachable from any public route, and
a guarantee that could not have failed is not a pass. max_attemptsbounds attempts, not executor invocations. ASuspendedexecutor holds its
reservation and everyControl.resumeruns on that same attempt, so an elicitation loop is one
dispatch however many rounds it takes. The gateway bounds those withmax_elicitation_rounds;
a directControl.resumecaller has no bound, and this release adds none.- A refused attempt number is spent. Raising
max_attemptsfrom 2 to 4 after a refusal buys
one further dispatch, not two. - The classifier's register sees only this library's own sends. A claim is about the executor
run, not about one connection, andControlmarks a register on every send through
ctrlrun.transportorctrlrun.gateway.transport.request. An executor that sends part of the
effect throughrequests, through httpx directly, or on a raw socket, and then uses the
classifier, can be handed aNotExecutedthat is true of these connections and false of the
effect. So can one that raises a claim while a sibling thread's request is still in flight. The
claim holds where every request of the effect goes through the classifier on the executor's
context, and that sentence is in the module docstring, the class docstring and §2.3. - A reused
action_idstill leaves the attempt a late write is about undecided, on every
backend. The attempt number is now monotonic, so no two reservations of one key carry the same
number; what is not closed is attempt identity. A transition names its holder byaction_id
alone, so attempt 1's write, arriving after its lease lapsed and after the sameActionwas
retried, can read attempt 2's record, find its own id in a state it expects, and land there. A
latefail_effectis the sharp case: it writesFAILEDover an executing attempt 2, and
FAILEDis the state that permits a renewal. Amark_ambiguousreaches the same place in one
more step, through areconcilehook that is asked about the effect key and not about the
attempt, so it needs no person.ctrlrun resolvecarries no attempt number either, so someone
who inspected attempt 1 can resolve attempt 2's ambiguity. The fix inside the frozen
StateStoreprotocol is a store-side memo of the attempt each reservation wrote, which is a
schema change of its own; §12.3a states the argument so the next milestone inherits it. - An approval granted and presented inside
request()is spent beforeControlknows the
request exists (§6.4). What would take that away is a store call recording a request and its
fingerprint in one write, andStateStoreis frozen. - A malformed value of a key a receipt schema declares still raises out of
from_dict. One
UPDATEputting a float among a receipt'scontrolsblindsctrlrun receipts,
receipts --verify-chain,inspect,statsand G11 together, where the schema-level and
added-key cases are each reported at theirseqand leave every other row readable. 0.6.1
behaves the same and v0.7 neither introduces nor widens it. Fixing it needs a new name in
CHAIN_BREAKS, a closed set on av0.6 §6.5surface, or a reader that walks raw rows; it is a
named item on the roadmap before v1.0.
Added
-
ctrlrun.transport, theNotExecutedclassifier, in core (SPEC-v0.7 §2, build-list item
2).v0.1 §5.5leaves the one decision the product exists to get right,FAILEDor
AMBIGUOUS, to the executor, and until now the correct rule was reachable only through
ctrlrun[gateway].ctrlrun.transport.urlopen,HTTPConnectionandHTTPSConnectionare
urllibandhttp.clientwith a counter: they raiseNotExecuted, chained from the original
exception, only where the connection they opened fresh failed before a single request byte
was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every
other failure is the original exception, which the kernel recordsAMBIGUOUS: a reset or a
timeout after the request was offered, asendallthat raised part way, a reused connection, a
socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel
after itsCONNECTline was sent. The count is taken from evidence, never from an exception's
type, and above TLS. No redirect is followed, no HTTP status is everNotExecuted, and no
parameter, attribute or environment variable changes a classification. The module is stdlib
only and is not imported byimport ctrlrun.The rule itself,
ctrlrun.transport.effect_state, is the one implementation: the gateway's
Transportis now the core one, andgateway/outcome.pyasks the core rule rather than keeping
a copy.ctrlrun.gateway.transport.requestoffers the gateway's httpx mapping to an executor
that uses httpx, on a client built for the one call. The gateway's ownNotExecuted, for an
upstream it never reached, is now chained from the httpx exception and its receipt names it.
ctrlrun verifygains G12, "a byte written is ambiguous", 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-urlnames
and to loopback listeners verify bound itself, and the test suite's network guard admits
exactly that: IPv4 on the127.0.0.1literal, to a port the process bound through a stream
socket that is still open, and nothing else.The claim is about the executor run, not about one connection. An independent review showed
that every falseNotExecutedit could produce came from two connections in one effect: the
first delivered the request, the second was refused, and a per-connection classifier judged the
second alone.xmlrpc.client's retry,FancyURLopenerfollowing a303, an opener whose
handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that
pair.Controlnow opens a register around each executor call; every send through
ctrlrun.transportorctrlrun.gateway.transport.requestmarks it before the first byte, and a
claim needs it unmarked as well as the connection's own evidence. Outside an executor run
nothing is claimed. The limit is stated in the module, the class and the specification: the
register sees only this library's own sends, so an executor that sends part of the effect
through another transport and then uses the classifier can be handed a claim that is true of
these connections and false of the effect. A send through this library on a thread that did not
copy the executor's context is seen: it belongs to no register, so it marks every register
open in the process, which costs claims in unrelated concurrent runs and never safety.A continuation leg never records
FAILED, and 0.6.1 did. A continuation exists only
because the remote answered and is holding the exchange, so nothing on that leg can say the
remote did nothing.Control.resumenow runs with the register already marked, and the gateway
refuses to recordFAILEDfor anything a continuation meets: a refused connection, a
pre-dispatch JSON-RPC code, the401rule ofv0.2 §6.8, and a tool error under an
operator'snot_executed_on_error: true, which asserts that that tool reports errors before
acting and cannot speak for a call it did not answer. At 0.6.1 each of those recorded
FAILEDand, 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 nowAMBIGUOUS
and needsctrlrun resolve.Behind a proxy the gateway is stricter than 0.6.1. httpx reports an unreachable proxy and a
TLS failure with the target after the proxy answered theCONNECTline with the same
ConnectError, andctrlrun.transportcounts a writtenCONNECTline as a byte. Where the
environment names a proxy,ConnectErrorandProxyErrorare now an unknown outcome: an
intercepted call that would have been recordedFAILEDwith-41011is recordedAMBIGUOUS
with-41010, and needsctrlrun resolve. With no proxy configured nothing changes. -
Clock-skew detection (SPEC-v0.7 §3, item 1).
PostgresStateStoremeasures its server's
clock against the application's at open, and again when an expired lease is declared
AMBIGUOUS(at most once perDEFAULT_LEASE), in one round trip whose half is the
measurement's bound, so latency alone is never reported as skew. It keeps its latest
measurement as the optional, read-onlyclock_skewattribute, actrlrun.state.ClockSkew;
Controlreads it at the start of everyexecuteandresumeand after anAmbiguousEffect,
and appends one new event type,CLOCK_SKEW_DETECTED, for a measurement past
clock_skew_threshold(default one second, at mostDEFAULT_LEASE, and no value switches it
off). It observes and reports, and changes no decision: every lease is still evaluated
against the application clock exactly as at 0.6.1, no reservation outcome changes, and a
measurement that fails is logged and changes nothing. Verify gains G13, graded against a
Postgres--store-urlandN/Aon SQLite, and the catalogue moves to
ctrlrun.guarantees/v3; the store conformance suite gains aclockcase,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: 3is
the first attempt and two renewals. It needsschema: ctrlrun.policy/v5, a new schema version
that is a superset ofv4asv4is ofv3;0, a negative, abool, a float, a string and
a mapping are each aPolicyErrorat load, naming the key, the action and the line. The ceiling
is inside the policy hash, so a receipt records which one refused an attempt.
An attempt, not an executor invocation: aSuspendedexecutor holds its reservation and
everyControl.resumeruns on that same attempt, so an elicitation loop is one dispatch however
many rounds it takes. The gateway bounds those withmax_elicitation_rounds; a direct
Control.resumecaller has no bound, and this adds none.
The decision is taken on the attempt number the store assigned, after the reservation and
before the executor, because two callers that both read attempt N-1 would both pass a read
taken before reserving. Above the ceiling the executor is not called, the record is released as
FAILEDwith an error naming the ceiling,EFFECT_RESERVATION_REFUSEDcarries
reason: "attempt_ceiling"with the attempt and the ceiling, ablockedreceipt is written,
andActionDenied(reason="attempt_ceiling")is raised. The refused attempt number is spent:
raisingmax_attemptsfrom 2 to 4 after a refusal buys one further dispatch, not two. A read of
the record before the approval gate refuses the ordinary sequential case earlier, writing
nothing, spending no presented approval and creating no approval request; it refuses only a
FAILEDrecord and is never the guarantee. On any other route the approval gate comes
first, so on anAPPROVEaction a human can be asked, and answer, for an attempt that is then
refused: a wasted answer, never an execution, anddocs/SPEC-v0.7.md§5.2 and §5.5 say so
rather than closing it. In observe mode the refusal is recorded aswould_have.blocked_reason: "attempt_ceiling"and the action runs. Verify gains G15, and G5 and G14 now select only an
action whose ceiling permits a renewal, reportingN/Awhere the ceiling is the only reason
they cannot, because each one's control is a renewal andmax_attempts: 1would otherwise
report a correct kernel as a failure. No new error type, no new event type, no newStateStore
method, no newControlmethod, and no CLI change. -
The provider idempotency token (SPEC-v0.7 §4, item 3).
ctrlrun.idempotency_token(), a new
zero-argument accessor re-exported at package import, answers inside an executor with the token
of the attempt it is running:ctrlrun.effect.idempotency_token_for(effect_key, attempt), a
SHA-256 over the canonical form of(effect_key, attempt)under the domain 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 acrossSPEC-v0.1.md§5.4's renewal, so a provider given it would answer the one
retry the kernel permits, permitted because the executor proved nothing happened, with the
cached failure of the attempt that failed. It is stable within one attempt, including across a
Control.resumeof a suspended one, and different after a renewal. What it is for is
reconciliation: a deterministic handle to ask a provider what became of an attempt whose
outcome is unknown, by a key the provider already indexes. It does not make a retry safe, and
after anAMBIGUOUSoutcome the kernel still refuses one. Nothing is stored: the token is a pure
function of two fields every receipt of an attempt that ran already carries, so a receipt
re-derives it and areconcilehook reads the attempt off the record. The executor signature is
unchanged, and an executor that never calls the accessor runs exactly as it did at 0.6.1. Outside
an executor, for an action with no effect key, for an observe-mode attempt whose reservation was
refused, and on a thread started without a copy of the executor's context, it 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)
andControl.execute(..., preconditions=provider), where the provider takes theActionand
returns a mapping of the state an approval depends on. A precondition fingerprint narrows
the window between a human's approval and the action's execution; it does not close it.
UnderAPPROVEthe provider is called when the approval is requested, and the result is kept only
as asha256:fingerprint on the request (ApprovalRequest.precondition_fingerprint, stored in the
newapprovals.precondition_fingerprintcolumn). On the presenting pass it is called again,
strictly before the store call that consumes the approval, and the action is refused with
ApprovalMismatchand a reason of its own:precondition_changedwhere the two fingerprints
differ,precondition_missingwhere only one side has one (a store that lost the column, or the
gateway and the ACS hook, which name no provider), andprecondition_unavailablewhere the
provider raises or returns something that is not a canonicalizable mapping. Every refusal reserves
nothing and leaves the approval granted. On the request pass a provider that fails refuses the
action withActionDenied(reason="precondition_unavailable")before any human is asked, and a
fingerprint that is computed and then not recorded (a store without the column, a third-party
ApprovalProviderbuilding 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,
andnot_withdrawn:<status>in the evidence where neither write was possible (the provider raised
after recording, or the store refused). §6.4 states that bound and its residual. A withdrawal is
adeny_approval, sofind_denied_requestreturns it and the gateway's "no is an answer"
pre-check refuses every call for that action hash until the request expires, as though a human had
said no: fail-closed, bounded by the TTL, and traceable through the approver
ctrlrun:precondition-not-recorded.ALLOW,DENYandControl.resumenever call the provider;
observe mode compares, records and runs.
The comparison is a network call, so it runs outside the atomic reservation write, and a change
that lands after the comparison and before the reservation is not refused: T261b opens that
window and asserts exactly that. Raw provider output reaches no receipt, event, log line or
table, and a provider's exception is recorded by its type name only. Noskip_preconditions,
and no timeout parameter: a provider that hangs holds the call and reserves nothing. -
Migration
0005_precondition_fingerprintaddsapprovals.precondition_fingerprint,NULLon
every existing row, on SQLite and Postgres. A database built by 0.6.1's own code migrates keeping
every row, and 0.6.1 refuses the migrated database at open naming0005. Stop every 0.6 process
before any 0.7 process opens the store: a store checks migrations only at open, so a 0.6.1
process already running would consume a fingerprinted approval with no comparison, and would
rehash everyv4receipt underv3's keys and report a correct chain as altered. The trigger is
the first receipt a 0.7 process writes, not the first caller that passespreconditions=, and
nothing in the new process can see the old one. -
G16 in
ctrlrun verify, "a moved fingerprint is refused" before the reservation, 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 applicableonly where
no action requires approval. The store conformance suite gains aprecondition-fingerprintcase
and a broken-store fixture that fails it by name. -
Python 3.13 and 3.14 are tested and declared. CI's
checkjob runs the full suite on
3.11, 3.12, 3.13 and 3.14, and the package classifiers name all four. The floor is unchanged:
requires-pythonstays>=3.11, and mypy and ruff still check against 3.11. No library code
changed; the one test fix is below.
Changed
-
An action entry may declare
max_attempts, and a renewal overFAILEDcan now be bounded.
This is stricter than 0.6.1 only where an operator asks for it: an action that declares no
max_attemptsrenews without bound, exactly as before, and every document that loaded at 0.6.1
loads unchanged. There is no default ceiling, and no value of the key means "unlimited". -
ctrlrun.receipt/v4, withprecondition_at_requestandprecondition_at_recheck, and the
first receipt-schema bump that does not report older receipts as altered. A receipt read from a
store is now hashed as the document it was read from (docs/SPEC-v0.7.md§6.11, amending
SPEC-v0.6.md§6.4's last bullet), so everyv3receipt a released 0.6 wrote still rehashes to its
stored hash and a chain spanningv3andv4verifies end to end. A key added to a stored
receipt, a relabelledschema, a removed one or an unknown one iscontent_alteredat itsseq,
and no longer something a reader could miss. Visible:to_dict(),ctrlrun receipts --json
andctrlrun inspectrender each receipt under its own schema, so a pre-v0.6 receipt shows its
ownv1orv2label and keys where 0.6.1 showedv3. Upgrade every reader before any writer:
av4JSONL line handed to 0.6.1 rehashes wrongly. -
ctrlrun verifyprints each distinct note once, where it printed only the first note in the
report, which would have dropped G16's beneath G3's. CI'sverifyjob expectsverified 13/13
with two not applicable, andverified 7/7with eight, measured from a run of the merged
catalogue rather than carried over from either branch. -
A receipt chain reader no longer stops at a row it cannot hash. A stored document holding a
value with no canonical form (a float, a lone surrogate) madeverify_chainraise, so one
tampered row ended the walk:ctrlrun receipts --verify-chainexited with no report and a forged
field at anotherseqwent unnamed. Such a row iscontent_alteredat itsseq, named by the
refusal's type and never its message. -
APPROVAL_CONSUMEDcarries what the presenting pass compared, where a precondition was
compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the
comparison its first leg made.
Fixed
-
PostgresStateStore.events()read a missingaction_idback as the string"None". An
event about no action (the threeDELEGATION_*types, and nowCLOCK_SKEW_DETECTEDreported at
open) named a proposal called "None" on Postgres alone; SQLite and the in-memory store returned
None. It now returnsNoneon all three. -
The migration tests' release fixtures could not build a venv on some interpreters.
venv.createcopies the interpreter by default, and a copied binary from a shared-libpython
build (uv's CPython 3.14 on macOS) aborted insideensurepip, so all five release fixtures
errored before a release was installed. The fixture now symlinks, aspython -m venvdoes on
POSIX. -
The Postgres store could hand out one attempt number twice (
docs/SPEC-v0.7.md§5.6).
0.6.1's renewal afterFAILEDread the record with a plainSELECTand then updated it on
effect_keyandstate = 'failed'alone. So a renewal planned against attempt k could land
after another process had renewed to k+1, run and failed, and write k+1 a second time: two
dispatches, and two receipts, under one attempt number. TheUPDATEis now also conditioned on
the attempt it was planned from, with the row count checked, and a stale renewal is refused with
DuplicateEffect. SQLite carries the same condition, where it was already unreachable because
BEGIN IMMEDIATEholds the read and the write together. -
A stale Postgres write could put an older attempt number back (
docs/SPEC-v0.7.md§5.6).
0.6.1's compare-and-set underresolve_effect,extend_lease,hold_continuation, the kept
AMBIGUOUSwrite and the four transitions matchedeffect_key,action_idandstate, and
wrote back the attempt number it had read. A caller that retries oneActionreuses its
action_id, so the sameaction_idand state could come round again at a newer attempt between
the read and the write. A human'sctrlrun resolvedecided on attempt 1 then wroteFAILEDat
1 over attempt 2's unknown outcome, and the next renewal handed out attempt 2 a second time.
On Postgres, every write to an effect record is now also conditioned on the attempt it read,
and a write whose record moved is re-read rather than landed (SQLite's writes are already inside
theBEGIN IMMEDIATEthat holds their read, and are unchanged). The fix for the renewal above
depends on this one: it holds only because the number can no longer move backwards. What this
closes is the race between a write's own read and its write. The window a human stands in is
longer, becausectrlrun resolvecarries no attempt number: someone who inspected attempt 1 can
still resolve attempt 2's ambiguity. An attempt argument on the CLI would close that, and it is
not in this change. -
A Postgres write that carried an outcome could be refused and drop it. With the condition
above in place, acommit_effectormark_ambiguouswhose record moved under it wrote nothing
and raised, so the effect record, which is what gates the next renewal, said nothing about an
attempt that may have acted: a review measured a renewal to a third attempt with a committed
refund recorded on no record at all. Both are now re-issued once against the re-read, so the
outcome lands on the record as it stands.begin_executionandfail_effectare still refused
there, becauseFAILEDasserts that nothing happened and the newer attempt may be running. -
A refused write on a moved record claimed the wrong thing. All of these were
DuplicateEffect(state="in_progress"), which means another attempt holds a live reservation,
and after a staleresolve_effectthe record isAMBIGUOUSat a newer attempt, which is nobody's
reservation. The refusal now takes its type from what the re-read found,AmbiguousEffector
DuplicateEffectwithcommittedorin_progress, and its message names the move. -
An unknown outcome could vanish when the store refused to record it, on both backends and
since before 0.6.Controlcaught two store refusals around its outcome writes, and a record a
human resolvedFAILEDwhile the attempt was still running answers a third: an executor that
raisedTimeoutErrorthen produced no receipt and noEXECUTION_AMBIGUOUSevent, and the caller
was handed a store error about its own effect key instead of its executor's exception.Control
now catches everyCTRLRunErrorfrom an outcome write, writes the receipt and the event whatever
the store answered, names the refusal in both, and re-raises the caller's own exception. Nothing
is reconciled on a record the attempt could not mark. -
Two bounds on the Postgres store's re-issues reset each other. The stale re-issue and the
lost-commit re-issue ofdocs/SPEC-v0.6.md§4.3.2 each carry a flag that permits one attempt,
and neither passed the other's flag on, so a lostCOMMITinside a re-issue and a re-issue
inside a lostCOMMITalternated without end: a review drove both halves at once and 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 raisedNotExecutedproduced identical receipts,
naming the store's refusal and nothing else. For the first the remote very likely acted and for
the second it very likely did not, and with the effect record carrying neither, that is the one
fact whoever runsctrlrun resolvehas to go on. The receipt now carries both. -
A lapsed Postgres approval could be marked expired over a consumption.
_expirewrote
status = expiredonapproval_idalone, from a read that sawgrantedpastexpires_at, so a
consumption committing in between was overwritten and an approval that authorised a real effect
readexpired. It is now conditioned on the status it read, as every other write on that table
already was. -
After a lost
COMMIT, a Postgres reservation could return an attempt number it did not
write. Where a reservation'sCOMMITwas lost and the re-read found the write absent, 0.6.1
re-issued it and then returned the reservation it had first planned, discarding the re-issue's.
If another process had renewed, or inserted, and failed in between, the caller and its receipt
held attempt k+1 while the record held k+2, and k+1 was a number another dispatch had
already been handed. The reservation methods now return what the re-issue wrote. -
A lost
COMMITon a Postgres renewal could take another process's reservation for its own.
0.6.1's re-read accepted anyRESERVEDrecord carrying the renewal'saction_idas proof the
commit had landed.docs/SPEC-v0.6.md§4.3.3 had already ruled that out on the insert path,
becauseaction_idis caller-supplyable, and a caller that rebuilt the sameActionrenews
under the same one. Two processes then held one attempt. The renewal's re-read now applies the
same whole-row identity check, and a row that is not its own write is refused.
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: theNotExecutedclassifier promoted from the gateway into core asctrlrun.transport,
clock-skew detection, a provider idempotency token derived from(effect_key, attempt), an
operator-set ceiling on renewal afterFAILED(max_attempts, written as an amendment 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 toSPEC-mcp-operator.mdandSPEC-scan.md);
public names are frozen in §9; guarantees G12 to G16 joinctrlrun.guarantees/v3.Reading the code changed nine things the plan had assumed, and §1.4 lists them. Four matter
beyond this document. On Postgres a renewal could reuse an attempt number, and a lostCOMMIT's
re-issue could return a number other than the one it wrote; v0.7 makes the number load-bearing,
so both are fixed first, in a new item 3a, "attempt numbers never repeat", its own pull
request stacked before item 3 and independently reviewed because it changes a store. Bumping the
receipt schema the ordinary way would have reported every receipt a released 0.6 wrote as
altered, so a receipt now renders under the schema it was written with. In enforce mode one
granted approval buys one dispatch, not unlimited ones, because every renewal of an approved
action needs a new granted approval; "granted" is not always a human (a scripted provider, an
automatedwait=Trueloop and approvals granted ahead of a gateway all count), observe mode needs
none, and an adapter can still put a human in front of an attempt the ceiling will refuse, which
§5.5 records. The unbounded case the ceiling exists for is the action the policy allows outright.
Andmax_attemptsneedsctrlrun.policy/v5.An independent review of the draft found seven blocking defects and nine smaller ones, and
every one became an edit: among them the two store defects above, a G15 that passed with the
ceiling's own check deleted, a G5 that would have failed a correct kernel undermax_attempts: 1,
a receipt rule that let a fabricated field verify, three falseN/Areasons, and a verify
network rule that was already untrue under--store-url. -
README.mdnow says whatctrlrun.transportis for beside the sentence that names
NotExecuted, says thatctrlrun verifybinds loopback listeners of its own rather than
claiming it opens no socket at all, and says that a precondition fingerprint narrows the window
between a human's answer and the execution. -
The documentation site,
CTRLRun/ctrlrun-docs, carries the generated pages for this
release: everyctrlrun.transportname in the Python API reference,ctrlrun.policy/v5and
ctrlrun.receipt/v4in the schema references, G12 to G16 inverify.mdand in the readiness
block, the classifier and the residual precondition window inTHREAT_MODEL.md, and
CLAIMS.mdregenerated so every cited line number resolves.ROADMAP.mdmarks v0.7 shipped and
carries two items named before v1.0: a malformed value of a schema-declared key blinding every
receipt reader, and thestate -> receipt -> policy -> authority -> stateimport cycle, which
contradictsARCHITECTURE.md§6.