Action contracts
The YAML that states what must hold before an agent acts, the limits on the action, and the effects that must and must not appear in the systems of record afterwards.
Last changed Markdown versionllms.txt
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).
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.
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
- 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
- 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
- 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
- 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
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
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
- 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
- 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
- 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_atis the reference point for everything. It is therequested_atsent when the action was recorded (default: the time of recording).- Effects:
occurred_at >= requested_atandoccurred_at <= requested_at + within. The window is inclusive at both ends. - Preconditions:
occurred_at <= requested_at(a state fact stamped exactly atrequested_atcounts as "before"). - Facts are matched for the action's
subject_idand for every subject linked to it throughsubject_links, in either direction. Links come from connectors or from thelinkslist 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 satisfiescustomer_notifiedfor the order, and the orphan-effect report never lists a linked subject. withinis 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:
statusispendingwhile 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
statusissettled: 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/factshas the watermark the pusher last declared for it inwatermarks({"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.
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.
1. Push facts from the systems of record with the evidence key (or let a connector do it):
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:
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:
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 and connectors/otel.md). 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 inreversed_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 insourcesandsummary.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 thatsourcehas 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_idand itssubject_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_atandoccurred_atare stored to the second. - Limits read only the action's own
paramsas sent by the agent; they are not checked against the effect. Useeffect_attr_exceedsto bound what actually happened in the system of record,effect_attr_differsto require it to equal what was requested, andaggregate_limitsto 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/summarycounts the policy verdict of the latest verification per action;pendingoverlaps with the other buckets.