> ## Documentation Index
> Fetch the complete documentation index at: https://ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> The boundary ctrlrun owns, the six steps every protected call takes, the data model, and the key design decisions with their trade-offs.

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`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.1.md). This document explains the shape and the reasoning.

## 1. The boundary ctrlrun owns

```
Agent reasoning                     (not ours)
      │  "I want to do X"
      ▼
┌──────────────────────────────┐
│           ctrlrun            │
│  normalize → decide →        │
│  approve → reserve →         │
│  execute → resolve → record  │
└──────────────────────────────┘
      │
      ▼
Real-world effect                   (not ours either)
```

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

```
function call (kwargs)
      │
      ▼
   ACTION  ──────────── canonicalize ──► action_hash
      │
      ▼
   POLICY  ──► ALLOW / APPROVE / DENY        (unknown → DENY)
      │              │            │
      │              ▼            └──► receipt(denied)
      │        approval request
      │              │
      │        human grants (bound to action_hash)
      │              │
      ▼              ▼
   consume approval + reserve effect         (one transaction)
      │
      ▼
   EXECUTING ──► executor runs
      │
      ├── returns            ──► COMMITTED
      ├── raises NotExecuted ──► FAILED     (retry permitted)
      └── raises anything    ──► AMBIGUOUS  (retry refused; human resolves)
      │
      ▼
   RECEIPT + events
```

## 3. Four public concepts

Internally the machinery has a dozen types. Publicly a developer needs four:

| Concept | Question it answers |
| - | - |
| **Action** | What exactly does the agent want to do? |
| **Decision** | Can it happen — automatically, with a human, or not at all? |
| **Effect** | What happened in the real world? |
| **Receipt** | Can we prove it? |

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)

```sql theme={null}
effects(
  effect_key TEXT PRIMARY KEY,
  state TEXT NOT NULL,
  action_id TEXT NOT NULL,        -- current/last attempt
  attempt INTEGER NOT NULL DEFAULT 1,
  lease_expires_at TEXT,
  result_json TEXT, error TEXT,
  created_at TEXT, updated_at TEXT
);
approvals(
  approval_id TEXT PRIMARY KEY,
  action_hash TEXT NOT NULL,
  status TEXT NOT NULL,           -- pending|granted|denied|expired|consumed
  action_json TEXT NOT NULL,
  approver TEXT, created_at TEXT, granted_at TEXT, expires_at TEXT, consumed_at TEXT
);
receipts(receipt_id TEXT PRIMARY KEY, action_id TEXT, effect_key TEXT, result TEXT, json TEXT, ts TEXT);
events(event_id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, type TEXT, action_id TEXT, effect_key TEXT, approval_id TEXT, data_json TEXT);
delegations(                      -- v0.3; a new table, which is why v0.3 needed no migration
  delegation_id TEXT PRIMARY KEY, -- "dlg_" + 32 hex
  parent_id TEXT NOT NULL,        -- a root grant's id, or another delegation_id
  depth INTEGER NOT NULL,         -- recorded, never trusted: evaluation walks to the root
  grant_json TEXT NOT NULL,       -- the child grant; `state.py` stores the string, `authority.py` parses it
  created_by_agent TEXT NOT NULL, created_by_user TEXT,
  created_via TEXT NOT NULL,      -- api|cli: an act, or an assertion at a terminal
  created_at TEXT NOT NULL, revoked_at TEXT, revoked_by TEXT
);
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).
```

**The schema lives in `migrations.py`**, not here and not in `state.py`: from v0.6 a database is
brought to it by an ordered, forward-only runner rather than by replaying a script on open, and
two copies of the schema is the drift that item exists to prevent.

`delegations` holds rows, not grants. `grant_json` stays a string in `state.py` so the store does
not import `authority.py` and transitively acquire `policy.py`, which is the edge §6 puts in
`state.py`'s "must not know about" column.

Pragmas: `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`.

## 6. Module map

| Module | Owns | Must not know about |
| - | - | - |
| `action.py` | model, canonicalization, hash | policy, storage |
| `policy.py` | YAML → rules → Decision; `effect:`/`resource:` templates | approvals, effect *state* |
| `identity.py` | `IdentityProvider`, `IdentityContext`, static and header providers | policy, authority, storage |
| `authority.py` | `Grant`, `Subject`, `Authority`, matching, containment, delegation planning | approvals, effect state, executors, sinks |
| `approval.py` | request/grant/consume, providers | executors |
| `adapter.py` | `FrameworkInterrupt`, `PendingApproval`, `ApprovalAnswer`, `InterruptApprovalProvider`, `needs_approval`, `banner` | the policy evaluator, authority, effect state, executors, sinks, any framework |
| `effect.py` | key templating, state enum, transition rules, the idempotency token from `(effect_key, attempt)` | SQLite |
| `transport.py` | the transport half of SPEC-v0.1 §5.5's asymmetry: what a transport observed, what that records, and the counting connections for `http.client` and `urllib` | policy, approvals, storage, sinks, `Control`, anything from an extra |
| `migrations.py` | the schema, the ordered migration list, the runner, and whether a database may open at all | policy, decorator, sinks, `Control` |
| `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator, sinks |
| `postgres.py` | the `StateStore` on Postgres, its migrations, its lost-commit resolution, and the clock it measures against this host's | policy, decorator, sinks |
| `control.py` | `Control` orchestration, decorator, context, suspend/resume | CLI |
| `receipt.py` | Receipt/Event models, `EventSink`, JSONL sink | everything else |
| `verify/` | the guarantee registry, scenario derivation, the scratch store, reporting | the gateway, `otel`, `jwt_identity`; anything from an extra |
| `conformance/` | the adapter suites, the broken-adapter fixtures, the report | the gateway, `otel`, `jwt_identity`; anything from an extra |
| `cli/` | click commands, demo | internals beyond `Control` |

Dependencies point downward only. `Control` is the only module that composes the others.

`migrations.py` (v0.6) sits **beside** `state.py` rather than above it, and imports only stdlib
and `errors.py`. It is what decides whether a store may open, and a store whose admission check
lived somewhere else would be a store with two front doors.

`verify/` (v0.4) sits **above** `control.py`, beside `cli/`: it composes `Control`, `Policy` and
`Authority` the way an application does, and nothing in the kernel imports it. `import ctrlrun`
does not import `ctrlrun.verify`, which is asserted in a subprocess — a verification tool in the
execution path is a dependency nobody meant to take.

The gateway (v0.2) does not change this. It builds an Action and calls `Control` — including
`Control.resume` for an elicitation's second leg — rather than reserving and committing for
itself. A gateway that owned the reservation would be a second module composing the others,
and a second implementation of SPEC-v0.1 §5.5's asymmetry, which is the one rule in this
codebase that must not drift.

v0.7 narrows that asymmetry on one path, in both places at once so they cannot drift apart:
**a continuation leg never records `FAILED`**. A continuation exists only because the remote
answered once already and is holding the exchange, so nothing on the second leg can truthfully
say the remote did nothing. `Control.resume` opens its executor run already marked, so
`transport.py` will not claim there, and the gateway refuses `FAILED` for every path that could
reach it on a continuation, an operator's `not_executed_on_error` included. An executor's own
`NotExecuted` is still believed; what changed is that nothing in the library hands it one
(SPEC-v0.7 §12.2.12).

The same holds for authority (v0.3). `authority.py` reads the store through the `StateStore`
protocol and **writes nothing and appends nothing**: `Authority.evaluate` returns a result and
`plan_delegation` returns the record it *would* write, and `Control` performs every write and
fans every event out to the sinks. An `Authority` that wrote for itself would be a second module
composing storage and evidence, and it would leave the highest-privilege operations in the
release as the only ones invisible to the export path (SPEC-v0.3 §4.8).

One exception, added in v0.2 and worth stating rather than discovering: `policy.py` imports the
template grammar (`template_placeholders`) from `effect.py`, because SPEC-v0.2 §3.1 requires an
`effect:` / `resource:` template to be validated when the policy loads and the grammar is
security-critical enough that a second copy of it is worse than the import. Policy still knows
nothing of effect state — no records, no transitions, no reservations — and `effect.py` does not
import `policy.py`, so there is no cycle.

v0.5 adds `conformance/` beside `verify/` and `cli/`, above `control.py`: it composes the
kernel the way an application does, nothing in the kernel imports it, and `import ctrlrun` does
not reach it. It is core, and adds no dependency, for `verify/`'s reason — a check somebody has to
remember to install is a check that does not run — and `SPEC-v0.5.md` §12.1 records why it is
not the extra it was planned as.

v0.5 adds `adapter.py` below `control.py` and does not change the direction. It imports
`action.py`, `approval.py`, `effect.py`, `errors.py` and two names from `policy.py` — `OBSERVE`
for the banner and `Decision` for the predicate — and takes a `Control` as a **parameter** in
`needs_approval` and `banner` rather than importing it at module scope, so there is no cycle and no second composer:
it appends no event, owns no sink, and reserves nothing. The one place it writes is
`grant_approval`/`deny_approval` in `InterruptApprovalProvider.wait`, which is the same call
`ctrlrun approve` and the webhook make — and it is the *only* place, so no adapter can grow a
second approval path even by accident (SPEC-v0.5 §2.4).

v0.3 makes the same exception once more, for the same reason: `authority.py` imports the
condition parser and evaluator (`Condition`, `parse_conditions`) from `policy.py`, because a
grant's `constraints:` is in exactly a rule's `when:` syntax and the two axes MUST share one
evaluator (SPEC-v0.3 §4.5). A second condition evaluator would be a second place for `True` to
start comparing equal to `1`. Policy still cannot see a principal: `agent_eq` and every other
reserved name are still refused at load (§4.7).

**The sentence that followed that one said *`policy.py` does not import `authority.py`, so there
is no cycle*, and a v0.7 review found it is no longer true.** `policy.py` reaches
`authority.py` from inside two functions, `state.py` imports `receipt.py`, `receipt.py` imported
`policy.py` and `authority.py` imports `state.py`, so there was a cycle:
`state` → `receipt` → `policy` → `authority` → `state`. It did not break `import ctrlrun`,
because the two edges out of `policy.py` are function-level and run after every module is
loaded, which is exactly why it went unnoticed for five milestones. What it cost is this
section's own rule: with a cycle in place *dependencies point downward only* is a statement
about import order rather than about the module map, and the map is what a reader uses to work
out what may know about what. Recorded 2026-09-12.

**v0.12 breaks it at `receipt` → `policy`, and the rule is now a test.** `Decision` and
`POLICY_UNAPPROVED` moved into `decision.py`, which imports nothing from the package and sits
below everything that produces or records a decision. That was the whole of what a receipt
needed from the decider, and it was vocabulary rather than behaviour: an evidence type reaching
**up** into the decider is the edge that most contradicts this table, which lists `receipt.py`
as used by *everything else*. **No public name moved.** `policy.py` re-exports both, so
`from ctrlrun.policy import Decision` resolves to the same object and `SPEC-v0.1.md` §8's frozen
`__init__` block is unchanged.

`tests/test_module_graph.py` walks every module's AST and asserts the graph is acyclic. It
distinguishes the two questions this section kept conflating: the **import-order** graph, which
is module-level imports and is what runs at `import ctrlrun`, and the **layering** graph, which
counts deferred imports too and is what this table describes. The cycle above was invisible to
the first and is exactly what the second is for. Writing that guard immediately found a second
cycle nobody had recorded, `jwt_identity` ⇄ `revocation`, where `revocation.py` imported
`_NoRedirects` from inside a method to avoid a second copy of a security-critical redirect
handler. One copy was always right and the direction was wrong: the class now lives in
`revocation.py`, which `jwt_identity.py` already imports, and `jwt_identity` re-exports it.

**And then the last one went too, from the side that was actually loose.** `policy <-> authority`
looked unfixable, and the reason given was true but incomplete: `_from_section` *constructs* an
`Authority` and `canonical_grants` *consumes* one, so neither moves below `policy.py`, and moving
the two callers up to `control.py` would change what `policy_hash` is taken over, which is
evidence in every receipt rather than an implementation detail. All of that holds. It only
describes one side of the cycle.

The other side was `authority.py` importing eight names from `policy.py`, and not one of them is
`Policy`: the supported schemas, the strict YAML loader, the condition grammar, type-strict
equality, key validation. That is the policy **document grammar**, and it is shared vocabulary
rather than either axis's property. It now lives in `grammar.py`, below both, so `authority.py`
does not import `policy.py` at all.

The v0.3 exception above is not repealed by this, it is honoured more exactly. `SPEC-v0.3.md`
§4.5 requires the two axes to share **one** condition evaluator, because a second would be a
second place for `True` to start comparing equal to `1`. That one evaluator is now owned by
neither axis instead of by one of them.

Nothing moved but an address: every block was moved verbatim and `policy.py` re-exports all
thirty-four names, so `SPEC-v0.1.md` §8's frozen `__init__` block and `SPEC-v0.3.md` §8's
`from .policy import Condition, parse_conditions` both stay literally true.

**`RECORDED_LAYERING_CYCLES` is now empty, and it is meant to stay empty.** A new entry is a
decision rather than a fix, and it carries the reason the edge cannot be relocated.

## 7. What changes after v0.1 (and what doesn't)

Stable from v0.1 onward: the four public concepts, the action canonical form (versioned), the effect state machine, fail-closed defaults, the executor outcome mapping.

Expected to change: policy language (providers), StateStore backends, approval providers, receipt fields (additive only). See [`ROADMAP.md`](/docs/ROADMAP).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.