feat(a2a): x402 payment gate on the inbound door + price on the agent card (Abilityai/trinity-enterprise#679) - #3213
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>
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>
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>
…n (abilityai/trinity-enterprise#679) Checkpoint A of the x402 payment gate on the A2A inbound door: the money logic the gate will run, extracted to one home, plus the SDK version the in-band A2A flow needs. No new endpoint, no behaviour change on any existing route except the two named below, and no schema (T4 — attribution rides the payer wallet on rows that already carry it). payments-py 1.2.1 -> 1.18.0 (ruling 3), exact and equal in docker/backend/Dockerfile and tests/requirements-test.txt. CI already ran 1.18.0 against a 1.2.1 image (trinity-enterprise#763), which by construction cannot catch an incompatible SDK call, so the pin parity is now guarded in the #1891 shape. 1.18.0 declares ~15 RUNTIME dependencies the image did not pin; they are pinned explicitly at the versions the test venv resolved (T8), because `payments_py.payments` imports the a2a package at module load — one unimportable transitive flips NEVERMINED_AVAILABLE to False and both payment doors answer 501 with a green build and nothing but a WARNING. The same test imports the SDK and asserts that expression resolves True. services/paid_turn_service.py is the verify -> dedup -> execute -> settle lifecycle lifted out of routers/paid.py (T3), so the three #1018 settle branches exist once instead of once per door. Every collaborator is a PARAMETER, never an import (decision 20): three test files patch `paid.db`, `paid.idempotency_service` and `paid.NEVERMINED_AVAILABLE`, and an extraction that imported those names here would have silently detached every one of those patches. test_1018_settlement_ordering, test_679_callers and test_3114_pull_route_callers pass UNEDITED (49 tests) — they are the behaviour net, and the paid door's response bytes are unchanged on every branch. Also in the orchestrator's scope, from the plan's engineering review: * `endpoint` is threaded through build_402_response / verify_payment / settle_payment / settle_payment_once, defaulting to today's paid chat URL (decision 19). An x402 v3 token signs `resourceUrl` and the facilitator compares origin+path, so the A2A gate must mint and verify against `{base}/a2a/{name}`; the default keeps the paid door identical. * `NeverminedPaymentResult.retryable` (decision 22): a facilitator timeout, an SDK error or a saturated gate is Trinity failing to decide, not a rejected token. The paid door still answers 403 either way; the A2A gate will tell a retryable caller to retry rather than tell a human to buy another token. * A fleet-wide facilitator concurrency bound (decision 23, NEVERMINED_MAX_INFLIGHT=8, bounded wait then a named retryable refusal). Each call holds a thread for 15-97 s and a priced agent's door needs no credential to make us dial out, so per-IP limiting cannot bound it. * a2a_protocol gains X402_STATUS_VERIFIED / X402_STATUS_REJECTED and the provider-side reader `payment_payload_from_message` (the in-band rail the #3185 client writes). Vocabulary only here; the gate that consumes it is checkpoint B. Two deliberate behaviour changes, both on the paid door and both on paths nothing asserted: 1. Cancellation is phase-aware (decision 21/E5). Before an execution result the idempotency claim is released — previously a client disconnect stranded it in-flight and 409'd the payer's own retry for the key's whole TTL. After a successful turn the settle runs under asyncio.shield and the claim is completed, so a caller that walked away cannot cause the LLM work to be repeated or the burn to go unrecorded. 2. The #1672 resume-sentinel rejection now runs through a `pre_execute` hook, at exactly the position it ran before (after the dedup gate, before execution), so the 400 and its ordering are preserved. Mutation-verified: fail()-instead-of-complete() on an unsettled success, settling a failed/cancelled turn, and dropping the settle shield each turn the new tests red (7 failures), restored byte-identical. Tests: 332 passed, 4 skipped across unit/test_{1018,679_callers,3114,3185_*,157, idempotency,894,ent500,ent679_*}. Not run: the image build and a live facilitator (both Before-merge items). Refs abilityai/trinity-enterprise#679. Stacks on #3185. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
…lityai/trinity-enterprise#679)
Checkpoint C of three. A caller could meet the 402 from `POST /a2a/{name}`
(checkpoint B) only by calling first and being refused. The card is A2A's
discovery document, so a priced agent now declares its price there: an
x402-speaking client mints a token from `agentId` + `planId` off the card alone
and meets the paywall on its first request, and a human follows
`paymentInfoUrl` to the public `GET /api/paid/{name}/info` document.
`a2a_card_service.with_payment_extension` is pure — no I/O, no edition
awareness. `_card_with_exposed_skills` stays THE single card producer (ent#180
FR-3), so both surfaces (public well-known + authenticated per-agent) carry the
price block by construction; the no-decrypt config read lives in the router,
where the skills-provider lookup already lives, and fails open because a card
route has never 5xx'd (the gate re-reads the config and still answers 402).
The hard line holds: an unpriced agent, a DISABLED config, or an enabled one
missing its plan or agent id all return the card object BY IDENTITY, so every
install that sells nothing is byte-identical. The official A2A x402 extension
URI is deliberately NOT declared even though the provider SDK's own card helper
appends it — declaring an extension advertises its `X-A2A-Extensions`
activation handshake, which Trinity does not run, so a generic client would
activate it and then wait for a negotiation that never comes. We speak the
vocabulary only.
T9: `credits_per_request` accepts 0. A Nevermined duration plan charges by
time — Trinity sends no amount to the facilitator and the plan defines the
burn — so the old `>= 1` floor forced an operator to claim a per-call price
nothing would ever charge. A negative is still a named 422, the default is
still 1, and such a plan's card declares `paymentType: "dynamic"` rather than
the contradictory `fixed`/0 that reads as free and that the SDK's own card
validator rejects for a paid plan. The one frontend line relaxes with a
`Number.isFinite` guard: `>= 0` alone would be a regression, because
`v-model.number` leaves a cleared input as `''` and both `''` and `null` coerce
to true against 0 in JS — the old `>= 1` was blocking an empty field by
accident.
Docs (mechanism only, #1461): `requirements/mcp.md` §32.6 is the feature's one
home and covers all three checkpoints; `requirements/public-access.md` §23.7 is
a pointer naming what changed on the paid door's side; the two feature flows
and the `architecture/backend.md` catalog lines carry the gate, the shared
orchestrator and the card. Stated in all of them, per T1: in OSS a configured
price block points at a door that answers 404 while A2A exposure is off — the
card says what the agent COSTS, not that the door is OPEN.
No migration, no Alembic revision, nothing under src/backend/enterprise.
Tests: `tests/unit/test_ent679_a2a_priced_card.py` (22) drives both card routes
over a TestClient rather than asserting source text, and is mutation-proven —
dropping the router wiring, declaring the official URI, hardcoding
`paymentType`, or restoring the credits floor each go red on behaviour.
`test_157_a2a_inbound_server.py` gains one fixture line stubbing the payment
config to None, so its "card unchanged" assertions prove the unpriced path
rather than the fail-open exception path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion (C1, abilityai/trinity-enterprise#679) The shielded settle's recovery handler awaited `settle_task` and only then wrote `finalize_settled` / the settle-failed row / `idem.complete`. That works under `asyncio.Task.cancel()` (edge-triggered: one CancelledError, later awaits proceed) and NOT under the cancellation the real consumer produces: Starlette's `StreamingResponse` — the A2A `message/stream` door — runs its body generator inside an anyio task group and cancels that group's cancel SCOPE on client disconnect, and anyio cancellation is level-triggered. The `await` on line 410 re-raised immediately, so none of the rows were written: the facilitator burned credits with no `settle` row and the claim stayed in-flight for the key's whole 24 h TTL, after which the payer's identical retry re-ran the LLM and re-settled. The settle and ALL of its bookkeeping now run inside one detached task (`_settle_and_record`), which is not inside the cancelled scope and therefore completes either way; the `except CancelledError` handler only re-raises and never awaits. A settle that RAISES is recorded as unsettled by the detached task and the exception is handed back to the awaiting frame, so the paid door's response bytes are unchanged on every branch. `_PENDING_SETTLES` holds a strong reference to the detached task — asyncio keeps a running task only weakly, which is the same lost burn by another route. Tests: two new scope-level probes (`anyio.create_task_group`, cancelling the SCOPE rather than the task) assert the settle row is written and the claim converges after a mid-settle disconnect; both were red on the previous code for exactly that missing row. The pre-existing edge-triggered `test_disconnect_during_settle_*` pair keeps its assertions and gains the join point the detachment requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he payer binding, document the facilitator bound (I2/I5/I6/I7, abilityai/trinity-enterprise#679)
I2 — `pre_execute` refusing after `begin()` returned without releasing the
claim, so a wallet the allow-list refuses had its identical retry answered
IN_FLIGHT ("a duplicate paid request is still being processed") instead of the
refusal that says why, for the key's whole 24 h TTL. Nothing is charged and
nothing delivered on that branch, so it now `idem.fail(decision)`s like the
cancelled/failed/raised execution branches already do. No response bytes change
on either door; pinned by `test_pre_execute_abort_releases_the_fresh_claim`,
which was red on the previous code.
I5 — `NeverminedPanel.vue` accepted 0 credits in `isFormValid` (T9) while the
input still carried `min="1"`, so a typed 0 was marked `:invalid` and the
spinner floor fought an operator configuring a duration plan. One attribute.
I6 — `db/nevermined.py::payer_owns_execution` is the whole of T5 and every gate
test stubbed it, so the one new query on the money path never executed in CI.
`test_ent679_payer_owns_execution.py` runs it against a throwaway SQLite
carrying the real `nevermined_payment_log`: the payer matches their own settled
and settle_failed task across the facilitator's unstable checksum casing;
another payer, another agent and an unknown execution are all False; a
SQL-shaped wallet is a bound parameter; a missing argument fails closed.
Mutation-checked — dropping `func.lower` or the guard turns cases red.
I7 — `NEVERMINED_MAX_INFLIGHT` and `NEVERMINED_FACILITATOR_WAIT_SECONDS` were
env-only and undocumented. Now in `.env.example` at their code defaults and in
the nevermined flow (new Configuration section) with a pointer from the
`backend.md` catalog line: mechanism only — what the bound protects (the shared
thread executor, against a caller that needs no credential to make us dial out),
why it is fleet-wide rather than per agent, and why the gate is per event loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
…branch side kept on ancestry-only conflicts (abilityai/trinity-enterprise#679)
…e image (abilityai/trinity-enterprise#679) `tests/requirements-test.txt` pinned `payments-py==1.18.0` exactly but left its own runtime dependencies to pip. payments-py declares `pyjwt<3.0.0,>=2.9.0` and nothing in this file narrowed it, so CI resolved the newest release (2.15.1) against a `docker/backend/Dockerfile` pinning 2.14.0 and `test_ent679_payments_pin_parity[pyjwt]` failed — the exact image-vs-CI divergence the `payments-py` pin itself was added to prevent (#763), one layer down. Pin all thirteen, not just pyjwt. The other twelve were green only because the newest release still happened to equal the image pin; the Dockerfile's "versions are the ones the test venv resolved" note was true when written and expires the moment any of them publishes. Leaving them floating keeps twelve more copies of this failure armed. The parity test is unchanged — it asserts image-pin == installed-version, which is the property worth having, and loosening it would re-open the gap. Verified with a fresh resolve into a clean venv: PyJWT-2.14.0, and every other member of the set equal to its Dockerfile pin. `pytest-asyncio` carries both the existing `>=0.24.0` test-tooling floor and the new `==1.4.0` pin; pip intersects them to 1.4.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (abilityai/trinity-enterprise#679)
`POST /a2a/{name}` now depends on `get_user_or_anonymous`, which CALLS
`get_current_user` directly rather than depending on it — so FastAPI never
consults a `dependency_overrides[get_current_user]` entry. The loopback test
still overrode the latter, so its credentialled peer arrived as anonymous, took
the new x402 branch, and 500'd on `is_priced` reading a method its `SimpleNamespace`
stub does not carry.
The test exists to prove #738 federation's premise — a Trinity calling a Trinity
with an MCP key — which is the principal path ruling T6/AC4 keeps byte-identical.
Overriding what the route actually depends on restores exactly that, and every
assertion in the test is unchanged. This is the same adaptation the branch
already made in `test_157_a2a_inbound_server.py`'s fixture; that file's
`fake_db` also gained a `get_nevermined_config` stub, and this one needs it for
the same reason — so the card the loopback fetches is the unpriced card rather
than the card producer's fail-open-on-exception path.
No production change: the unpriced and fleet paths are untouched. Full file is
79 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d (abilityai/trinity-enterprise#679) T6 added `a2a` to `INTERACTIVE_TRIGGERS` — an inbound JSON-RPC caller is blocked in-line on the reply — which puts it in `_CLAIM_WAITING_TRIGGERS`, so `dispatch_and_await_terminal` now claim-waits and then reads the row once before waiting for the terminal. `test_a_queued_dispatch_waits_and_rebuilds_from_the_row` seeded a row that was ALREADY terminal at dispatch time, so that read short-circuited and `wait_for_sync_terminal` was never reached, failing the two assertions about how it was called. The behaviour is right and deliberate: a row that is already terminal should be returned, not waited on. What was wrong is the fixture — an already-terminal row cannot precede the wait the test exists to exercise. The row now starts claimed-and-running and goes terminal inside the wait, which is the real sequence. Every assertion is unchanged, including the rebuilt cost/response and the `agent timeout + buffer` deadline, so the test still pins "QUEUED is not an outcome; the worker's terminal is the answer" — and now pins it for the trigger T6 actually changed. The sibling property (a trigger with nobody blocked on it skips the claim phase) is already covered on `schedule` in test_3114, alongside that file's new `test_a2a_takes_the_claim_phase`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
This PR requires the following before merge:
Review required, not blocking on its own:
After merge (cross-tracker): |
… payer binding honestly (Abilityai/trinity-enterprise#679) Two /validate-pr findings on #3213. NEVERMINED_MAX_INFLIGHT and NEVERMINED_FACILITATOR_WAIT_SECONDS were read by the backend and documented in .env.example but forwarded by none of the three compose files, whose backend service takes an explicit environment list — so the .env knobs did nothing on deploy (the #1056 class). Wired into docker-compose.yml, docker-compose.prod.yml and docker-compose.hosted.yml at the code defaults. The payer→task binding was documented as written mid-turn. It is not: run_paid_turn learns the execution id from execute()'s return value, and execute() is dispatch_and_await_terminal, which returns at the terminal. What the row does buy is a binding that exists before the settle and without a settle row. The docstrings, the feature flow and the test docstrings now say that, and a payer's tasks/cancel is described as what it is (it can only answer "already terminal"). No behaviour change. Tests: the binding is asserted on the settle_in_progress branch, where no settle row is written (red with the binding row removed), and a second test pins that no row carries the execution id while the turn is running. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Addressed in 038445d:
Still open from the PR's own Before-merge list: the |
trinity-ability
left a comment
There was a problem hiding this comment.
/validate-pr (Lane C) on 038445d: both code findings from the earlier comment are fixed, Tier 1 is green, and journey-smoke and e2e are green on the PR. Approving the code. The live sandbox verify + settle run and the /verify-local image run from the Before-merge list are still open and are the operator's call.
…lus docs (#3215) Operator scope-add (2026-10-04T11:48:59Z): a settled `message/send` returned a Task with `metadata: null` while `a2a-protocol.md` promised "a normal A2A Task whose metadata carries the payment status and a receipt". The metadata WAS built and placed — on `status.message.metadata`, which is the spec location (the provider SDK's own `X402A2AUtils` reads exactly there) — but top-level `Task.metadata` was never set, so a typed `a2a-sdk` client rendered a charged turn as `metadata: null`. `_task_object` now mirrors it from the same assignment, the same dict object, so the two locations cannot drift. The docs name `status.message.metadata` as the primary location and the mirror as the compatibility half. The free path still emits no `metadata` key at all, so an unpaid Task's bytes and every idempotency snapshot built from them are unchanged. Docs: - `user-docs/integrations/a2a-protocol.md` — names both locations precisely. - `user-docs/integrations/nevermined-payments.md` — crypto vs card plans and the scheme/network each advertises; why the 402's `resource.url` origin matters and which knob fixes it. - `user-docs/guides/deploying/public-access.md` — the A2A door was published by #3213 and the narrow tunnel table had no row for it. Added with ANCHORED rules (`^/a2a/[^/]+$`, `^/a2a/[^/]+/\.well-known/agent-card\.json$`): an unanchored `/a2a/*` would also match `/api/agents/{name}/a2a/call`, Trinity's authenticated OUTBOUND caller, which has no business on a public hostname. - `memory/requirements/public-access.md` §23.2–23.3, `memory/requirements/mcp.md` FR-4 + new FR-4a, `memory/feature-flows/nevermined-payments.md` (two new sections + the two new knobs), `memory/architecture/backend.md` one-liners for `nevermined_payment_service.py`, `paid.py` and `a2a.py`. 11 new tests; 382 green across the #3215 files and every payments/A2A neighbour. Mutation proven red: removing the mirror → 6 red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes Abilityai/trinity-enterprise#679. It is the provider side of #3185 / #3209, which merged into
devas 918e0b0. Base isdev. This PR was cut stacked onfeature/3185-a2a-outbound-402. #3209 was squash-merged, sodevwas merged back into this branch (9c3dc62).The squash breaks ancestry, so the Commits tab also lists the six #3185 commits. The PR's diff against
devis this change only: 35 files. Draft until the Before-merge items below are done.What
An exposed, priced agent can be called and paid over its A2A door. A non-Trinity client can pay it using nothing but the agent card.
services/paid_turn_service.py. It holds the verify → claim → run → settle → bookkeeping sequence that used to live inline inrouters/paid.py. Both doors call it, with door-specific behaviour injected as parameters. The paid door's 402/403 bytes are unchanged, and its existing tests pass unedited.services/a2a_payment_gate.py.POST /a2a/{name}now accepts an anonymous caller for an agent that is both exposed and priced. The resolution lives independencies.get_user_or_anonymous.message/sendandmessage/streamanswer HTTP 402, using the same payment-required vocabulary as the paid door, withresource.urlset to/a2a/{name}.x402.payment.status/.payload). Thepayment-signatureheader is accepted only as a deprecated fallback. The result is reported in-band too (x402.payment.receipts).derive_payment_key(token, text), scoped per payer. A consumer that retries with the same text and token after a timeout is replayed, not charged again. A settle that fails or is cancelled leaves an unsettled row, the same as on the paid door.tasks/get/tasks/cancelserve a payer only if their token verifies and matches the execution's ownverify/settlelog rows. A mismatch answers-32001, the same as an unknown task, so the endpoint gives no oracle. Theverifyrow now carriesexecution_id, so the operator payment log shows twoverifyrows per paid turn.a2a_card_service.with_payment_extensionadds the payment extension (scheme, network, plan, price,paymentInfoUrl) to a priced agent's card on both card surfaces. Unpriced cards stay byte-identical.x402:{payer}. The existing A2A caller allow-list is consulted with that identity and fails closed.payments-pygoes from 1.2.1 to 1.18.0 indocker/backend/Dockerfile, with its first-level transitive set pinned.tests/requirements-test.txtis pinned to the same exact version.test_ent679_payments_pin_paritykeeps the two in lockstep and import-smokes the SDK.validate_creditsandNeverminedPanel.vueaccept0(two Vue lines), so time-based plans can setcredits_per_request = 0.NEVERMINED_MAX_INFLIGHT) and a wait bound (NEVERMINED_FACILITATOR_WAIT_SECONDS), both documented in.env.example. On the A2A door, a retryable verify failure answers-32603withdata.retryable. The paid door keeps its 403.docs/user-docs/integrations/a2a-protocol.mddocs/memory/feature-flows/a2a-inbound-server.mdandnevermined-payments.mddocs/memory/requirements/mcp.mdandpublic-access.mddocs/memory/architecture/backend.mdNo schema change and no Alembic revision: the payer binding reads existing log rows.
.claudeandsrc/backend/enterpriseare untouched.Rulings carried
The orchestrator made these rulings on the operator's behalf. They are recorded in the plan file.
message/send.a2ajoinsINTERACTIVE_TRIGGERS(disclosed below).x402:{payer}, fail-closed./verify-localbefore merge.>= 0, backend and one form field.test_2842_2843is narrowed to== {"a2a"}, and onetest_3114case now usesschedule.pre_executehook at the same position.Review + security
/review(claude-fable-5-1, report only) ran over092a6513..90e8dfceand returned MERGEABLE AFTER FIXES. The rider checks hold: one money path, the 402 / in-band / card contract, gate order, no schema change, exact pin parity, a seam-clean docs pass, and.claude/ enterprise untouched.Fixed in three pushed checkpoints:
message/streamclient disconnect) could burn credits with no settle row and leave a 24 h stranded claim. Regression tests cancel an anyio scope; they were red on the old code.min="0".-32001for a non-task error.verifyrow now carriesexecution_id.data.codeappears onmessage/senderror envelopes. The endpoint is new in this PR./cso --diff:resource.urlis derived from the Host header, while the card URL comes from the configured public URL (live run below).X-Forwarded-Fortrust in the per-IP bucket is pre-existing.Tests
The builder reports, on the fix commits:
The reviewer, on the pre-fix range, reports the rider set at 353 passed / 1 skipped, and the unedited paid-door + auth guards at 203 passed.
The orchestrator checked these with
git merge-treeon refs fetched by explicit refspec:dev(918e0b0) is clean.Not run here: the full unit suite (CI), the Docker image build, and a live facilitator.
Before merge
/verify-local:payments_py.paymentsimport smoke;resource.url = {base}/a2a/{name};resource.urlagree behind the deployment's proxy.devare not listed. Whichever merges second rebases.src/backend/routers/paid.py. This branch moved the paid turn intopaid_turn_service.py, so theirpaid.pyedits likely belong in the new service.docs/memory/requirements/mcp.mddocs/memory/architecture/backend.mdHandoffs
detail.paymentblock and the card extension.task_id, rather than reporttimeoutas terminal (thetimeoutoutcome carries notask_idtoday).A2A_SEND_PAYMENT_SIGNATURE_HEADERcan be reconsidered after the live run. The provider reads the header on polls, which is the carrier its docstring was waiting for.POST /api/paid/{name}/chathas no per-IP limiter. This is pre-existing; the A2A door gets one here.messageIdcan dedup an SDK client's retry. The paid turn's existing at-least-once residual is wider for v3 clients.Built through the trinity-pm build chain (
chain-679, builder-agent project step 9).🤖 Generated with Claude Code