---
title: "Verification"
description: "How a tool declares what its effect can prove: immediate response bindings, staged verification plans with readback and webhook stages, and the per-tier limits on what the runtime will admit."
---
Verification is the part of a receipt that says how much the evidence is worth. It is declared per tool, evaluated by the gateway, and deliberately conservative: missing, contradictory, or ambiguous evidence becomes `inconclusive` or `contradicted`, never optimistic success.
## What a tool declares
Every tool carries a required [`EffectSpec`](/connectors/effectspec), and its `verification` field is one of three shapes:
| Shape | Meaning |
|---|---|
| `none` | Nothing is asserted. Legal on a read, and on a mutation only when it also declares [`no_evidence`](/receipts/evidence#mutations-with-no-evidence-path) |
| `response_binding` | Assertions evaluated against the immediate response, plus a trust rule |
| `VerificationPlan` | A staged plan: immediate, readback, and webhook stages with a deadline and a completion predicate |
A read concludes `not_required` — there is no effect to prove. `EffectSpec::validate` refuses the one shape that would let a mutation quietly assert nothing: a non-read effect with `verification: none` and no `no_evidence` reason.
## Immediate verification
A response binding is evaluated by a deterministic in-process verifier with no I/O, no clock, no randomness, and no policy lookup. It selects values through a closed selector language — request body pointer, response body pointer, response header, HTTP status — and evaluates a closed assertion language: `exists`, `equals_literal`, `equals_request`, `one_of`, and (in staged plans) `absent`.
Assertion **outcomes** reach the receipt. Compared **values** never do.
| Outcome | Meaning |
|---|---|
| `passed` | The assertion held |
| `failed` | The assertion did not hold |
| `missing` | The value was not selectable, or the response was unusable |
| `invalid` | The selected value exceeded the verifier's bounds |
The conclusion follows from the outcomes and the trust of the observation. A failure produces `contradicted` only if the trust class [may contradict](/receipts/evidence). Otherwise any failure, any `missing`, any `invalid`, or a trust class that is not authoritative for this binding yields `inconclusive`. An empty assertion set also yields `inconclusive` — defence in depth, because a verdict must never be optimistic about evidence it did not evaluate.
The binding's `trust` rule chooses whether a [trusted worker](/connectors/trusted-workers)'s derived fields count: `gateway_observed_only` refuses them, `allow_connector_attested` admits them. `gateway_observed_only` is also what keeps a federated binding away from `verified`.
## Verification plans
A `VerificationPlan` is immutable, versioned data. Its digest and the containing bundle digest are pinned to the invocation, so republishing a bundle can never change an in-flight verification.
A plan carries:
- a non-empty set of uniquely identified **stages**, each tagged `immediate`, `readback`, or `webhook`, each with an effect claim and its assertions;
- an overall **deadline**;
- a **completion predicate**: explicit `(claim, trust)` pairs, never an ordering.
There is no "minimum assurance" shortcut. The predicate names exactly which combinations satisfy the plan, and schema validation rejects any predicate that would let `untrusted` evidence produce `verified`.
```json Verification plan
{
"schema_version": 1,
"deadline_s": 1800,
"stages": [
{
"stage_id": "immediate",
"kind": "immediate",
"claim": "accepted",
"assertions": [
{ "type": "exists", "name": "refund_id_present",
"actual": { "source": "response_body", "pointer": "/id" } },
{ "type": "one_of", "name": "status_known",
"actual": { "source": "response_body", "pointer": "/status" },
"allowed": ["succeeded", "pending", "requires_action"] }
]
},
{
"stage_id": "settled",
"kind": "readback",
"claim": "persisted",
"after_s": 30,
"probe": {
"transport": "http",
"probe_target_id": "pt_stripe",
"method": "GET",
"path_template": "/v1/refunds/{id}",
"path_params": [{ "name": "id", "input": { "from": "upstream_id" } }]
},
"assertions": [
{ "type": "equals_literal", "name": "refund_succeeded",
"actual": { "source": "response_body", "pointer": "/status" },
"expected": "succeeded" }
]
},
{
"stage_id": "delivered",
"kind": "webhook",
"claim": "delivered",
"event_types": ["charge.refunded", "refund.updated"],
"assertions": [
{ "type": "equals_literal", "name": "event_says_refunded",
"actual": { "source": "response_body", "pointer": "/data/object/status" },
"expected": "succeeded" }
]
}
],
"completion": {
"acceptable": [
{ "claim": "persisted", "trust": "gateway_observed" },
{ "claim": "delivered", "trust": "vendor_signed" }
]
}
}
```
A webhook stage is the only path to `vendor_signed`.
## How a staged plan runs
When the invocation finalizes, the gateway pins the reviewed bundle, the plan, a digest of the connection target, and bounded safe probe parameters into workspace-qualified Redis state, alongside the scheduling projection: one job per asynchronous stage plus a deadline job. Immediate stages are evaluated at this point.
The control worker owns durable scheduling — due times, leases, attempt counts, deadlines, correlation, retries, and Postgres projections. Jobs are keyed by invocation and stage and processed at least once. Control never synthesizes a conclusion or a receipt.
For a readback, control mints a short-lived capability with a dedicated issuer and audience, bound to workspace, invocation, connection, stage id, plan digest, declared operation, nonce, expiry, and expected revision, and calls the internal gateway endpoint with both service authentication and that capability.
The gateway consumes the nonce atomically, then re-verifies the connection, the plan digest, the bundle digest, and the expected revision against pinned Redis state. It resolves the probe descriptor, selectors, and auth only from the pinned plan — a caller can never supply a destination or a selector — and loads only Redis and snapshot state, failing closed rather than reaching for the control/Postgres fallback. A control-minted capability authorizes a request; its claims are never authority by themselves.
The gateway performs the readback (or reads the correlated webhook evidence), applies the deterministic verifier, and atomically advances the evidence sequence. It emits one append-only `VerificationEvent` per accepted evidence item, and mints a new receipt revision only when the public summary changes.
Webhook evidence follows the same shape, except the evidence arrives first and is correlated afterwards. See [Webhook evidence](/receipts/webhooks).
### Correlation
The correlation index binds workspace, connection, subscription, signed event type, and safe upstream id. It deliberately excludes arbitrary stage labels, so renaming a stage cannot make an ambiguous resource match unique. The gateway checks uniqueness and repeats the exact matched-tuple check inside the evidence commit; a correlation that matches more or fewer than one live invocation is refused. Retained candidate lookup is bounded and fails closed on overflow.
### Withdrawal and contradiction
Both directions fail closed.
- Evidence of a given trust class may **withdraw** a `verified` conclusion the same class established, returning the invocation to `inconclusive` while retaining the first-verified timestamp.
- Only `gateway_observed` and `vendor_signed` evidence may assert `contradicted`.
- A positive later read cannot erase an authoritative contradiction already recorded for that stage.
A deadline that expires with an unmet required stage reconciles to a terminal `inconclusive` revision. Pending is the initial conclusion only while an unmet required asynchronous stage remains.
## Per-tier limits
What the runtime admits is narrower than what the contract can express, and a plan that fails admission is refused at snapshot validation rather than silently downgraded.
| Limit | Rule |
|---|---|
| Readback custody | A `readback` stage requires a **native** tier connection with **Obol custody** and an **OpenAPI** target |
| Readback transport | Only the HTTP probe executes. An MCP probe is rejected at admission — the MCP readback runtime is unavailable |
| Broker readback | Rejected at admission |
| Probe method | `GET` or `HEAD` only. A POST readback is a write-shaped hole in a read-only contract |
| Probe destination | Resolved from the pinned bundle's probe targets, re-checked against the bundle's egress schemes, ports, and hosts, and refused if the rendered URL leaves the target origin |
| Connection drift | The connection target is digest-pinned at invocation time; a changed target fails the readback rather than probing a new destination |
| Live connection | The readback runs only against an active connection in an unfrozen workspace, and refuses if the credential appears in the response body or a selected header |
| Response size | Readback bodies are bounded, and the probe times out well inside the job's window |
| Deadline | A plan deadline must be greater than zero and at most 86,400 seconds |
| Retention | Late authoritative reconciliation stays available for one further day after the deadline; gateway verification state is retained for up to seven days |
The federated consequence is the one to keep in view: a federated write has no admissible readback, so its strongest reachable outcome today is `accepted` with `untrusted` or `broker_attested` evidence, concluding `inconclusive`. That is the honest ceiling, and the connector UI must state it at connect time. See [Evidence trust](/receipts/evidence).
## Declaring the evidence a mutation already names
A routed capability (`cap.*`) is a provider-independent job description, so its `EffectSpec` cannot assume any one vendor's response shape. It still may not opt out of proving anything: the same validation that refuses an assertion-free mutation applies to a capability spec, and because capability specs are validated as part of the workspace snapshot, one bad effect takes the whole snapshot down.
The resolution is to assert the evidence the effect already names rather than to invent one. Every mutating capability declares its `upstream_id` at `/id`, so a job named `cap..create` is only served by a provider whose response carries the created id — asserting that it exists is provider-independent and demands nothing a concrete binding does not already satisfy.
Two details of that fix are load-bearing:
- The trust rule is `gateway_observed_only`, not `allow_connector_attested`. A federated binding cannot reach it, which is exactly the rule that a federated route never reads `verified`, and it keeps a remote-MCP target out of the attestation path in the same validation pass.
- Declaring `no_evidence` instead would have been **false** for most providers and would have made every routed mutation permanently `inconclusive`. Reach for `no_evidence` when the vendor genuinely offers no evidence path, not when the assertion is inconvenient to write.
## Where verification lives
| Component | Responsibility |
|---|---|
| `apps/gateway/crates/obol-route/src/verification.rs` | Deterministic response-binding verifier |
| `apps/gateway/crates/obol-route/src/staged.rs` | Staged evidence evaluation and plan summarization |
| `apps/gateway/crates/obol-gateway/src/verification.rs` | Capability-bound readback and evaluation endpoints; credentials and transport stay here |
| `apps/control/app/services/verification.py` | Durable scheduling, leases, deadlines, correlation, retries, and Postgres projection |
| `packages/proto/verification_plan.schema.json` | The wire contract, generated and drift-checked with the rest of the contract pack |
Control is the scheduler, correlator, and persistence owner. The gateway is the receipt authority. That split is what keeps plaintext credentials inside the gateway process and keeps a second component from becoming a second auditor.
## Related
The five classes, the per-source table, and probe rules.
Where an EffectSpec and a VerificationPlan are authored and reviewed.