feat: enforce buyer-agent billing gates#2072
Conversation
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Framework-level commercial enforcement on sync_accounts lands the AdCP 3.1 buyer-agent billing gate cleanly, and the per-row vs. request-level split is forced by the schema's row-key requirement, not invented.
Things I checked
- Order-preserving merge in
src/lib/server/decisioning/runtime/from-platform.ts:5047-5063. Walked all-accepted, all-rejected, and[failed, accepted, failed, accepted]mixed cases —acceptedIndexincrements only on the accepted branch and never overrunsrows[]. The empty-accepted short-circuitPromise.resolve([])at L5046 keepsaccounts.upsertfrom being called with[]when policy rejects everything. - Error-code conformance against the SDK's pinned 3.1.0-beta.5 schema. All four codes (
BILLING_NOT_SUPPORTED,BILLING_NOT_PERMITTED_FOR_AGENT,PAYMENT_TERMS_NOT_SUPPORTED,BRAND_REQUIRED) are inErrorCodeValuesatsrc/lib/types/enums.generated.ts:77, and thedetailsshapes matchBillingNotPermittedForAgentDetailsSchema/BillingNotSupportedDetailsSchemaatschemas.generated.ts:5007-5015. The clamped single-suggested_billingecho is spec-canonical, not an SDK opinion. BRAND_REQUIREDwhole-request throw. The schema requiresbrand+operatornon-optional on row shape (tools.generated.ts:21505-21580) — there is no way to emit a per-row rejection without a row key. The request-level throw atfrom-platform.ts:4965is forced, not chosen.- Idempotency cache bypass at
create-adcp-server.ts:2013-2050.hasUncacheableSyncAccountRejectiongates only mutations underidempotencyCheck && idempotency—list_accountsand other read paths don't reach this code.IDEMPOTENCY_IN_FLIGHTcollapses concurrent same-key retries before the gate re-executes. - Frozen
BuyerAgentinvariant. New code (enforceSyncAccountsCommercialPolicy,suggestBilling,buildBillingNotPermittedError) only readsagent.billing_capabilitiesvia.has()and never enters the wire shape.Object.isFrozenguard at L3621 stays intact. - Changeset.
minoris correct: additive surface (suggestBilling,supportedPaymentTerms), gated behavior change (only fires whenagentRegistryis configured), no exported-symbol removal or required-flip.
Follow-ups (non-blocking — file as issues)
- Onboarding-oracle closure is partial. The docstring at
create-adcp-server.ts:1387claims unmapped bearers get the "oracle-safeBILLING_NOT_SUPPORTEDsurface rather thanBILLING_NOT_PERMITTED_FOR_AGENT." That closes thedetails-shape leak but opens an error-code distinguisher: unmapped bearer →BILLING_NOT_SUPPORTED, mapped-but-no-capability →BILLING_NOT_PERMITTED_FOR_AGENT. An attacker who knows the seller's defaultsupportedBillingscan probe a credential and learn whether it's provisioned in the registry without changing state. The mirror pattern attools.generated.ts:25827-25831(AgentPermissionDenied) is consistent — so this is the canonical clamp shape, not a regression — but the docstring overclaims the closure. Either tighten the doc to "preventsdetails.supported_billingleakage to unmapped probes" or unify the unmapped-bearer path toPERMISSION_DENIED. Spec follow-up worth filing against adcp#3831 if the protocol intent is to fully close credential-existence enumeration. - Idempotency-cache bypass is spec-silent SDK policy. AdCP 3.0 GA's
idempotency_keycontract is same-key→same-response within TTL.UNCACHEABLE_SYNC_ACCOUNT_ERROR_CODESdeliberately breaks that contract so capability changes take effect on retry — defensible, but cross-SDK adopters on other runtimes will replay the old rejection. File a sibling spec PR alongside adcp#3831 codifying the carve-out, or note in the migration guide that this is@adcp/sdk-specific behavior. supportedBillingsFordefaults to['agent']when omitted. Inconsistent withsupportedPaymentTerms' "omit → defer toaccounts.upsert" philosophy in the new docstring atcapabilities.ts:236-241. Either align (omit → no framework gate) or document the asymmetric default. Surprises operator-billed retail-media sellers who don't set the field.- Test coverage gaps. Every test ships exactly one account, so the order-preserving merge has zero behavioral coverage. Add
[rejected, accepted, rejected, accepted]with assertions on positions +captures.upsertCalls === 1carrying only the accepted refs. Also missing:agentRegistryundefined entirely, non-stringentry.billing, theentry.account.brandduck-typed branch ofentryBrandOperator. - Adopter observability on framework rejections.
accounts.upsertnever sees the entries the framework rejected — adopters can't log, audit, or override. Consider aonCommercialRejection?(rejectedRows, ctx)hook before shipping the v8 surface freeze.
Minor nits (non-blocking)
- Field-path inconsistency.
from-platform.ts:4927,4942emitfield: 'accounts[].billing'(no index) while payment-terms at L5000 and BRAND_REQUIRED at L4968 emitaccounts[${index}]. ThreadindexthroughbuildBillingNotSupportedError/buildBillingNotPermittedErrorfor buyer DX symmetry. toWireSyncAccountRowcall sites duplicated. L5049 (early return) and L5063 (merged branch) bothcombined.map(toWireSyncAccountRow). Future projection bug fixes have to land twice. Cosmetic — collapse onceoriginalCount === acceptedCountis the only fast-path optimization.BILLING_VALUESduplicates spec enum at L4861. Import the value-list fromtools.generated.tsif exported, otherwise drop a// keep in sync with BillingPartypointer.- Phase-2 cleanup pass is finally complete — the docs noted the gate was coming for three releases. Stale "Phase 2 lands in #1292" wording is gone in this PR. Worth the audit.
Approving on the strength of the schema conformance plus the forced row-key shape — every wire surface validates and the request-level throw is the only schema-conformant escape hatch. Land the multi-entry mixed-merge test in a follow-up; the implementation is correct on paper.
There was a problem hiding this comment.
Solid. Right shape — gate fires only when agentRegistry !== undefined, so opt-in adopters get enforcement and everyone else sees no behavior change. code-reviewer, security-reviewer, and ad-tech-protocol-expert ran in parallel; protocol verdict was sound-with-caveats against adcontextprotocol/adcp#3831. Three real follow-ups below — one production-relevant, the others design/coverage. PR description noted the protocol and code-review agents didn't return before opening; the items below are roughly what they would have caught.
Things I checked
- Changeset present,
minoris correct — behavior change is opt-in viaplatform.agentRegistry, no existing adopter shifts. BILLING_NOT_SUPPORTED(unmapped bearer) omitsdetails.scope— matches the normative "MUST omit" rule in adcp#3831'sbilling-not-supported.json.BILLING_NOT_PERMITTED_FOR_AGENT.detailsis{ rejected_billing, suggested_billing? }with nopermitted_billingleak (additionalProperties: false-compliant). Test at L156-176 asserts the negative explicitly. Good.suggestBillingreturns single value,operator > advertiser > agent— spec-aligned commercial fallback; never the agent's full permitted set.failedSyncAccountRowrebuild path coversfield/suggestion/retry_after/detailsfromAdcpStructuredError— no field loss on the throw branch.- Original
params.accountsarray is not mutated;acceptedParams = { ...params, accounts: acceptedEntries }is a shallow clone with a fresh array.
Follow-ups (non-blocking — file as issues)
-
Production-relevant:
isPaymentTermsistypeof value === 'string'(from-platform.ts:4867). The value lands verbatim in the response envelope at L4998:\Payment terms "${entry.payment_terms}" are not supported...`. Withvalidation: 'off'an attacker-controlledpayment_termsstring flows into wire responses, server logs, and any LLM downstream of the sync result. Perdocs/guides/CTX-METADATA-SAFETY.md, wire-strip doesn't protect log lines or LLM context. The schema defines a closed enum — narrow the guard. Same pattern applies to any future error-message echo of buyer-suppliedentry.*` fields. -
supportedBillingsFordefault of['agent'](from-platform.ts:4871-4874). Whencapabilities.supportedBillingsis undefined or empty, the helper defaults to['agent']and rejectsoperator/advertiser. The reference adapter atexamples/hello_seller_adapter_guaranteed.ts:528-532had to addsupportedBillings: ['operator', 'agent']specifically because of this — that's the tell. Compare againstsupportedPaymentTerms's "omit to leave validation toaccounts.upsert" doc; the seller-wide billing capability should default the same way. Otherwise every adopter who upgrades silently rejects valid traffic. -
BRAND_REQUIREDaborts the entire batch (from-platform.ts:4964-4970). Every other gate emits a per-row failure viafailedRows.set(index, …);BRAND_REQUIREDthrows an envelope error that kills the wholesync_accountscall. A buyer batching ten accounts where one is malformed gets zero rows processed instead of nine accepted + one rejected. Inconsistent failure-blast-radius. Spec doesn't pin batch semantics here, but per-row is what the rest of the policy already does. -
Idempotency partial-accept re-executes
accounts.upserton previously-accepted rows. WhenhasUncacheableSyncAccountRejectionreturns true (any row carries one of the three codes), the entire response bypasses the cache (create-adcp-server.ts:2040-2042). On retry with the same key, policy re-runs and accepted entries hitaccounts.upsertagain. Adopterupsertis implicitly expected to be row-level idempotent — that contract isn't stated inBUILD-AN-AGENT.md. Either cache the combined response (rejected rows replay verbatim, buyer uses a new key after capability upgrade) or document the adopter contract loudly. -
hasUncacheableSyncAccountRejectionshort-circuits on adopter rejections withouterrors[](create-adcp-server.ts:2032:return record.status === 'rejected'whenerrorsis not an array). Any adopter-returned{status:'rejected'}row without anerrors[]field bypasses the cache, even for stable rejections (credit hold, brand suspended). Either requireerrors[]and code-match, or document the broader cache-bypass policy in the comment at L4870-4874. -
Test coverage gap — merge-back ordering is never exercised with mixed batches. All seven cases in
test/server-buyer-agent-billing.test.jspassaccounts: [account]. Thefor (let index = 0; index < originalCount; index += 1)interleaving loop at L5052-5062 only runs withacceptedIndex=0orfailedRows.size=0. Add a[good, bad, good]case asserting positions are stable — the load-bearing behavior the PR claims to enable.
Minor nits (non-blocking)
-
fieldpointer indexing is inconsistent.BILLING_NOT_SUPPORTEDandBILLING_NOT_PERMITTED_FOR_AGENTuseaccounts[].billing(no index);PAYMENT_TERMS_NOT_SUPPORTEDandBRAND_REQUIREDuseaccounts[i].payment_terms/accounts[i].brand. adcp#3831 doesn't normatively pin the convention so neither form is wire-wrong, but the inconsistency in a single response is a defect for agents reconciling per-row failures. Recommend indexing all three.from-platform.ts:4927,4942vs.4969,5000. -
Self-introspection of bearer mapping.
BILLING_NOT_PERMITTED_FOR_AGENTis observationally distinct from the unmapped-bearerBILLING_NOT_SUPPORTEDsurface — an authenticated buyer can probe their own bearer's mapping state. This is the spec's design intent in adcp#3831, not a SDK bug, and the unmapped-vs-seller-wide oracle that the PR explicitly closes is closed correctly. Flagging only because the docstring atcreate-adcp-server.ts:1387-1389calls the path "oracle-safe" unqualified — the framing applies only to the unmapped vs seller-wide-capability case, not to self-introspection.
LGTM. Follow-ups noted below.
There was a problem hiding this comment.
Right shape on the commercial gate and the oracle protection. Two concrete defects in the same enforceSyncAccountsCommercialPolicy function fail closed in the wrong direction — both reachable from realistic adopter flows.
Must fix
-
isPaymentTermsis unbounded —src/lib/server/decisioning/runtime/from-platform.ts:4867-4869. Returnstruefor any string.PaymentTermsin the generated types is the closed enum'net_15' | 'net_30' | 'net_45' | 'net_60' | 'net_90' | 'prepay'. Two failure modes downstream:- When
supportedPaymentTermsis unset (line 4992-4994), the entry skips the gate entirely andaccounts.upsertreceives the junk value. Framework gate is fail-open on its own contract. - When
supportedPaymentTermsis set, the attacker string is interpolated intoerrors[0].messageat line 4998 verbatim with no charset or length cap. Log-poisoning surface on every adopter that ships structured-error logging.
Mirror the closed-enum membership pattern from
isBillingParty/BILLING_VALUESat lines 4861-4865. - When
-
account_id-only billing rejection collapses the whole batch —from-platform.ts:4898-4909. Walk the flow for{ account: { account_id: 'X' }, billing: 'agent' }:entryHasAccountIdreturns true (line 4894), so theBRAND_REQUIREDguard at 4964 does NOT fire.failureis assignedBILLING_NOT_PERMITTED_FOR_AGENT(orBILLING_NOT_SUPPORTED).failedSyncAccountRowrunsentryBrandOperator(entry)— returns undefined.- Rethrows as a top-level
AdcpError, destroying every sibling accepted row in the same batch.
The synthesized row at lines 4910-4916 needs
brand+operatorto anchor against. Either widenSyncAccountsResultRow.brand/operatorto optional whenaccount_idis present and emit a row-level rejection, or rejectaccount_id-only billable entries up front with a top-level error before partitioning. Test gap below masked this — every test wraps a single entry inaccounts: [account], so the projector's index-alignment loop (lines 5051-5062) is unexercised on mixed batches.
Follow-ups (non-blocking — file as issues)
- Multi-entry batch coverage in
test/server-buyer-agent-billing.test.js. The projector at 5051-5062 is the most fragile new code in the PR. Add: (a) accepted + rejected mixed batches verifying ordering preservation, (b)account_id-only billing-rejection entries (the must-fix #2 above), (c)IDEMPOTENCY_CONFLICTfor same key + differentaccounts[]after thepolicy.acceptedParamsrewrite at line 5039-5041. BRAND_REQUIREDenvelope-level throw at 4965-4969 diverges from the per-row failed pattern used everywhere else in the policy.ad-tech-protocol-expertflagged this as an open question against adcp#3831 — confirm whether the spec registersBRAND_REQUIREDas top-level or per-row before locking the behavior.supportedBillingsdefaults to['agent']at 4871-4874. Silent narrowing for any 3.0.x adopter that wiredagentRegistrywithout declaringsupportedBillingsand is live with operator- or advertiser-billed relationships. Either bootstrap-warn whenagentRegistryis set andsupportedBillingsis unset, or call this out indocs/migration-buyer-agent-registry.md§ Billing Enforcement.dispatchCtxrewritesctx.input.accountsat 5039-5041 to the filtered slice. Contract change for adopteraccounts.upsertimplementations readingctx.input. Worth a doc line.- Idempotency canonical-payload digest — if the framework hashes post-rewrite
paramsforIDEMPOTENCY_CONFLICTdetection, a retry where the buyer fixes only a rejected row will canonical-diff and trip a spurious conflict. Confirm the digest is computed on pre-rewrite params.
Things I checked
- Changeset present,
minorbump matches the additive surface (.changeset/billing-agent-gates.md). Type widening onSyncAccountsResultRow.errors[]is structurally non-breaking —{code, message}literals still satisfy the newSyncAccountError. - AdCP 3.1 error-code shapes match adcp#3831:
BILLING_NOT_PERMITTED_FOR_AGENT.details = {rejected_billing, suggested_billing?},BILLING_NOT_SUPPORTED.details = {scope?, supported_billing?},suggested_billingsingular (NOTpermitted_billing[]),recovery: 'correctable'.core.generated.ts:25862-25879confirmsadditionalProperties: false. - Oracle protection at
from-platform.ts:4980-4984omitsdetails.scopeon the unmapped-bearer path. Perad-tech-protocol-expert, this is the normative shape from adcp#3831. buildBillingNotPermittedErrorvalue reflection is bounded —requestedBillingclearsisBillingParty(closed enum) before reaching the error builder. No injection vector on the billing path (only thepayment_termspath above).hasUncacheableSyncAccountRejectionpredicate atcreate-adcp-server.ts:2022-2034walks the right structuredContent path and conservatively falls back tostatus === 'rejected'whenerrors[]is absent.suggestBillingorder (operator > advertiser > agent) matches the spec's commercial fallback discussion.
Minor nit
- Inconsistent
fieldindexing.from-platform.ts:4927usesaccounts[].billing(non-indexed) but line 5000 usesaccounts[${index}].payment_terms(indexed). Pick one.
Request changes for the two must-fix items. Happy path and oracle-protection shape are right — these are bounded fixes.
There was a problem hiding this comment.
LGTM. The SDK enforces framework-level commercial policy at the declared-capability boundary; adopters never see rows the seller can't sell, and the wire shape matches adcp#3831 exactly.
Things I checked
- Generated
detailsshapes match:BillingNotSupportedDetails(src/lib/types/core.generated.ts:25871) andBillingNotPermittedForAgentDetails(core.generated.ts:25861).scope,supported_billing,rejected_billing,suggested_billingare all schema-defined — none invented. - Oracle-safety:
from-platform.ts:4980-4984returnsBILLING_NOT_SUPPORTEDwith emptydetailsfor unmapped-bearer requests under a configured registry. Matchesmanifest.generated.ts:107("MUST receiveBILLING_NOT_SUPPORTEDinstead… per-agent code oraccount-scope hint without established identity is a cross-tenant onboarding oracle"). Correct. - Witness-not-translator holds: framework dispatches
policy.acceptedEntriestoaccounts.upsert; failed rows reconstructed positionally at projection (from-platform.ts:5051-5063). Adopter never sees fabricated rows. - Idempotency claim/release symmetry: cache-write gate (
create-adcp-server.ts:4887) and release-on-no-cache (:4915) both pivot onshouldCacheIdempotencyResponse. No path leaves a claim held without caching. - Changeset present (
minor), matches the additive surface (supportedPaymentTerms,suggestBilling). - Tests cover all four codes + the replay-bypass scenario across a capability widening.
Follow-ups (non-blocking — file as issues)
- Combined-row merge has no length check.
from-platform.ts:5059castsrows[acceptedIndex] as SyncAccountsResultRowwith no assertion thatrows.length === policy.acceptedEntries.length. Adopter returning fewer rows promotes partial-success to a 500 viatoWireSyncAccountRow(undefined). Assert up front and emit aSERVICE_UNAVAILABLEwith a "handler returned N rows for M requested" diagnostic — contract violation the buyer can't fix, framework should surface loudly. - Replay-protection coverage gap.
create-adcp-server.ts:2030-2032returnsrecord.status === 'rejected'whenerrorsis not an array, treating malformed adopter rejection rows as uncacheable. An adopter emitting{status:'rejected', action:'failed'}withouterrors[](transient downstream outage shape) defeats replay caching for that response — every retry re-executes the upstream side-effect. Invert the fallback (non-arrayerrors→ cacheable) and add a regression test. Security-reviewer Medium; worth fixing today. BRAND_REQUIREDis request-level; every other gate is per-row.from-platform.ts:4965-4970throws and aborts the whole batch when one billable entry lacks brand/operator/account_id.manifest.generated.ts:121-124describesBRAND_REQUIREDwithfield: accounts[N].brand— per-row positioning. Convert to per-row using row index as the synthetic key. Same fix covers the related re-throw atfailedSyncAccountRow(from-platform.ts:4898-4909): anaccount_id-only entry that passes BRAND_REQUIRED but fails billing gets promoted to request-level becauseentryBrandOperatorreturns undefined. Stamp synthetic brand/operator from the resolved Account, or widenSyncAccountsResultRowto allowaccount_id-only failed shape. No test covers this path.suggestBillingorder docstring overstates the spec.buyer-agent.ts:209claims "spec-discussed commercial fallback order: operator > advertiser > agent."adcp#3831only normatively pins "typically operator."advertiser > agentafter operator is an SDK opinion. Soften the docstring to "SDK convention; adopters can wrap" or raise the ordering with the spec WG.- Idempotency bypass for the three commercial codes is undocumented at the spec level.
CLAUDE.md's protocol-wide rule says "Handlers must return the same response when the same key is replayed." Defensible for framework-gated rejections (the handler never ran the first time), but file an issue againstadcontextprotocol/adcpto pin the semantics — otherwise the bypass is SDK opinion that's hard for adopters to anticipate.
Minor nits (non-blocking)
isPaymentTermspredicate is a lie.from-platform.ts:4867returnsvalue is PaymentTermsfrom a baretypeof === 'string'check. Narrow against a literal union derived fromPaymentTerms, or rename toisStringPaymentTermsCandidate.supportedBillingsdefault is hidden.from-platform.ts:4873falls back to['agent']when the platform omitssupportedBillings(or sets it empty).capabilities.ts:233docstring doesn't mention this — easy to miss when wiring a new platform. One line in the docstring.
LGTM. File Follow-ups 1 and 2 today.
Summary
Closes #1292.
sync_accounts.billingagainst seller-widesupportedBillingsand resolvedBuyerAgent.billing_capabilitiesBILLING_NOT_SUPPORTED,BILLING_NOT_PERMITTED_FOR_AGENT,PAYMENT_TERMS_NOT_SUPPORTED, andBRAND_REQUIREDBuyerAgentRegistry.suggestBilling()andDecisioningCapabilities.supportedPaymentTermsExpert Review
Validation
npm run typechecknpm run build:libnpm run format:checkNODE_ENV=test node --test --test-timeout=60000 test/server-buyer-agent-billing.test.js test/server-account-store-ctx-forwarding.test.js test/server-idempotency.test.js