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
Do not stash effect data inannotations 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 inCONNECTOR.md; it is a real product limitation.
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:
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
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 fromdispatch_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:
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 variantEffectSpec::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
claimis never compared againstcompletion.acceptable. A plan whose only stage claimsacceptedwhile the predicate accepts onlypersistedvalidates cleanly and can never complete. deadline_s. Never validated. It may be0, and no stage’safter_sis compared against it.destructiveversuseffect.kind. Never derived, never cross-checked.- The tautology rule.
Checklist
-
schema_version: 1on the spec, and on the plan if there is one - A plan written bare, never wrapped in
{"type":"plan"} - Every non-
GETOpenAPI operation carries a completex-obol-effect - Effect kind authored, not guessed;
external_side_effectonly where the effect leaves vendor state -
destructiveset 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_idpresent only where a stable id genuinely exists - The real idempotency binding authored;
unsupportedrecorded with its no-auto-redispatch consequence - Reads verify
none; mutations declare at least one assertion on some stage, or declareno_evidencewith a reason - No assertion is a tautology of
dispatch_statuses - No plan on anything that becomes a
ToolSnapshot -
make -C apps/gateway schemasrun if a wire type changed