Overview
TheA2AVerificationInterceptor is the central component of QWED A2A. Every inter-agent message passes through it before reaching the recipient.
The
trace_id parameter is required and must be provided by the caller. This ensures all verdicts and JWT attestations are deterministic and auditable. The HTTP gateway (POST /a2a/intercept) generates this automatically.Verification pipeline
1
Schema Validation
The incoming
AgentMessage is validated by Pydantic:sender_agent_idandreceiver_agent_idmust be 1–256 chars, no control characterspayloadis required (dict)payload_typedefaults toGENERALif not specified- Timestamps are timezone-aware UTC
2
Trust Boundary
The trust boundary evaluates the sender→receiver pair. Over HTTP the sender is the authenticated API-key identity, never the body-declared
sender_agent_id:- Global blocklist check
- Pair-level block check
- Allowlist check (in strict mode)
- Token-bucket rate limiting
config.trusted_agents are pre-added to the trust boundary allowlist at interceptor construction time. There is no separate bypass step — trusted agents still flow through engine routing and receive a normal verdict.3
Engine Routing
Based on
payload_type, the message is routed to the appropriate verification engine (finance_guard, logic_guard, code_guard). Payload types with no engine (GENERAL, DATA_QUERY) return an unverifiable engine result instead of being silently forwarded.4
Verdict & Attestation
The engine result is wrapped in a
VerificationVerdict. VERIFIED, BLOCKED, HEURISTIC_PASS and ERROR verdicts are signed with an ES256 JWT attestation whose verdict claim names the status. UNVERIFIABLE verdicts carry no JWT: issuing a signed token for content that was never verified would be a false cryptographic claim.Verification engines
Finance guard
Verifies financial claims using deterministic Decimal arithmetic. Recomputes totals from line items and compares against theclaimed_total.
claimed_total, the finance guard returns verified — the only admissible verdict in QWED A2A. See Admissibility.
Negative values return
UNVERIFIABLE. A negative claimed_total, line-item amount, or line-item quantity returns status=unverifiable with no JWT attestation. The payload has no credit/debit convention, so the finance guard cannot verify a sign-inverted transaction even when its arithmetic holds.Empty or malformed payloads return
UNVERIFIABLE, not VERIFIED. If a FINANCIAL_TRANSACTION payload is missing data, line_items, or claimed_total, or contains non-numeric amounts, the finance guard returns status=unverifiable with no JWT attestation. Earlier releases collapsed these cases to a signed pass verdict, which falsely attested that the math had been checked.Logic guard
Detects logical contradictions — claims where the same proposition is both asserted and negated.- Unicode NFKC normalization
- Removal of invisible format characters, such as zero-width spaces
casefold()- Collapsing of repeated whitespace
"UserIsAdmin" and "userisadmin" are treated as the same claim, and so are "payment sent" and "payment sent". The negated field must be a boolean. Any other value, such as the string "true", returns unverifiable.
Empty or malformed logic payloads return
UNVERIFIABLE. A LOGIC_ASSERTION payload with a missing, non-list, or empty assertions array — or with malformed assertion entries — returns status=unverifiable and no JWT attestation. There is nothing to check, so nothing is attested.Code guard
Scans code payloads using a two-layer approach. AST structural analysis runs first; regex heuristics only run when the AST layer finds nothing. Layer 1 — AST structural analysis (primary). The code is parsed into an abstract syntax tree and inspected for direct dangerous constructs:
The AST layer resolves receivers through every binding a name may have. Each of these is blocked:
- Import aliases:
import os as o; o.system(...) - Alias chains of any length and order:
x = os; y = x; y.system(...). A later re-assignment does not hide an earlier binding. - Re-bound functions:
s = os.system; s(...),e = eval; e(...),g = getattr; g(os, "system") - Aliased from-imports:
from posix import system as s; s(...) - Annotated (
x: T = os), walrus ((x := os)), and tuple-unpacking (a, b = os, 1) bindings
eval cannot hide a builtin eval(...) call.
Before parsing, the code guard checks the input:
- Code larger than 256 KiB is
BLOCKEDbeforeast.parseruns, because parsing amplifies memory roughly 140-250x. - A
codevalue that is not a string returnsUNVERIFIABLEwith no attestation.
If either layer finds a threat, the verdict is
BLOCKED (the reason notes which layer triggered). If both layers are clean, the verdict is HEURISTIC_PASS — a signed attestation that no known dangerous constructs were found, not a deterministic guarantee that the code is safe to execute. A HEURISTIC_PASS is not admissible as proof of safety.
Passthrough (unverifiable)
Messages withpayload_type of GENERAL or DATA_QUERY have no verification engine. The interceptor returns an UNVERIFIABLE verdict with engine="passthrough", no JWT attestation, and reason "No verification engine available for this payload type". The message is not silently forwarded — callers must decide whether to route unverified content downstream based on their own policy.
Verdict statuses
Every call tointercept() returns a VerificationVerdict with one of five statuses. Every status except unverifiable carries a signed JWT attestation whose verdict claim names that status, so a signature on its own does not mean the payload passed. Only verified is admissible: gate on verdict.status.admissible (or ADMISSIBLE_VERDICTS), never on status != blocked, which would admit heuristic_pass, unverifiable and error. error is returned when verification cannot complete — for example when an engine run exceeds its execution budget (engine engine_timeout) or the engine pool is saturated (engine engine_busy).
VerdictStatus.FORWARDED is a deprecated alias of VerdictStatus.VERIFIED (value "verified"), and a legacy "forwarded" value still parses to VERIFIED on input. Neither is ever emitted; both will be removed in a future minor release. If your code compares against "forwarded", switch it to "verified" or to verdict.status.admissible.Admissibility
Onlyverified is admissible — a verdict a consumer may act on without running its own checks. QWED A2A defines admissibility in one place:
ADMISSIBLE_VERDICTS: afrozensetof admissibleVerdictStatusmembers. It currently contains onlyVerdictStatus.VERIFIED.VerdictStatus.admissible:Truewhen the status is inADMISSIBLE_VERDICTS.VerificationVerdict.admissible: a computed field on every verdict, derived fromstatus. It is serialized with the verdict, so thePOST /a2a/interceptJSON response carries it too.
admissible flag instead of comparing status strings:
A2ACryptoService.is_admissible(claims).
Migrating from forwarded to verified
For compatibility:
VerdictStatus.FORWARDEDremains a deprecated alias ofVerdictStatus.VERIFIED.- The legacy wire value
"forwarded"still parses toverifiedin HTTP verdicts and attestation claims, so tokens from 0.3.x issuers keep verifying on upgraded readers. /a2a/metricsreportstotal_verifiedand keepstotal_forwardedas a deprecated alias with the same count.
"forwarded" with "verified". If you relied on logic assertions being admitted, they now return heuristic_pass. Add your own policy for them.
Configuration reference
TheInterceptorConfig controls which engines are active:
Request size limits
The interceptor enforcesmax_payload_size_bytes at two layers:
- HTTP body.
POST /a2a/interceptrejects bodies larger thanmax_payload_size_bytesplus a 64 KiB envelope allowance with413, before the body is buffered or parsed. The router checks a declaredContent-Lengthup front and caps chunked bodies as they stream in. An invalidContent-Lengthreturns400. The check is built into the router, so it applies wherever you mountrouterwith no extra middleware. - Payload.
intercept()returnsblockedwith enginesize_limitwhen the payload’s compact UTF-8 JSON exceedsmax_payload_size_bytes. This check runs before trust evaluation or any engine, so it also covers direct library callers.
Engine execution budget
Verification engines run on a dedicated thread pool, off the event loop./a2a/health, /a2a/metrics, JWKS, and other intercepts keep responding while an engine works.
- Each engine run has a wall-clock budget set by
engine_timeout_seconds. A run that exceeds it returnserrorwith engineengine_timeout. - Each interceptor allows at most 36 runs in flight: 4 running and 32 waiting for a thread. Beyond that, a run is refused immediately with
errorand engineengine_busyinstead of queueing without bound. - A waiting run whose caller already timed out is skipped.
close() from your application’s shutdown hook to release the pool. Call it only after in-flight intercepts have drained. An intercept that starts after close() returns error.
Limits. Python cannot kill a running thread. A timed-out run that already started finishes in the background and holds its thread until it completes. Under sustained overload, runs time out or are refused, both fail-closed. Long C-level calls that hold the GIL, such as
ast.parse or regex, can still delay the event loop. The size limits above bound those calls. Each worker process has its own pool and state, so multiple workers add capacity but not isolation.Error handling
Verification that does not complete always fails closed with anERROR verdict. An engine error is neither a pass (VERIFIED) nor a content rejection (BLOCKED), and no configuration turns it into a pass. The verdict is signed and its verdict claim is "error".
block_on_error is deprecated and ignored: earlier releases let block_on_error=False pass a message through despite an engine error. Setting it to False now only logs a deprecation warning.