Skip to main content
Context: agent frameworks model work as model → tool call → response. That is fine for reads. For writes it is missing the semantics every serious system has around consequential operations: authorization bound to the exact operation, identity of the effect (not the request), atomic reservation, and an honest distinction between failed and unknown. CTRLRun adds those semantics around the dangerous part and nothing else. The contract is in SPEC-v0.1.md. This document explains the shape and the reasoning.

1. The boundary CTRLRun owns

CTRLRun sits between intention and consequence. It does not sit between prompt and model. Everything upstream (planning, prompting, retrieval, memory) and everything downstream (the remote system’s own semantics) is out of scope.

2. Canonical flow

3. Four public concepts

Internally the machinery has a dozen types. Publicly a developer needs four: Every other type is subordinate to one of these. Don’t promote a fifth to the public surface before v0.3.

4. Key decisions and trade-offs

4.1 Autonomy is per action, not per agent

The same agent is autonomous for customer.read, supervised for stripe.refund over €500, and prohibited from iam.grant_admin. This is the product’s central idea. There are no “modes”; there is one policy file. Trade-off: the policy language must stay tiny or this becomes OPA. v0.1 has six comparison ops and first-match-wins. That is deliberate.

4.2 Approval binds to a hash, not a request ID

A human approves a canonical action, not a ticket. If the agent changes any material field between approval and execution, the hash differs and the approval is void. This is what makes human oversight mean something. Trade-off: canonicalization becomes security-critical. Floats are rejected because equal money can hash differently. Argument order must not matter. A schema version is embedded so future changes can’t silently invalidate old approvals.

4.3 Effect identity is separate from action identity

A retry is a new proposal (action_id) for the same logical effect (effect_key). Idempotency keyed on the request would let a retry through. Idempotency keyed on the intent (refund:{payment_id}) catches it. Trade-off: the developer has to declare the key. We make that one decorator argument and fail loudly on a bad template rather than silently degrading.

4.4 AMBIGUOUS is a first-class terminal state

A timeout after a request was sent is not a failure. The remote may have committed. Frameworks that map timeout → failed → retry are how double refunds happen. CTRLRun refuses to guess: AMBIGUOUS blocks retries until a human resolves it. Trade-off: this creates operational work (someone must run ctrlrun resolve). That is the correct place for the work to land. v0.2 adds a reconcile hook (SPEC-v0.2.md §2) for executors that can ask the remote what happened: it is the second — and only other — authority permitted to move a record out of AMBIGUOUS, and only where its answer points. An answer it cannot give is "unknown", which changes nothing.

4.5 The executor opts into FAILED

Only NotExecuted maps to FAILED. Every other exception is AMBIGUOUS. The library cannot know whether an arbitrary exception fired before or after the side effect; the executor author can. Making the safe outcome the default means a lazy integration is a safe integration.

4.6 Reservation is atomic across processes

Agents run as separate workers. Thread locks are not enough. SQLite with BEGIN IMMEDIATE and a unique constraint gives a real cross-process lock for a single host; Postgres (v0.6) extends it across hosts. The concurrency test spawns processes, not threads, so this can’t regress unnoticed.

4.7 Fail closed, not configurable

Unknown action, missing policy, expired approval, inconsistent state → DENY. There is no default: allow. Permissive defaults are the one thing that must be impossible by accident. If a user wants reads to be free, they list them.

4.8 Receipts are portable JSON, not a dashboard

Evidence has to leave the system to be useful (audit, SIEM, a PR comment). JSONL on disk plus SQLite. No UI in v0.1, no server, no lock-in.

4.9 Leases, not locks

A reservation that never completes (worker crash) can’t hold the key forever, but it can’t be silently released either — the effect may have happened. Expired lease → AMBIGUOUS. Same principle as 4.4. The length is the caller’s (Control(lease=...), @protect(lease=...), five minutes by default) because only they know how long the work takes; the meaning of expiry is not. A default that is too short for a slow action would make every success ambiguous, and the user’s remedy would be to drop the effect key and lose duplicate protection altogether — so we make the knob, not the escape hatch, the obvious move.

5. Data model (SQLite)

schema_version( — v0.6; SPEC-v0.6 §3. Applied migration ids, RECORDED and migration_id TEXT PRIMARY KEY, — never inferred: PRAGMA table_info answers “what is applied_at TEXT NOT NULL, — there”, which is not “what has been applied”, and the two ctrlrun_version TEXT NOT NULL — diverge the moment a migration does a backfill or a repair ); receipt_chain( — v0.6; SPEC-v0.6 §6.3. One row. The chain head, so that id INTEGER PRIMARY KEY CHECK (id = 1), — truncation at the END is detectable: deleting the seq INTEGER NOT NULL, — last N receipts leaves an internally consistent chain, and hash TEXT NOT NULL — only a head naming a seq no row carries catches it ); — and receipts gains seq, prev_hash and hash, NULL for every row written before the — chain existed. 0002_receipt_chain does not backfill them (SPEC-v0.6 §3.7).