ctrlrun 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. ctrlrun-0.2.0/CHANGELOG.md +187 -0
  2. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/MANIFEST.in +6 -0
  3. {ctrlrun-0.1.0/src/ctrlrun.egg-info → ctrlrun-0.2.0}/PKG-INFO +55 -1
  4. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/README.md +48 -0
  5. ctrlrun-0.2.0/docs/ACS.md +205 -0
  6. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/ARCHITECTURE.md +18 -5
  7. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/CLAIMS.md +30 -1
  8. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/ROADMAP.md +17 -4
  9. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/SPEC-v0.1.md +7 -1
  10. ctrlrun-0.2.0/docs/SPEC-v0.2.md +1432 -0
  11. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/THREAT_MODEL.md +37 -0
  12. ctrlrun-0.2.0/examples/acs/ctrlrun.yaml +19 -0
  13. ctrlrun-0.2.0/examples/acs/main.py +146 -0
  14. ctrlrun-0.2.0/examples/agent-race/ctrlrun.yaml +11 -0
  15. ctrlrun-0.2.0/examples/agent-race/main.py +104 -0
  16. ctrlrun-0.2.0/examples/approval-mutation/ctrlrun.yaml +13 -0
  17. ctrlrun-0.2.0/examples/approval-mutation/main.py +98 -0
  18. ctrlrun-0.2.0/examples/approval-replay/ctrlrun.yaml +13 -0
  19. ctrlrun-0.2.0/examples/approval-replay/main.py +100 -0
  20. ctrlrun-0.2.0/examples/double-refund/ctrlrun.yaml +11 -0
  21. ctrlrun-0.2.0/examples/double-refund/main.py +93 -0
  22. ctrlrun-0.2.0/examples/policies/devops.yaml +47 -0
  23. ctrlrun-0.2.0/examples/policies/e-commerce.yaml +54 -0
  24. ctrlrun-0.2.0/examples/policies/government.yaml +48 -0
  25. ctrlrun-0.2.0/examples/policies/healthcare.yaml +43 -0
  26. ctrlrun-0.2.0/examples/policies/hr.yaml +47 -0
  27. ctrlrun-0.2.0/examples/policies/insurance.yaml +47 -0
  28. ctrlrun-0.2.0/examples/policies/legal.yaml +45 -0
  29. ctrlrun-0.2.0/examples/policies/payments.yaml +46 -0
  30. ctrlrun-0.2.0/examples/policies/security.yaml +53 -0
  31. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/pyproject.toml +30 -1
  32. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/__init__.py +11 -2
  33. ctrlrun-0.2.0/src/ctrlrun/acs.py +417 -0
  34. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/cli/demo.py +9 -2
  35. ctrlrun-0.2.0/src/ctrlrun/cli/main.py +574 -0
  36. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/control.py +505 -67
  37. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/effect.py +105 -1
  38. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/errors.py +46 -0
  39. ctrlrun-0.2.0/src/ctrlrun/gateway/__init__.py +94 -0
  40. ctrlrun-0.2.0/src/ctrlrun/gateway/legacy.py +50 -0
  41. ctrlrun-0.2.0/src/ctrlrun/gateway/mcp.py +315 -0
  42. ctrlrun-0.2.0/src/ctrlrun/gateway/outcome.py +195 -0
  43. ctrlrun-0.2.0/src/ctrlrun/gateway/server.py +914 -0
  44. ctrlrun-0.2.0/src/ctrlrun/otel.py +222 -0
  45. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/policy.py +143 -9
  46. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/receipt.py +35 -6
  47. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/state.py +368 -32
  48. ctrlrun-0.2.0/src/ctrlrun/webhook.py +372 -0
  49. {ctrlrun-0.1.0 → ctrlrun-0.2.0/src/ctrlrun.egg-info}/PKG-INFO +55 -1
  50. ctrlrun-0.2.0/src/ctrlrun.egg-info/SOURCES.txt +83 -0
  51. ctrlrun-0.2.0/src/ctrlrun.egg-info/requires.txt +15 -0
  52. ctrlrun-0.2.0/tests/test_acs.py +491 -0
  53. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_concurrency.py +12 -4
  54. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_demo.py +78 -25
  55. ctrlrun-0.2.0/tests/test_elicitation.py +388 -0
  56. ctrlrun-0.2.0/tests/test_examples.py +293 -0
  57. ctrlrun-0.2.0/tests/test_gateway.py +501 -0
  58. ctrlrun-0.2.0/tests/test_gateway_server.py +656 -0
  59. ctrlrun-0.2.0/tests/test_inspect.py +404 -0
  60. ctrlrun-0.2.0/tests/test_mcp.py +423 -0
  61. ctrlrun-0.2.0/tests/test_otel.py +477 -0
  62. ctrlrun-0.2.0/tests/test_outcome.py +266 -0
  63. ctrlrun-0.2.0/tests/test_packaging.py +190 -0
  64. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_policy.py +177 -1
  65. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_protect.py +196 -0
  66. ctrlrun-0.2.0/tests/test_reconcile.py +643 -0
  67. ctrlrun-0.2.0/tests/test_resume.py +343 -0
  68. ctrlrun-0.2.0/tests/test_sinks.py +382 -0
  69. ctrlrun-0.2.0/tests/test_webhook.py +538 -0
  70. ctrlrun-0.1.0/CHANGELOG.md +0 -73
  71. ctrlrun-0.1.0/src/ctrlrun/cli/main.py +0 -260
  72. ctrlrun-0.1.0/src/ctrlrun.egg-info/SOURCES.txt +0 -40
  73. ctrlrun-0.1.0/src/ctrlrun.egg-info/requires.txt +0 -7
  74. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/LICENSE +0 -0
  75. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/SECURITY.md +0 -0
  76. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/VISION.md +0 -0
  77. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/ctrlrun.example.yaml +0 -0
  78. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/setup.cfg +0 -0
  79. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/action.py +0 -0
  80. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/approval.py +0 -0
  81. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/cli/__init__.py +0 -0
  82. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/py.typed +0 -0
  83. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/dependency_links.txt +0 -0
  84. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/entry_points.txt +0 -0
  85. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/top_level.txt +0 -0
  86. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/conftest.py +0 -0
  87. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_action.py +0 -0
  88. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_approval.py +0 -0
  89. {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_effect.py +0 -0
@@ -0,0 +1,187 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Public API names are frozen in `docs/SPEC-v0.1.md` §8. Before 1.0 they may still change, and
8
+ any change to one appears here.
9
+
10
+ ## [Unreleased]
11
+
12
+ Nothing yet.
13
+
14
+ ## [0.2.0] — unreleased
15
+
16
+ Everything below ships. `pip install ctrlrun` still installs nothing but `pyyaml` and
17
+ `click`; the gateway, the ACS hook and the OpenTelemetry sink live in extras.
18
+
19
+ ### Added
20
+
21
+ - **MCP gateway** — `ctrlrun gateway --upstream <url> --alias <name>`, in `ctrlrun[gateway]`.
22
+ An existing MCP tool server gets CTRLRun semantics with no agent changes: `tools/call`
23
+ becomes an Action, everything else is relayed unchanged. The request forwarded upstream is
24
+ built from the action's *canonical* arguments, so what was hashed, reserved and recorded is
25
+ byte-for-byte what the tool receives. Serves `2026-07-28` and `2025-03-26`–`2025-11-25` in
26
+ passthrough. A fresh connection per intercepted call, because "the connection was never
27
+ established" is the only observation that proves non-execution.
28
+ - **Reconciliation hook** — `@protect(..., reconcile=...)`. The second authority permitted to
29
+ move a record out of `AMBIGUOUS`, and only where its answer points. An exception, a nonsense
30
+ return value and a hook that is never called all mean `"unknown"`, which changes nothing.
31
+ - **`Suspended` and `Control.resume`** — an executor may say "the remote asked for something
32
+ before it will finish". The record stays `EXECUTING`, the lease is extended, the
33
+ continuation is held, no receipt is written, and the signal reaches the caller. Built for
34
+ MCP elicitation; used by the ACS adapter for the same reason.
35
+ - **`EventSink`** — a protocol receiving every `Event` and `Receipt`, called after the
36
+ authoritative store write. `JSONLEventSink` is the v0.1 file writer under that interface.
37
+ A sink that raises is logged and skipped; it can never change a decision or an outcome.
38
+ - **`OTelEventSink`** — in `ctrlrun[otel]`. One OpenTelemetry span per action, one span event
39
+ per step, `ctrlrun.*` attributes. Argument values are withheld unless asked for.
40
+ - **`WebhookApprovalProvider`** — core, over stdlib `urllib.request`. One signed POST on
41
+ `APPROVAL_REQUESTED`; the gateway serves the signed inbound grant/deny at
42
+ `POST /ctrlrun/approvals/<request_id>`. An undelivered notification is not an approval.
43
+ - **`ctrlrun inspect <action_id>`** — one action's whole history: proposal, decision,
44
+ approvals, effect, receipt and the event timeline. `--json` emits `ctrlrun.inspection/v1`.
45
+ - **Policy `schema: ctrlrun.policy/v2`** — per-action `effect:`, `resource:` and `mcp:`
46
+ templates, needed because a gateway call has no decorator to carry them. Where a decorator
47
+ and the policy disagree, the decorator wins and the mismatch is warned about once.
48
+ - **ACS control hook** — `ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]`. Answers the
49
+ OWASP Agent Control Standard's `steps/toolCallRequest` and `steps/toolCallResult`. See
50
+ `docs/ACS.md` for the mapping and for the four places ACS is silent. **No compliance
51
+ claim**: at the commit read there is no ACS reference implementation and no conformance
52
+ suite, so there is nothing to be conformant with.
53
+ - **`examples/`** — four standalone failure scenarios, an ACS integration example, and nine
54
+ sector policy templates under `examples/policies/`.
55
+
56
+ ### Changed
57
+
58
+ - `StateStore.append_event` returns the event as stored, where it returned `None`. Sinks must
59
+ be called with the `event_id` the store assigned, and the store is the only thing that knows
60
+ it. Callers that ignore the return value are unaffected. Recorded in `SPEC-v0.1.md` §8.
61
+ - `SQLiteStateStore` no longer writes JSONL. `Control` does, through `JSONLEventSink`, and
62
+ `Control.from_file()` installs one by default — so the two files land exactly where v0.1 put
63
+ them and existing evidence directories are unchanged.
64
+ - `docs/SPEC-v0.2.md` §9 amended: it forbade an ACS adapter on the reading that ACS had no
65
+ stable interface. The v0.1.0 schemas say otherwise, so the adapter ships. The no-claim rule
66
+ is untouched.
67
+
68
+ ### Deprecated
69
+
70
+ - **`ctrlrun gateway --principal-from-client-info` — removed in 0.3. Use
71
+ `--principal-header`.** It takes the agent's name from `_meta["io.modelcontextprotocol/
72
+ clientInfo"]`, which the MCP revision says is self-reported and *"SHOULD NOT"* be relied on
73
+ for security decisions. It is offerable in 0.2 only because of a fact that stops being true:
74
+ a v0.1 policy cannot address the principal at all (`SPEC-v0.1.md` §3.2 refuses `agent_eq`
75
+ and every other reserved name at load), so an unauthenticated principal misattributes
76
+ evidence and cannot widen an outcome. v0.3's authority model makes the principal an
77
+ authorization input, at which point a self-reported name cannot be one. The flag warns at
78
+ startup and its `--help` says so.
79
+
80
+ ### Removed
81
+
82
+ - `SQLiteStateStore.journal`, and the `EventLog` class behind it. `JSONLEventSink` is that
83
+ class under the sink interface, and it is `Control`'s now.
84
+
85
+ ### Fixed
86
+
87
+ - `.gitignore` ignored `ctrlrun.yaml` unanchored, so it matched at any depth and silently kept
88
+ every example's policy file out of the repository. Anchored to `/ctrlrun.yaml`.
89
+
90
+ ### Compatibility
91
+
92
+ - **A v0.1 `ctrlrun.yaml` loads unchanged.** `ctrlrun.policy/v2` is opt-in and additive; a
93
+ document declaring `v1` that uses a v2 key is a load-time `PolicyError` naming the key and
94
+ the schema it needs, because a v0.1 reader would ignore the template and execute with no
95
+ duplicate protection at all.
96
+ - **The receipt schema is unchanged** — `ctrlrun.receipt/v1` still describes every receipt
97
+ v0.2 writes. `EFFECT_RESOLVED` gains `data.resolved_by`, and four event types join the set:
98
+ `RECONCILIATION_STARTED`, `RECONCILIATION_RESOLVED`, `EXECUTION_SUSPENDED`,
99
+ `EXECUTION_RESUMED`.
100
+ - A database written by v0.1 is read by v0.2 without migration: the one new table
101
+ (`continuations`) is created on open.
102
+
103
+ ### Notes
104
+
105
+ - The spec is written against **MCP revision 2026-07-28**, which removed the `initialize`
106
+ handshake, protocol-level sessions and `Mcp-Session-Id`, and made `Mcp-Method` / `Mcp-Name`
107
+ required request headers that servers must validate against the body. The gateway will also
108
+ serve `2025-03-26` through `2025-11-25` in passthrough mode, relaying session ids, `GET` SSE
109
+ streams and `DELETE` without interpreting them; header–body validation applies only where the
110
+ headers exist. Decisions come from the parsed body on every revision, so header trust is
111
+ never the guarantee. The deprecated `2024-11-05` HTTP+SSE transport is not served.
112
+ - An MCP tool call held open across a multi round-trip elicitation keeps its effect reservation
113
+ in `EXECUTING` with an extended lease, so concurrent duplicates stay blocked for the whole
114
+ exchange and only the final result is mapped to an outcome. This needs the upstream to supply
115
+ a `requestState`, the protocol's only correlator and an optional one; without it the first
116
+ leg is `AMBIGUOUS`, because the alternative would let any client walk past duplicate
117
+ protection by inventing an `inputResponses` field.
118
+ - A policy file using the new `effect:` / `resource:` / `mcp:` keys must declare
119
+ `schema: ctrlrun.policy/v2`. `ctrlrun.policy/v1` files keep loading unchanged; a `v2` file
120
+ will not load on 0.1.0, which is the point — 0.1.0 would ignore the effect template and
121
+ execute with no duplicate protection.
122
+ - MCP tool arguments that CTRLRun cannot canonicalize — any JSON number with a fraction — will
123
+ be refused by the gateway, never rounded or coerced. Tools that move money through the
124
+ gateway need integer minor units or decimal strings in their schema.
125
+
126
+ ## [0.1.0] — 2026-09-03
127
+
128
+ First packaged release. The v0.1 kernel is complete: every acceptance test in
129
+ `docs/SPEC-v0.1.md` §7 passes, including the multi-process concurrency test.
130
+
131
+ ### Added
132
+
133
+ - **Action** — canonical form, `action_hash`, deep-frozen arguments. `float` is rejected in
134
+ arguments: `0.1` and `0.10` are the same money and different hashes.
135
+ - **Policy** — YAML loader with `ALLOW` / `APPROVE` / `DENY` and fail-closed defaults. An
136
+ unknown action is denied; there is no default-allow. See the config-breaking rule below
137
+ for how condition keys are validated.
138
+ - **`@ctrlrun.protect()`** — binds a function call to an Action, evaluates it, and executes
139
+ from the action's canonical arguments rather than the caller's objects.
140
+ - **Approval binding** — approvals carry the `action_hash` of what a human saw, and are
141
+ single-use and expiring. A mutated action cannot present an approval granted for another.
142
+ - **Effect key and reservation** — template-resolved effect identity, reserved atomically
143
+ across processes via `BEGIN IMMEDIATE` and a unique constraint on `effect_key`.
144
+ - **Effect state machine** — `NEW → RESERVED → EXECUTING → COMMITTED | FAILED | AMBIGUOUS`.
145
+ Only an executor raising `NotExecuted` produces `FAILED`; every other exception, timeouts
146
+ included, produces `AMBIGUOUS`, and only a human resolves it.
147
+ - **Receipts and events** — portable JSONL evidence for every action.
148
+ - **CLI** — `init`, `demo`, `approve`, `deny`, `receipts`, `effects`, `resolve`.
149
+ - **`ctrlrun demo`** — four failure scenarios, in process, no network.
150
+ - `SECURITY.md` and `docs/CLAIMS.md`, which maps every README claim to its code and test.
151
+
152
+ ### Config-breaking rules
153
+
154
+ Rules that reject a policy file which an earlier build of this kernel would have loaded.
155
+ A `ctrlrun.yaml` written before this release may need an edit; the process refuses to start
156
+ until it gets one, which is the point.
157
+
158
+ - **A condition key naming an `Action` field is now a load-time `PolicyError`.** The
159
+ reserved names are `action_id`, `agent`, `environment`, `principal`, `resource` and
160
+ `user`. `when: { environment_eq: production }` reads exactly like it scopes a rule to
161
+ production, and matched nothing at all — conditions address an action's *arguments*, and
162
+ those are not arguments. Combined with a catch-all `decision: allow` beneath it, a rule
163
+ that looked restrictive silently permitted everything. If a protected function genuinely
164
+ takes an argument by one of those names, rename the argument (SPEC-v0.1 §3.2). Only the
165
+ whole name is reserved: `resource_id_eq` is unaffected.
166
+ - **A condition on an argument the action does not carry now logs a warning.** The decision
167
+ is unchanged — still false, still never an error, per SPEC-v0.1 §3.2 — but a typo such as
168
+ `amont_lte` no longer disappears in silence. Nothing to edit; expect new log output.
169
+
170
+ ### Notes
171
+
172
+ - Requires Python ≥ 3.11. Runtime dependencies are `pyyaml` and `click`.
173
+ - Single-host only: reservation is atomic across processes on one machine via SQLite.
174
+ Multi-host needs the Postgres store planned for v0.6.
175
+ - Receipts are not signed. A database administrator can alter history (v0.6).
176
+ - Approver identity is free text and is not authenticated (v0.3).
177
+ - Generated ids (`act_`, `apr_`, `ctr_`) are 128 bits. An approval id is not a bearer token
178
+ in v0.1 — consuming one needs write access to the store — but it becomes one with the
179
+ webhook provider in v0.2, and an id format cannot be widened after records exist.
180
+ - Effect key templates do not escape placeholder values, so a crafted argument can make two
181
+ distinct effects share one key. The result is a refusal rather than a double execution;
182
+ `docs/THREAT_MODEL.md` states the limit and the workaround.
183
+ - Policy conditions address an action's arguments only. Scoping a rule by environment,
184
+ resource or principal arrives with the authority model in v0.3.
185
+
186
+ [Unreleased]: https://github.com/CTRLRun/ctrlrun/compare/v0.1.0...HEAD
187
+ [0.1.0]: https://github.com/CTRLRun/ctrlrun/releases/tag/v0.1.0
@@ -14,6 +14,12 @@ include VISION.md
14
14
  recursive-include docs *.md
15
15
  recursive-include tests *.py
16
16
 
17
+ # `examples/` travels because T31 runs every script and loads every sector template out of
18
+ # the source tree (SPEC-v0.2 §1.1). Without these the sdist would ship a test suite that
19
+ # fails on a fresh checkout, which is the failure `prune internal` exists to avoid.
20
+ recursive-include examples *.py
21
+ recursive-include examples *.yaml
22
+
17
23
  # Local-only: working notes, and anything an operator's run left behind. Nothing here is
18
24
  # picked up by default — these are belt and braces, so a stray file cannot ride along.
19
25
  prune internal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrlrun
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Make consequential AI-agent actions safe to execute.
5
5
  Author-email: Arpan Ghoshal <contact@arpanghoshal.com>
6
6
  License-Expression: Apache-2.0
@@ -23,6 +23,12 @@ Provides-Extra: dev
23
23
  Requires-Dist: pytest>=8; extra == "dev"
24
24
  Requires-Dist: mypy>=2.3; extra == "dev"
25
25
  Requires-Dist: ruff>=0.16; extra == "dev"
26
+ Provides-Extra: gateway
27
+ Requires-Dist: httpx>=0.27; extra == "gateway"
28
+ Provides-Extra: otel
29
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
30
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
31
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
26
32
  Dynamic: license-file
27
33
 
28
34
  # CTRLRun
@@ -96,6 +102,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig
96
102
 
97
103
  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.
98
104
 
105
+ ## Protect an existing MCP server
106
+
107
+ No agent changes. Point the client at the gateway instead of at the tool server:
108
+
109
+ ```bash
110
+ pip install "ctrlrun[gateway]"
111
+ ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent
112
+ ```
113
+
114
+ The gateway prints, on the line that starts it, every action in your policy that has no
115
+ `effect:` template — because a write with no effect key is exactly the configuration this
116
+ exists to prevent, and it should not be discovered in a receipt three weeks later:
117
+
118
+ ```text
119
+ 1 action(s) have no effect: template and get no reservation:
120
+ mcp.acme.list_payments
121
+ That is right for a read, and wrong for anything that changes the world.
122
+ ```
123
+
124
+ Tools become actions named `mcp.<alias>.<tool>`, decided by the same `ctrlrun.yaml`. Declare
125
+ their effect and resource templates there, since a tool call has no decorator to carry them:
126
+
127
+ ```yaml
128
+ schema: ctrlrun.policy/v2
129
+
130
+ actions:
131
+ mcp.acme.create_refund:
132
+ effect: "refund:{payment_id}"
133
+ resource: "payment:{payment_id}"
134
+ decision: approve
135
+ ```
136
+
137
+ Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the
138
+ retry exactly as it does in-process — that is the whole point of putting it here.
139
+
140
+ ## Two more things
141
+
142
+ **Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a
143
+ function that asks the remote what happened to an effect, and it is the only thing besides a
144
+ human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer
145
+ points.
146
+
147
+ **Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an
148
+ `OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values
149
+ stay out of it unless you ask for them.
150
+
99
151
  ## What `ctrlrun demo` shows
100
152
 
101
153
  ```console
@@ -152,6 +204,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does
152
204
  | Doc | Purpose |
153
205
  |---|---|
154
206
  | [`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 |
207
+ | [`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 |
208
+ | [`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 |
155
209
  | [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions |
156
210
  | [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 |
157
211
  | [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't |
@@ -69,6 +69,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig
69
69
 
70
70
  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.
71
71
 
72
+ ## Protect an existing MCP server
73
+
74
+ No agent changes. Point the client at the gateway instead of at the tool server:
75
+
76
+ ```bash
77
+ pip install "ctrlrun[gateway]"
78
+ ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent
79
+ ```
80
+
81
+ The gateway prints, on the line that starts it, every action in your policy that has no
82
+ `effect:` template — because a write with no effect key is exactly the configuration this
83
+ exists to prevent, and it should not be discovered in a receipt three weeks later:
84
+
85
+ ```text
86
+ 1 action(s) have no effect: template and get no reservation:
87
+ mcp.acme.list_payments
88
+ That is right for a read, and wrong for anything that changes the world.
89
+ ```
90
+
91
+ Tools become actions named `mcp.<alias>.<tool>`, decided by the same `ctrlrun.yaml`. Declare
92
+ their effect and resource templates there, since a tool call has no decorator to carry them:
93
+
94
+ ```yaml
95
+ schema: ctrlrun.policy/v2
96
+
97
+ actions:
98
+ mcp.acme.create_refund:
99
+ effect: "refund:{payment_id}"
100
+ resource: "payment:{payment_id}"
101
+ decision: approve
102
+ ```
103
+
104
+ Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the
105
+ retry exactly as it does in-process — that is the whole point of putting it here.
106
+
107
+ ## Two more things
108
+
109
+ **Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a
110
+ function that asks the remote what happened to an effect, and it is the only thing besides a
111
+ human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer
112
+ points.
113
+
114
+ **Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an
115
+ `OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values
116
+ stay out of it unless you ask for them.
117
+
72
118
  ## What `ctrlrun demo` shows
73
119
 
74
120
  ```console
@@ -125,6 +171,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does
125
171
  | Doc | Purpose |
126
172
  |---|---|
127
173
  | [`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 |
174
+ | [`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 |
175
+ | [`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 |
128
176
  | [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions |
129
177
  | [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 |
130
178
  | [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't |
@@ -0,0 +1,205 @@
1
+ # CTRLRun and the Agent Control Standard
2
+
3
+ What was read, what maps, what does not, and how the adapter is built.
4
+
5
+ ## What was read
6
+
7
+ The [Agent Control Standard](https://agentcontrolstandard.org/), a project of the OWASP GenAI
8
+ Security Project, in
9
+ [`GenAI-Security-Project/agent-control-standard`](https://github.com/GenAI-Security-Project/agent-control-standard)
10
+ at commit **`c7ad162f69386daac94b89073e3b751e8cdf28b2`** (2026-08-11, *"Raise the Python floor
11
+ to unblock Dependabot security updates"*). The repository was at release **v0.1.1**, with the
12
+ specification version decoupled from the release version at commit `1af1f92` (2026-08-11) and
13
+ tracked separately at **v0.1.0**. Code is Apache-2.0; documentation is CC BY-SA 4.0.
14
+
15
+ Specifically:
16
+
17
+ | File | What it gave |
18
+ |---|---|
19
+ | `specification/ACS/acs_schema.json` | the v0.1.0 aggregator; 22 hooks; `oneOf` a RequestEnvelope or a ResponseEnvelope |
20
+ | `specification/v0.1.0/request-envelope.json` | JSON-RPC 2.0; method namespaces; `AcsParams` and its `metadata` identity fields |
21
+ | `specification/v0.1.0/response-envelope.json` | `AcsResult`, and the five decisions |
22
+ | `specification/v0.1.0/hooks/tool-call-request.json` | `steps/toolCallRequest` — `tool`, `arguments`, `operation`, `capability`, `intent` |
23
+ | `specification/v0.1.0/hooks/tool-call-result.json` | `steps/toolCallResult` — `exit_status`, `outputs`, `request_id_ref`, `duration_ms` |
24
+ | `specification/v0.1.0/ask-details.json` | what an `ask` decision must carry |
25
+ | `CONTRIBUTING.md` | DCO required (`git commit -s`); spec changes open a Discussion first; no CLA |
26
+
27
+ Two things the repository does **not** have at that commit, both of which shaped this work:
28
+
29
+ - **No reference implementation.** The README's roadmap places a Guardian Agent sample and
30
+ FastMCP instrumentation at v1. There is a `pyproject.toml`, but it serves the docs and
31
+ version-sync tooling. So there is nothing to conform *to* except the schemas, and this
32
+ adapter is written against them directly.
33
+ - **No `examples/` directory**, and so no house format for a community example. `examples/acs/`
34
+ therefore follows CTRLRun's own convention.
35
+
36
+ ## What ACS defines
37
+
38
+ Three layers: **Instrument** (runtime hooks and the Guardian Agent pattern), **Trace**
39
+ (OpenTelemetry and OCSF with agent-specific conventions), and **Inspect** (CycloneDX, SPDX and
40
+ SWID for a dynamic Agent BOM). Only Instrument is relevant here.
41
+
42
+ The wire format is JSON-RPC 2.0. A host fires a hook as a request whose `method` is
43
+ `steps/<hookName>`; a Guardian answers with an `AcsResult` carrying one of five decisions:
44
+
45
+ ```text
46
+ allow · deny · modify · ask · defer
47
+ ```
48
+
49
+ `deny` requires `reasoning`. `modify` requires `reasoning` and `modifications`. `ask` requires
50
+ `reasoning` and `ask_details`. `defer` requires `reasoning` and `defer_details`.
51
+
52
+ Of the 22 hooks, CTRLRun answers **two**, and it is worth being explicit that it declines the
53
+ other twenty: `SessionStart`, `SessionEnd`, `AgentTrigger`, `TurnStart`, `TurnEnd`,
54
+ `UserMessage`, `AgentResponse`, `KnowledgeRetrieval`, `MemoryContextRetrieval`, `MemoryStore`,
55
+ `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `SkillRegister`, `SkillLoad`,
56
+ `SkillUnload`, `SystemPing`, `AgbomSnapshot`, `AgbomChanged`. Those are checkpoints about what
57
+ the model is thinking, remembering or composed of. CTRLRun's product rule is that it decides
58
+ actions that can affect the real world, and nothing else — so an unanswered method returns a
59
+ JSON-RPC error in ACS's reserved range rather than an opinion.
60
+
61
+ ## The mapping, hook by hook
62
+
63
+ ### `steps/toolCallRequest` → build the Action, decide it, take the reservation
64
+
65
+ | ACS field | CTRLRun |
66
+ |---|---|
67
+ | `params.metadata.agent_id` | `Principal.agent` |
68
+ | `params.metadata.user_context.user_id` | `Principal.user` |
69
+ | `params.metadata.environment` | `Action.environment` (ACS's enum passes through; v0.1 §2.1 takes any non-empty string) |
70
+ | `payload.tool.name`, `payload.tool.provider`, `payload.operation` | `Action.name`, as `<prefix>.<provider>.<tool>[.<operation>]` |
71
+ | `payload.arguments` | `Action.arguments`, unwrapped from `{name: {value, provenance}}` to `{name: value}` |
72
+ | — *(ACS has no resource field)* | `Action.resource`, from the policy's `resource:` template (SPEC-v0.2 §3) |
73
+ | `params.request_id` | the continuation that joins this hook to its result |
74
+
75
+ `operation` is part of the name because two verbs on one tool are two actions, and a policy has
76
+ to be able to say different things about `create` and `void`.
77
+
78
+ The decision maps out:
79
+
80
+ | CTRLRun | ACS |
81
+ |---|---|
82
+ | `ALLOW` | `allow` |
83
+ | `DENY` | `deny` + `reasoning` + `reason_codes` |
84
+ | `APPROVE` (`ApprovalRequired`) | `ask` + `reasoning` + `ask_details` |
85
+ | `DuplicateEffect` | `deny`, `reason_codes: ["ctrlrun.duplicate_effect", <state>]` |
86
+ | `AmbiguousEffect` | `deny`, `reason_codes: ["ctrlrun.ambiguous_effect"]` |
87
+ | `ApprovalMismatch` | `deny`, `reason_codes: ["ctrlrun.blocked", <reason>]` |
88
+
89
+ `ask_details` carries `approver` (`{type: "human", id}`), a `question` naming the request id a
90
+ human answers with `ctrlrun approve`, and `timeout_seconds`. All three are required by
91
+ `ask-details.json`.
92
+
93
+ `ask_details.intent_extension` is **not** used. It grants capabilities for `this_request` or
94
+ `session`, which is an authority model — CTRLRun has none until v0.3, and a grant it cannot
95
+ represent is one it must not claim to honour.
96
+
97
+ ### `steps/toolCallResult` → close the reservation
98
+
99
+ ACS describes this hook as *"fires after tool execution, before results reach the agent,
100
+ serving as an output redaction checkpoint"*. CTRLRun redacts nothing, so it always answers
101
+ `allow`; the work is the outcome it records.
102
+
103
+ `exit_status` is `success | failure | timeout | blocked`. **ACS does not say what any of them
104
+ means for the side effect.** This is the fail-closed reading, and it is the same one
105
+ SPEC-v0.2 §6.8 applies to MCP:
106
+
107
+ | `exit_status` | Effect state | Why |
108
+ |---|---|---|
109
+ | `success` | `COMMITTED` | the only status that asserts the effect happened |
110
+ | `blocked` | `FAILED` | a control refused it before dispatch — this is what `NotExecuted` means |
111
+ | `failure` | `AMBIGUOUS` | a tool that failed *after* acting and one that failed *before* send the same string |
112
+ | `timeout` | `AMBIGUOUS` | the same, and the case this library exists for |
113
+ | `failure` **with** `mcp.not_executed_on_error: true` | `FAILED` | an operator's per-tool assertion (SPEC-v0.2 §3.1) |
114
+
115
+ A result whose `request_id_ref` matches no held reservation — a restarted Guardian, a result
116
+ fired twice, one arriving out of order — is logged and nothing is written about an effect.
117
+ Guessing which call it meant is how a duplicate gets committed.
118
+
119
+ ## Where ACS is silent
120
+
121
+ These are not criticisms of ACS; they are the seam. ACS governs *whether an action may
122
+ proceed*. It has almost nothing to say about *what happened afterwards*, because that is not
123
+ what the Instrument layer was built for.
124
+
125
+ **1. No effect identity.** `params.request_id` is a per-hook-invocation UUID and
126
+ `request_id_ref` links a result to its request. Neither identifies the *effect*: two calls that
127
+ would refund the same payment get two unrelated UUIDs. There is no idempotency key, no
128
+ `capability`-plus-argument identity, nothing an implementer could use to recognise that a retry
129
+ is a repeat. CTRLRun supplies one from the policy's `effect:` template.
130
+
131
+ **2. `exit_status` is a status of the call, not an outcome of the effect.** Four values, and
132
+ the vocabulary itself carries the confusion: `timeout` sits alongside `failure` as though both
133
+ were kinds of not-working. The distinction that matters — *did the side effect land?* — has no
134
+ representation. A conformant Guardian reading `timeout` has no way to say "unknown, and a
135
+ retry is unsafe until a human resolves it".
136
+
137
+ **3. Nothing binds an approval to the exact action.** `ask_details` carries a question and an
138
+ approver. What comes back is a decision on *the request*, and `intent_extension` can widen a
139
+ capability for the session. Neither pins the approval to a canonical form of the arguments, so
140
+ nothing in ACS prevents an agent from getting `refund(2000)` approved and then calling
141
+ `refund(5000)`. CTRLRun binds to `action_hash`, which covers the principal, the arguments, the
142
+ resource and the environment.
143
+
144
+ **4. No terminal unknown state.** ACS's decisions are about the future of a call. There is no
145
+ way for a Guardian to record that an effect's outcome is unresolved and that *no* further call
146
+ on that effect may proceed until a human says which way it went. `AMBIGUOUS` has no ACS
147
+ counterpart, and it is the state most of CTRLRun's design exists to protect.
148
+
149
+ **5. The Guardian does not execute.** ACS is advisory by construction — the platform runs the
150
+ tool. That is a reasonable separation, but it means a Guardian cannot make reserve-and-execute
151
+ atomic. The best available is what this adapter does: reserve at the request hook, close at the
152
+ result hook, and accept that a platform which never fires the result hook leaves a reservation
153
+ to lease-expire into `AMBIGUOUS` by the ordinary path of v0.1 §5.3 E3.
154
+
155
+ ## The adapter's design
156
+
157
+ `ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]` — it needs no new dependency, and it is
158
+ kept out of core for the same reason the gateway is: `import ctrlrun` must not grow.
159
+
160
+ The shape is forced by the seam above. ACS is advisory and CTRLRun is executing, so one action
161
+ is split across two hooks and the reservation is held between them. That is exactly the shape
162
+ `Suspended` and `Control.resume` were built for in SPEC-v0.2 §6.9 — a reservation held across a
163
+ round trip the kernel does not control — so the adapter reuses them rather than reaching for
164
+ the store itself:
165
+
166
+ ```text
167
+ steps/toolCallRequest
168
+ → Action ← envelope metadata + payload + policy templates
169
+ → Control.execute(action, executor=raise Suspended(request_id), effect_key)
170
+ · policy decided, approval consumed, effect reserved, EXECUTION_STARTED
171
+ · executor suspends: no outcome, no receipt, lease extended, continuation held
172
+ → allow | deny | ask
173
+
174
+ steps/toolCallResult
175
+ → Control.resume(request_id_ref, executor=report(exit_status))
176
+ · the same outcome mapping execute uses (v0.1 §5.5)
177
+ · commit / fail / ambiguous, one receipt, same action_id and attempt
178
+ → allow
179
+ ```
180
+
181
+ `Control` remains the only module that composes the others (ARCHITECTURE §6). The adapter
182
+ translates two vocabularies and decides nothing.
183
+
184
+ **What it does not do.** It does not use `modify` — CTRLRun refuses or permits an action as
185
+ proposed, and rewriting an agent's arguments is a different product. It does not use `defer`.
186
+ It does not answer the other twenty hooks. It makes no claim of conformance: the schemas are
187
+ `v0.1.0`, the repository is a public preview, there is no reference implementation to test
188
+ against, and there is no conformance suite. **The words "ACS-compatible" do not appear in this
189
+ repository's README, docstrings or CLI output**, and should not until something exists to be
190
+ compatible *with*.
191
+
192
+ ## What the OTel attributes would have to become
193
+
194
+ SPEC-v0.2 §8's sink emits `ctrlrun.*` attributes. ACS's Trace layer extends OpenTelemetry with
195
+ its own agent conventions and maps security events to OCSF; `acs_schema.json` carries
196
+ `TraceOtelMapping` and `TraceOcsfMapping` definitions for that purpose.
197
+
198
+ Aligning would mean renaming `ctrlrun.action.name`, `ctrlrun.principal.agent`,
199
+ `ctrlrun.decision` and the rest onto ACS's conventions. The cost is not the rename: it is that
200
+ receipts already written carry the old names, and a receipt is evidence that has to outlive the
201
+ tool that wrote it. Any alignment is therefore additive — emit both, deprecate neither — or it
202
+ is a schema version bump on the receipt, which SPEC-v0.2 §11 explicitly does not do.
203
+
204
+ That work is not in this release, and the mapping table is deliberately not written yet: it
205
+ would be a compliance claim with nothing behind it.
@@ -86,7 +86,7 @@ A retry is a new proposal (`action_id`) for the same logical effect (`effect_key
86
86
  ### 4.4 AMBIGUOUS is a first-class terminal state
87
87
  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.
88
88
 
89
- *Trade-off:* this creates operational work (someone must run `ctrlrun resolve`). That is the correct place for the work to land. v0.2+ adds `QUERY_STATE` reconciliation for executors that can check the remote side.
89
+ *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.
90
90
 
91
91
  ### 4.5 The executor opts into FAILED
92
92
  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.
@@ -135,16 +135,29 @@ Pragmas: `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`.
135
135
  | Module | Owns | Must not know about |
136
136
  |---|---|---|
137
137
  | `action.py` | model, canonicalization, hash | policy, storage |
138
- | `policy.py` | YAML → rules → Decision | approvals, effects |
138
+ | `policy.py` | YAML → rules → Decision; `effect:`/`resource:` templates | approvals, effect *state* |
139
139
  | `approval.py` | request/grant/consume, providers | executors |
140
140
  | `effect.py` | key templating, state enum, transition rules | SQLite |
141
- | `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator |
142
- | `control.py` | `Control` orchestration, decorator, context | CLI |
143
- | `receipt.py` | Receipt/Event models, JSONL writer | everything else |
141
+ | `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator, sinks |
142
+ | `control.py` | `Control` orchestration, decorator, context, suspend/resume | CLI |
143
+ | `receipt.py` | Receipt/Event models, `EventSink`, JSONL sink | everything else |
144
144
  | `cli/` | click commands, demo | internals beyond `Control` |
145
145
 
146
146
  Dependencies point downward only. `Control` is the only module that composes the others.
147
147
 
148
+ The gateway (v0.2) does not change this. It builds an Action and calls `Control` — including
149
+ `Control.resume` for an elicitation's second leg — rather than reserving and committing for
150
+ itself. A gateway that owned the reservation would be a second module composing the others,
151
+ and a second implementation of SPEC-v0.1 §5.5's asymmetry, which is the one rule in this
152
+ codebase that must not drift.
153
+
154
+ One exception, added in v0.2 and worth stating rather than discovering: `policy.py` imports the
155
+ template grammar (`template_placeholders`) from `effect.py`, because SPEC-v0.2 §3.1 requires an
156
+ `effect:` / `resource:` template to be validated when the policy loads and the grammar is
157
+ security-critical enough that a second copy of it is worse than the import. Policy still knows
158
+ nothing of effect state — no records, no transitions, no reservations — and `effect.py` does not
159
+ import `policy.py`, so there is no cycle.
160
+
148
161
  ## 7. What changes after v0.1 (and what doesn't)
149
162
 
150
163
  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.
@@ -8,7 +8,7 @@ removed from the README in the same commit — the README is not allowed to desc
8
8
  that no longer ships. If you find a row here that does not hold against the version you
9
9
  installed, that is a bug: please open an issue.
10
10
 
11
- Regenerated for: **v0.1.0**. Line numbers refer to that tag.
11
+ Regenerated for: **v0.2.0**. Line numbers refer to that tag.
12
12
 
13
13
  ## The opening paragraph
14
14
 
@@ -32,6 +32,35 @@ Regenerated for: **v0.1.0**. Line numbers refer to that tag.
32
32
  | "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` |
33
33
  | "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` |
34
34
 
35
+
36
+ ## What v0.2 adds to the README
37
+
38
+ Every sentence the README gained in this release, mapped the same way.
39
+
40
+ | Claim | Code | Proof |
41
+ |---|---|---|
42
+ | "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` |
43
+ | "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` |
44
+ | "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` |
45
+ | "Tools become actions named `mcp.<alias>.<tool>`" | `Gateway._intercept` — `gateway/server.py` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` |
46
+ | "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` |
47
+ | "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` |
48
+ | "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` |
49
+ | "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` |
50
+ | "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` |
51
+ | "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` |
52
+ | "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` |
53
+
54
+ ### Two claims the README deliberately does not make
55
+
56
+ - **Nothing about ACS conformance.** `ctrlrun.acs.AcsControlHook` (`acs.py:74`) exists and is
57
+ tested (T51–T55), but at the ACS commit read there is no reference implementation and no
58
+ conformance suite. "ACS-compatible" appears nowhere in the README, in docstrings, or in CLI
59
+ output. `docs/ACS.md` says what was read and where the standard is silent.
60
+ - **Nothing about exactly-once execution.** Unchanged from v0.1, and still the honest line:
61
+ CTRLRun guarantees it will not *knowingly* execute the same logical effect twice, and that
62
+ it will never treat an unknown outcome as a failure.
63
+
35
64
  ## Two claims stated as limits
36
65
 
37
66
  The README also makes two negative claims. They matter as much as the positive ones.