Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,84 @@ any change to one appears here.

## [Unreleased]

Work towards 0.2.0. Version is `0.2.0.dev0`; nothing here has shipped.
Nothing yet.

## [0.2.0] — unreleased

Everything below ships. `pip install ctrlrun` still installs nothing but `pyyaml` and
`click`; the gateway, the ACS hook and the OpenTelemetry sink live in extras.

### Added

- `docs/SPEC-v0.2.md` — the v0.2 contract, written as a delta over v0.1. Eight deliverables:
a reconciliation hook, `examples/` scripts and sector policy templates, per-action
`effect:` / `resource:` in policy, an `EventSink` protocol, `ctrlrun inspect`, an MCP
gateway, a webhook approval provider, and an OpenTelemetry sink. Acceptance tests T13–T31.
- Empty `gateway` and `otel` extras in `pyproject.toml`. `pip install ctrlrun` will keep
installing nothing but `pyyaml` and `click`; anything needing an HTTP server or a
third-party SDK goes in an extra, imported lazily.
- **MCP gateway** — `ctrlrun gateway --upstream <url> --alias <name>`, in `ctrlrun[gateway]`.
An existing MCP tool server gets CTRLRun semantics with no agent changes: `tools/call`
becomes an Action, everything else is relayed unchanged. The request forwarded upstream is
built from the action's *canonical* arguments, so what was hashed, reserved and recorded is
byte-for-byte what the tool receives. Serves `2026-07-28` and `2025-03-26`–`2025-11-25` in
passthrough. A fresh connection per intercepted call, because "the connection was never
established" is the only observation that proves non-execution.
- **Reconciliation hook** — `@protect(..., reconcile=...)`. The second authority permitted to
move a record out of `AMBIGUOUS`, and only where its answer points. An exception, a nonsense
return value and a hook that is never called all mean `"unknown"`, which changes nothing.
- **`Suspended` and `Control.resume`** — an executor may say "the remote asked for something
before it will finish". The record stays `EXECUTING`, the lease is extended, the
continuation is held, no receipt is written, and the signal reaches the caller. Built for
MCP elicitation; used by the ACS adapter for the same reason.
- **`EventSink`** — a protocol receiving every `Event` and `Receipt`, called after the
authoritative store write. `JSONLEventSink` is the v0.1 file writer under that interface.
A sink that raises is logged and skipped; it can never change a decision or an outcome.
- **`OTelEventSink`** — in `ctrlrun[otel]`. One OpenTelemetry span per action, one span event
per step, `ctrlrun.*` attributes. Argument values are withheld unless asked for.
- **`WebhookApprovalProvider`** — core, over stdlib `urllib.request`. One signed POST on
`APPROVAL_REQUESTED`; the gateway serves the signed inbound grant/deny at
`POST /ctrlrun/approvals/<request_id>`. An undelivered notification is not an approval.
- **`ctrlrun inspect <action_id>`** — one action's whole history: proposal, decision,
approvals, effect, receipt and the event timeline. `--json` emits `ctrlrun.inspection/v1`.
- **Policy `schema: ctrlrun.policy/v2`** — per-action `effect:`, `resource:` and `mcp:`
templates, needed because a gateway call has no decorator to carry them. Where a decorator
and the policy disagree, the decorator wins and the mismatch is warned about once.
- **ACS control hook** — `ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]`. Answers the
OWASP Agent Control Standard's `steps/toolCallRequest` and `steps/toolCallResult`. See
`docs/ACS.md` for the mapping and for the four places ACS is silent. **No compliance
claim**: at the commit read there is no ACS reference implementation and no conformance
suite, so there is nothing to be conformant with.
- **`examples/`** — four standalone failure scenarios, an ACS integration example, and nine
sector policy templates under `examples/policies/`.

### Changed

- `StateStore.append_event` returns the event as stored, where it returned `None`. Sinks must
be called with the `event_id` the store assigned, and the store is the only thing that knows
it. Callers that ignore the return value are unaffected. Recorded in `SPEC-v0.1.md` §8.
- `SQLiteStateStore` no longer writes JSONL. `Control` does, through `JSONLEventSink`, and
`Control.from_file()` installs one by default — so the two files land exactly where v0.1 put
them and existing evidence directories are unchanged.
- `docs/SPEC-v0.2.md` §9 amended: it forbade an ACS adapter on the reading that ACS had no
stable interface. The v0.1.0 schemas say otherwise, so the adapter ships. The no-claim rule
is untouched.

### Removed

- `SQLiteStateStore.journal`, and the `EventLog` class behind it. `JSONLEventSink` is that
class under the sink interface, and it is `Control`'s now.

### Fixed

- `.gitignore` ignored `ctrlrun.yaml` unanchored, so it matched at any depth and silently kept
every example's policy file out of the repository. Anchored to `/ctrlrun.yaml`.

### Compatibility

- **A v0.1 `ctrlrun.yaml` loads unchanged.** `ctrlrun.policy/v2` is opt-in and additive; a
document declaring `v1` that uses a v2 key is a load-time `PolicyError` naming the key and
the schema it needs, because a v0.1 reader would ignore the template and execute with no
duplicate protection at all.
- **The receipt schema is unchanged** — `ctrlrun.receipt/v1` still describes every receipt
v0.2 writes. `EFFECT_RESOLVED` gains `data.resolved_by`, and four event types join the set:
`RECONCILIATION_STARTED`, `RECONCILIATION_RESOLVED`, `EXECUTION_SUSPENDED`,
`EXECUTION_RESUMED`.
- A database written by v0.1 is read by v0.2 without migration: the one new table
(`continuations`) is created on open.

### Notes

Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig

Now the same agent can refund €100 on its own, must get a human to approve €2,000, and cannot refund €20,000 at all. Neither can it refund a negative amount, which is a charge wearing a refund's name — an upper bound alone is not a range. Unknown actions are denied. CTRLRun fails closed.

## Protect an existing MCP server

No agent changes. Point the client at the gateway instead of at the tool server:

```bash
pip install "ctrlrun[gateway]"
ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent
```

The gateway prints, on the line that starts it, every action in your policy that has no
`effect:` template — because a write with no effect key is exactly the configuration this
exists to prevent, and it should not be discovered in a receipt three weeks later:

```text
1 action(s) have no effect: template and get no reservation:
mcp.acme.list_payments
That is right for a read, and wrong for anything that changes the world.
```

Tools become actions named `mcp.<alias>.<tool>`, decided by the same `ctrlrun.yaml`. Declare
their effect and resource templates there, since a tool call has no decorator to carry them:

```yaml
schema: ctrlrun.policy/v2

actions:
mcp.acme.create_refund:
effect: "refund:{payment_id}"
resource: "payment:{payment_id}"
decision: approve
```

Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the
retry exactly as it does in-process — that is the whole point of putting it here.

## Two more things

**Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a
function that asks the remote what happened to an effect, and it is the only thing besides a
human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer
points.

**Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an
`OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values
stay out of it unless you ask for them.

## What `ctrlrun demo` shows

```console
Expand Down Expand Up @@ -125,6 +171,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does
| Doc | Purpose |
|---|---|
| [`docs/SPEC-v0.1.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.1.md) | The v0.1 contract: models, invariants, acceptance tests |
| [`docs/SPEC-v0.2.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.2.md) | The v0.2 delta: gateway, sinks, reconciliation, webhooks |
| [`docs/ACS.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ACS.md) | The OWASP Agent Control Standard: what maps, and where it is silent |
| [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions |
| [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 |
| [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't |
Expand Down
31 changes: 30 additions & 1 deletion docs/CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ removed from the README in the same commit — the README is not allowed to desc
that no longer ships. If you find a row here that does not hold against the version you
installed, that is a bug: please open an issue.

Regenerated for: **v0.1.0**. Line numbers refer to that tag.
Regenerated for: **v0.2.0**. Line numbers refer to that tag.

## The opening paragraph

Expand All @@ -32,6 +32,35 @@ Regenerated for: **v0.1.0**. Line numbers refer to that tag.
| "records what actually happened" | `ReceiptResult` — `receipt.py:44`; `Event` and JSONL `append_event` — `receipt.py:222` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` |
| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:233` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:164`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load (`policy.py:50`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` |


## What v0.2 adds to the README

Every sentence the README gained in this release, mapped the same way.

| Claim | Code | Proof |
|---|---|---|
| "Point the client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:135`; `serve` — `gateway/server.py:892` | `test_T19_the_upstream_receives_the_canonical_arguments` |
| "No agent changes" | `tools/call` is intercepted and every other method relayed unchanged — `gateway/mcp.py:84` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` |
| "The gateway prints … every action in your policy that has no `effect:` template" | `_announce_actions_without_an_effect` — `cli/main.py` | Shown in the README block; produced by `Policy.effect_template` — `policy.py:281` |
| "Tools become actions named `mcp.<alias>.<tool>`" | `Gateway._intercept` — `gateway/server.py` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` |
| "Declare their effect and resource templates there" | `Policy.effect_template` / `resource_template` — `policy.py:281`; `McpOptions` — `policy.py:187` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` |
| "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` |
| "A lost response over the wire blocks the retry exactly as it does in-process" | `classify` — `gateway/outcome.py:144`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` |
| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:713`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` |
| "and only in the direction its answer points" | `"unknown"` is absent from `RECONCILED_STATES` — `effect.py` | `test_T15_a_hook_that_cannot_answer_leaves_the_record_ambiguous` |
| "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` |
| "Argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` |

### Two claims the README deliberately does not make

- **Nothing about ACS conformance.** `ctrlrun.acs.AcsControlHook` (`acs.py:74`) exists and is
tested (T51–T55), but at the ACS commit read there is no reference implementation and no
conformance suite. "ACS-compatible" appears nowhere in the README, in docstrings, or in CLI
output. `docs/ACS.md` says what was read and where the standard is silent.
- **Nothing about exactly-once execution.** Unchanged from v0.1, and still the honest line:
CTRLRun guarantees it will not *knowingly* execute the same logical effect twice, and that
it will never treat an unknown outcome as a failure.

## Two claims stated as limits

The README also makes two negative claims. They matter as much as the positive ones.
Expand Down
19 changes: 16 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,31 @@ Exit: all acceptance tests in `SPEC-v0.1.md §7` pass; demo < 60 s; README liter

Standards: none. `THREAT_MODEL.md` is the only compliance-adjacent claim.

## v0.2 — Zero-friction deployment
## v0.2 — Zero-friction deployment (shipped)

- MCP adapter and `ctrlrun gateway --upstream <mcp server>` so an existing MCP tool server gets CTRLRun semantics with no agent changes.
- OpenTelemetry export of events (align with ACS observability; don't invent a tracing format).
- Webhook approval provider (Slack/Teams/anything that can POST back).
- `ctrlrun inspect <action_id>`.
- Reconciliation hook: executor may implement `check(effect_key) -> committed | not_executed | unknown` to resolve AMBIGUOUS automatically.
- Reconciliation hook: `@protect(..., reconcile=...)` resolves AMBIGUOUS automatically, and only where its answer points.
- `examples/` directory with standalone scripts per scenario (`double-refund/`, `approval-mutation/`, `agent-race/`, `approval-replay/`). In v0.1 `ctrlrun demo` is the example; separate scripts earn their keep once there is more than one way to wire CTRLRun in.
- Sector policy templates: `examples/policies/<sector>.yaml` for devops, payments, e-commerce, insurance, healthcare, legal, security, government, hr. Header comment: *"Starting point on the v0.1 kernel. Adapt before use."* Uses only v0.1 primitives.

Adoption story: *existing MCP server + one CTRLRun gateway = action safety.*

**Reconciled against what shipped.** Three things arrived earlier than this file expected, and
one arrived that it did not list:

- The **OWASP ACS adapter** was a v0.3 standards line. Reading the v0.1.0 schemas showed a
stable enough interface to build against, so it shipped here — with no compliance claim, and
`docs/ACS.md` recording where the standard is silent.
- **`Suspended` / `Control.resume`** were not on any milestone. MCP elicitation (§6.9) needs a
reservation held across a round trip the kernel does not control, and so does an advisory
hook model like ACS. It is public API now, frozen in `SPEC-v0.2.md` §11.
- **Policy `schema: ctrlrun.policy/v2`** grew out of the gateway rather than being planned:
a tool call has no decorator to carry an effect template.
- **`EventSink`** replaced the store's file writing, which the v0.1 kernel had owned.

Standards: OpenTelemetry export (code), MCP gateway. The OWASP ACS adapter shipped in v0.2 (see `docs/ACS.md`); "ACS-compatible" is still unearned and waits on an ACS conformance suite to measure against.

## v0.3 — Authority
Expand All @@ -37,7 +50,7 @@ Standards: OpenTelemetry export (code), MCP gateway. The OWASP ACS adapter shipp

This is the first point at which `VISION.md` may be opened for design input.

Standards: align principal/delegation semantics with NIST agent identity work and OAuth-based agent identity. Wording is "consumes identities from", never "implements".
Standards: align principal/delegation semantics with NIST agent identity work and OAuth-based agent identity. Wording is "consumes identities from", never "implements". `--principal-from-client-info` is removed here: it exists in v0.2 only because a v0.1 policy cannot address the principal at all, and the authority model makes a self-reported name an authorization input.

## v0.4 — Verification

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun"
version = "0.2.0.dev0"
version = "0.2.0"
description = "Make consequential AI-agent actions safe to execute."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Loading