Skip to content

feat(a2a): x402 payment gate on the inbound door + price on the agent card (Abilityai/trinity-enterprise#679) - #3213

Merged
vybe merged 17 commits into
devfrom
feature/ent679-a2a-payment-gate
Oct 4, 2026
Merged

vybe merged 17 commits into
devfrom
feature/ent679-a2a-payment-gate

Conversation

@vybe

@vybe vybe commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Fixes Abilityai/trinity-enterprise#679. It is the provider side of #3185 / #3209, which merged into dev as 918e0b0. Base is dev. This PR was cut stacked on feature/3185-a2a-outbound-402. #3209 was squash-merged, so dev was 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 dev is 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.

  • One money path for both doors. New file: services/paid_turn_service.py. It holds the verify → claim → run → settle → bookkeeping sequence that used to live inline in routers/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.
  • The gate. New file: 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 in dependencies.get_user_or_anonymous.
    • Without a token, message/send and message/stream answer HTTP 402, using the same payment-required vocabulary as the paid door, with resource.url set to /a2a/{name}.
    • With a token, the token is read in-band first, from message/task metadata (x402.payment.status / .payload). The payment-signature header is accepted only as a deprecated fallback. The result is reported in-band too (x402.payment.receipts).
    • Internal fleet calls and subscription tenants never reach the gate.
  • Idempotency and honest unsettled. The dedup key is the paid door's 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.
  • Payer-bound polling. tasks/get / tasks/cancel serve a payer only if their token verifies and matches the execution's own verify/settle log rows. A mismatch answers -32001, the same as an unknown task, so the endpoint gives no oracle. The verify row now carries execution_id, so the operator payment log shows two verify rows per paid turn.
  • Price on the card. a2a_card_service.with_payment_extension adds the payment extension (scheme, network, plan, price, paymentInfoUrl) to a priced agent's card on both card surfaces. Unpriced cards stay byte-identical.
  • Identity on the payment log. Calls arrive as x402:{payer}. The existing A2A caller allow-list is consulted with that identity and fails closed.
  • Pin bump. payments-py goes from 1.2.1 to 1.18.0 in docker/backend/Dockerfile, with its first-level transitive set pinned. tests/requirements-test.txt is pinned to the same exact version. test_ent679_payments_pin_parity keeps the two in lockstep and import-smokes the SDK.
  • Zero-credit plans. validate_credits and NeverminedPanel.vue accept 0 (two Vue lines), so time-based plans can set credits_per_request = 0.
  • Bounded facilitator. Verify/settle calls go through a concurrency gate (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 -32603 with data.retryable. The paid door keeps its 403.
  • Docs.
    • docs/user-docs/integrations/a2a-protocol.md
    • docs/memory/feature-flows/a2a-inbound-server.md and nevermined-payments.md
    • docs/memory/requirements/mcp.md and public-access.md
    • docs/memory/architecture/backend.md

No schema change and no Alembic revision: the payer binding reads existing log rows. .claude and src/backend/enterprise are untouched.

Rulings carried

The orchestrator made these rulings on the operator's behalf. They are recorded in the plan file.

  • Issue body:
    • 402 on message/send.
    • The same idempotency and honest-unsettled behaviour as the paid door.
    • The card states the price.
    • Fleet and subscription tenants are unaffected.
    • A non-Trinity client can pay from the card alone, using the A2A x402 extension's message vocabulary with no library dependency.
    • The payment log attributes the calling identity.
  • Issue comment, 2026-10-02: credits and subscription plans are verified the same way. The unit of price is one A2A call.
  • Issue comment, 2026-10-03 (the newest, so it takes precedence):
    • Payment travels in-band in task metadata.
    • The header is a deprecated fallback.
    • Bump payments-py 1.2.1 → 1.18.0, with tests pinned to the same version.
    • One sandbox verify + settle run (see Before merge).
  • Plan taste decisions T1–T9: all accepted as recommended.
    • T1: OSS mechanism, with exposure behind the existing gate.
    • T2: HTTP 402 with paid-door bytes.
    • T3: extract the shared orchestrator with parameter injection.
    • T4: no migration.
    • T5: payer polling bound to the log rows, no oracle.
    • T6: a2a joins INTERACTIVE_TRIGGERS (disclosed below).
    • T7: allow-list consulted with x402:{payer}, fail-closed.
    • T8: pin the transitive set, with /verify-local before merge.
    • T9: >= 0, backend and one form field.
  • T6 disclosure: an A2A turn now takes the interactive claim phase on pull pilots. The trigger-set disjointness guard in test_2842_2843 is narrowed to == {"a2a"}, and one test_3114 case now uses schedule.
  • Two paid-door behaviour changes beyond byte parity. Both come from the plan's engineering review, and the paid-door tests pass unedited:

Review + security

/review (claude-fable-5-1, report only) ran over 092a6513..90e8dfce and 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:

  • 2d072c3 (C1, money integrity). Settle and its bookkeeping now run inside the detached task. Before, a level-triggered anyio cancel (a message/stream client 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.
  • ea55b51.
    • I2: a refused turn releases its claim.
    • I5: min="0".
    • I6: an executing test of the payer-binding query (8 cases).
    • I7: the facilitator bounds are documented.
  • e659ced.
    • I1: a retryable verify is answered as retryable.
    • I3: send and stream share one refusal classification, and stream never emits -32001 for a non-task error.
    • I4: the verify row now carries execution_id.
    • Additive change: data.code appears on message/send error envelopes. The endpoint is new in this PR.

/cso --diff:

  • HIGH: C1, fixed above.
  • MEDIUM, CSO-2: payments-py 1.18.0 declares docs/test tooling as runtime dependencies, so about 40 second-level packages float unpinned in the backend image. This needs a docker resolve → Before merge.
  • Hypotheses, not counted:
    • resource.url is derived from the Host header, while the card URL comes from the configured public URL (live run below).
    • RFC1918 X-Forwarded-For trust in the per-IP bucket is pre-existing.

Tests

The builder reports, on the fix commits:

  • The ent679 / paid-turn / 3185 / pull-claim sets: 411 passed, 1 skipped, plus 139 auth/route guards green.
  • Mutation batteries on the gate, card and payer binding: green.

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-tree on refs fetched by explicit refspec:

  • The merge against live dev (918e0b0) is clean.
  • The merged tree equals this branch's tree (f5aeb88b).
  • There is no Alembic revision in range.

Not run here: the full unit suite (CI), the Docker image build, and a live facilitator.

Before merge

Handoffs

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

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 13 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>
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)
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 3, 2026
Trinity Agent (trinity) and others added 3 commits October 4, 2026 03:58
…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>
@vybe

vybe commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

/validate-pr (Lane C — dependencies.py, routers/paid.py, docker/backend/Dockerfile) on fa046d4: REQUEST CHANGES. Tier 1 is green, journey-smoke and e2e are green on the PR, and the secret/host-path scans are clean. Not merged, for the reasons below.

This PR requires the following before merge:

  • Wire the two new backend env vars into both compose files. NEVERMINED_MAX_INFLIGHT and NEVERMINED_FACILITATOR_WAIT_SECONDS are read in services/nevermined_payment_service.py and documented in .env.example, but appear in neither docker-compose.yml nor docker-compose.prod.yml backend.environment:. The backend uses an explicit environment list (no env_file), so setting them in .env does nothing on deploy (the feat(voice): VoIP telephony — agents place/receive phone calls over Gemini Live #1056 class). The code defaults (8 / 5.0) still apply, so the gate works; the documented knobs do not.
  • The PR's own Before-merge list is still open (the PR is a draft on that basis): the /verify-local image run with the second-level dependency pins (CSO-2), and the live sandbox verify + settle run against payments-py 1.18.0. CI covers part of the first: the image builds with 1.18.0 and import main succeeds in it with no "payments-py not installed" warning. Nothing covers the second — every test of the bumped SDK is mocked, and the bump also sits under the existing paid door.

Review required, not blocking on its own:

  • The payer→task binding is not written mid-turn, so a payer's tasks/cancel is unreachable. run_paid_turn writes the verify row carrying execution_id only after execute() returns, and execute is dispatch_and_await_terminal, which returns at terminal on both push and pull (a wait timeout returns FAILED and takes the no-binding branch). So every bound task is already terminal: a mid-turn tasks/get answers -32001, and tasks/cancel can only answer "already in a terminal state". The fail direction is safe. The claims are what need correcting, or the binding needs to be written when the execution row is created:
    • _payer_for_task docstring ("Reachable MID-TURN (I4)") and feature-flows/a2a-inbound-server.md ("The binding is written mid-turn").
    • test_the_payer_task_binding_is_written_when_the_execution_exists asserts the row precedes the settle row, which holds for a post-terminal write too.
    • test_a_bound_payer_can_cancel_a_running_task seeds a binding on a running row, a state the real flow does not produce.

After merge (cross-tracker): Fixes Abilityai/trinity-enterprise#679 does not auto-promote; set status-in-dev on that issue by hand.

@vybe vybe added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 4, 2026
… 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>
@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 4, 2026
@vybe

vybe commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 038445d:

  • Compose wiring: NEVERMINED_MAX_INFLIGHT and NEVERMINED_FACILITATOR_WAIT_SECONDS are now forwarded in docker-compose.yml, docker-compose.prod.yml and docker-compose.hosted.yml at the code defaults.
  • Payer binding: no behaviour change. The docstrings, feature-flows/a2a-inbound-server.md and the test docstrings now state that the binding is written once execute() returns (before the settle, and without needing a settle row), not mid-turn, and that a payer tasks/cancel can only answer "already terminal". New test asserts the binding on the settle_in_progress branch, where no settle row is written; it goes red with the binding row removed.

Still open from the PR's own Before-merge list: the /verify-local image run with the second-level pins, and the live sandbox verify + settle run.

@vybe
vybe marked this pull request as ready for review October 4, 2026 08:57

@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.

/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.

@vybe
vybe merged commit 1279475 into dev Oct 4, 2026
28 checks passed
trinity-ability pushed a commit that referenced this pull request Oct 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants