Skip to content

spec(accounts): buyer-agent identity model + billing error-code coverage for sync_accounts#3831

Merged
bokelley merged 2 commits into
mainfrom
bokelley/adcp-client-1269-review
May 2, 2026
Merged

spec(accounts): buyer-agent identity model + billing error-code coverage for sync_accounts#3831
bokelley merged 2 commits into
mainfrom
bokelley/adcp-client-1269-review

Conversation

@bokelley

@bokelley bokelley commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds the spec/doc backing for adcp-client #1269 (BuyerAgentRegistry) — registers four billing/account error codes and one new code (BILLING_NOT_PERMITTED_FOR_AGENT) for the per-buyer-agent commercial gate, plus two error-details/ schemas locking the recovery shapes.
  • New "Buyer-agent identity" section in accounts-and-agents.mdx names the two-layer identity model the spec already implies (signed-request agent_url derivation OR seller credential map, plus brand.json/authorized_operators).
  • security.mdx "Agent identity" tightens agent_url derivation as the agents[] entry whose jwks_uri resolved the keyid — not a JWK/JWS/envelope claim — and forbids introducing an envelope-side buyer_agent_url on the bearer/OAuth path. Tier-3 follow-up tracked as Tier 3 follow-ups for #1269 BuyerAgentRegistry: conformance fixtures + cross-language naming #3828.

Why these changes

Reviewing #1269 surfaced three classes of missing spec backing the JS SDK would otherwise paper over:

  1. BILLING_NOT_SUPPORTED was referenced in sync_accounts.mdx but never registered in the canonical error-code.json enum. Same for PAYMENT_TERMS_NOT_SUPPORTED and BRAND_REQUIRED. The doc table also referenced PAYMENT_REQUIRED where the registered code is ACCOUNT_PAYMENT_REQUIRED — corrected.
  2. No spec name for the per-buyer-agent commercial gate the SDK introduces. BILLING_NOT_PERMITTED_FOR_AGENT is the new code, distinct from BILLING_NOT_SUPPORTED so agents can dispatch on autonomous-retry vs surface-to-human without parsing prose.
  3. Ambiguity around how agent_url is established. security.mdx mentioned verified_signer.agent_url in the request context but didn't name the derivation. Spelling it out closes a loophole where an SDK could surface a buyer-asserted agent_url from the envelope.

Security clamps applied during review

The change went through ad-tech-protocol-expert, adtech-product-expert, security-reviewer, docs-expert, and code-reviewer. Notable hardenings:

  • Cross-tenant onboarding oracle closed. Sellers MUST emit BILLING_NOT_PERMITTED_FOR_AGENT only when agent identity has been established via signed-request derivation or a credential-to-agent mapping; in all other cases (including bearer credentials not mapped to a specific agent record) sellers MUST return BILLING_NOT_SUPPORTED and MUST omit error.details.scope. Same shape as the *_NOT_FOUND uniform-response rule.
  • Per-agent commercial state oracle closed. error-details/billing-not-permitted-for-agent.json is additionalProperties: falserejected_billing (echo) plus optional single suggested_billing retry value. The schema description normatively forbids carrying the agent's full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, or any other per-agent commercial state.
  • One-shot retry bound. A second BILLING_NOT_PERMITTED_FOR_AGENT on the suggested fallback MUST surface to a human, not loop.
  • TOCTOU window on brand.json caching called out — high-value or first-time-on-brand provisioning SHOULD bypass the cache.
  • verify_agent.agent_url aside in security.mdx keeps the new prohibition from being read as banning legitimate buyer-asserted verifier references.

Files

  • static/schemas/source/enums/error-code.json — 4 new codes, dual-surface (enumDescriptions + enumMetadata).
  • static/schemas/source/error-details/billing-not-permitted-for-agent.jsonnew. Clamped recovery shape.
  • static/schemas/source/error-details/billing-not-supported.jsonnew. scope discriminator + optional supported_billing echo, with the omit-on-unauthenticated-path rule.
  • docs/accounts/tasks/sync_accounts.mdx — normative line on billing two-gate model; cross-linked error rows; PAYMENT_REQUIRED typo fix.
  • docs/building/implementation/error-handling.mdx — new "Billing and Account Setup" subsection with the uniform-response rule, clamp normative line, one-shot-retry rule, side-by-side recovery snippet, example envelope.
  • docs/building/integration/accounts-and-agents.mdx — new "Buyer-agent identity" section.
  • docs/building/implementation/security.mdxagent_url derivation paragraph + bearer/OAuth tightening + verify_agent aside.

Tier-3 follow-up

#3828 tracks: (a) conformance fixtures upstream for the per-buyer-agent billing gate so other-language SDKs inherit the same enforcement contract, (b) cross-language naming alignment with the in-flight Python v3-identity-bundle-design.md (spec-side: BrandAuthorizationResolver, not AdagentsResolveradagents.json is publisher-side and models a different relationship).

Test plan

  • node scripts/lint-error-codes.cjs — clean for new codes (3 pre-existing storyboard errors on BRAND_NOT_FOUND are unrelated).
  • npm run build:schemas — regenerates dist; manifest count updates from new codes.
  • Manual smoke: cross-link anchors resolve in the rendered docs.
  • CI pipeline (lint, typecheck, format) passes.

🤖 Generated with Claude Code

bokelley and others added 2 commits May 2, 2026 11:50
…age for sync_accounts

Adds the spec/doc backing that adcp-client #1269 (BuyerAgentRegistry) needs
to land without inventing wire behavior. Registers four billing/account
error codes referenced by sync_accounts but missing from the canonical
enum, plus one new code for the per-buyer-agent commercial gate
(BILLING_NOT_PERMITTED_FOR_AGENT). Adds two error-details schemas locking
the recovery shapes — the per-agent schema clamps additionalProperties:
false to prevent full-subset commercial-state disclosure in a single
probe; the per-supported schema requires omitting scope on the
unauthenticated path so the hint cannot itself become a per-account
oracle. New "Buyer-agent identity" section in accounts-and-agents.mdx
names the two-layer identity model (signed-request agent_url derivation
or seller credential map, plus brand.json/authorized_operators) the spec
already implies. security.mdx tightens agent_url derivation as the
agents[] entry whose jwks_uri resolved the keyid — not a JWK/JWS/envelope
claim — and forbids introducing an envelope-side buyer_agent_url on the
bearer/OAuth path. Tier 3 follow-up (conformance fixtures, Python
BrandAuthorizationResolver naming alignment) tracked as #3828.

Reviewed by ad-tech-protocol-expert, adtech-product-expert,
security-reviewer, docs-expert, and code-reviewer; all MUST FIX and
SHOULD FIX findings addressed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build:schemas script regenerates static/schemas/source/index.json from
package.json. Pre-push hook caught the drift between the registry's
adcp_version (3.0.4) and the package version (3.0.3); rerunning the
build aligns them and populates published_version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit d024eb8 into main May 2, 2026
18 checks passed
@bokelley bokelley deleted the bokelley/adcp-client-1269-review branch May 2, 2026 16:10
bokelley added a commit to adcontextprotocol/adcp-client that referenced this pull request May 2, 2026
…1269) (#1293)

* feat(server): BuyerAgentRegistry Phase 1 Stage 1 — types + factory functions (#1269)

Adds the durable buyer-agent identity surface: `BuyerAgent` record, kind-
discriminated `AdcpCredential`, and `BuyerAgentRegistry` with three factory
functions (`signingOnly`, `bearerOnly`, `mixed`) that encode the implementer
posture at construction.

The credential answers "who signed?" / "who holds this token?"; the
`BuyerAgent` answers "who is this counterparty in our books?" — a durable
commercial relationship distinct from the per-request credential. Same
pattern as an SSP's `buyer_id` row keyed to a DSP regardless of credential
type.

This stage ships the types and factories only. Stage 2 wires the resolve
seam in the dispatcher; Stage 3 ships the `ResolvedAuthInfo` migration
shim; Stage 4 adds status enforcement / multi-credential conflict / log
redaction; Stage 5 adds the caching decorator and conformance fixtures.
All Phase 1 stages are no-wire-emission and ship in 3.0.x.

Phase 2 (#1292) — framework-level `billing_capability` enforcement and
the AdCP-3.1 error-code emission from adcontextprotocol/adcp#3831 — is
gated on the SDK's 3.1 cutover.

19 unit tests cover factory contracts (kind-routing, null on unhonored
kinds, construction-time validation, resolver-throw propagation),
`BuyerAgent` shape semantics, and `BillingMode` Set-membership.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(buyer-agent): tighten bearerOnly foot-gun + reject malformed http_sig credentials

Three fixes from expert review on PR #1293 (no blockers, all in-scope for
Stage 1):

1. Doc-clarify that the legacy `ResolvedAuthInfo` synthesis is Stage 3's
   job, not Stage 1's. The previous comment promised behavior the code
   didn't implement.

2. `ResolveBuyerAgentByCredential` JSDoc now requires implementations to
   switch on `credential.kind` and reject unrecognized kinds. Closes the
   foot-gun where a `WHERE token = $1` lookup against an api-key table
   could mis-resolve when handed an `http_sig` credential whose `keyid`
   collides with an existing api-key value — the credential variants share
   no key namespace.

3. Runtime guard against malformed `http_sig` credentials. A misbehaving
   authenticator could produce `kind: 'http_sig'` without a non-empty
   `agent_url`; without the guard, `signingOnly` and `mixed` would
   silently pass `undefined` to the resolver. `signingOnly` now returns
   null on malformed; `mixed` rejects rather than falling through to the
   bearer path (which would make `mixed` strictly weaker than
   `signingOnly` — a malformed http_sig could otherwise bypass signed-
   path enforcement by routing through the bearer table).

Test coverage extended to 22 cases (+3): malformed-http_sig refusal in
both `signingOnly` and `mixed`; empty-agent_url refusal.

Deferred items (Stages 2, 3, Phase 2) recorded on #1269 / #1292.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 2, 2026
…ionResolver naming guidance

Tier-3 follow-up to #3828 / #3831 (BuyerAgentRegistry spec backing).

New universal storyboard `billing-gate-dispatch` exercises the
two-gate dispatch contract on `sync_accounts.billing` rejection:
seller-wide capability gate (`BILLING_NOT_SUPPORTED` with
`error.details.scope: "capability"` and supported_billing echo) and
per-buyer-agent commercial-relationship gate
(`BILLING_NOT_PERMITTED_FOR_AGENT` with the clamped rejected_billing
+ optional suggested_billing shape). The recover phase chains off
the per-agent reject under the same idempotency_key (matching the
spec contract that recovery is a retry of the same upsert with a
different `billing` value), capturing suggested_billing into
context so the seller's nominated fallback is what actually
provisions. Negative-shape assertions actively forbid permitted_billing,
rate_card, payment_terms, credit_limit, billing_entity, and account_id
from leaking through error.details — the per-agent commercial-state
oracles that error-details/billing-not-permitted-for-agent.json's
additionalProperties: false is meant to close.

New test kit `test-kits/billing-gate-runner.yaml` documents the
coordination contract: account.unsupported_billing_probe gates the
capability phase; commercial_relationship gates the per-agent
phases. Both are precondition-gated rather than capability-gated —
sellers without the precondition grade not_applicable on those
phases. Conformance and compliance-catalog docs updated; doc-parity
lint clean.

SDK naming guidance added to accounts-and-agents.mdx Buyer-agent
identity section: SDKs SHOULD name the brand-operator authorization
Protocol BrandAuthorizationResolver (after the file consulted,
brand.json/authorized_operators), NOT AdagentsResolver
(adagents.json is publisher-side and models a different
relationship). Cross-coordination filed as adcp-client-python#346.

Reviewed by ad-tech-protocol-expert and nodejs-testing-expert; all
MUST FIX findings addressed (idempotency-key reuse, test-kit
fields declared, suggested_billing not strict-pinned to operator),
plus key SHOULD FIX findings (action/status/recovery assertions,
response_schema on error responses, SDK naming softened to SHOULD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 2, 2026
…ERMITTED_FOR_AGENT gates (#3851)

* feat(training-agent): implement BILLING_NOT_SUPPORTED + BILLING_NOT_PERMITTED_FOR_AGENT gates

Reference implementation of the two billing gates from #3831
(BuyerAgentRegistry spec backing). Lands first on the legacy /mcp route
via handleSyncAccounts; v6 per-tenant routes have no accounts.upsert
yet, so wiring there follows in a separate PR.

Capability gate: validates input.billing against SUPPORTED_BILLINGS
and emits BILLING_NOT_SUPPORTED with error.details.scope: "capability"
and an echoed supported_billing list per
error-details/billing-not-supported.json. Also fixes the stale
advertisement (was 'agent'-only, handler accepted all three) so
get_adcp_capabilities and the gate agree.

Per-buyer-agent gate: new commercial-relationships.ts module maps the
authenticated principal to a commercial-relationship enum
(passthrough_only or agent_billable). Two demo bearer prefixes
recognized — demo-billing-passthrough-* and demo-billing-agent-billable-* —
matching the existing DEMO_TEST_KIT_KEY_PATTERN authenticator. Emits
BILLING_NOT_PERMITTED_FOR_AGENT with the clamped details shape per
error-details/billing-not-permitted-for-agent.json
(additionalProperties: false — rejected_billing + suggested_billing
only). Strict toEqual assertion in the test enforces the clamp at the
wire level.

Bright-line uniform-response rule: principals without an onboarded
record return undefined from getCommercialRelationship and fall through
to the seller-wide capability gate only — preventing the per-agent code
from acting as an onboarding oracle for unrecognized callers.

tenantId reserved on getCommercialRelationship signature so the v6
follow-up doesn't force a signature break across call sites.

20 unit tests pass (14 existing + 6 new): capability-gate rejection,
passthrough rejection of agent and advertiser, autonomous recovery via
operator, agent-billable acceptance of all three values,
uniform-response for unrecognized principal, and dry_run does not
suppress billing-gate rejections.

Reviewed by code-reviewer; SHOULD FIX (tenantId plumbing) and
both NICE TO HAVE items (toEqual clamp assertion, dry_run test)
addressed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: export SUPPORTED_BILLINGS, document spoofable test principals, multi-account batching test

Addresses SHOULD FIX findings from second-pass review of #3851:

- Export SUPPORTED_BILLINGS from account-handlers.ts and import it in
  task-handlers.ts so the gate constant and the supported_billing
  advertisement are mechanically locked rather than comment-coupled.
- Document the security posture of the static:demo:* principal
  namespace explicitly in commercial-relationships.ts: spoofable by
  design (any caller with the bearer self-stamps the principal — fine
  for conformance runners deterministically targeting each branch),
  with a forward-looking guard that future production billing stores
  must not key on this namespace.
- Add a multi-account batching test: [passing, rejected, passing]
  sequence proves the per-account `continue` loop does not poison
  passing siblings and result ordering matches request ordering —
  closes the test surface around the load-bearing per-account
  semantics of sync_accounts.

22 tests pass; full server unit suite (3088 tests) clean except for
one pre-existing flaky LLM test in addie-router.test.ts unrelated to
this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(training-agent): wire bearer→principal in standalone for local gate testing

Mirrors server/src/training-agent/index.ts:84 — any bearer matching
the demo-test-kit pattern stamps `static:demo:<token>` as the
principal. Lets the standalone runner exercise the per-buyer-agent
billing gate locally over real HTTP without spinning up the full
server's auth chain. Used to validate #3839 against #3851 end-to-end:

  npx tsx server/src/training-agent/standalone.ts &
  curl -X POST http://localhost:4100/mcp \\
    -H 'Authorization: Bearer demo-billing-passthrough-v1' \\
    -d '{"jsonrpc":"2.0",...,"params":{"name":"sync_accounts",...}}'

Confirmed end-to-end: the auth chain → principal → gate path produces
BILLING_NOT_PERMITTED_FOR_AGENT with the right error.details shape on
the wire (not just in the in-process simulateCallTool harness).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 2, 2026
…ionResolver naming guidance (#3839)

* spec(accounts): billing-gate conformance storyboard + BrandAuthorizationResolver naming guidance

Tier-3 follow-up to #3828 / #3831 (BuyerAgentRegistry spec backing).

New universal storyboard `billing-gate-dispatch` exercises the
two-gate dispatch contract on `sync_accounts.billing` rejection:
seller-wide capability gate (`BILLING_NOT_SUPPORTED` with
`error.details.scope: "capability"` and supported_billing echo) and
per-buyer-agent commercial-relationship gate
(`BILLING_NOT_PERMITTED_FOR_AGENT` with the clamped rejected_billing
+ optional suggested_billing shape). The recover phase chains off
the per-agent reject under the same idempotency_key (matching the
spec contract that recovery is a retry of the same upsert with a
different `billing` value), capturing suggested_billing into
context so the seller's nominated fallback is what actually
provisions. Negative-shape assertions actively forbid permitted_billing,
rate_card, payment_terms, credit_limit, billing_entity, and account_id
from leaking through error.details — the per-agent commercial-state
oracles that error-details/billing-not-permitted-for-agent.json's
additionalProperties: false is meant to close.

New test kit `test-kits/billing-gate-runner.yaml` documents the
coordination contract: account.unsupported_billing_probe gates the
capability phase; commercial_relationship gates the per-agent
phases. Both are precondition-gated rather than capability-gated —
sellers without the precondition grade not_applicable on those
phases. Conformance and compliance-catalog docs updated; doc-parity
lint clean.

SDK naming guidance added to accounts-and-agents.mdx Buyer-agent
identity section: SDKs SHOULD name the brand-operator authorization
Protocol BrandAuthorizationResolver (after the file consulted,
brand.json/authorized_operators), NOT AdagentsResolver
(adagents.json is publisher-side and models a different
relationship). Cross-coordination filed as adcp-client-python#346.

Reviewed by ad-tech-protocol-expert and nodejs-testing-expert; all
MUST FIX findings addressed (idempotency-key reuse, test-kit
fields declared, suggested_billing not strict-pinned to operator),
plus key SHOULD FIX findings (action/status/recovery assertions,
response_schema on error responses, SDK naming softened to SHOULD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(storyboard): validate billing-gate-dispatch end-to-end against training-agent (#3851)

Three bugs surfaced by running the storyboard against the running
training-agent reference implementation. Lint couldn't catch any of
them — exactly the brittleness the validation was meant to surface
before shipping cold.

1. check: error_code does not accept a `path` parameter for per-account
   error extraction. The runner pulls the code from default paths
   (adcp_error.code / errors[0].code at root). For sync_accounts'
   per-account errors at accounts[0].errors[0].code, switch to
   check: field_value with the explicit path. Two occurrences fixed.

2. expect_error: true requires a transport-level error marker (MCP
   isError / A2A failed). sync_accounts produces transport-level
   success with per-account errors inside the success envelope —
   accounts[].errors[] is the per-account-error shape, not transport
   failure. Removed expect_error from both gate phases with
   explanatory comments.

3. Idempotency-key reuse across reject/recover phases produced
   IDEMPOTENCY_CONFLICT. The recover phase submits a different payload
   (different `billing` value), and per error-handling.mdx, same key +
   different payload within the replay window MUST reject. Recover
   phase now uses a fresh idempotency_key with a distinct
   `#billing_gate_dispatch_passthrough_recover` stability tag. Also
   corrected three places in the narrative + storyboard structure that
   previously asserted the wrong "retry under same key" semantics.

Plus a runner-side documentation gap: today's storyboard runner does
not auto-extract auth.api_key from the test kit. Callers pass it
explicitly via --auth. The test-kit comment now spells out the
canonical CLI invocation:

  npx adcp storyboard run <agent-url> \\
    --auth demo-billing-passthrough-v1 \\
    --file static/compliance/source/universal/billing-gate-dispatch.yaml

Test-kit api_key updated to demo-billing-passthrough-v1 to match the
training-agent's recognized prefix family. commercial_relationship:
"passthrough_only" uncommented as the default-active precondition.

Storyboard now passes 3/3 strict assertions against the training-agent
per-agent-gate flow (capability_discovery + per_agent_gate_reject +
per_agent_gate_recover); capability_gate phase grades not_applicable
correctly when the seller advertises all three billing values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 2, 2026
…-scope enum (closes #3883) (#3887)

* spec(errors): register agent-permission-denied details + shared error-scope enum (closes #3883)

- New error-details/agent-permission-denied.json with scope:agent + oneOf
  exclusivity on status:[suspended,blocked] vs reason:[sandbox_only],
  additionalProperties:false. Per-agent commercial gate recovery shape so
  cross-language SDKs can dispatch on the discriminator without parsing prose.
- New enums/error-scope.json registering the shared discriminator vocabulary
  (capability | account | agent). Each per-shape error-details schema
  constrains its own subset; this enum is the single source of truth for
  the union.
- error-code.json PERMISSION_DENIED description references the new shape and
  carries the cross-tenant onboarding oracle clamp (scope:agent only on
  established-identity path; full channel-coverage rules in error-handling.mdx).
- billing-not-supported.json scope description cross-references the shared
  vocabulary.
- error-handling.mdx adds a Per-Agent Authorization Gate subsection with the
  comprehensive *_NOT_FOUND-style channel coverage list (response shape,
  HTTP/A2A/MCP status, headers, side effects, observability, latency parity,
  retry-counter side channel, cache warmth) plus dispatch example.

Builds on #3831 (BILLING_NOT_PERMITTED_FOR_AGENT precedent + cross-tenant
clamp). Cross-references #3871 (open follow-up to elevate suspended/blocked
to dedicated error codes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(errors): fix kebab→snake in dispatch comment + clarify wire-level recovery field on per-agent gate

DX/protocol expert review of #3887 caught two issues:

- Stale `'sandbox-only'` (kebab) in the JS dispatch example comment after the
  wire enum value was renamed to `'sandbox_only'` (snake) for cross-language
  SDK compat. SDK authors copy-pasting the comment would write a never-matches
  branch.
- Envelope example shows `recovery: "correctable"` (matches registered
  enumMetadata) while prose says terminal-pending-onboarding — readers who
  branch on `recovery` would auto-retry against the no-retry MUST. Added
  prose adjacent to the envelope clarifying that buyer agents MUST branch on
  `details.scope` itself, not the wire-level `recovery` field, and noting
  that conditional-recovery support is a follow-up (either dedicated codes
  per #3871 or an enumMetadata extension).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant