---
title: "EffectSpec"
description: "The required per-tool declaration of effect kind, idempotency propagation, dispatch-status classification, upstream id selection, and verification."
---
Every tool carries an `EffectSpec` (ADR-0019). It is required, not advisory:
`ToolSnapshot.effect` and `BundleTool.effect` are both non-optional, and
`WorkspaceSnapshot::validate` calls `effect.validate()` per tool and rejects the whole
snapshot on the first failure. There is no "later" and no parking an EffectSpec in a
design note.
The type is `apps/gateway/crates/obol-types/src/effect.rs`; the published contract is
`packages/proto/effect_spec.schema.json`.
## Shape
```text
EffectSpec
schema_version: 1 # required
kind: read | create | update | delete | external_side_effect
idempotency: unsupported | header{name} | body_pointer{pointer}
dispatch_statuses: { accepted: StatusMatcher[], definitely_rejected: StatusMatcher[] }
upstream_id: ValueSelector | null
verification: none | response_binding | a bare VerificationPlan
no_evidence: NoEvidenceReason | null # ADR-0036
```
`StatusMatcher` is tagged on `type`: `{"type":"exact","status":404}` or
`{"type":"range","start":200,"end":299}`. `IdempotencyBinding` is tagged on `type` too.
## Two rules to read before authoring anything
**`verification` is an untagged union, and a plan is written bare.** There is no
`{"type": "plan", …}` wrapper — writing one fails deserialization.
| Intent | Exact wire form |
|---|---|
| No verification (reads) | `{"type": "none"}` |
| Synchronous evidence | `{"type": "response_binding", "config": {"trust": …, "assertions": […]}}` |
| Staged evidence | the bare plan object — `{"schema_version": 1, "deadline_s": …, "stages": […], "completion": {…}}` |
`schema_version: 1` is required at the top of every `EffectSpec`, and again inside a
plan. Copy `packages/proto/examples/effect_spec.json` (plan form) or the tools in
`packages/proto/examples/workspace_snapshot.json` (immediate form) rather than
hand-writing from memory.
**Never publish a `VerificationPlan` on a `ToolSnapshot`.** The runtime verifier matches
only `VerificationSpec::ResponseBinding` and returns an empty assertion vector for a
plan. Zero assertions means no `failed`, `missing`, or `invalid` outcome — so a
plan-carrying mutation would otherwise mint a verdict from evidence nobody evaluated.
`VerificationMethod` is `none | response_binding` only, so a receipt cannot name
plan-based verification anyway.
Author plans in a `ConnectorOverlay` or `ConnectorBundle`. A tool that reaches a
published snapshot uses `none` or a bare `response_binding`.
Do not stash effect data in `annotations` either. That field is executor dispatch config
only — `{"openapi": {"path": …, "method": …}}`.
## Fields
### `kind`
| Method | Import behavior |
|---|---|
| `GET` | `EffectSpec::read_only()` — automatic |
| `POST` / `PUT` / `PATCH` / `DELETE` | Requires a complete `x-obol-effect` extension |
`external_side_effect` is **never derived**. Author it for sends, notifications, and
payments — anything whose effect leaves the vendor's own state and cannot be read back
from it.
`destructive` is a *separate* signal feeding Cedar and [approvals](/policy/approvals):
`method == "delete" || x-obol-destructive == true`. Nothing cross-checks it against
`effect.kind`, and no compiler derives it, so set it by hand or a mutation skips prod
approval.
### `idempotency`
- **`header { name }`** — the name must parse as an HTTP header name, and the executor
refuses a protected header at dispatch (`x-obol-*`, `connection`, `cookie`,
`set-cookie`, and the hop-by-hop set) or the credential header.
- **`body_pointer { pointer }`** — the key rides in the body (PagerDuty's
`/payload/dedup_key`). The pointer must target a field **the request already sends**;
the executor replaces, it does not create, and fails with "idempotency body pointer is
absent" otherwise.
- **`unsupported`** — the honest default for a vendor with no primitive. Nothing is
propagated outbound. **Consequence:** such a mutation may never be automatically
redispatched after an ambiguous attempt. Write that down in `CONNECTOR.md`; it is a real
product limitation.
Every call still gets an Obol idempotency key ([invariant 7](/concepts/invariants)). The
binding controls *outbound propagation* only. Author the real binding from the vendor
brief — never default to `unsupported` because the research was thin.
### `dispatch_statuses`
Accepted and definitely-rejected matchers must not overlap, and the check is real:
`validate_dispatch_statuses` chains both lists into one set and brute-forces every status
from 100 to 599. Two overlapping ranges **inside `accepted` alone** also trip it.
Anything matched by neither is *ambiguous* for a state-changing call, and becomes
`unknown` / `inconclusive`. That is a feature.
A conservative starting point for mutations:
```text
accepted = [200..=299]
definitely_rejected = [400..=499] minus {408, 409, 425, 429}
```
Leave 408, 425, and 429 out — the request may have been partially processed. Leave 409
out — on a create it can mean "your own retry already made it." 3xx and 5xx are
ambiguous. For `kind: read` only, all 4xx and 5xx may be definitely rejected: a failed
read changes nothing.
Never assume a status class proves an effect. ADR-0019 rejects "treat a successful HTTP
or MCP response as verification" by name.
### `upstream_id`
A `ValueSelector` over a closed source set, with per-source field rules:
| `source` | Carries |
|---|---|
| `request_body`, `response_body`, `mcp_structured` | a JSON pointer; no header |
| `response_header` | a header name only; no pointer |
| `http_status` | neither |
`mcp_structured` selects `structuredContent` on an MCP `tools/call` result. It names the
**same evidence** as `response_body` — the relay normalizes structured content into the
observation's response body before the verifier sees it. Use it to record intent, never
to get different behavior.
Emit `upstream_id` **only** when the 2xx schema actually declares a stable identifier, and
**never guess**: no fallback chain of `sha`, `number`, `key`, `_id`. A
confidently wrong identifier corrupts webhook correlation, idempotency replay, and every
`{"from":"upstream_id"}` probe parameter that reads it.
### `verification`
`none` for reads. For a state-changing effect, at least one assertion is mandatory
(`MutationWithoutAssertions`) — unless the tool declares `no_evidence`, below.
Write a bare **`response_binding`** when every piece of evidence is in the synchronous
response. This is the only shape the runtime evaluates, and the only shape allowed on a
published `ToolSnapshot`. It carries `trust: gateway_observed_only |
allow_connector_attested`. `allow_connector_attested` is the sole way a worker's own claim
can reach `verified`, and it is rejected outright on a remote MCP connection
(`RemoteMcpCannotAttest`).
Write a **full plan** when evidence arrives after the response. A plan has **no `trust`
field**: trust is per evidence source (ADR-0025), and `completion.acceptable` names the
acceptable `(claim, trust)` pairs explicitly. Bundle or overlay only.
A response binding *is* the degenerate one-stage `immediate` case of a plan;
`VerificationPlan::from_response_binding` is that mapping in code. ADR-0019 and ADR-0021
are reconciled — this is settled, not an open contract conflict.
The mutation-needs-assertions rule spans every stage: a readback-only or webhook-only plan
satisfies it, because the evidence simply arrives later than the response.
### `no_evidence` (ADR-0036)
Some vendors make evidence impossible. `customerio.delete` answers a successful delete
with an empty `200`, publishes no read-by-id endpoint anywhere in its spec, and ships no
signed webhook the closed `SignatureProfile` registry can express. There is no response
body to assert against, no readback to schedule, and no asynchronous evidence to wait for.
Faced with a rule forbidding it to say so, an author will write the only assertion
available — `equals_literal http_status == 200` against `accepted: [200..=299]` — which
proves nothing and yet reaches a passing verdict. **The rule meant to prevent an empty
claim produced a false one instead.**
So `EffectSpec` carries `no_evidence: Option`. When set, a mutation may
declare `verification: none` and `MutationWithoutAssertions` is not raised.
`NoEvidenceReason` is a closed enum, because free text could not be rendered or audited:
| Reason | Meaning |
|---|---|
| `empty_response_body` | The vendor returns no content on success |
| `no_readback_endpoint` | No read-by-id exists for the mutated resource |
| `effect_leaves_vendor_state` | A send, notification, or payment not readable from the vendor at all |
| `write_only_transport` | The transport admits no safe-method readback (the GraphQL POST-only case) |
Three validated rules: `no_evidence` is legal only on a mutation; a tool declaring it must
declare `verification: none`; and the reason must come from the enum.
No verifier change was required, and that is the point. `verify_effect` already returns
`inconclusive` for an empty assertion set, so a tool declaring `no_evidence` concludes
**permanently `inconclusive`** — never `verified`, never `contradicted`. That is the
honest verdict. It also belongs in the connector note beside the tier decision, with the
same standard of evidence: the absence of a readback endpoint is a fact about the spec,
and the spec is in the pack. ADR-0030 requires the connector UI to state the achievable
evidence ceiling at connect time, and `no_evidence` is the first field that states it as
data rather than prose.
From `packages/connectors/customerio/overlay.json`:
```json
{
"operation_id": "delete",
"destructive": true,
"effect": {
"schema_version": 1,
"kind": "delete",
"idempotency": { "type": "unsupported" },
"dispatch_statuses": {
"accepted": [{ "type": "range", "start": 200, "end": 299 }],
"definitely_rejected": [
{ "type": "exact", "status": 400 },
{ "type": "exact", "status": 401 },
{ "type": "exact", "status": 403 }
]
},
"upstream_id": null,
"verification": { "type": "none" },
"no_evidence": "no_readback_endpoint"
}
}
```
## Assertions — a closed language
```text
exists { name, actual }
absent { name, actual } # kind: delete only, plan stages only
equals_request { name, actual, expected_request }
equals_literal { name, actual, expected }
one_of { name, actual, allowed }
```
There are **two assertion enums and only one has `absent`**. A `response_binding` uses
`effect.rs`'s four-variant `VerificationAssertion` — you cannot prove a deletion from the
deleting call's own response. Plan stages use `plan.rs`'s five-variant `Assertion`, which
adds `absent`.
There is **no general expression language**: no arithmetic, tolerance, value ranges,
regex, substring, case folding, iteration, or wildcards. Ranges exist only in
`StatusMatcher` over integer HTTP status, and a pointer must resolve to a scalar.
Bounds: 32 assertions, 256-character assertion names, 512-byte pointers, 32 allowed
values, 8 plan stages, 8 probe params. A plan validates assertions **per stage**, so names
must be unique within a stage, not across the plan.
**Compared values never appear in an assertion result, a receipt, or a log.** An assertion
result carries a name and an outcome (`passed`, `failed`, `missing`, `invalid`) and
nothing else.
### The tautology rule
Reject a stage whose assertions are all derivable from `dispatch_statuses`. If `accepted =
[200..=299]` and the only assertion is `exists` over `http_status`, the assertion adds no
evidence beyond dispatch classification and mints a meaningless green check — exactly the
defect ADR-0019 exists to fix.
**Nothing enforces this.** No validator, no test. It is on the author and the reviewer,
and several shipped examples in `packages/proto/examples/workspace_snapshot.json` trip it.
Do not copy them. An author who genuinely has no evidence now has somewhere else to go —
`no_evidence`.
## Worked examples
### GitHub `merge_pull_request` — immediate form
Snapshot-safe, because it is a bare response binding.
```json
{
"schema_version": 1,
"kind": "update",
"idempotency": { "type": "unsupported" },
"dispatch_statuses": {
"accepted": [{ "type": "exact", "status": 200 }],
"definitely_rejected": [
{ "type": "exact", "status": 403 },
{ "type": "exact", "status": 404 },
{ "type": "exact", "status": 405 },
{ "type": "exact", "status": 422 }
]
},
"upstream_id": { "source": "response_body", "pointer": "/sha", "header": null },
"verification": {
"type": "response_binding",
"config": {
"trust": "gateway_observed_only",
"assertions": [
{
"type": "equals_literal",
"name": "merged_true",
"actual": { "source": "response_body", "pointer": "/merged", "header": null },
"expected": true
}
]
}
}
}
```
`409` ("head branch was modified") is in neither set — genuinely ambiguous — so it becomes
`unknown` / `inconclusive`, and with `idempotency: unsupported` the invocation is not
eligible for automatic redispatch. That is correct.
### Attio `createRecord` — plan form
Overlay only. Immediate assertions that are not restatements of the status, then a
readback that proves persistence at `gateway_observed`. Trimmed from
`packages/connectors/attio/overlay.json`:
```json
{
"schema_version": 1,
"deadline_s": 900,
"stages": [
{
"stage_id": "immediate",
"claim": "accepted",
"kind": "immediate",
"assertions": [
{
"type": "exists",
"name": "record_id",
"actual": { "source": "response_body", "pointer": "/data/id/record_id" }
},
{
"type": "exists",
"name": "web_url",
"actual": { "source": "response_body", "pointer": "/data/web_url" }
}
]
},
{
"stage_id": "settled",
"claim": "persisted",
"kind": "readback",
"after_s": 15,
"probe": {
"transport": "http",
"probe_target_id": "pt_attio",
"method": "GET",
"path_template": "/v2/objects/{object}/records/{record_id}",
"path_params": [
{ "name": "object", "input": { "from": "request_pointer", "pointer": "/path/object" } },
{ "name": "record_id", "input": { "from": "upstream_id" } }
]
},
"assertions": [
{
"type": "exists",
"name": "record_persisted",
"actual": { "source": "response_body", "pointer": "/data/id/record_id" }
}
]
}
],
"completion": {
"acceptable": [{ "claim": "persisted", "trust": "gateway_observed" }]
}
}
```
Watch the three different discriminator keys: a stage flattens its kind under `kind`, a
`ReadbackProbe` uses `transport` with the probe's fields as siblings, and a `ProbeTarget`
in the bundle's `probe_targets` flattens under `type`.
## How an EffectSpec drives policy and receipts
**Before the call**, the spec is what makes an argument-level decision possible and what
makes an idempotency guarantee real. `destructive` (not the effect kind) is the flag Cedar
and the approvals path read; custody mode, tier, evidence ceiling, and
idempotency-propagation mode are Cedar-visible on every snapshot tool entry, so a
workspace can forbid a tier or require a minimum achievable evidence ceiling. See
[Policy](/policy/overview).
**After the call**, the spec is what the deterministic verifier evaluates. Authorization,
dispatch, and verification stay separate receipt summaries, and the receipt exposes route
tier, evidence trust, bundle version, and whether the vendor credential is Obol-custodied
or federated. Missing, contradictory, or ambiguous evidence becomes `inconclusive` or
`contradicted` — never optimistic success. See [Receipts](/receipts/overview) and
[Verification](/receipts/verification).
## Validation errors
Every variant `EffectSpec::validate` and `VerificationPlan::validate` can raise:
| Variant | The authoring mistake |
|---|---|
| `MutationWithoutAssertions` | `kind != read` with no verification, an empty binding, or a plan whose every stage has empty assertions |
| `InvalidStatus(u16)` | an exact matcher outside 100–599 |
| `InvalidStatusRange{start,end}` | `start > end`, `start < 100`, or `end > 599` |
| `OverlappingStatus(u16)` | one status matched by two matchers — across the two lists **or within one** |
| `TooManyAssertions` | over 32 assertions in one binding or one stage |
| `EmptyAssertionName` / `AssertionNameTooLong` | empty name, or over 256 characters |
| `DuplicateAssertionName(String)` | two assertions sharing a name inside one binding or stage |
| `TooManyAllowedValues` | `one_of` with over 32 entries |
| `VerificationValueOutOfBounds{field}` | a literal or allowed value past the JSON size/depth limits |
| `InvalidHeaderName{field}` | the idempotency header, or a `response_header` selector, is not a valid header name |
| `PointerTooLong{field}` / `InvalidPointer{field}` | a pointer over 512 bytes; a non-empty pointer not starting with `/`; a `~` not followed by `0` or `1` |
| `InvalidSelectorFields{selector_source}` | pointer/header combination wrong for the selector's source |
| `UnsupportedPlanVersion(u32)` | a plan whose `schema_version` is not `1` |
| `PlanWithoutStages` | `"stages": []` — write `none` instead |
| `TooManyStages` | over 8 |
| `EmptyCompletionPredicate` | `"acceptable": []` — the plan could never finish |
| `UntrustedCannotVerify` | any acceptable pair with `"trust": "untrusted"`. ADR-0021's one mandatory check |
| `EmptyStageId` / `DuplicateStageId(String)` | an empty or repeated `stage_id` |
| `EqualsRequestOutsideImmediate(name)` | `equals_request` on a readback or webhook stage — the canonical request is no longer in memory |
| `RequestSelectorOutsideImmediate(name)` | an assertion whose `actual` reads `request_body` outside an immediate stage |
| `AbsentRequiresDelete(name)` | `absent` on any kind other than `delete` |
| `WebhookWithoutEventTypes(stage_id)` | a webhook stage with no event types |
| `Probe(String)` | a readback probe failed `ReadbackProbe::validate` |
`Probe(...)` wraps a probe error: a `probe_target_id` that is not slug-shaped; an HTTP
`path_template` not starting with `/` (which is what rejects an absolute URL); over 8
params; a duplicate param name; a malformed `request_pointer`; a `decisive_negative`
declaring anything but 404 or 410; or an MCP probe argument fed from the request.
## What no validator checks
Hold these yourself:
- **Stage claim reachability.** A stage's `claim` is never compared against
`completion.acceptable`. A plan whose only stage claims `accepted` while the predicate
accepts only `persisted` validates cleanly and can never complete.
- **`deadline_s`.** Never validated. It may be `0`, and no stage's `after_s` is compared
against it.
- **`destructive` versus `effect.kind`.** Never derived, never cross-checked.
- **The tautology rule.**
## Checklist
- [ ] `schema_version: 1` on the spec, and on the plan if there is one
- [ ] A plan written bare, never wrapped in `{"type":"plan"}`
- [ ] Every non-`GET` OpenAPI operation carries a complete `x-obol-effect`
- [ ] Effect kind authored, not guessed; `external_side_effect` only where the effect
leaves vendor state
- [ ] `destructive` set independently wherever prod approval is required
- [ ] Accepted and definitely-rejected matchers do not overlap, in either list
- [ ] 408, 409, 425, 429 left ambiguous for mutations
- [ ] `upstream_id` present only where a stable id genuinely exists
- [ ] The real idempotency binding authored; `unsupported` recorded with its
no-auto-redispatch consequence
- [ ] Reads verify `none`; mutations declare at least one assertion on some stage, or
declare `no_evidence` with a reason
- [ ] No assertion is a tautology of `dispatch_statuses`
- [ ] No plan on anything that becomes a `ToolSnapshot`
- [ ] `make -C apps/gateway schemas` run if a wire type changed