# Action contracts

An **action contract** (`kind: action`) verifies, after the fact, that an action an AI agent took was valid, within its limits, and produced the intended effects in the customer's systems of record. Each recorded action receives a **PASS / FAIL / UNKNOWN** verdict under two lenses, with evidence pointers and a hash-chained verdict, using the same engine that verifies billed outcomes (see [METHODOLOGY.md](/methodology)).

The engine only reads. It does not sit in the agent's write path, does not block or execute anything, and cannot change a system of record. It compares what the agent said it did with what the systems of record show happened.

## Vocabulary

| Term | Meaning | Stored as |
|---|---|---|
| **Action** | One request by an agent to change a record: "refund order 10231 for 180 USD at 10:42". Carries `params` (the agent's own arguments), a `subject_id` (the record acted on) and `requested_at`. | a `claims` row under the action contract (`raw_ref = agent_api:action:<action_id>`) |
| **Fact** | One event observed in a system of record about a subject: `stripe.refund_created` at 10:42:07 with `amount = 180`. | a `facts` row |
| **Effect** | A fact for the action's subject (or a linked subject) observed at or after `requested_at`, within the contract's window. | — |
| **State (precondition)** | The latest fact of a given type recorded at or before `requested_at`, within a lookback. | — |
| **Outcome lens** | Did every expected effect happen? | the engine's `vendor` lens |
| **Policy lens** | …and were the preconditions, limits and prohibitions respected? | the engine's `strict` lens |

Verdict labels are display names over the engine's fixed vocabulary: `VERIFIED → PASS`, `NOT_VERIFIED → FAIL`, `UNVERIFIABLE → UNKNOWN`. API responses carry both (`verdict` and `raw_verdict`); the public hash-verification endpoint reports the raw value.

## YAML reference

An action contract is a YAML document with the usual contract envelope plus an `action:` block. `lenses:` must be absent (the two lenses are derived, see below); `exclusions:` and `billing:` are allowed as in outcome contracts.

```yaml
id: refund_workflow            # lowercase snake_case, 3-64 chars
version: 1                     # >= 1; edits are new versions
kind: action                   # required for action contracts
vendor: internal               # who runs the agent (used as the claim's vendor when no agent_id is sent)
vendor_display: "Refund workflow (internal agent)"
action_type: refund_order      # default action_type for recorded actions; also the default outcome_type
outcome_type: refund_processed # optional; defaults to action_type, then "action"
claim_source: agent.actions    # optional; defaults to agent.actions
subject_kind: order            # optional; defaults to record
description: >
  Plain-language statement of the policy (shown on the public definitions page).
source_citation:               # required: where the policy comes from
  url: "internal://policies/refunds"
  retrieved: "2026-09-28"
  title: "Internal refund policy (template)"
  notes: "..."
unknown_fields: []
action:
  window: "15m"
  preconditions: [...]
  limits: [...]
  expected_effects: [...]
  forbidden_effects: [...]
  unverifiable_if: ...
billing:
  unit_price: 0
  currency: USD
```

### The `action:` block

| Key | Required | Meaning |
|---|---|---|
| `window` | no (default `1h`) | Settlement window, measured from `requested_at`. Effects are only counted inside it, and the verdict stays `pending` until it closes (unless the outcome lens already passes). Durations are `<n>m`, `<n>h`, `<n>d`, `<n>w`. |
| `preconditions` | no | List of state checks that must hold at the moment of the action. Each is `state_before_action: {...}` (positive spelling, recommended) or `precondition_unmet: {...}`; both compile to the violation primitive `precondition_unmet`. Policy lens only. |
| `limits` | no | List of bounds on the action's own `params`. Entries are `param_max: {...}` (alias of `param_exceeds`) or `param_in: {...}` (alias of `param_not_in`); the primitive names are accepted too. Policy lens only. |
| `expected_effects` | **yes, at least one** | List of `effect_observed: {...}`. All of them must be observed for PASS. Both lenses. An effect may carry `min_trust: self_reported \| shared \| independent`: a fact from a source rated below that does not satisfy it (reported as observed but not ok, reason `effect_below_trust:<source>.<type>`). Trust is the connection's level or the operator's per-source override on the facts-API connection, read at verification time. Default: no gate. |
| `forbidden_effects` | no | List of `effect_count_exceeds`, `effect_attr_exceeds`, `effect_attr_differs` or `effect_observed`. Any observed → FAIL. Policy lens only. |
| `aggregate_limits` | no | List of `aggregate_exceeds` (alias `aggregate_max`): a total of an effect attribute, or a count of effects, across the actions in scope (this agent's, this subject's, or every action under the contract) over a lookback. Policy lens only. |
| `reversed_by` | no | List of `reversal_observed` (`effect_observed` is accepted as the spelling): a later record that undoes the action, looked for within its own `within` (default `30d`). Any observed → FAIL under **both** lenses; one pushed after the verdict settled re-verifies the action. |
| `unverifiable_if` | no | A condition tree (`any` / `all` / `not` over primitives, same syntax as outcome contracts). When it holds the verdict is UNKNOWN under both lenses. Typically `missing_fact_source: [stripe, shopify]`. A source counts as present when a healthy or syncing connection provides it; a connection in error does not, and a caller that knows a source could not be read for one verification (the sandbox's extractor) can name it as unavailable for that verification. |

Every list entry is a single-key mapping `{primitive: argument}`. Any other primitive in these lists is rejected at validation time (`'csat_negative' is not allowed here`).

### Primitives

Identifiers (`type`, `source`, `attr`, `param`, keys of `where`) must match `[a-z][a-z0-9_.:-]{0,63}` (input is lowercased). Values embedded in comparisons (`where` values, `values`) must match `[A-Za-z0-9_.:@/ -]{1,120}`; anything else is rejected at validation. String comparisons are case-insensitive; numeric comparisons read the attribute as a number.

#### `effect_observed` — expected effect

```yaml
- effect_observed:
    type: refund_created          # fact_type to look for (required)
    source: stripe                # fact source (optional but recommended; also drives coverage/UNKNOWN)
    within: "15m"                 # optional; filled from action.window when omitted
    where: {status: succeeded}    # optional; every attribute must equal the value
    message: "Payment refund was not created"   # optional, <= 200 chars; used as the FAIL headline
```

A bare string (`- effect_observed: refund_created`) is shorthand for `{type: refund_created}`.

True when at least one fact with that `type` (and `source`, and `where` attributes) exists for the action's subject or a linked subject with `requested_at <= occurred_at <= requested_at + within`. Evidence is the earliest matching fact. Reason codes: `effect_observed:<source>.<type>` when seen, `expected_effect_missing:<source>.<type>` when not.

#### `effect_count_exceeds` — forbidden: too many matching effects

```yaml
- effect_count_exceeds: {source: stripe, type: refund_created, max: 1}
```

Same effect arguments as `effect_observed` plus `max` (non-negative integer, default `1`). True when **more than** `max` matching effects exist in the window. Evidence is the `(max+1)`-th matching fact (for a duplicate refund, the second refund). Reason code `duplicate_effect:<source>.<type>`.

#### `effect_attr_exceeds` — forbidden: an effect carries a value above a ceiling

```yaml
- effect_attr_exceeds: {source: stripe, type: refund_created, attr: amount, max: 250}
```

Same effect arguments plus `attr` and `max` (number, required). True when any matching effect in the window has `attributes[attr] > max`. Evidence is the earliest such fact. Reason code `forbidden_effect_observed:<source>.<type>`.

#### `effect_observed` in `forbidden_effects`

Allowed: the effect must **not** be seen. Its reason code is the positive `effect_observed:<source>.<type>` (there is no dedicated "forbidden" code for this spelling); the check table and headline still report it as a forbidden effect.

#### `effect_attr_differs` — forbidden: an effect carries a value that differs from its reference

```yaml
- effect_attr_differs: {source: payments, type: payout_created, attr: amount, equals_param: amount}
- effect_attr_differs:
    source: ledger
    type: account_balance
    attr: balance
    equals_state: {source: ledger, type: account_balance, attr: balance, lookback: "30d"}
    minus_param: amount            # or plus_param; optional
    tolerance: 0.005               # optional, default 0 (absolute)
    message: "Ledger balance after the payout is not the balance before minus the amount"
```

Same effect arguments plus `attr` and exactly one reference: `equals_param` (one of the action's own `params`) or `equals_state` (the `attr` of the **latest** state fact of that `type`/`source` at or before `requested_at`, as `state_before_action` finds it), optionally `plus_param` or `minus_param`. True when any matching effect in the window has `attributes[attr]` set and `|attributes[attr] - reference| > tolerance`. **No reference, no violation**: a missing parameter or a missing state record makes the comparison NULL, so require the state with a precondition when it must exist. Evidence is the earliest mismatching fact plus the reference value; the check's `value` is `{expected, observed}`. Reason code `effect_attr_mismatch:<source>.<type>.<attr>`.

#### `aggregate_exceeds` (alias `aggregate_max`) — a total across many actions

```yaml
aggregate_limits:
  - aggregate_exceeds:
      source: payments
      type: payout_created
      attr: amount                 # omit to count matching effects instead of summing an attribute
      max: 5000
      over: "24h"                  # lookback, required
      by: agent                    # agent (default) | subject | contract
      message: "This agent's payouts over 24 hours exceed the daily total"
```

Every other check judges one action; this one judges the effects of the **actions in scope**: those under the same contract whose `requested_at` lies between `over` before this action and the close of this action's window, filtered to the same `agent_id` (`by: agent`), to this action's own subject and its links (`by: subject`), or to none (`by: contract`). The matching effects on those actions' subjects (own and linked) are summed over `attr`, or counted, within `(requested_at + within - over, requested_at + within]`, so the aggregate includes this action's own effects. True when the total is above `max`. Evidence is the latest contributing fact plus the total; the check's `value` is the total. Reason code `aggregate_exceeded:<source>.<type>[.<attr>]`. An action is judged on its own lookback, so an earlier action that passed stays PASS when a later one pushes the total over.

#### `reversal_observed` — a later record undoes the action

```yaml
reversed_by:
  - effect_observed: {source: payments, type: payout_returned, within: "30d", message: "The payout was returned"}
```

Same arguments as `effect_observed`; `within` defaults to `30d` rather than the action window. True when a matching fact exists for the subject (or a linked subject) with `requested_at <= occurred_at <= requested_at + within`. It fails the **outcome** lens as well as the policy lens, because the intended effect did not hold. Reason code `reversed:<source>.<type>`. When a fact of a type named in any `reversed_by` is pushed (`POST /v1/facts`, or inline in `/v1/actions/verify`), every action it undoes is re-verified by a `verify_action` job (idempotency key `verify_action:<claim_id>:fact:<fact_id>`); the new run stores new verdicts, the latest verification becomes FAIL and `action.verified` is emitted again.

#### `state_before_action` / `precondition_unmet` — precondition

```yaml
- state_before_action:
    source: shopify
    type: order_status
    where: {status: eligible}     # required, non-empty
    lookback: "30d"               # optional, default 30d
    message: "Order was not eligible for a refund at the time of the action"
```

Looks up the **latest** fact of that `type` (and `source`) for the subject with `requested_at - lookback <= occurred_at <= requested_at`, and requires every `where` pair to match it. The precondition is **unmet** when that fact does not match **or when no such fact exists**. Evidence is the fact that was inspected. Reason code `precondition_unmet:<source>.<type>`.

#### `param_exceeds` (alias `param_max`) — limit on the action's own parameter

```yaml
- param_max: {param: amount, max: 250}
```

True when `params[param] > max`. A missing or non-numeric parameter is treated as `0`, so it never exceeds the limit. Reason code `limit_exceeded:<param>`; evidence is the claim record with the parameter's value.

#### `param_not_in` (alias `param_in`) — allowed values for a parameter

```yaml
- param_in: {param: reason, values: [customer_cancelled, damaged, not_delivered]}
```

True (violation) when `lower(params[param])` is not one of `values`. A missing parameter is not a violation (limits apply to parameters the agent actually sent, as with `param_max`). Reason code `param_not_allowed:<param>`.

### Window and time semantics

- `requested_at` is the reference point for everything. It is the `requested_at` sent when the action was recorded (default: the time of recording).
- Effects: `occurred_at >= requested_at` and `occurred_at <= requested_at + within`. The window is inclusive at both ends.
- Preconditions: `occurred_at <= requested_at` (a state fact stamped exactly at `requested_at` counts as "before").
- Facts are matched for the action's `subject_id` and for every subject linked to it through `subject_links`, in either direction. Links come from connectors or from the `links` list pushed with facts (`POST /v1/facts`): a support ticket and the order it is about are one case, so a notification sent on the ticket satisfies `customer_notified` for the order, and the orphan-effect report never lists a linked subject.
- `within` is copied into every effect argument that does not set its own, so the window becomes part of each primitive's call key and therefore of the verdict hash (see determinism below).

## How the two lenses are derived

Action contracts do not declare `lenses:`. The schema compiles the `action:` block into the engine's two lenses:

| Engine lens | Displayed as | `verified_if` | `not_verified_if` | `unverifiable_if` |
|---|---|---|---|---|
| `vendor` | **outcome** | `all` of `expected_effects` | `any` of `reversed_by` (none when the block is empty) | `action.unverifiable_if` |
| `strict` | **policy** | `all` of `expected_effects` | `any` of `preconditions + limits + aggregate_limits + forbidden_effects + reversed_by` | `action.unverifiable_if` |

Contract-level `exclusions` apply to both lenses, as in outcome contracts. Overrides are always empty, so transcript-assist labels can never affect an action verdict.

| Verdict | Outcome lens | Policy lens |
|---|---|---|
| **PASS** | every expected effect was observed in the window | …and no precondition, limit or forbidden effect fired |
| **FAIL** | at least one expected effect was not observed (or the action is excluded) | a precondition was unmet, a limit exceeded, a forbidden effect observed, or an expected effect missing |
| **UNKNOWN** | the subject has no record in any fact source, a required source has never reported, or nothing relevant was observed | same |

Because the policy lens tests violations *before* expected effects, an action whose refund went through but exceeded the limit is **PASS on outcome and FAIL on policy**. The summary counts (`GET /v1/actions/summary`) use the policy verdict.

## Evaluation order

For each lens, tests run in this fixed order; the first that applies decides.

| Step | Test | Verdict | Reason codes |
|---|---|---|---|
| 1 | a contract `exclusion` matches | FAIL | `excluded_by_contract`, plus the primitive |
| 2 | no fact exists for the subject or any linked subject | UNKNOWN | `identity_unmatched` |
| 3 | `unverifiable_if` holds (e.g. a named source has never reported) | UNKNOWN | the matched primitive(s), e.g. `missing_fact_source` |
| 4 | *policy lens:* any precondition / limit / aggregate / forbidden effect / reversal fires; *outcome lens:* any reversal fires | FAIL | every violation that fired, in contract order: `precondition_unmet:…`, `limit_exceeded:…`, `aggregate_exceeded:…`, `duplicate_effect:…`, `forbidden_effect_observed:…`, `effect_attr_mismatch:…`, `param_not_allowed:…`, `reversed:…` |
| 5 | every expected effect was observed | PASS | `effect_observed:…` for each |
| 6 | the subject has facts, but none of the expected effect types were ever observed | UNKNOWN | `no_structured_signal` |
| 7 | otherwise | FAIL | `verified_condition_not_met`, then `expected_effect_missing:…` for each missing effect |

Each verdict is computed in SQL and re-derived in Python from the same columns; a disagreement raises and nothing is written.

## Settlement window and `pending`

`settles_at = requested_at + window`. After a verification:

- `status` is **`pending`** while the verdict is provisional: the outcome lens is not PASS **and** either the window is still open (`now < settles_at`) or the window has closed but a source the contract reads has not yet reported past it (below).
- Otherwise `status` is **`settled`**: all expected effects already appeared (an early PASS settles at once and schedules no re-check), or the window has closed and every source has caught up, or the bounded wait ran out. Any action can still be re-verified manually at any time (`POST /v1/actions/verify`), which stores a new run.

### Settlement waits for evidence, not for the clock

A window that closes on the wall clock while a connector is still syncing turns lag into a false FAIL. So a verdict that is not yet a PASS settles only when every source named in the action block (`preconditions`, `aggregate_limits`, `expected_effects`, `forbidden_effects`, `reversed_by`) has a **watermark at or past `settles_at`**:

- a synced connection's watermark is the one it reports after each sync;
- a source pushed through `POST /v1/facts` has the watermark the pusher last declared for it in `watermarks` (`{"crm": "2026-10-13T10:45:00Z"}`: everything up to then has been sent), and none until it does;
- a source with no watermark is taken as current.

While a source is behind, the view and the list carry `settlement.waiting_on: [{ source, watermark, behind_seconds }]`, the headline reads `PENDING — <observed> of <expected> expected effects observed; window closed <settles_at>, waiting for crm (2h 10m behind) to catch up`, and the queued re-check carries `waiting_on` so a successful sync of that source (or a push that advances its watermark) brings the re-check forward instead of waiting for the next tick (`SETTLEMENT_RECHECK_INTERVAL`, default 15m). The wait is bounded by `SETTLEMENT_MAX_LAG` after `settles_at` (default 48h): past it the verdict settles on what is known, `settlement.stale_sources` names the sources still behind, and the FAIL headline ends with `(crm (3d behind) at settlement)`.

A pending verification is persisted like any other (a `Run` with `triggered_by = action` and two chained verdicts) and marked provisional in the run stats. The engine enqueues a `verify_action` job at `settlement.next_check_at` (idempotency key `verify_action:<claim_id>:<run_at>`). That job re-runs the same evaluation, stores a new run and verdicts, and emits `action.verified` once the verdict settles. The first provisional verdict emits `action.pending` once; re-checks that stay pending are quiet. The most recently computed verdict is what `GET /v1/actions` and the summary report.

Every verification also reports `sources` (trust level and watermark per source the contract read), `reversal` (the later record that undid the action, when one did), `previous` (the verdict pair this run replaced) and `changed`.

While pending with the window open, the headline is `PENDING — <observed> of <expected> expected effects observed so far; window closes <settles_at>` when the first failing check is an expected effect. If a precondition, limit or forbidden effect has already fired, the headline is the `FAIL — …` line for that check even though `status` is still `pending`.

### The completion gate: do not say "done" before it settled

A workflow that tells the customer "your order is cancelled and refunded" is making a claim. The gate is the
customer's own workflow reading the verdict before it makes that claim: the action runs through the native
controls and the durable runtime as before, CommitLayer observes the systems of record, and the step that reports
completion waits for the settled verdict.

`GET /v1/actions/{ref}/wait?timeout=<seconds>` holds the request until the verdict settles or the timeout passes
(at most 60 seconds per call; the SDKs loop: `cl.wait_for_settlement(ref, timeout=300)`,
`await cl.waitForSettlement(ref, {timeoutMs: 300_000})`). It returns the verification plus `timed_out` and
`waited_seconds`. On every poll it evaluates the evidence that has arrived; the moment the evaluation settles (an
early PASS when the last expected effect lands, or the window closed with every source caught up) it stores that
verification once, like a scheduled re-check would, and returns it. It never moves a verdict: facts decide.

What the workflow does with the answer is the workflow's policy, and should be written down:

| Result | Meaning | Typical handling |
|---|---|---|
| `status: settled`, outcome `PASS` | The intended outcome landed across every system and nothing forbidden was observed | Tell the customer; close the case |
| `status: settled`, outcome `FAIL` | An expected effect is missing, or a forbidden effect, limit, precondition or reversal fired | Route to recovery: a compensating action (`compensates: <ref>`), a human, an incident channel |
| `timed_out: true`, `status: pending` | Still inside the window, or a source is behind its watermark | The workflow's decision: wait longer, tell the customer it is in progress, or escalate. Never report success. |

The gate adds no latency to the action itself and is never a single point of failure for it: the refund is already
issued when the gate is consulted, and a workflow that cannot reach CommitLayer falls back to its own policy. The
Temporal and Inngest integrations ship this as a verifier activity and a step helper (`integrations/`).

### Headline rules (`summary.headline`)

Checks are scanned in the order preconditions → limits → aggregate limits → forbidden effects → reversals → expected effects; the first failing check produces the line:

| Situation | Headline |
|---|---|
| both lenses PASS | `PASS — all N expected effects observed within <window>; preconditions, limits and prohibitions respected` |
| both lenses UNKNOWN | `UNKNOWN — <label of the first reason code>` |
| precondition unmet | `FAIL — <message or description>` |
| limit exceeded | `FAIL — limit exceeded: <description> (was <value>)` |
| aggregate limit exceeded | `FAIL — <message or description> (was <total>)` |
| forbidden effect observed | `FAIL — <message>` or `FAIL — forbidden effect observed: <description>` |
| reversal observed | `FAIL — <message or description>` |
| expected effect missing, window open | `PENDING — …` |
| expected effect missing, window closed | `FAIL — <message>` or `FAIL — expected effect not observed: <description>` |

`message` on an effect or precondition exists for this purpose: write it as the sentence you want an operator to read when that check fails.

## Full example: `refund_workflow`

The library template (`contracts/library/refund_workflow.yaml`). It is an internal policy, so the citation points at the customer's own policy document.

```yaml
id: refund_workflow
version: 1
kind: action
vendor: internal
vendor_display: "Refund workflow (internal agent)"
action_type: refund_order
outcome_type: refund_processed
claim_source: agent.actions
subject_kind: order
description: >
  The agent may refund an order only when the order is eligible and the amount is within the limit.
  A successful refund must show up as a refund in the payment system, a cancelled order in the order system,
  a note on the customer record, and a customer notification — all within 15 minutes. A second refund for
  the same order, or a refund above the limit, is forbidden.
source_citation:
  url: "internal://policies/refunds"
  retrieved: "2026-09-28"
  title: "Internal refund policy (template)"
  notes: >
    Template values. Replace with your policy document URL and the limits your finance team signed off.
unknown_fields: []
action:
  window: "15m"
  preconditions:
    - state_before_action:
        source: shopify
        type: order_status
        where: {status: eligible}
        message: "Order was not eligible for a refund at the time of the action"
  limits:
    - param_max: {param: amount, max: 250}
  expected_effects:
    - effect_observed:
        source: stripe
        type: refund_created
        message: "Payment refund was not created"
    - effect_observed:
        source: shopify
        type: order_cancelled
        message: "Shopify order remained active despite a successful payment refund"
    - effect_observed:
        source: crm
        type: note_added
        message: "No note was added to the customer record"
    - effect_observed:
        source: messaging
        type: customer_notified
        message: "Customer was not notified"
  forbidden_effects:
    - effect_count_exceeds: {source: stripe, type: refund_created, max: 1}
    - effect_attr_exceeds: {source: stripe, type: refund_created, attr: amount, max: 250}
  unverifiable_if:
    - missing_fact_source: [stripe, shopify]
billing:
  unit_price: 0
  currency: USD
```

Compiled, this yields: outcome lens `verified_if = all(4 expected effects)`; policy lens additionally `not_verified_if = any(precondition_unmet, param_exceeds, effect_count_exceeds, effect_attr_exceeds)`; both `unverifiable_if = missing_fact_source(stripe, shopify)`. `ledger contracts explain contracts/library/refund_workflow.yaml` prints the plain-language version; `GET /v1/public/definitions/refund_workflow` serves it with `kind: action`, `lens_labels`, `window` and per-section rule lists.

Worked verdicts for this contract (from `tests/test_actions.py`):

| Facts for the order | Outcome | Policy | Reason codes (policy) | Headline |
|---|---|---|---|---|
| eligible before; refund 180, CRM note, notification after; **no cancellation** | FAIL | FAIL | `verified_condition_not_met`, `expected_effect_missing:shopify.order_cancelled` | `FAIL — Shopify order remained active despite a successful payment refund` |
| same, then `order_cancelled` arrives and the action is re-verified | PASS | PASS | `effect_observed:…` ×4 | `PASS — all 4 expected effects observed within 15m; …` |
| status `fulfilled` before; two refunds of 400; all four effects present; `params.amount = 400` | PASS | FAIL | `precondition_unmet:shopify.order_status`, `limit_exceeded:amount`, `duplicate_effect:stripe.refund_created`, `forbidden_effect_observed:stripe.refund_created` | `FAIL — Order was not eligible for a refund at the time of the action` |
| no fact of any kind for the subject | UNKNOWN | UNKNOWN | `identity_unmatched` | `UNKNOWN — Subject not found in fact sources` |
| requested just now; eligible state and refund present, three effects not yet | — | — | — | `PENDING — 1 of 4 expected effects observed so far; window closes …` (status `pending`, re-check job queued) |

## Recording actions and pushing facts

Two credentials, one per side of the line. The agent runtime holds a **recorder** key (`actions:record`): it records actions and reads their verdicts and can do nothing else. The pipeline that relays a system of record holds an **evidence** key (`facts:write`), bound to the sources it pushes for. The key that records an action can never also supply the facts that verify it. People signed in to the app are governed by role (`analyst` or higher to record, push and verify; `viewer` to read). Full request/response shapes are in [API.md](/docs/api#actions).

**1. Push facts** from the systems of record with the evidence key (or let a connector do it):

```bash
curl -s -X POST "$API_URL/v1/facts" -H "X-API-Key: $COMMITLAYER_EVIDENCE_KEY" -H 'content-type: application/json' -d '{
  "facts": [
    {"source": "shopify", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "order_status",
     "occurred_at": "2026-09-28T10:41:30Z", "attributes": {"status": "eligible", "total": 180.0}},
    {"source": "stripe", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "refund_created",
     "occurred_at": "2026-09-28T10:42:07Z", "attributes": {"amount": 180.0, "currency": "USD", "refund_id": "re_1Q2x"}}
  ]}'
# → {"ingested": 2}
```

**2. Record the action** when the agent requests it, with the recorder key:

```bash
curl -s -X POST "$API_URL/v1/actions" -H "X-API-Key: $COMMITLAYER_RECORDER_KEY" -H 'content-type: application/json' -d '{
  "action_id": "act_demo_refund_001", "contract_id": "refund_workflow",
  "workflow_id": "wf_cancel_and_refund", "agent_id": "whatsapp-support-agent", "action_type": "refund_order",
  "subject_id": "order_10231", "subject_kind": "order", "requested_at": "2026-09-28T10:42:00Z",
  "params": {"amount": 180.0, "currency": "USD", "reason": "customer_cancelled"}, "channel": "whatsapp"}'
# → 201 {"action": {..., "latest": null}}
```

Recording does not verify. `latest` stays `null` until the first verification.

**3. Verify** — by reference, or inline in one call (action plus facts), optionally as a dry run:

```bash
curl -s -X POST "$API_URL/v1/actions/verify" -H "X-API-Key: $COMMITLAYER_RECORDER_KEY" -H 'content-type: application/json' \
  -d '{"action_id": "act_demo_refund_001"}'
```

(A recorder key may ask for a verdict by reference; inline `facts` on this call need the evidence key.)

The CLI wraps the same path: `ledger actions verify --tenant <slug|id> --file body.json [--dry-run]`, where `body.json` is the `/v1/actions/verify` request body.

**Facts from the systems of record, not from the agent.** Effects come from connectors or from the pipeline that reads the system of record (payment provider webhooks, order-system events), through an evidence key bound to those sources. The recorder key cannot push facts, so the agent cannot supply its own "I did it" evidence. A source a live connector serves cannot be pushed at all. Where a contract must not accept a self-reported fact for an effect, say so with `min_trust: independent` on that effect.

**OpenTelemetry.** Spans carrying `agent.action.type` and `agent.action.subject_id` on the tenant's OTLP endpoint become facts with `source: otel`, `subject_kind: ticket`, `fact_type` mapped from the action type (`status_change`, `escalate`/`handoff` → `escalation`, `reply` → `agent_message`, anything else → `agent_action`) and `attributes {action, from, to, actor: "agent"}` (see [otel-convention.md](/docs/otel-convention) and [connectors/otel.md](/docs/connectors/otel)). An action contract can therefore name them, e.g. `effect_observed: {source: otel, type: agent_action, where: {action: close_ticket}}`. OTel spans do **not** create actions; the action itself is recorded through `POST /v1/actions` or `/v1/actions/verify`.

## Compensation: COMPENSATED without changing a verdict

A verdict is what happened; a correction is what happened next. When an agent (or a person driving one) records a later action with `compensates: "<action_id of the failed one>"`, the failed action carries a `resolution` beside its unchanged verdict and hash:

| `resolution.state` | Meaning |
|---|---|
| `compensating` | A correcting action was recorded and is not yet settled |
| `compensated` | The correcting action settled PASS under its own contract |
| `compensation_failed` | The correcting action settled FAIL or UNKNOWN |

`resolution` also carries `by` (the correcting action id), `by_claim_id` and `at`. The failed action's headline gains ` · COMPENSATED by <action_id>`, `GET /v1/actions?verdict=COMPENSATED` lists compensated actions, the summary counts them in `compensated` (they stay counted under `fail` too, since the verdict stands), and `action.compensated` is emitted once the correction settles PASS. A compensation is therefore itself verified: nothing is marked resolved on anyone's say-so, only on a passing verdict from the systems of record.

## Idempotency

**Actions** are keyed on `action_id`. The stored `raw_ref` is `agent_api:action:<action_id>`; recording the same `action_id` again updates `requested_at`, `params`, `workflow_id`, `agent_id`, `subject_id` and `channel` on the existing action (it keeps its internal id and its verification history). `contract_id`, `action_type` and the currency are set on first record and not changed by an update. If `action_id` is omitted, one is generated (`act_…`) and every call creates a new action, so always send your own id from the agent's execution log. `GET /v1/actions/{ref}` accepts either the `action_id` or the internal id.

**Facts** are keyed on `raw_ref`. Send the source system's own event id (`re_1Q2x`, a webhook delivery id) as `raw_ref` when you have one. When omitted, the engine derives `agent_api:fact:<sha256>` over `[source, subject_id, fact_type, occurred_at, attributes]`, so re-sending an identical fact ingests nothing (`{"ingested": 0}`), while any change in an attribute produces a **new** fact rather than an update. Facts are never modified or deleted by the API.

**Verification** is deterministic: verifying the same action twice over the same facts produces the same verdict hashes (`computed_at` is stored but not hashed). Each persisted verification is a new `Run`; the latest one is what lists and summaries show. Re-checking a pending action is scheduled once per `settles_at` value.

**Contract version.** `contract_version` on the request is resolved when the action is recorded and pinned on the action; every later verification of that action uses the pinned version (the latest version at record time when none was sent). The version used is recorded on each verdict (`contract_version`).

## Reason codes

| Code | Lens | Meaning |
|---|---|---|
| `effect_observed:<source>.<type>` | both | The expected effect was observed in the window (PASS reasons). Also the code emitted when an `effect_observed` entry in `forbidden_effects` fires. |
| `expected_effect_missing:<source>.<type>` | both | The expected effect was not observed within the window (follows `verified_condition_not_met`). |
| `duplicate_effect:<source>.<type>` | policy | More than `max` matching effects were observed (`effect_count_exceeds`). |
| `forbidden_effect_observed:<source>.<type>` | policy | A matching effect carried `attr` above `max` (`effect_attr_exceeds`). |
| `precondition_unmet:<source>.<type>` | policy | The latest state record before the action did not match, or no state record exists. |
| `precondition_met:<source>.<type>` | — | Emitted only when `state_before_action` is used as a positive primitive outside the `action:` block. |
| `limit_exceeded:<param>` | policy | `params[param]` is above the contract's maximum. |
| `aggregate_exceeded:<source>.<type>[.<attr>]` | policy | The total (or count) of the effect across the actions in scope over the lookback is above `max` (`aggregate_exceeds`). |
| `effect_attr_mismatch:<source>.<type>.<attr>` | policy | A matching effect's `attr` differs from its reference beyond the tolerance (`effect_attr_differs`). |
| `reversed:<source>.<type>` | both | A later record undid the action within the lookahead (`reversal_observed`). |
| `param_not_allowed:<param>` | policy | `params[param]` is not one of the allowed values (or is missing). |
| `verified_condition_not_met` | both | Engine: facts exist but not every expected effect was observed. Always followed by the `expected_effect_missing` codes. |
| `identity_unmatched` | both | Engine: no fact for the subject or any linked subject. |
| `no_structured_signal` | both | Engine: the subject has facts, but none of the expected effect types were ever observed. |
| `missing_fact_source` | both | A source named in `unverifiable_if` has never reported for this tenant (no connection and no pushed fact from it). |
| `excluded_by_contract` | both | The action matches a contract exclusion. |

When `<source>` is omitted in the contract the code carries the bare type (`expected_effect_missing:order_cancelled`). Labels for the UI come from `app/engine/reasons.py` (`Expected effect not observed: shopify order_cancelled`, `Limit exceeded: amount`, …).

In the `checks` table of a verification response, `code` names the check's violation code regardless of whether it fired, except for expected effects where it switches between `effect_observed:…` and `expected_effect_missing:…`; use `ok` for the result.

## Limitations

- **Read-only, after the fact, no enforcement.** Verification runs after the action was requested and only reads facts; it never blocks, approves, executes, retries or reverses an action, and there is no hook for doing so in v1. Use the verdict, the webhook and the evidence in your own workflow.
- **Only what the systems of record show.** A FAIL means the expected effect was not observed in the facts available when the verdict settled; it does not assert why. Settlement waits for every source's watermark to pass the window (bounded by `SETTLEMENT_MAX_LAG`), so a lagging connector delays the verdict rather than failing it; a source that declares no watermark is taken as current. Facts that arrive after settlement are stored and count in a later manual re-verification, but the settled verdict is not re-issued automatically, except for a fact named in `reversed_by`, which re-verifies the actions it undoes.
- **Trust is reported, never applied.** Each source carries the trust level of the connection behind it (`independent`, `shared`, `self_reported`; see SECURITY.md §16). A verdict built on self-reported facts is labelled as such in `sources` and `summary.evidence_trust`; the engine does not weigh facts by it.
- **UNKNOWN when a required source has never reported.** `missing_fact_source: [stripe]` treats a source as available once a healthy connection for it exists or at least one fact with that `source` has been pushed through `/v1/facts` (or inline in `/v1/actions/verify`). Until then every action under that contract is UNKNOWN under both lenses. `identity_unmatched` (no fact at all for the subject) is tested earlier and wins.
- **Coverage disclosure, not inference.** Effects from a source that is not connected are treated as absent; nothing is inferred from the agent's own report.
- **One subject per action.** Effects are matched on the action's `subject_id` and its `subject_links`; an effect recorded under an unlinked id (a Stripe charge id instead of the order id) is not found. Push facts under the same subject id the agent uses, or create links.
- **Timestamps are compared as UTC.** `requested_at` and `occurred_at` are stored to the second.
- **Limits read only the action's own `params`** as sent by the agent; they are not checked against the effect. Use `effect_attr_exceeds` to bound what actually happened in the system of record, `effect_attr_differs` to require it to equal what was requested, and `aggregate_limits` to bound it across many actions.
- **Aggregates are read-only and after the fact.** An aggregate limit says the total was exceeded once the effects were observed; it never holds an action. Nothing here is a velocity control in the execution path.
- **Pinned contract version.** An action keeps the contract version it was recorded under; publish a new version and only new actions use it.
- **Nothing is enforced by the summary.** `GET /v1/actions/summary` counts the policy verdict of the latest verification per action; `pending` overlaps with the other buckets.
