Repository navigation
feat(a2a): outbound client speaks 402 — payment-token credential + distinct payment-required outcome (#3185) - #3209
Merged
Merged
Conversation
…3185) Checkpoint A of three: the backend client, the outcome vocabulary and the redaction. `a2a_client._read_capped` collapsed every HTTP >= 400 into `rpc_http_error` without reading the body, so a priced remote answering 402 Payment Required was unreachable — the caller saw neither the price nor a way to attach a token. Client (`services/a2a_client.py`): * `A2ACallError` gains kw-only `remote_status`, `payment`, `task_id`; `remote_status` is now set on every `*_http_error`, so any 4xx/5xx is diagnosable. * Three new reasons: `payment_required` (HTTP 402 or in-band `payment-required`), `payment_rejected` (in-band `payment-failed`, or a 403 to an endpoint whose credential kind is `payment_token`), `rpc_forbidden` (any other 403). All carry `remote_status`, so 402 ("buy") is always distinguishable from 403 ("top up"). * 402/403 on the RPC hop are classified BEFORE the encoding and length guards: a CDN-gzipped or oversized "pay me" previously reported `rpc_encoding` / `rpc_too_large` — an outage, for an endpoint working perfectly. The body is still never decoded, and is bounded by a ceiling 16x tighter than the answer cap; when it cannot be read the outcome survives from the status alone, flagged `truncated`. The card hop's "never read an error body" contract is untouched (the branch keys on `error_prefix`). * A `payment_token` credential rides the x402 metadata (`x402.payment.status` / `.payload`, the decoded token) AND the deprecated `payment-signature` header on the SAME request — a fallback that waited for a 402 would be an automatic retry, which AC2 forbids. The header sits behind `A2A_SEND_PAYMENT_SIGNATURE_HEADER` with a removal note. An `api_key` endpoint — every record written before this change, since the kind defaults — sends the same header set, the same Authorization value and no `metadata` key. An opaque token degrades to header-only rather than announcing undecodable bytes in-band. * `_raise_for_payment_state` runs before `_parse_task` on BOTH the send and the poll path: a priced peer answers `input-required` with "pay me" in its metadata, and parsed as a task that is an ordinary prompt an agent polls forever. `payment-completed` is recorded, never surfaced. * The `payment` block handed to the agent is `{summary, x402, truncated}`: a flat Trinity-owned summary plus the raw requirements object under a top-level-key allowlist, per-leaf 512 chars, accepts <= 8, 16 KiB ceiling. Every string passes the credential scrubber, whose secret set is now the token AND its base64 forms AND the decoded payload's long string leaves — a remote echoing the decoded signature back otherwise walks past exact-value redaction of the base64 token. * No payments SDK import: the token codec is a stdlib base64/JSON mirror. Shared vocabulary (`services/a2a_protocol.py`): the x402 metadata key and status constants live with the rest of the dialect, so the inbound side reads the same names rather than a second copy. Store + service: `ResolvedEndpoint.credential_kind` (default `api_key`, additive-safe for every existing row; the kind is in the repr, the value never is), normalised fail-safe for a provider we do not own, and threaded to `call_endpoint` / `get_task`. `payment_status` reaches the activity row and audit `details` — money leaving must be visible to the operator — and not the agent response. Router: `payment_required` maps to HTTP 402 with `detail = {reason, message, payment, remote_status?, task_id?}`. `payment_rejected` and `rpc_forbidden` stay on the 502 default so a remote 403 is never echoed as this route's own 403. The success allowlist does not grow. Tests: new `tests/unit/test_3185_a2a_payment_outcome.py` (54 cases over the codec, the secrets list, the bounded block and both outcome raisers), plus transport cases over a real httpx client (what goes on the wire for each credential kind, the gzipped and oversized 402, the in-band rails, the poll path), route cases (402 status + detail allowlist + frozen success shape + the claim released and never snapshotted), the RPC refusal-order characterisation, and three hypothesis properties (the payment block is total, bounded and leak-free over arbitrary peer JSON). 551 pass. Checkpoints B (credential kind on the store / settings write path / MCP) and C (docs) follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) Checkpoint B of three. Checkpoint A taught the outbound client to read a 402 and to attach an x402 payment token when the resolved endpoint says its credential is one. This is the half that decides whether it says so — the store, the request model, the settings route, the audit row, and the MCP surface that an agent and an operator actually meet. Store (`services/a2a_outbound.py`): * `upsert_endpoint(..., credential_kind=...)` — keyword-only, every existing caller unchanged. The kind is a LABEL on the existing credential slot, never a second secret, and it rides that slot's three write paths rather than adding a fourth: omitted with a new credential it is INFERRED from the value, given explicitly it wins, given alone it RE-LABELS the stored secret (so an operator who pasted a payment token before the field existed can fix the label without re-typing something they may hold no other copy of), and `clear_credential` drops the label with the value it described. * A kind with no credential under it is refused, and so is kind + `clear_credential`: both would report `credential_kind: payment_token` for an endpoint that sends no payment at all — the one wrong answer this surface can give to the person who has just been handed a 402. Unknown kinds name the domain and never echo the input. * `api_key` is persisted as the ABSENCE of the key, which is exactly what every pre-#3185 record says, so a relabel back leaves a record identical to one written before the field existed rather than inventing a second spelling of the default that future readers have to keep in agreement. * T6 inference and T4 single-use detection both read `a2a_protocol.decode_payment_token` — the same predicate the client uses to decide whether it may announce a token in-band. Two spellings of "is this an x402 token" would produce a credential the store calls `payment_token` and the transport silently declines to send as one, which reads as a platform bug rather than as the remote's refusal. The codec moved from `a2a_client` to `a2a_protocol` (the module that already owns the x402 vocabulary) for that reason; the client keeps its two private wrappers, which now only pin the outbound ceiling. * T4: an x402 v3 token authorises ONE settlement, so `payload.authorization.nonce` is flagged `credential_single_use` rather than refused — a provider that issues only single-use tokens must stay usable. The flag describes the stored value and cannot outlive it, because a stale warning would tell an operator to re-paste a token that is perfectly good. * `_public_record` reports the kind only when a credential exists. The kind is always safe to show (an operator debugging a 402 needs to know which slot they filled); the value still never crosses. Model + route: `A2AOutboundEndpointUpsert.credential_kind` is an optional `Literal`, defaulting to `None` — "infer" is a different instruction from "this is an API key", and collapsing them would make the inference unreachable over HTTP. A `model_validator` refuses kind + `clear_credentials` with a named 422 that never echoes the credential. `PUT /api/settings/a2a-endpoints` passes the kind down and reports the store's CONCLUSION, so an operator pasting a token just bought after a 402 does not have to know the field exists; a single-use token gets a one-time hint in the same response as the write. The audit row records the label, never the value. MCP: `call_a2a_agent` / `get_a2a_task` map the new outcomes to flags an agent can act on — 402 → `payment_required` + `payment` + `task_id` + `do_not_retry` (a non-JSON 402 from a proxy still carries the flag, because the status is the fact and the body is a courtesy), 502 + `detail.reason` → `remote_forbidden` or `payment_rejected` (also terminal). `detail` is read through the existing defensive detail-unwrap pattern, so a parser cannot turn a readable refusal into a crash. The description tells the agent what the flag is FOR: relay it to a person once, do not retry, do not re-route to another endpoint, and pass the returned `task_id` when told to try again — a `do_not_retry` with no named actor produces an agent that tries a different endpoint instead. `register_a2a_endpoint` gains the `credential_kind` enum, mirrors the kind-with-clear refusal before spending a round trip, and relays the store's single-use warning under its own key so two different warnings cannot overwrite each other. Tests: new `tests/unit/test_3185_a2a_credential_kind.py` (38 cases over the store round-trip, legacy-record default, inference and its fail-safe direction, the relabel path and its refusals, clear semantics, single-use flagging and expiry, the shared-codec property, the model Literal + validator, and the route/audit/GET contract — 31 of them red against checkpoint A's tip, verified by reverting the five source files and re-running). The MCP suites gain 11 cases (payment outcome mapping on both tools, the non-JSON 402, the ordinary 502 unchanged, both descriptions, the register pass-through and its refusal). `src/mcp-server/node_modules` is absent on this agent, so `npm test` was NOT executed here — those 11 cases are unverified until someone runs the TS suite. The Python A set is re-run green: 547 passed. Checkpoint C (docs) follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checkpoint C of three — the documentation for what checkpoints A and B built. Mechanism only, no paid catalog, no private module internals (the #1461 guard pattern was run locally over docs/ and the seam file: no hits). * `requirements/mcp.md` §32.5 gains **FR-14** (a priced remote is a distinct, non-retryable outcome: both read rails, the outcome vocabulary and why 402 is classified before the encoding guards, the allowlisted `detail.payment`, and the rule that Trinity never buys and never snapshots a 402) and **FR-15** (the credential kind: a label on the one slot, inferred when omitted with the same predicate the client sends on, single-use flagged rather than refused, reads and audit carrying the label and never the value). * `feature-flows/a2a-outbound-call.md` — a new "A priced remote" section, the credential-kind subsection under credential handling, three new error rows plus the `remote_status` note, the end-to-end diagram showing the metadata carriage and the new classification order, the two new test files, and "buying anything" added to what is deliberately not here. * `architecture/{api-endpoints,backend,mcp-server}.md` — the owning catalog entries, each extended in place: the 402 status and detail shape on the call route, `credential_kind` on the settings route, the kind on the store seam, the pre-guard classification + pre-parse in-band check on the client, the x402 vocabulary and shared token codec on `a2a_protocol`, and the new MCP flags and register parameter. * `feature-flows.md` — the changelog row and the index description. * `docs/user-docs/integrations/a2a-protocol.md` — the operator-facing version: when to pass `credential_kind` (and why you need not), a worked "the remote charges" walkthrough with the four-step human relay, 402-vs-403, the single-use caveat, three troubleshooting rows, the updated GET shape, and three security notes (nothing is paid automatically, a completed payment is recorded, and the price a remote quotes is untrusted text). Guards run: `test_2306_architecture_split.py` (18), `test_1406_requirements_split.py`, `test_2339_testing_docs_consolidated.py` — all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…single kind constant (#3185) Review follow-ups on the #3185 outbound-402 branch. C1: the MCP test "an ignored agent_name does not change the write" asserted a body without `credential_kind`, so it would not have caught the field being dropped from the write. Expect it explicitly, as the sibling test does. I1: three texts said a payment token rides "instead of" the API key / Bearer header. The transport sends the Bearer header, the `payment-signature` header and the `x402.payment.*` metadata on one request — so they now say "in addition to". Texts only; the wire and its transport test are untouched. I2: the comment above the x402 plumbing pointed at "the token codec below", which moved to `services/a2a_protocol.py` (shared with the endpoint store). I3: `CREDENTIAL_KIND_PAYMENT_TOKEN` was declared in both `a2a_client.py` and `a2a_outbound.py`. The kind vocabulary now lives once in `a2a_protocol.py`, beside the method names and x402 keys it belongs with, and both sides import it; `a2a_outbound` re-exports all three names so existing references resolve unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 tasks
CodeQL py/clear-text-logging-sensitive-data (alert 380) on the PR merge ref: the normalisation warning logged the provider-supplied credential_kind verbatim. It is a field Trinity does not own, on a record that also carries the secret, so a provider that misplaced the token would have it written to the log. Log the normalised kind only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vybe
marked this pull request as ready for review
October 3, 2026 18:06
CodeQL alert 381 traced taint through normalize_credential_kind() into the logged value. On that branch the result is always api_key, so log the literal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
approved these changes
Oct 3, 2026
trinity-ability
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr (Lane B). Tier 1 green on 092a651; CodeQL alert cleared by logging a constant instead of the provider-supplied credential_kind. Mutation-checked: in-band payment state, 403 split, metadata carriage and decoded-leaf scrubbing are each caught by a test.
trinity-ability
pushed a commit
that referenced
this pull request
Oct 3, 2026
…en, 402 parity with the paid door (abilityai/trinity-enterprise#679)
Checkpoint B of the x402 payment gate. `POST /a2a/{name}` authenticated a
Trinity MCP key and nothing else, so a stranger holding a perfectly good x402
payment token — including a remote Trinity using #3185's client — got 401 and
could never reach the 402 that would let it pay. This is the branch that serves
that caller.
`dependencies.get_user_or_anonymous` is the seam. It delegates to
`get_current_user` (one place decides what a Trinity credential means) and
degrades to None on a **401 only**; a **403 is re-raised**. That asymmetry is
the point: collapsing 403 into None would turn every containment fence inside
`get_current_user` — the connector scope, the ephemeral-key fence — into a
downgrade onto the payment path, where a credential Trinity recognised and then
REFUSED could buy the access it was just denied.
The router branches once. A principal takes today's path byte-identically:
`_authorize_inbound` → dispatch, free, with its own attribution, no facilitator
call, no payment row, no payment metadata and no paying-bucket rate limit. That
is the hard line (AC4 — internal fleet traffic and subscription tenants) and
`TestPrincipalPathUnaffected` asserts each half of it rather than assuming it.
Anonymous callers go through `services/a2a_payment_gate.py`, in an order where
every step is cheaper than the next: per-IP AND per-agent limiters first
(one hit can cost a 15-second facilitator verify, and a distributed flood
passes every per-IP bucket), then exposed-and-priced, then the SDK. `is_priced`
deliberately does NOT fold in `NEVERMINED_AVAILABLE`: "this agent takes
payment" and "this install can process one right now" are different facts, and
fusing them answers 401 — "authenticate" — to a caller holding a valid token
for an agent whose card advertises a price, when no credential it could obtain
would work. So the first absence is 401 (today's bytes, uniform with an unknown
agent, no new signal for anyone mapping the fleet) and the second is 501 (the
paid door's answer: the door exists and is broken).
Token extraction is metadata-first per ruling 3 — `x402.payment.payload`
re-encoded with the SDK's own `encode_access_token`, with the deprecated
`payment-signature` header as fallback and metadata winning when both are
present (the SDK's `inband_token or header_token`; header-first would let a
stale header silently decide what a migrating client pays with). Every
malformed payload shape falls through to the header and then to the 402, never
raising: each field is caller-controlled on a route reachable with no Trinity
credential. No token → 402 with the paid door's body and base64 header from the
one shared builder, but `resource.url` on the A2A door (#679 E2): an x402 v3
token signs `resourceUrl` and the facilitator compares origin+path, so a 402
quoting the paid door would have the caller mint a token that cannot authorize
`/a2a/{name}`.
A valid token runs through `paid_turn_service.run_paid_turn` (checkpoint A), so
the #1018 settle branches stay in one home. Two A2A-specific choices: the dedup
scope is `a2a:{agent}:pay:{payer}` resolved from the verify result, so one
payer's key can never resolve to another's snapshot (which carries the agent's
full response text); and the key is `derive_payment_key(token, text)`, NOT
`messageId` — #3209's client mints a fresh uuid4 per call, so a messageId key
would make every retry after its 30-second RPC timeout a fresh execution AND a
fresh settle, and the payer would pay twice for one answer. Outcomes render
through one table: `payment-completed` + a spec-shaped receipt when settled;
`payment-verified` + a named error code when delivered-but-unsettled, artifact
KEPT, because #3209 parses a task normally on anything it does not recognise as
a refusal (#1018 deliver-then-reconcile on this wire); no artifact on a failed
turn; text kept on a cancelled one; nothing charged on either.
T7: the enterprise allow-list is consulted after verify (the wallet only exists
once the facilitator answers) as `x402:{payer}`, and it fails **CLOSED** — the
opposite bias to `a2a_gate.check_inbound_allowed`, which fails open because the
caller it guards is already authenticated as owner/shared. Here the payment IS
the authorization, so a provider error must refuse rather than admit an
unlisted wallet.
T5: `tasks/get` / `tasks/cancel` are payer-bound through the settle-row join
(`db.nevermined_payer_owns_execution` — a targeted query, not a scan of the
newest 50 rows, which on a busy agent would lose a payer access to its own task
within minutes). EVERY mismatch — no token, a token that fails verify, a verify
that raises, a wallet with no row, and payer A polling payer B's EXISTING task
— answers byte-identical `-32001 Task not found`. A differential answer would
be an execution-id oracle, and an anonymous caller is exactly who must not have
one. Residual, stated in the code: a poll arriving before the settle row exists
reads as not-found; the payer's own `message/send` retry is what recovers the
artifact.
**T6 changes queue treatment on the PRINCIPAL path too, deliberately.** `"a2a"`
joins `INTERACTIVE_TRIGGERS`, so an inbound A2A turn is claimed ahead of batch
work (#2842) and takes the claim-waiting phase (#3114): on a pull pilot, a row
no worker claims within one agent timeout now comes back FAILED/CAPACITY — the
same answer push gives an agent with no free slot — instead of leaving a caller
blocked until its RPC deadline on a row that was never going to run. This
applies to authenticated A2A traffic as well as paid, because the JSON-RPC
request is held open for the whole turn on both. `a2a` is consequently the one
member of BOTH trigger sets, which the sets' own questions make coherent (a
caller is blocked; no PERSON on this install reads the reply, so the
skill-not-found alert still belongs). The disjointness guard in
test_2842_2843_pull_claim_order is therefore NARROWED to that one documented
member rather than deleted, so a third overlap still fails; and
test_3114's autonomous-skips-the-claim-phase test, which happened to use `a2a`
as its example, is re-driven with `schedule` and gains a companion test pinning
the new a2a behaviour.
The test_157 fixture had to move its override from `get_current_user` to
`get_user_or_anonymous`: the new dependency CALLS the former rather than
depending on it, so the old override would never have been consulted and every
test in that file would have silently exercised the anonymous branch (E1/F4).
`_route_census` gains an OWN_AUTH entry for `a2a_jsonrpc` (it authenticates
itself now, two credential kinds decided in-handler) and the human-only
baseline shrinks 360→359.
The in-band token never reaches the agent's prompt or the logs: the message's
TEXT goes to the execution stack, the token only to the facilitator,
`derive_payment_key` stores a SHA-256, and the log rows carry the payer wallet,
which is an identity rather than a credential. A test asserts the token's
absence from the dispatch kwargs.
No Alembic revision and no schema change (T4 — attribution rides the payer
wallet on rows that already carry it); one read-only db query added. Nothing
under src/backend/enterprise.
Tests: 415 passed across test_ent679_a2a_payment_gate (79 new),
test_157_a2a_inbound_server, test_1018_settlement_ordering, test_679_callers,
test_3114_pull_route_callers, test_ent679_paid_turn_service,
test_3185_a2a_payment_outcome, test_2996_human_only_routes,
test_186_enumeration_uniformity and both test_1310 files; plus 117 passed over
test_2842_2843_pull_claim_order, test_2048_pull_pilot_reach,
test_3114_pull_route_interactive, test_293_admin_gate_rejects_agent_keys and
test_models_centralized. Mutation-proven from a scratch copy, restored
byte-identically: dropping the limiters reddens the ordering tests, inverting
the token precedence reddens the precedence test, and claiming
`payment-completed` on an unsettled turn reddens both settlement tests. Not
run: the live facilitator and a real payments-py A2A client (both Before-merge).
Refs abilityai/trinity-enterprise#679. Stacks on #3185.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
trinity-ability
pushed a commit
that referenced
this pull request
Oct 3, 2026
…stream refusals once, bind the payer mid-turn (I1/I3/I4, abilityai/trinity-enterprise#679) I1 — `NeverminedPaymentResult.retryable` was set on a facilitator timeout, an SDK error and a saturated concurrency gate (E7), tested at the service layer, and read by nothing: every `VERIFY_FAILED` rendered as a 403. #3209's client maps that 403 to `payment_rejected` — stop retrying, go buy another token — for what is OUR side being busy, so the payer paid again for our outage. The A2A door now answers `-32603` with `data={"code": "verify_unavailable", "retryable": true}`, and `run_paid_turn` RELABELS the log row from `reject` to `verify` (success=False, error kept) rather than skipping it: an operator reconciling a facilitator outage needs the attempts, and a `reject` row reads as "this wallet was refused". The paid door keeps its 403 bytes (T3). I3 — `_stream_paid_task` carried its own second classification table and answered `-32001` (A2A **TaskNotFound**) for a payment refusal, telling a streaming client its task vanished when the truth was "pay"; an allow-list refusal and an in-flight duplicate both flattened to `-32603 Task execution failed`, the latter losing `data.retryable`. Both doors now render ONE classification (`_classify_paid_refusal`): `send` uses its HTTP shape where it has one, `stream` uses the rpc triple — never `-32001`, with `data.code` (`payment_rejected` · `verify_unavailable` · `not_allowed` · `in_flight` · `execution_error`) as the discriminator and `data.retryable` on both retryable cases. The defensive fallback for an unclassified kind is also not `-32001`. I4 — branch taken: ADD the row. Grepped every consumer of `nevermined_payment_log` (`db/nevermined.py` get_payment_log / payer_owns_execution / get_payment_log_entry / get_settlement_failures, `routers/nevermined.py`, `NeverminedPanel.vue`, MCP `get_nevermined_payments`, `db/agent_cleanup.py`, `canary/snapshot.py`): nothing counts or aggregates `action="verify"` rows — the only action filter anywhere is `== "settle_failed"`, the Vue badge map is rendering with a gray fallback, and the MCP `count` is the length of the whole list. So `run_paid_turn` writes the `verify` row carrying `execution_id` + payer right after `attach_execution`. Before this, only the terminal `settle` / `settle_failed` rows carried that pair, so every payer-bound task was already finished: a `tasks/get` during the turn read as not-found and a payer's `tasks/cancel` was unreachable by construction. No schema change — the columns were already on the row. T5's no-oracle property is untouched: every failure is still the uniform `-32001`. Tests: 11 new cases across the three layers (router classification on both `send` and `stream`, the service's relabel and binding row, the db predicate reading a `verify` row). Each was mutation-checked — dropping the retryable branch, restoring `-32001`, reverting the relabel or dropping the binding row turns the matching case red. The pre-existing exact action-list assertions were updated for the second verify row (its one visible consequence: the operator payment log shows the attempt and the binding as two rows per paid turn). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
trinity-ability
pushed a commit
that referenced
this pull request
Oct 3, 2026
…branch side kept on ancestry-only conflicts (abilityai/trinity-enterprise#679)
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3185
What
The outbound A2A client can now pay a priced remote agent and report back when it hits a price.
credential_kindis an optional field. When it is omitted, the kind is inferred: a value that decodes to a JSON object withx402Versionandpayloadis apayment_token, anything else is anapi_key. The response reports the kind. v3 single-use tokens are detected when written and flagged ascredential_single_use: true, with a hint.x402.payment.status/.payload), and the same request also carries it in thepayment-signatureheader. The header is a deprecated fallback behindA2A_SEND_PAYMENT_SIGNATURE_HEADER. The token is sent in addition toAuthorization: Bearer, not instead of it. Plain API-key endpoints send exactly the same bytes as before.payment-requiredtask state, is returned as its own outcome that is never retried./a2a/callanswers HTTP 402 withdetail = {reason: "payment_required", message, payment, remote_status?, task_id?}.paymentis{summary: {plan_id, scheme, network, resource_url, description, credits_per_request, error}, x402: <bounded raw>, truncated}. The success allowlist is unchanged.payment_rejectedand keepsremote_status: 403. On any other endpoint it isrpc_forbidden. Either way the caller can tell "buy" apart from "top up".register_a2a_endpoint,call_a2a_agentandget_a2a_tasksurface the outcome in a form the calling agent relays to a person once, then stops.list_a2a_endpoints.docs/user-docs/integrations/a2a-protocol.mdanddocs/memory/*are updated.The provider side (
routers/paid.py's 402 vocabulary) is unchanged. The A2A x402 extension handshake/negotiation is out of scope.Rulings carried
payment-signatureheader vocabulary. The 402 outcome is distinct and never retried. A 403 must be distinguishable. Tokens are redacted. No new protocol.payment-requiredtask state.payment-signature, base64payment-required,x402Version: 2).payment_rejected/rpc_forbidden, withremote_status: 403.purchase_url.Review + security
/review(Fable) over406408a0..51c275bdreturned MERGEABLE AFTER FIXES:a2a.test.tsdeep-equal missingcredential_kind.services/a2a_protocol.py, anda2a_outboundre-exports them.credential_kindparam description, was corrected in the same commit./cso --difffound nothing at the 8/10 gate. One hypothesis was logged at confidence 4: a peer'spayment.summary.descriptionis text an LLM reads. It is bounded, allowlisted and scrubbed, which is the same posture as the existingremote_error.[\x21-\x7E]+..claudeand the enterprise submodule untouched.Tests
tests/unit/test_3185_*.pyandtests/unit/test_736_a2a_outbound_*.py: 478 passed, on the fix commit.src/mcp-server:npm run buildis clean andnpm testgives 667 / 667 pass, on the fix commit.test_736_a2a_url_validation,test_ent435_settings_sink_guard,test_186_enumeration_uniformity): 69 passed.dev(150515f) withgit merge-tree: it is clean, with no Alembic revision involved.mcp-server-test.ymlare left to CI.Before merge
mcp-server-test.yml, which triggers onsrc/mcp-server/**.a2a_client.py,a2a_outbound.py,a2a_outbound_service.py,routers/a2a.py,routers/settings/integrations.py,models.pyand fourdocs/memoryfiles. Whichever merges second needs a rebase.routers/paid.pyvocabulary.Handoffs
call_a2a_agent's newpayment_requiredreturn is a failure, but the MCP audit records it as success untilwithAuditchanges. This is one more instance of that debt; it is not fixed here (plan T5).summary.purchase_urlfrom the card once the provider defines it. The removal note on thepayment-signaturefallback names feat(executions): plumb cancel signal into agent task-runner reply (#671 defense-in-depth) #679's pin bump.credential_kindexplicitly and rendercredential_single_use. It consumes the 402detailshape above.Built through the trinity-pm build chain (
chain-3185, builder-agent project step 8).🤖 Generated with Claude Code