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

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

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: 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). 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:
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: 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, <resource>_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<NoEvidenceReason>. 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: 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:

Assertions — a closed language

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.
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:
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. 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 and Verification.

Validation errors

Every variant EffectSpec::validate and VerificationPlan::validate can raise: 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