Skip to content

feat(a2a): outbound client speaks 402 — payment-token credential + distinct payment-required outcome (#3185) - #3209

Merged
vybe merged 6 commits into
devfrom
feature/3185-a2a-outbound-402
Oct 3, 2026
Merged

vybe merged 6 commits into
devfrom
feature/3185-a2a-outbound-402

Conversation

@vybe

@vybe vybe commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Closes #3185

What

The outbound A2A client can now pay a priced remote agent and report back when it hits a price.

  • Payment-token credential. A registered outbound endpoint can store a payment token instead of an API key. credential_kind is an optional field. When it is omitted, the kind is inferred: a value that decodes to a JSON object with x402Version and payload is a payment_token, anything else is an api_key. The response reports the kind. v3 single-use tokens are detected when written and flagged as credential_single_use: true, with a hint.
  • How the token is sent. It goes in A2A message/task metadata (x402.payment.status / .payload), and the same request also carries it in the payment-signature header. The header is a deprecated fallback behind A2A_SEND_PAYMENT_SIGNATURE_HEADER. The token is sent in addition to Authorization: Bearer, not instead of it. Plain API-key endpoints send exactly the same bytes as before.
  • 402 outcome. A remote 402, or an in-band payment-required task state, is returned as its own outcome that is never retried. /a2a/call answers HTTP 402 with detail = {reason: "payment_required", message, payment, remote_status?, task_id?}. payment is {summary: {plan_id, scheme, network, resource_url, description, credits_per_request, error}, x402: <bounded raw>, truncated}. The success allowlist is unchanged.
  • 403 vs 402. On an endpoint whose credential is a payment token, a 403 is payment_rejected and keeps remote_status: 403. On any other endpoint it is rpc_forbidden. Either way the caller can tell "buy" apart from "top up".
  • MCP. register_a2a_endpoint, call_a2a_agent and get_a2a_task surface the outcome in a form the calling agent relays to a person once, then stops.
  • Redaction. The token, its two base64 alphabets and its decoded long fields are scrubbed from every error builder, from logs and from list_a2a_endpoints.
  • Docs. docs/user-docs/integrations/a2a-protocol.md and docs/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

  • Issue body, 2026-10-02: the consumer side is OSS-core. Use the payment-signature header vocabulary. The 402 outcome is distinct and never retried. A 403 must be distinguishable. Tokens are redacted. No new protocol.
  • Design note on the issue, 2026-10-03 (the newest comment, so it takes precedence): the token travels in metadata first, with the header as a deprecated fallback. The requirement is read from the 402 response or from a payment-required task state.
  • AC6 pre-build check: satisfied by Abilityai/trinity-enterprise#763. Trinity already uses the x402 v2 names (payment-signature, base64 payment-required, x402Version: 2).
  • Plan taste decisions T1–T7: all accepted as recommended.
    • T1: HTTP 402 with a structured detail.
    • T2: metadata and header sent on the same request. A fallback that waited for a 402 first would be an automatic retry, which AC2 forbids.
    • T3: payment_rejected / rpc_forbidden, with remote_status: 403.
    • T4: single-use tokens are flagged, not refused.
    • T5: deferred (see Handoffs).
    • T6: optional kind, inferred when omitted.
    • T7: summary block plus the bounded raw object, with no invented purchase_url.
  • Review rulings: the payment token rides in addition to Bearer. Only the docs, the MCP description and the docstring were corrected. The wire did not change, because T2 stands.

Review + security

  • /review (Fable) over 406408a0..51c275bd returned MERGEABLE AFTER FIXES:
    • C1 was the a2a.test.ts deep-equal missing credential_kind.
    • I1 was wording that contradicted the wire.
    • I2 was a stale codec comment.
    • I3 was a duplicated kind constant.
    • All four are fixed in 88eb4a1. The kind constants now live only in services/a2a_protocol.py, and a2a_outbound re-exports them.
    • A fourth "instead of" text, the credential_kind param description, was corrected in the same commit.
  • /cso --diff found nothing at the 8/10 gate. One hypothesis was logged at confidence 4: a peer's payment.summary.description is text an LLM reads. It is bounded, allowlisted and scrubbed, which is the same posture as the existing remote_error.
    • The payment path never follows a URL supplied by the peer.
    • The redirect guard still runs before the 402/403 branch.
    • The header value is limited to [\x21-\x7E]+.
    • No dependency, workflow or frontend change is in range.
  • Rider checks all pass: one predicate and one carriage, Bearer byte-identical, no automatic retry on 402, no Alembic, no frontend, .claude and the enterprise submodule untouched.

Tests

  • tests/unit/test_3185_*.py and tests/unit/test_736_a2a_outbound_*.py: 478 passed, on the fix commit.
  • src/mcp-server: npm run build is clean and npm test gives 667 / 667 pass, on the fix commit.
  • CSO guard subset (test_736_a2a_url_validation, test_ent435_settings_sink_guard, test_186_enumeration_uniformity): 69 passed.
  • I checked the merge against live dev (150515f) with git merge-tree: it is clean, with no Alembic revision involved.
  • The full unit suite and mcp-server-test.yml are left to CI.

Before merge

  • CI is green, including mcp-server-test.yml, which triggers on src/mcp-server/**.
  • Merge order with feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) #2956 (AAuth agent identity, also a draft): the two branches conflict on a test merge in a2a_client.py, a2a_outbound.py, a2a_outbound_service.py, routers/a2a.py, routers/settings/integrations.py, models.py and four docs/memory files. Whichever merges second needs a rebase.
  • No live round trip against a real paid door. The provider side, Abilityai/trinity-enterprise#679, is not built yet. The 402/403 shapes are covered at unit level against the routers/paid.py vocabulary.

Handoffs

Built through the trinity-pm build chain (chain-3185, builder-agent project step 8).

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 4 commits October 3, 2026 12:12
…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>
Comment thread src/backend/services/a2a_outbound.py Fixed
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
vybe marked this pull request as ready for review October 3, 2026 18:06
Comment thread src/backend/services/a2a_outbound.py Fixed
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 trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vybe
vybe merged commit 918e0b0 into dev Oct 3, 2026
27 checks passed
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants