Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/account-requests-seller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@adcp/sdk': minor
---

Sellers registering `request_account` (`accounts.requestAccount`) now advertise AdCP 3.3 `account.requests` on `get_adcp_capabilities`, with `request_task`, `read_task: 'list_accounts'` and the `account.requests` experimental feature id, so `client.accounts.ensure` routes to them. `capabilities.accountRequests` supplies `changeableAttributes`, `pendingActionKinds` and `eventType`. On the platform path, the new `accounts.listRequests(request, ctx)` hook serves the caller's requests as `account_requests[]` and `account_requests_pagination` on `list_accounts` when the buyer sends `include_requests`, `request_ids` or `requests_pagination`. `Account.revision` is now served on `list_accounts`, so buyers can read the revision a change is based on. Fixes #3575.

**Behavior change for 3.3 beta adopters:** a seller that registers `request_account` must declare who bills, because `account.requests` lives in the account capability block, whose schema requires `supported_billing`. A server that registers it without one is now refused at construction. Fix: add `capabilities: { account: { supportedBilling: ['agent'] } }` to `createAdcpServer`, or `capabilities.supportedBillings` on the platform path, with your actual billing model. The SDK never fills in this claim for you.
62 changes: 62 additions & 0 deletions docs/guides/account-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,68 @@ resolve: async (ref, ctx) => {
When `resolveAccount` is configured, a supplied unknown reference returns
`ACCOUNT_NOT_FOUND`, even on optional account tools.

### Account requests (`request_account`, AdCP 3.3 experimental)

A seller that serves `request_account` registers it through the handler merge
seam, and serves the caller's requests back on `list_accounts` through
`accounts.listRequests`:

```ts
createAdcpServerFromPlatform(
{
capabilities: {
// ...
supportedBillings: ['agent'], // required with account requests: who bills
accountRequests: {
changeableAttributes: ['billing', 'payment_terms', 'timezone'],
pendingActionKinds: ['review', 'accept_terms'],
},
},
accounts: {
resolution: 'explicit',
resolve,
list,
// Only when the buyer sends include_requests, request_ids or requests_pagination.
listRequests: async (request, ctx) => {
const page = await db.requestsFor(callerOf(ctx), {
ids: request.request_ids,
cursor: request.requests_pagination?.cursor,
});
return { items: page.records, nextCursor: page.next };
},
},
},
{ accounts: { requestAccount: async (params, ctx) => ({ request: await db.request(params, ctx) }) } }
);
```

Registering `requestAccount` advertises `account.requests` on
`get_adcp_capabilities` (`supported: true`, `request_task: 'request_account'`,
`read_task: 'list_accounts'`, plus `changeable_attributes`,
`pending_action_kinds` and `event_type` from `capabilities.accountRequests`) and
adds `account.requests` to `experimental_features`. Callers on releases before
3.3 see neither. Buyers route `client.accounts.ensure` and
`client.accounts.change` by this block. The same applies to `createAdcpServer`,
with `capabilities.accountRequests`.

The block lives in the account capability block, whose schema requires
`supported_billing`, so declare who bills: `capabilities.supportedBillings` here,
or `capabilities.account.supportedBilling` on `createAdcpServer`. A server that
registers `requestAccount` without one is refused at construction; the SDK never
fills in a billing claim for you.

`listRequests` returns a `CursorPage` of wire account-request records, which the
framework serves as `account_requests[]` and `account_requests_pagination`; the
accounts page is unaffected. Return only the caller's own requests, newest
`updated_at` first and at most 100 per page, and omit unknown or foreign
`request_ids` silently. It needs `accounts.list`. Without it, the framework warns
at startup that buyers cannot poll a pending request.

Return `revision` on the accounts `accounts.list` lists. Buyers read it from
`list_accounts` and send it with a change, and a money change (`billing`,
`billing_entity`, `payment_terms`) without it should be refused with
`INVALID_REQUEST`; a stale one with `CONFLICT`.

### Strict account references (`strictAccountReferences`)

SDK 14 keeps four compatibility behaviors that strict mode removes:
Expand Down
66 changes: 66 additions & 0 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ import type {
GetMediaBuyIssueResponse,
RequestAccountResponse,
GetAdCPCapabilitiesResponse,
AccountRequestsSupported,
CreatePropertyListResponse,
UpdatePropertyListResponse,
GetPropertyListResponse,
Expand Down Expand Up @@ -1418,6 +1419,33 @@ export interface AdcpCapabilitiesConfig {
* them via their dedicated fields on {@link AdcpCapabilitiesConfig}.
*/
overrides?: AdcpCapabilitiesOverrides;
/**
* Seller-supplied fields of the `account.requests` block (AdCP 3.3, experimental). The block itself
* is advertised automatically when `accounts.requestAccount` is registered: `supported: true`,
* `request_task: 'request_account'`, `read_task: 'list_accounts'`, and `account.requests` in
* `experimental_features`. Requires `accounts.requestAccount`.
*/
accountRequests?: AccountRequestsCapabilityOptions;
}

/**
* What a seller registering `request_account` adds to its `account.requests` capability block. Both
* lists are advisory to buyers; the seller still validates each request.
*/
export interface AccountRequestsCapabilityOptions {
/**
* Account attributes the seller accepts in a change request (`account_id` plus fields). Absent or
* empty means the seller only opens accounts. `currency` and `timezone` are changeable only when
* listed.
*/
changeableAttributes?: readonly NonNullable<AccountRequestsSupported['changeable_attributes']>[number][];
/** Pending-action kinds the seller may issue, so buyers can prepare the people who will act. */
pendingActionKinds?: readonly NonNullable<AccountRequestsSupported['pending_action_kinds']>[number][];
/**
* Set when the seller fires `account_request.changed`. A seller declaring it also declares
* `capability_changes.notifications.supported: true`; without it, buyers poll `list_accounts`.
*/
eventType?: 'account_request.changed';
}

/** Inclusive AdCP release range in which a registered tool is available. */
Expand Down Expand Up @@ -3947,7 +3975,12 @@ function filterCapabilitiesForToolAvailability(
const experimentalTools = [
['list_collections', ['media_buy.collection_catalog', 'media_buy.collection_cards']],
['list_proposals', ['media_buy.open_opportunities']],
['request_account', ['account.requests']],
] as const;
if (data.account?.requests && !(registeredTools.has('request_account') && isAvailable('request_account', release))) {
// `account.requests` names request_account; a release without the tool has no such block.
delete data.account.requests;
}
const features = new Set(data.experimental_features ?? []);
for (const [tool, requiredFeatures] of experimentalTools) {
for (const feature of requiredFeatures) {
Expand Down Expand Up @@ -9599,6 +9632,39 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
applyCapabilityOverrides(capabilitiesData, capConfig.overrides);
}

// AdCP 3.3 `account.requests`: a seller serving request_account advertises the block, and
// `filterCapabilitiesForToolAvailability` adds the feature id for releases that define the tool
// (and removes both for earlier ones). Seller-supplied lists come from `accountRequests`; an
// adopter override of the block keeps its optional fields, but the task names are fixed.
if (registeredToolNames.has('request_account')) {
// account.requests lives in the account block, whose schema requires supported_billing. Who
// bills is the seller's claim to make, so the SDK never fills it in.
if (
!Array.isArray(capabilitiesData.account?.supported_billing) ||
capabilitiesData.account.supported_billing.length === 0
) {
throw new Error(
'createAdcpServer: request_account is advertised in the account capability block, which requires supported_billing. ' +
"Declare who bills: capabilities.account.supportedBilling (on createAdcpServerFromPlatform, capabilities.supportedBillings), for example ['agent']."
);
}
const adopterRequests = capabilitiesData.account.requests as Partial<AccountRequestsSupported> | undefined;
const options = capConfig?.accountRequests;
capabilitiesData.account.requests = {
...(adopterRequests?.supported === true ? adopterRequests : {}),
supported: true,
request_task: 'request_account',
read_task: 'list_accounts',
...(options?.changeableAttributes?.length && { changeable_attributes: [...options.changeableAttributes] }),
...(options?.pendingActionKinds?.length && { pending_action_kinds: [...options.pendingActionKinds] }),
...(options?.eventType && { event_type: options.eventType }),
};
} else if (capConfig?.accountRequests) {
throw new Error(
'createAdcpServer: capabilities.accountRequests describes request_account; register accounts.requestAccount too.'
);
}

if (webhooks) {
const profile = webhooks.tag ?? 'adcp/webhook-signing/v1';
if (profile !== 'adcp/webhook-signing/v1') {
Expand Down
28 changes: 28 additions & 0 deletions src/lib/server/decisioning/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
GetAccountFinancialsResponse,
GetAccountFinancialsSuccess,
} from '../../types/tools.generated';
import type { AccountRequest } from '../../types/tools.generated';
import type { ServerPayload } from '../../types/server-payload';
import { projectReportingDeliveryConfigStates } from '../reporting-delivery-config';
import {
Expand Down Expand Up @@ -92,6 +93,8 @@ export type ReportUsagePayload = ServerPayload<ReportUsageResponse>;
export type GetAccountFinancialsPayload = ServerPayload<GetAccountFinancialsResponse>;
export type GetAccountFinancialsSuccessPayload = ServerPayload<GetAccountFinancialsSuccess>;
export type ListAccountsHandlerResult<TCtxMeta = Record<string, unknown>> = CursorPage<Account<TCtxMeta>>;
/** A page of the caller's account requests: `account_requests[]` on `list_accounts` (AdCP 3.3). */
export type ListAccountRequestsHandlerResult = CursorPage<AccountRequest>;
export type ListAccountChangesHandlerResult = ListAccountChangesPayload;
export type SyncAccountsHandlerResult = SyncAccountsResultRow[];
export type SyncGovernanceHandlerResult = SyncGovernanceRow[];
Expand Down Expand Up @@ -136,6 +139,13 @@ export interface Account<TCtxMeta = Record<string, unknown>> {
*/
timezone?: string;

/**
* The account's optimistic-concurrency revision (AdCP 3.2+). Maps to wire `Account.revision`.
* Buyers read it from `list_accounts` and send it with a `request_account` change or a
* `sync_accounts` settings update, so a concurrent change is never overwritten.
*/
revision?: number;

/**
* The advertiser whose rates apply to this account. Maps to wire
* `Account.advertiser`. Use when the account is operated by an agency on
Expand Down Expand Up @@ -869,6 +879,23 @@ export interface AccountStore<TCtxMeta = Record<string, unknown>> {
*/
list?(request: ListAccountsRequest, ctx?: ResolveContext): Promise<ListAccountsHandlerResult<TCtxMeta>>;

/**
* The caller's account requests on `list_accounts` (AdCP 3.3 experimental `account.requests`).
* Called only when the buyer asks for them, with `include_requests`, `request_ids` or
* `requests_pagination`; the framework returns `items` as `account_requests[]` and projects
* `account_requests_pagination` from `nextCursor` / `totalCount`. The accounts page is unaffected.
*
* Implementations MUST return only the caller's own requests (identify the caller from
* `ctx.authInfo` / `ctx.agent`, as for `list`): every open request (`in_review`, `ambiguous`) plus
* requests that changed state in the last 30 days, newest `updated_at` first, at most 100 per page.
* With `request.request_ids`, return only those, omitting unknown IDs and other callers' IDs
* silently. Page from `request.requests_pagination.cursor`.
*
* This is how buyers poll a pending request (`client.accounts.refresh()`). Pair it with
* `opts.accounts.requestAccount`; requires {@link list}.
*/
listRequests?(request: ListAccountsRequest, ctx?: ResolveContext): Promise<ListAccountRequestsHandlerResult>;

/**
* list_account_changes API surface. Returns a durable feed page whose
* cursor remains bound to the authenticated principal, account, and
Expand Down Expand Up @@ -1171,6 +1198,7 @@ export function toWireAccount<TCtxMeta>(account: Account<TCtxMeta>): WireAccount
if (account.brand !== undefined) wire.brand = account.brand;
if (account.operator !== undefined) wire.operator = account.operator;
if (account.timezone !== undefined) wire.timezone = account.timezone;
if (account.revision !== undefined) wire.revision = account.revision;
if (account.advertiser !== undefined) wire.advertiser = account.advertiser;
if (account.billing_proxy !== undefined) wire.billing_proxy = account.billing_proxy;
if (account.billing !== undefined) {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/server/decisioning/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
} from '../../types/tools.generated';
import type { ProductMetricOptimizationLike } from '../../utils/capability-rollups';
import type { MediaBuyFeatures, AccountCapabilities, CreativeCapabilities } from '../../utils/capabilities';
import type { AdcpCapabilitiesOverrides } from '../create-adcp-server';
import type { AccountRequestsCapabilityOptions, AdcpCapabilitiesOverrides } from '../create-adcp-server';

/**
* Pre-resolved alias for the wire `media_buy` block. Used as the projection
Expand Down Expand Up @@ -327,6 +327,14 @@ export interface DecisioningCapabilities<TConfig = unknown> {
*/
overrides?: AdcpCapabilitiesOverrides;

/**
* Seller-supplied fields of the `account.requests` capability block (AdCP 3.3, experimental):
* `changeableAttributes`, `pendingActionKinds` and `eventType`. The block itself, with
* `request_task` / `read_task` and the `account.requests` feature id, is advertised automatically
* when `opts.accounts.requestAccount` is registered. Requires `requestAccount`.
*/
accountRequests?: AccountRequestsCapabilityOptions;

/**
* Release-precision AdCP versions this platform supports (e.g. `["3.0", "3.1"]`).
* Forwarded into `get_adcp_capabilities.adcp.supported_versions`. 3.1+
Expand Down
48 changes: 47 additions & 1 deletion src/lib/server/decisioning/runtime/from-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2855,6 +2855,25 @@ export function createAdcpServerFromPlatform<P extends DecisioningPlatform<any,
const fwLogger = opts.logger ?? DEFAULT_FRAMEWORK_LOGGER;
const mergeOpts = { mode: opts.mergeSeam ?? 'warn', logger: fwLogger };

// AdCP 3.3 account.requests: requests are read back through list_accounts (`read_task`), so the
// hook that serves them rides on the platform's list_accounts handler.
if (platform.accounts.listRequests != null && platform.accounts.list == null) {
throw new PlatformConfigError(
'accounts.listRequests serves account_requests on list_accounts and needs accounts.list; wire both.'
);
}
if (
runtimeOpts.accounts?.requestAccount != null &&
platform.accounts.listRequests == null &&
runtimeOpts.accounts?.listAccounts == null
) {
fwLogger.warn(
'request_account is registered without accounts.listRequests: list_accounts (the advertised ' +
'account.requests read_task) returns no account_requests, so buyers cannot poll a pending request. ' +
'Implement accounts.listRequests.'
);
}

// Project per-domain capability blocks declared on the platform onto
// get_adcp_capabilities via createAdcpServer's `overrides.media_buy`
// deep-merge seam. Adopters declare audience_targeting /
Expand Down Expand Up @@ -3230,6 +3249,9 @@ export function createAdcpServerFromPlatform<P extends DecisioningPlatform<any,
...(platform.capabilities.capability_changes !== undefined && {
capability_changes: structuredClone(platform.capabilities.capability_changes),
}),
...(platform.capabilities.accountRequests !== undefined && {
accountRequests: structuredClone(platform.capabilities.accountRequests),
}),
...(hasOverridesObject && {
overrides: {
...(adopterOverrides ?? {}),
Expand Down Expand Up @@ -9271,14 +9293,38 @@ function buildAccountHandlers<P extends DecisioningPlatform<any, any>>(
return projectSync(
async () => {
const page = await accounts.list!(filter, resolveCtx);
// AdCP 3.3 account.requests: the caller's requests ride on list_accounts when asked for, and
// only on releases that define them (capabilities hide account.requests below 3.3 too).
const served = ctx.servedAdcpVersion;
const requests =
accounts.listRequests &&
isAdcpVersionAtLeast(served, '3.3.0-beta.0') &&
(filter.include_requests === true ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: The account_requests emission is gated by the buyer's request fields but not by the served release. filterCapabilitiesForToolAvailability deliberately hides account.requests from capabilities on pre-3.3 releases, yet this handler emits account_requests whenever the buyer sends include_requests/request_ids/requests_pagination at any release. Production defaults to validation: 'off' (create-adcp-server.ts:5732-5733), so a buyer pinned to 3.2 that sends those fields gets a 3.3-only feature on a 3.2 wire response — the exact asymmetry the capability gate exists to prevent. ctx.servedAdcpVersion is available and the file already gates other 3.3 behavior with isAdcpVersionAtLeast(servedAdcpVersion, '3.3.0-beta.0') (L1993); gate this call the same way, and add a test that a 3.2 list_accounts with include_requests returns no account_requests. (Same unconditional-emission pattern applies to Account.revision in toWireAccount, lower impact.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8f7ac80.

  • listRequests now runs only when isAdcpVersionAtLeast(ctx.servedAdcpVersion, '3.3.0-beta.0') holds. Below 3.3, account_requests and account_requests_pagination are never emitted, whatever request fields the buyer sends.
  • Account.revision is now removed from list_accounts rows served to a release before 3.2. When no served version is known, rows keep it, which is the existing behavior.
  • New test: a 3.1 and a 3.2 list_accounts call each send include_requests, request_ids and requests_pagination with validation off. Neither gets account_requests, and listRequests is never called. revision appears at 3.2 but not at 3.1. A 3.3 call still gets its requests.

filter.request_ids !== undefined ||
filter.requests_pagination !== undefined)
? await accounts.listRequests(filter, resolveCtx)
: undefined;
const listed = await projectAccountList(accounts, page.items, resolveCtx, logger);
// Account.revision is an AdCP 3.2 field: not served to an earlier release.
if (served !== undefined && !isAdcpVersionAtLeast(served, '3.2.0-beta.0')) {
for (const row of listed) delete row.revision;
}
const response = {
status: 'completed' as const,
accounts: await projectAccountList(accounts, page.items, resolveCtx, logger),
accounts: listed,
pagination: {
has_more: page.nextCursor != null,
...(page.nextCursor != null && { cursor: page.nextCursor }),
...(page.totalCount !== undefined && { total_count: page.totalCount }),
},
...(requests && {
account_requests: requests.items,
account_requests_pagination: {
has_more: requests.nextCursor != null,
...(requests.nextCursor != null && { cursor: requests.nextCursor }),
...(requests.totalCount !== undefined && { total_count: requests.totalCount }),
},
}),
};
return platform.reporting?.projectListAccounts
? platform.reporting.projectListAccounts(filter, response, resolveCtx)
Expand Down
Loading
Loading