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
@protecton anasync defrecorded 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 acommittedreceipt written before the body ran, and the legitimate retry was
then refused withDuplicateEffectfor ever. Async, generator and async-generator functions
are refused at decoration time.- One human approval could authorise two effects on Postgres. A lost
COMMITon a
renewal — a reservation over aFAILEDrecord, v0.1 §5.4's one automatic retry — re-issued
the approval without consuming it, sofind_granted_approvalwould hand the same "yes" out
again for a different effect key.v0.1 §4.2 A2requires single use consumed atomically with
the reservation. SQLite has no lost-commit resolution, so this was also a backend-switch
regression. ctrlrun verifyreported 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 aneffect:template containing{resource}— a code path--help
documents as "a guarantee FAILED" — because it invented an argument for a placeholder that
names the action'sresourcefield. Every shipped example underexamples/is verified in
CI now.- Webhook approvals were never routed.
WebhookApprovalProvideradvertises
POST /ctrlrun/approvals/<id>asrespond_toin everyAPPROVAL_REQUESTEDnotification, and
Gateway.handle_approvalhad 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_ambiguousraisedUnicodeEncodeErrorfrom inside the transaction — not a
CTRLRunError— and left the recordEXECUTING, which is neither outcome and blocks the
retry until the lease expires. Executor text is escaped withbackslashreplacewhere 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
onlyclose()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 withactions: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 delegateandctrlrun revokeignored the store. Both calledControl.from_file(),
which always opens.ctrlrun/state.dbbeside the policy, so on Postgres a delegation went
into a local file no agent reads andrevokereported success while the delegation stayed
live. Both take--store-urlnow.- Read commands created the store they were reporting on.
ctrlrun receiptsin a directory
without actrlrun.yamlcreated and migrated.ctrlrun/state.dband 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 malformedContent-Length— where-1bypassed
--max-body-bytesentirely — andhttpx.DecodingErrorandhttpx.InvalidURL, which inherit
fromRequestErrorand so matched neitherexcept. - The gateway relayed
Content-Encoding: gzipwith the decompressed bytes. httpx sends
Accept-Encoding: gzipby default, so an upstream doing nothing but honouring content
negotiation made the gateway unusable withDecodingError: incorrect header check. ctrlrun scanfailed the policyctrlrun inithad 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]]raisedTypeErroron every evaluation of that action rather than
aCTRLRunError, 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.6beside a 0.6.0 kernel, sopip install ctrlrun-langgrapheither refused to resolve or silently downgradedctrlrun. 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
MissingDependencythat could not be raised. SPEC-v0.5 §12.1 reversed that extra and
pyproject.tomlnever 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
ApprovalRequiredand
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 approvefrom 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. Itstitleis the category line and
itsdescriptionthe tagline, which is whatdocs/IA.mdassigns to each; the browser tab no
longer reads CTRLRun - CTRLRun. try-itputs 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.mdforbids social proof that does not exist, and a
count published four days after the first release measures mirrors.