Skip to content
Browse the documentation
Reference

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_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.

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 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.