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