car-runtime 0.50.0 → 0.52.0

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.
@@ -0,0 +1,677 @@
1
+ # Agent IR Specification
2
+
3
+ The **Agent Intermediate Representation** is the contract between models and the Common Agent Runtime. Models propose IR; the runtime validates and executes. Everything in this document is a stable wire-format guarantee — these shapes round-trip through the FFI bindings (NAPI, PyO3) and the WebSocket protocol unchanged.
4
+
5
+ > Source of truth: [`car-rs/crates/car-ir/src/`](../car-rs/crates/car-ir/src/). If this document drifts from the Rust types, the Rust types win — file a bug.
6
+
7
+ ---
8
+
9
+ ## ActionProposal
10
+
11
+ A batch of actions submitted to the runtime in one verify-then-execute round.
12
+
13
+ ```jsonc
14
+ {
15
+ "id": "abc123def456", // optional; auto-generated if absent
16
+ "source": "claude-opus-4-7", // optional; defaults to "unknown"
17
+ "timestamp": "2026-05-02T12:00:00Z", // optional; defaults to now
18
+ "actions": [ /* see below */ ],
19
+ "context": { // optional; freeform
20
+ "rationale": "User asked for a deploy",
21
+ "session_id": "..."
22
+ }
23
+ }
24
+ ```
25
+
26
+ | Field | Type | Required | Notes |
27
+ |-------|------|----------|-------|
28
+ | `id` | string | no | 12 hex chars from a UUIDv4 if omitted |
29
+ | `source` | string | no | who produced this proposal (model id, agent name) |
30
+ | `timestamp` | RFC3339 | no | when it was produced |
31
+ | `actions` | `Action[]` | **yes** | non-empty for the proposal to be useful |
32
+ | `context` | object | no | passed through to event log; not interpreted |
33
+
34
+ No proposal-level `reversibility` field exists, and that is deliberate — the
35
+ rollback contract belongs to an action, not to a batch. What a caller usually
36
+ wants is the batch's *derived* contract, `ActionProposal::rollback_contract()`:
37
+ the **worst** `reversibility` any of its actions declares, since a plan is only
38
+ as recoverable as its least recoverable step and partial execution is a real
39
+ outcome. An empty batch is `"reversible"` rather than the `"irreversible"`
40
+ default, because "there is nothing to undo" is a different statement from "the
41
+ author did not say". It surfaces on the wire as `permission.classify`'s
42
+ `declared_rollback_contract` (see `docs/websocket-protocol.md`); note that it
43
+ reads the **declared** IR fields, not `car-policy`'s classifier, so a proposal
44
+ written before this axis existed reports `"irreversible"` for every action.
45
+
46
+ ---
47
+
48
+ ## Action
49
+
50
+ A single unit of agent intent. Actions form a DAG via `state_dependencies`; independent actions execute concurrently.
51
+
52
+ ```jsonc
53
+ {
54
+ "id": "a1", // optional; auto-generated
55
+ "type": "tool_call", // required: see ActionType
56
+ "tool": "deploy", // required when type == "tool_call"
57
+ "parameters": { "env": "staging" }, // tool-specific
58
+ "preconditions": [
59
+ { "key": "tests_passed", "operator": "eq", "value": true }
60
+ ],
61
+ "expected_effects": { "deployed": true },
62
+ "state_dependencies": ["build_artifact"],
63
+ "invocation_mode": "one_shot", // default; "streaming" / "long_running" detach
64
+ "reversibility": "compensable", // default "irreversible"; can this be undone?
65
+ "compensation": { // required in spirit when "compensable"
66
+ "type": "tool", "tool": "rollback_deploy", "parameters": { "env": "staging" }
67
+ },
68
+ "idempotent": true,
69
+ "max_retries": 3, // default 3
70
+ "failure_behavior": "retry", // default "abort"
71
+ "timeout_ms": 30000,
72
+ "metadata": { "rationale": "..." }
73
+ }
74
+ ```
75
+
76
+ ### Field reference
77
+
78
+ | Field | Type | Required | Default | Notes |
79
+ |-------|------|----------|---------|-------|
80
+ | `id` | string | no | UUID-derived | unique within the proposal |
81
+ | `type` | `ActionType` | **yes** | — | see below |
82
+ | `tool` | string | conditional | — | required when `type == "tool_call"` |
83
+ | `parameters` | object | no | `{}` | passed verbatim to the tool callback |
84
+ | `preconditions` | `Precondition[]` | no | `[]` | all must hold; otherwise action skipped per `failure_behavior` |
85
+ | `expected_effects` | object | no | `{}` | claimed state changes — used by verify/simulate to model the action without running it |
86
+ | `state_dependencies` | string[] | no | `[]` | state keys this action reads; informs DAG ordering |
87
+ | `read_set` | string[] | no | `[]` | explicit transactional **read set** (survey §5.2.4). When empty, derived from `state_dependencies` + assumption keys. Used by `transaction_check` to detect read-write hazards and stale reads across concurrent actions/agents |
88
+ | `write_set` | string[] | no | `[]` | explicit transactional **write set**. When empty, derived from `expected_effects` (+ a `state_write`'s `key` param). Used to detect write-write races |
89
+ | `assumptions` | `StateAssumption[]` | no | `[]` | assumptions about shared state this action did not produce — each `{ key, expected_value?, read_version? }`. A stale `read_version` (state moved past it) or mismatched `expected_value` is flagged by `transaction_check` as belief divergence |
90
+ | `invocation_mode` | `ToolInvocationMode` | no | `"one_shot"` | how a `tool_call` runs: `"one_shot"` (dispatch awaits the result inline — unchanged default), or a detached mode (`"streaming"` / `"long_running"`) where dispatch *starts* the tool, returns `{ tool_handle, status: "running" }` as the action's output, and the DAG proceeds without blocking. Chunks/status are consumed via the handle (`tools.poll` / `tools.cancel` / `tools.stream.subscribe` on the WS, `toolPoll`/`tool_poll` + `toolCancel`/`tool_cancel` in the FFI). Detached results bypass the result cache AND the idempotency cache (a handle is a live invocation — deduping would return a stale handle instead of starting the tool) but stay rate-limited; `timeout_ms` bounds only the `execute_stream` startup, not the stream itself; requires a configured tool executor implementing `execute_stream` (the daemon's WS-callback executor does not yet — see the support-status note in `docs/websocket-protocol.md`). Ignored for non-`tool_call` actions |
91
+ | `reversibility` | `Reversibility` | no | `"irreversible"` | the **rollback contract** for this action's effects — orthogonal to the permission tier, which answers *who may authorize this*. See below; note the conservative default |
92
+ | `compensation` | `Compensation` | no | absent | how to undo the action once it has run. Meaningful only when `reversibility == "compensable"`; omitted from the serialized form when absent. See below |
93
+ | `idempotent` | boolean | no | `false` | enables result caching and safe retry |
94
+ | `max_retries` | u32 | no | `3` | only consulted when `failure_behavior == "retry"` |
95
+ | `failure_behavior` | `FailureBehavior` | no | `"abort"` | see below |
96
+ | `timeout_ms` | u64 | no | unbounded | tool-call timeout |
97
+ | `metadata` | object | no | `{}` | not interpreted by the runtime |
98
+
99
+ ### `type` — ActionType
100
+
101
+ Snake-case enum:
102
+
103
+ | Value | Meaning |
104
+ |-------|---------|
105
+ | `"tool_call"` | invoke a registered tool with `parameters` |
106
+ | `"state_write"` | set state keys directly (no tool dispatch) |
107
+ | `"state_read"` | read state keys; populate downstream actions |
108
+ | `"assertion"` | check that a state predicate holds — fail the proposal if not |
109
+
110
+ ### `reversibility` — Reversibility
111
+
112
+ Snake-case enum. Answers **can this be undone?** — a separate axis from the
113
+ permission tier (`read_only` / `sandbox_edit` / `full_access`), which answers
114
+ *who may authorize this*. The two were conflated until this field existed:
115
+ `full_access` is documented as "externally-consequential **or** irreversible",
116
+ which puts a `git push` (recoverable by force-pushing the prior ref) and a
117
+ charged card (not recoverable at all) on the same rung.
118
+
119
+ | Value | Meaning |
120
+ |-------|---------|
121
+ | `"reversible"` | undone by restoring the scope the action ran in — state writes, sandboxed filesystem writes. No compensating work needed |
122
+ | `"compensable"` | undone only by running a compensating action — a DB `INSERT` needs its `DELETE`, a `git push` needs a force-push of the prior ref, a deploy needs a rollback deploy. Should carry a `compensation` |
123
+ | `"irreversible"` (default) | cannot be undone once it reaches the world — a sent email, a charged card, `rm -rf` outside a snapshotted tree. Only the gate *before* it runs is a lever |
124
+
125
+ Two things to know about the default:
126
+
127
+ - **It is `"irreversible"`, deliberately.** The default is what the runtime
128
+ believes about an *unclassified* action, and the two directions fail
129
+ asymmetrically. Defaulting to `"reversible"` and being wrong means silently
130
+ believing a sent email can be unsent — a safety property failing quietly.
131
+ Defaulting to `"irreversible"` and being wrong means over-asking for approval
132
+ on something recoverable — annoying, visible, and locally fixable by
133
+ annotating the action.
134
+ - **Nothing enforces on it yet.** This field is typed, classified, and audited;
135
+ it is not a gate. Deferring the materialization of an irreversible effect
136
+ needs machinery CAR does not have (a checkpoint coupled to the filesystem —
137
+ today rollback restores the state map and leaves what a tool wrote to disk
138
+ where it is). Do not read `"reversible"` as a promise that the runtime will
139
+ undo anything for you.
140
+
141
+ ### `compensation` — Compensation
142
+
143
+ Tagged by `type`. The action-level analogue of the saga-pattern
144
+ `CompensationHandler` `car-workflow` applies per *stage*, restated natively in
145
+ the IR (which depends only on serde, uuid, and chrono).
146
+
147
+ | Variant | Shape | Meaning |
148
+ |---------|-------|---------|
149
+ | `"tool"` | `{ "type": "tool", "tool": "db.delete", "parameters": { … } }` | invoke a tool that reverses the effect. `parameters` defaults to `{}` |
150
+ | `"action_ref"` | `{ "type": "action_ref", "action_id": "undo-a1" }` | run another action from the same proposal, identified by its `id` — use this when one tool call is not enough |
151
+
152
+ `reversibility == "compensable"` with no `compensation` is representable but
153
+ incoherent; `Action::missing_required_compensation()` is the check that flags
154
+ it. The pairing is not enforced by the type because `Reversibility` is
155
+ deliberately a plain string enum that every binding surface mirrors.
156
+
157
+ Declaring a compensation is a *claim*, not a proof: nothing in the runtime
158
+ checks that the named tool is a true inverse of the action, exactly as nothing
159
+ checks `expected_effects`.
160
+
161
+ ### `failure_behavior` — FailureBehavior
162
+
163
+ Snake-case enum. What happens when this action's tool returns an error or a precondition fails:
164
+
165
+ | Value | Meaning |
166
+ |-------|---------|
167
+ | `"abort"` (default) | stop the proposal; downstream actions not executed |
168
+ | `"retry"` | retry up to `max_retries` times before aborting |
169
+ | `"skip"` | mark this action skipped and continue with the rest |
170
+
171
+ ### Action lifecycle (informational)
172
+
173
+ The runtime tags each action with an `ActionStatus` as it moves through validation and execution:
174
+
175
+ ```
176
+ Proposed → Validated → Executing → Succeeded
177
+ ↘ Rejected
178
+ ↘ Failed
179
+ ↘ Skipped
180
+ ```
181
+
182
+ `ActionStatus` is observable through the event log, not part of the input contract.
183
+
184
+ ---
185
+
186
+ ## Precondition
187
+
188
+ A state predicate that must hold before the action runs.
189
+
190
+ ```jsonc
191
+ { "key": "tests_passed", "operator": "eq", "value": true, "description": "" }
192
+ ```
193
+
194
+ | Field | Type | Required | Default | Notes |
195
+ |-------|------|----------|---------|-------|
196
+ | `key` | string | **yes** | — | state key to evaluate |
197
+ | `operator` | string | no | `"eq"` | see operator table |
198
+ | `value` | any JSON | depends | `null` | compared per the operator |
199
+ | `description` | string | no | `""` | human-readable; surfaced in errors |
200
+
201
+ ### Operator reference
202
+
203
+ | Operator | Semantics | `value` required? |
204
+ |----------|-----------|------------------|
205
+ | `eq` | `state[key] == value` | yes |
206
+ | `neq` | `state[key] != value` | yes |
207
+ | `gt`, `lt`, `gte`, `lte` | numeric ordered comparison; both must be numbers | yes |
208
+ | `exists` | `key` is present in state | no |
209
+ | `not_exists` | `key` is absent from state | no |
210
+ | `contains` | substring match (string) or membership (array) | yes |
211
+
212
+ Numeric operators silently return `false` if either side fails to coerce to `f64`.
213
+
214
+ ---
215
+
216
+ ## ToolSchema
217
+
218
+ Registered when a tool is added to the runtime. Carries everything the runtime needs to validate, cache, and rate-limit calls.
219
+
220
+ ```jsonc
221
+ {
222
+ "name": "deploy",
223
+ "description": "Deploys an artifact to a target environment.",
224
+ "parameters": {
225
+ "type": "object",
226
+ "properties": {
227
+ "env": { "type": "string", "enum": ["staging", "prod"] }
228
+ },
229
+ "required": ["env"]
230
+ },
231
+ "returns": { "type": "object" },
232
+ "idempotent": true,
233
+ "cache_ttl_secs": 60,
234
+ "rate_limit": { "max_calls": 5, "interval_secs": 60.0 }
235
+ }
236
+ ```
237
+
238
+ | Field | Type | Required | Default | Notes |
239
+ |-------|------|----------|---------|-------|
240
+ | `name` | string | **yes** | — | unique within a runtime |
241
+ | `description` | string | no | `""` | human-readable; included in tool catalog |
242
+ | `parameters` | JSON Schema | no | `{}` | validated by the runtime before dispatch |
243
+ | `returns` | JSON Schema | no | none | validated against tool return value when set |
244
+ | `idempotent` | boolean | no | `false` | enables cache + retry safety at the runtime level |
245
+ | `cache_ttl_secs` | u64 | no | none | when set, results are cached for this duration |
246
+ | `rate_limit` | `ToolRateLimit` | no | none | `{ max_calls, interval_secs }` |
247
+
248
+ ---
249
+
250
+ ## CostSummary, CostTarget, CostBudget
251
+
252
+ `CostSummary` is the post-execution accounting attached to every `ProposalResult`:
253
+
254
+ ```jsonc
255
+ {
256
+ "tool_calls": 3,
257
+ "actions_executed": 5,
258
+ "actions_rejected": 2,
259
+ "actions_skipped": 1,
260
+ "total_duration_ms": 1240.0,
261
+ "retries": 0
262
+ }
263
+ ```
264
+
265
+ | Field | Counts |
266
+ |-------|--------|
267
+ | `actions_executed` | actions that actually ran — `succeeded` **plus** `failed`. A failed action invoked its tool and the tool errored, so it consumed real work. |
268
+ | `actions_rejected` | actions blocked **before** execution, by the validator (unknown tool, unsatisfied dependency) or by policy. Nothing ran. |
269
+ | `actions_skipped` | actions never attempted because an earlier action aborted the run or a cost budget was exhausted. |
270
+
271
+ `actions_rejected` is new (Parslee-ai/car#624). Before it, rejections were counted in
272
+ `actions_executed`, so a proposal where every action was blocked still reported
273
+ "N actions executed" with `tool_calls: 0`. Readers should treat the field as
274
+ optional (`serde(default)`) — a summary produced by an older CAR omits it.
275
+
276
+ `CostTarget` is the **soft** scoring target used by `car-planner` to rank candidate proposals. Default values:
277
+
278
+ ```jsonc
279
+ {
280
+ "target_tool_calls": 5,
281
+ "target_duration_ms": 5000.0,
282
+ "target_actions": 10,
283
+ "cost_weight": 0.2
284
+ }
285
+ ```
286
+
287
+ `cost_weight` is in `[0.0, 1.0]`. Score is computed as
288
+ `success_likelihood * (1 - cost_weight) + cost_efficiency * cost_weight`.
289
+
290
+ `CostBudget` (in `car-engine`) is the **hard** counterpart — proposals that exceed it are rejected at verification time. See `verify`'s `max_actions` parameter for the canonical entry point.
291
+
292
+ ---
293
+
294
+ ## ProposalResult
295
+
296
+ Returned by `proposal.submit` (WebSocket), `executeProposal` (NAPI), `execute_proposal` (PyO3).
297
+
298
+ ```jsonc
299
+ {
300
+ "proposal_id": "abc123def456",
301
+ "results": [
302
+ {
303
+ "action_id": "a1",
304
+ "status": "succeeded",
305
+ "output": { "deployed": true },
306
+ "error": null,
307
+ "state_changes": {
308
+ "deployed": { "op": "set", "value": true },
309
+ "obsolete_key": { "op": "delete" }
310
+ },
311
+ "duration_ms": 1230.0,
312
+ "timestamp": "2026-05-02T12:00:01Z"
313
+ }
314
+ ],
315
+ "cost": { "tool_calls": 1, "actions_executed": 1, "actions_rejected": 0, "actions_skipped": 0, "total_duration_ms": 1230.0, "retries": 0 }
316
+ }
317
+ ```
318
+
319
+ `status` is one of: `"proposed"`, `"validated"`, `"rejected"`, `"executing"`, `"succeeded"`, `"failed"`, `"skipped"`.
320
+
321
+ Each `state_changes` value is a tagged `StateMutation`: `{"op":"set","value":…}`
322
+ sets the key (including explicitly setting it to JSON `null`), while
323
+ `{"op":"delete"}` removes it. Rust consumers can encode and decode that stable
324
+ wire shape with `car_ir::StateMutation`; the `ActionResult` field remains a
325
+ JSON-value map for source and wire compatibility.
326
+
327
+ ---
328
+
329
+ ## Verification result
330
+
331
+ Returned by `verify` (FFI standalone, WebSocket `verify`).
332
+
333
+ ```jsonc
334
+ {
335
+ "valid": true,
336
+ "issues": [
337
+ {
338
+ "action_id": "a1",
339
+ "severity": "warning",
340
+ "message": "expected_effects mentions key not in any tool's schema",
341
+ "tier": "decision_procedure" // how the finding was derived
342
+ }
343
+ ],
344
+ "simulated_state": { "deployed": true },
345
+ "execution_levels": [["a1"], ["a2", "a3"]],
346
+ "conflicts": [["a2", "a3", "deployed"]]
347
+ }
348
+ ```
349
+
350
+ | Field | Notes |
351
+ |-------|-------|
352
+ | `valid` | `false` if any issue has severity `"error"` |
353
+ | `issues` | flat list; severity ∈ `"error"` / `"warning"` / `"info"`, plus `tier` (below) |
354
+ | `simulated_state` | post-execution state if the proposal ran with no failures |
355
+ | `execution_levels` | DAG layered topological order — actions in the same layer are independent |
356
+ | `conflicts` | triples `(action_a, action_b, state_key)` where two actions write the same key without ordering |
357
+
358
+ ### `tier` — EvidenceTier
359
+
360
+ Snake-case enum on every issue. Names which **kind** of check produced the
361
+ finding, so a consumer no longer has to recognise the message string to tell
362
+ them apart:
363
+
364
+ | Value | Meaning |
365
+ |-------|---------|
366
+ | `"decision_procedure"` | the check *decides* the property it reports over the inputs it was handed — set membership, graph reachability, the STRIPS-style forward walk |
367
+ | `"heuristic"` | a proxy signal over complete inputs. In `verify` this is exactly one rule: repeated-identical-call loop detection, where the repeat count is exact but the step from "three identical calls" to "runaway loop" is a guess, so a legitimate 3× poll trips it |
368
+ | `"sampled"` | an exact measurement over incomplete inputs — `equivalent`'s probe states, Monte Carlo rollouts. **Does not currently appear on a verification `issue`:** neither of those checks produces one, and their reports (`MonteCarloResult`, `equivalent`) carry no `tier` on the wire. The variant is defined and reachable in Rust; a client should not write a `"sampled"` branch expecting `verify` to emit it. |
369
+
370
+ Three things it is **not**, all easy to get backwards:
371
+
372
+ - **Not `severity`.** Severity is how bad the finding would be; the tier is how
373
+ it was derived. They vary independently.
374
+ - **Not "does this block".** `car-engine`'s admission gate treats the
375
+ precondition and state-dependency findings as advisory *even though both are
376
+ `decision_procedure`*, because they are decided over a forward model that sees
377
+ only **declared** `expected_effects`. Whether the inputs match runtime is a
378
+ separate axis, carried by the `evidence` bundle's `assumptions` /
379
+ `untested_regions` / per-check `cannot_verify`.
380
+ - **Not a ranking.** The Rust `EvidenceTier` derives no `Ord` on purpose — a
381
+ proxy over complete inputs and an exact measurement over incomplete inputs
382
+ fail in different directions, so neither is categorically stronger. Match on
383
+ the value; do not filter down to `decision_procedure`.
384
+
385
+ `decision_procedure` is not a proof, not a soundness claim, and not a prediction
386
+ that the plan will run — there is no solver in this workspace. Older daemons
387
+ omit the field.
388
+
389
+ ### What `verify` detects
390
+
391
+ | Category | Detection |
392
+ |----------|-----------|
393
+ | Impossible plans | preconditions that no action provides |
394
+ | Missing dependencies | state keys read but never written |
395
+ | Write conflicts | unordered actions writing the same key |
396
+ | Infinite loops | duplicate identical tool calls |
397
+ | Resource exhaustion | proposals exceeding `max_actions` |
398
+ | Missing tools | `tool_call` referencing a tool not registered |
399
+
400
+ `simulate` (also in `car-verify`) returns just the post-state. `equivalent(p1, p2, test_states)` returns `true` if both proposals produce the same final state across every *supplied* test state — two trivial defaults (`{}` and `{x:1,y:2}`) when you pass none. It samples; it does not decide equivalence over all starting states, and two proposals writing different values to a key no probe state reaches will compare equal. `optimize` prunes phantom `state_dependencies` so independent actions can land in the same DAG level; it does not reorder actions.
401
+
402
+ ### `verify` and `simulate` treat blocked actions differently — on purpose
403
+
404
+ Both walk the DAG applying `expected_effects`, but they diverge on an action that **cannot run** — one whose preconditions fail, or whose state dependencies aren't available:
405
+
406
+ | | Blocked action's effects | Why |
407
+ |---|---|---|
408
+ | `verify` | **applied anyway** | It reports every problem in one pass. Withholding effects would bury the real findings under a cascade of knock-on "dependency not available" issues that are artifacts of the first failure, not independent defects. |
409
+ | `simulate` | **skipped** | It predicts what the executor leaves behind, and the executor rejects such an action *before* dispatch (`ActionStatus::Rejected`), so its effects never land. |
410
+
411
+ Under `simulate`, downstream actions then find their own dependencies missing and drop out in turn, so the cascade follows the data dependencies exactly as it does at runtime.
412
+
413
+ `simulate` models per-action gating, not `failure_behavior`. An *independent* action alongside a blocked one still contributes its effects, whereas the executor's default `FailureBehavior::Abort` may stop the run before reaching it. Read the result as "the state assuming execution proceeds as far as the dependency graph allows" — never as a claim that a provably-blocked action ran.
414
+
415
+ `simulate` used to share `verify`'s optimism, so it reported `deployed: true` for a deploy gated on a `tests_passed` precondition that provably could not hold (Parslee-ai/car#622). `equivalent` compares `simulate` output and inherited the same flaw — two proposals differing only in a gate gating one of them read as equivalent.
416
+
417
+ ---
418
+
419
+ ## Policies
420
+
421
+ Policies are runtime guardrails registered via `register_policy` (FFI) or `session.init` (WebSocket). Every action passes through every policy before execution; any non-empty return blocks the action with that string as the reason.
422
+
423
+ ### Built-in policy rules
424
+
425
+ CAR ships four rule types that cover the common cases without needing a custom callback:
426
+
427
+ #### `deny_tool`
428
+ Reject any action whose `tool` matches `target`.
429
+
430
+ ```typescript
431
+ rt.registerPolicy('no_shell', 'deny_tool', 'shell');
432
+ ```
433
+
434
+ | Param | Required | Notes |
435
+ |-------|----------|-------|
436
+ | `target` | yes | tool name to deny |
437
+
438
+ #### `deny_tool_param`
439
+ Reject any action where `tool == target` AND parameter `key` contains `pattern` (substring match on the string-coerced value).
440
+
441
+ ```typescript
442
+ rt.registerPolicy('no_rm_rf', 'deny_tool_param', 'shell', 'command', 'rm -rf');
443
+ ```
444
+
445
+ | Param | Required | Notes |
446
+ |-------|----------|-------|
447
+ | `target` | yes | tool name to gate |
448
+ | `key` | yes | parameter name |
449
+ | `pattern` | yes | substring that triggers denial |
450
+
451
+ #### `require_state`
452
+ Reject any action unless `state[key] == value`. Use to enforce ordering or feature flags.
453
+
454
+ ```typescript
455
+ rt.registerPolicy('require_tests', 'require_state', null, 'tests_passed', null, 'true');
456
+ ```
457
+
458
+ | Param | Required | Notes |
459
+ |-------|----------|-------|
460
+ | `key` | yes | state key |
461
+ | `value_json` | yes | required JSON value, encoded as a string |
462
+
463
+ #### `deny_tool_callback` (NAPI only)
464
+ Reject when a JS callback returns truthy. Requires `registerAgentRunner` to have stored the callback first. Use sparingly — synchronous policy callbacks are a hot-path cost.
465
+
466
+ | Param | Required | Notes |
467
+ |-------|----------|-------|
468
+ | `target` | yes | tool name to gate |
469
+
470
+ ### Declarative project rules (`.car/policies/*.toml`)
471
+
472
+ The rules above are **registered by a caller** — `registerPolicy` over FFI, `session.init` over the WebSocket. The rules below are **loaded from files**: every `*.toml` in a `.car/policies/` directory is read in sorted filename order, merged into one rule set, and lowered onto the same `PolicyEngine`, so an operator can govern their own machine or project without patching CAR or writing host code.
473
+
474
+ Which `.car` depends on who is loading:
475
+
476
+ | Loader | Directory | Scope |
477
+ |--------|-----------|-------|
478
+ | The daemon, per session | `~/.car/policies/*.toml` | the operator's rules — the daemon serves whatever project a client happens to be in, so its rule set cannot be per-project |
479
+ | `car do` / the assistant | `<working directory>/.car/policies/*.toml` | project-scoped; the working directory is `--dir` if given, otherwise `std::env::current_dir()` |
480
+
481
+ > ⚠️ **There is no walk-up for `car do`.** It joins `.car/policies` onto its working directory and looks there, once. Invoked from a subdirectory of a repo, the repo-root `.car/policies/` is not loaded — and this fails **open**: no warning, no error, the rules just do not apply. Run from the project root or pass `--dir <project root>`.
482
+ >
483
+ > This is inconsistent with neighbouring `.car/` discovery, and worth knowing because the other docs say the opposite: `.car/connectors.toml` walks up from cwd (`car-connectors::team_connectors_path`), and the cookbook's project-directory chapter describes `.car/` discovery in general as walking up from cwd. Policies do not.
484
+
485
+ Loading is strict. A missing `policies/` directory is not an error (most projects have none), but a present-but-malformed file **fails the session** — a security rule that fails to parse must surface rather than be silently skipped. The daemon refuses the WebSocket connection; `car do` prints the error and exits 2. In both cases the message names the offending file.
486
+
487
+ Two of these keys share a name with a `registerPolicy` rule type (`deny_tool`, `deny_tool_param`) and mean the same thing; they are simply the file-authored form. The others exist only here. Every rule is a *prohibition* — matching an action produces a violation — with one exception, `allow_tool_param`, which inverts the polarity and denies everything it does not permit.
488
+
489
+ A single file may set any combination of keys:
490
+
491
+ ```toml
492
+ # .car/policies/security.toml
493
+ deny_tool = ["deploy", "rm"]
494
+ deny_keyword = ["DROP TABLE", "rm -rf /"]
495
+
496
+ [[deny_tool_param]]
497
+ tool = "http_request"
498
+ param = "url"
499
+ contains = "169.254.169.254"
500
+ ```
501
+
502
+ #### `deny_tool`
503
+ Deny every action whose `tool` is named in the list.
504
+
505
+ ```toml
506
+ deny_tool = ["deploy", "rm"]
507
+ ```
508
+
509
+ | Param | Required | Notes |
510
+ |-------|----------|-------|
511
+ | (list of strings) | yes | tool names that may never be invoked |
512
+
513
+ #### `deny_keyword`
514
+ Deny any action — any tool — carrying this substring in **any** string-coerced parameter value. The coarse "never let this text near a tool" guard.
515
+
516
+ ```toml
517
+ deny_keyword = ["DROP TABLE", "rm -rf /"]
518
+ ```
519
+
520
+ | Param | Required | Notes |
521
+ |-------|----------|-------|
522
+ | (list of strings) | yes | case-sensitive substrings; matched against every parameter of every action |
523
+
524
+ #### `deny_tool_param`
525
+ Deny an action when `tool` matches and its `param` satisfies the stated condition. Set `equals` for an exact JSON-value match, `contains` for a case-sensitive substring of the string-coerced value, or both (then both must hold). With neither set the rule is a *presence* deny — any call to `tool` that carries `param` at all is denied. A parameter that is absent is never a violation.
526
+
527
+ ```toml
528
+ [[deny_tool_param]]
529
+ tool = "http_request"
530
+ param = "url"
531
+ contains = "169.254.169.254" # block cloud metadata exfiltration
532
+
533
+ [[deny_tool_param]]
534
+ tool = "shell"
535
+ param = "command"
536
+ equals = "shutdown"
537
+ ```
538
+
539
+ | Param | Required | Notes |
540
+ |-------|----------|-------|
541
+ | `tool` | yes | tool name the rule applies to |
542
+ | `param` | yes | parameter key inspected on the action |
543
+ | `equals` | no | deny when the parameter equals this JSON value exactly |
544
+ | `contains` | no | deny when the parameter's string form contains this substring |
545
+
546
+ #### `allow_tool_param`
547
+ The only **allowlist** in the format, and the only rule that denies an action for what it does *not* say. When an action calls `tool`, it is denied unless it carries `param` and that parameter's string form is exactly one of the entries in `allow`. Comparison is exact and case-sensitive. Actions for any other tool are untouched — one rule governs one tool.
548
+
549
+ It fails closed in all three ways it can fail: an absent parameter is denied (nothing proves the call is permitted), an empty `allow` denies every call to the tool, and a present-but-unlisted value is denied.
550
+
551
+ ```toml
552
+ [[allow_tool_param]]
553
+ tool = "deploy"
554
+ param = "target"
555
+ allow = ["staging", "preview"] # any other target — or none at all — is denied
556
+ ```
557
+
558
+ | Param | Required | Notes |
559
+ |-------|----------|-------|
560
+ | `tool` | yes | tool name the rule applies to |
561
+ | `param` | yes | parameter key that must be present and allowlisted |
562
+ | `allow` | no | permitted values; defaults to empty, which denies every call |
563
+
564
+ #### `deny_tool_param_matching`
565
+ The content counterpart to `deny_tool_param`, for prohibitions no fixed substring expresses — credential shapes, account numbers, an address family. `matches` is a regex over the string-coerced parameter value. The match is **unanchored**, so the pattern fires anywhere in the value; anchor it with `^`/`$` when that matters. Like `deny_tool_param`, an absent parameter is not a violation — use `allow_tool_param` when absence itself must be refused.
566
+
567
+ The pattern is compiled once when the rule set is applied, not per action. A pattern that fails to compile **denies every call to that tool** rather than disappearing, matching the loader's loud-error posture.
568
+
569
+ ```toml
570
+ [[deny_tool_param_matching]]
571
+ tool = "http_request"
572
+ param = "body"
573
+ matches = "sk-[A-Za-z0-9]{20,}" # never let an API-key-shaped string leave in a body
574
+ ```
575
+
576
+ | Param | Required | Notes |
577
+ |-------|----------|-------|
578
+ | `tool` | yes | tool name the rule applies to |
579
+ | `param` | yes | parameter key inspected on the action |
580
+ | `matches` | yes | regex source; unanchored; an uncompilable pattern denies the tool outright |
581
+
582
+ #### `rate_limit_tool`
583
+ A sliding-window cap on how often `tool` may be called. The call is denied when admitting it would make it the `max_calls + 1`-th call to `tool` within the trailing `interval_secs`. `max_calls = 0` denies every call. This bounds how much of a side effect an agent can produce in a stretch of wall-clock time, independently of whether any single call is legitimate.
584
+
585
+ Two behaviours are load-bearing:
586
+
587
+ - **Budget is consumed at admission, not at delivery.** A call this rule admits that later fails at dispatch still occupies its slot in the window. The cap bounds *attempts*, not confirmed successes — the conservative direction for a rule whose job is to bound blast radius.
588
+ - **Only a call denied *by this rule* consumes no budget.** Being refused for being over the cap does not push the window further out, so a caller that keeps retrying into a full window is admitted again as soon as the oldest admitted call ages out. A call refused by a *different* rule is not free: the policy engine collects every violation instead of stopping at the first, so the rate-limit check has already run and taken the slot. Size a cap on total attempts, not on the ones you expect to get through.
589
+
590
+ The window is per rule and per policy engine, starts empty, and is not persisted. **On the daemon that means per WebSocket connection, not per machine** — `create_session` builds a fresh runtime, and so a fresh engine, for every accepted connection. Two agents connected at once each get a full budget, and an agent that reconnects starts a new window immediately. Size a cap for one agent-session's blast radius; it is not a machine-wide quota. Under `car do` the assistant builds one runtime per run, so there the window is the run.
591
+
592
+ ```toml
593
+ [[rate_limit_tool]]
594
+ tool = "http_request"
595
+ max_calls = 10
596
+ interval_secs = 60.0
597
+ ```
598
+
599
+ | Param | Required | Notes |
600
+ |-------|----------|-------|
601
+ | `tool` | yes | tool name the rule applies to |
602
+ | `max_calls` | yes | calls permitted within the window; `0` denies every call |
603
+ | `interval_secs` | yes | window length in seconds |
604
+
605
+ A seventh key, `trace_rule`, expresses temporal constraints over the run's execution trace (`car-verify::trace_policy`). Unlike the six above it is **stateful** — evaluated against what has already run — so it cannot be registered as a per-action check and would have to be enforced at dispatch by a `TraceGate`.
606
+
607
+ **Nothing builds that gate outside tests, so loading a `[[trace_rule]]` is refused rather than accepted.** A policy file carrying one fails to load and names itself, instead of reporting a rule the runtime would never apply. The key is documented here so the error is recognisable, not because it is available: today, express the prohibition with one of the six enforced kinds above.
608
+
609
+ Violations from these rules carry a stable rule name derived from the rule itself: `deny_tool:deploy`, `deny_keyword:rm -rf /`, `deny_tool_param:http_request.url`, `allow_tool_param:deploy.target`, `deny_tool_param_matching:http_request.body`, `rate_limit_tool:http_request`. Neither the offending value nor the regex-matched text appears in the message — the matched text is precisely the secret such a rule exists to catch, and violation reasons are written to the event log.
610
+
611
+ ### Policy violations
612
+
613
+ When a policy denies an action, the runtime emits a `PolicyViolation`:
614
+
615
+ ```jsonc
616
+ { "policy_name": "no_rm_rf", "action_id": "a3", "reason": "param 'command' matches denied pattern 'rm -rf'" }
617
+ ```
618
+
619
+ The proposal then proceeds per the action's `failure_behavior` — `"abort"` halts everything, `"skip"` continues, `"retry"` is treated as `"abort"` (retrying a denied action would loop).
620
+
621
+ ### Inspector chain (advanced)
622
+
623
+ For dispatch-time guardrails — egress filtering, repetition detection, adversary review — `car-policy::inspectors` provides a short-circuiting `InspectorChain`. Inspectors evaluate `(tool_name, params)` and return `Allow` / `Deny(reason)`. Stops on first deny. This is a separate mechanism from the action-level policies above and runs at tool dispatch, not action validation.
624
+
625
+ ---
626
+
627
+ ## Stability guarantees
628
+
629
+ - **Field additions are non-breaking** — clients ignore unknown fields. CAR will add fields as capabilities grow.
630
+ - **Operator additions are non-breaking** — new precondition operators may appear; older runtimes treat unknown operators as failed checks.
631
+ - **Field removals are breaking** — any deprecation will go through CHANGELOG and a major version bump.
632
+ - **Default value changes are breaking.** Defaults appearing in this document are stable.
633
+
634
+ ---
635
+
636
+ ## Streaming / long-running tool contract (`car-ir/src/tool_stream.rs`)
637
+
638
+ Classic tool dispatch is one-shot: the executor awaits a single
639
+ `Result<Value, String>`. The streaming contract (EPIC C) describes tools
640
+ that produce output incrementally or run longer than a single request. All
641
+ types are `snake_case` on the wire and round-trip across every binding.
642
+
643
+ - **`ToolInvocationMode`** — capability marker: `one_shot` (default,
644
+ unchanged behavior), `streaming`, `long_running`.
645
+ - **`ToolHandle { id }`** — opaque handle returned when a detached tool is
646
+ started; passed back to poll / observe / cancel.
647
+ - **`ToolStreamChunk`** (tagged by `kind`): `text { text }`,
648
+ `data { data }`, `progress { fraction, message? }`, `done { result? }`
649
+ (terminal), `error { message }` (terminal).
650
+ - **`ToolControl`** — `poll` or `cancel` (cooperative).
651
+ - **`ToolStatus`** — `running` / `succeeded` / `failed` / `cancelled`.
652
+ - **`ToolStreamEvent { handle, chunk }`** — handle-tagged envelope streamed
653
+ to a client.
654
+
655
+ Shape: **start → handle → (poll | stream chunks) → cancel**. C2 wires it
656
+ end-to-end: `Action.invocation_mode` selects a detached mode, the executor
657
+ starts the tool and returns `{ tool_handle, status: "running" }` as the
658
+ action's output (the DAG proceeds), and the handle is driven via the daemon's
659
+ `tools.poll` / `tools.cancel` methods and the `tools.stream.subscribe` →
660
+ `tools.stream.event` notification stream (see `docs/websocket-protocol.md`),
661
+ or the FFI `toolPoll`/`tool_poll` + `toolCancel`/`tool_cancel` proxies.
662
+ `tools.poll` returns a `ToolPollResult` `{ handle, tool, action_id, status,
663
+ chunks, result?, error? }` — chunks drain per poll; the terminal status is
664
+ observable at least once, after which the handle is consumed (`null`).
665
+
666
+ ## Where to look in the source
667
+
668
+ | Concern | File |
669
+ |---------|------|
670
+ | `Action`, `ActionProposal`, `ActionResult` | `car-rs/crates/car-ir/src/actions.rs` |
671
+ | Streaming / long-running tool contract | `car-rs/crates/car-ir/src/tool_stream.rs` |
672
+ | `Reversibility` / `Compensation` | `car-rs/crates/car-ir/src/reversibility.rs` |
673
+ | `Precondition` operators | `car-rs/crates/car-ir/src/precondition.rs` |
674
+ | DAG ordering / `state_dependencies` resolution | `car-rs/crates/car-ir/src/dag.rs` |
675
+ | `verify`, `simulate`, `equivalent`, `optimize` | `car-rs/crates/car-verify/src/lib.rs` |
676
+ | `PolicyEngine`, built-in rules | `car-rs/crates/car-policy/src/lib.rs`, `car-rs/crates/car-ffi-napi/src/lib.rs` (rule dispatch) |
677
+ | Tool dispatch / DAG executor | `car-rs/crates/car-engine/src/lib.rs` |