Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/account-requests-seller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@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`. Fixes #3575.
50 changes: 50 additions & 0 deletions docs/guides/account-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,56 @@ 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: {
// ...
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`.

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

### Strict account references (`strictAccountReferences`)

SDK 14 keeps four compatibility behaviors that strict mode removes:
Expand Down
64 changes: 64 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,35 @@ 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.
let syntheticAccountBlock = false;
if (registeredToolNames.has('request_account')) {
if (capabilitiesData.account == null) {
// `supported_billing` is required whenever the account block is emitted. Same default as the
// platform path: 'agent' when the seller declared no account capabilities.
capabilitiesData.account = { supported_billing: ['agent'] };
syntheticAccountBlock = true;
}
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 Expand Up @@ -9848,6 +9910,8 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
(tool, version) =>
releaseDefinesTool(tool, { validationVersion: version }) && toolAvailableForRelease(tool, version)
);
// An account block emitted only to carry account.requests goes when the release has no requests.
if (syntheticAccountBlock && !data.account?.requests) delete data.account;
const selected = parseAdcpRelease(release.validationVersion);
const principalBlock = (data.adcp as { principal?: { supported_sections?: string[] } }).principal;
if (principalBlock && selected !== undefined) {
Expand Down
20 changes: 20 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 @@ -869,6 +872,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
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
38 changes: 38 additions & 0 deletions 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,6 +9293,14 @@ 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.
const requests =
accounts.listRequests &&
(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 response = {
status: 'completed' as const,
accounts: await projectAccountList(accounts, page.items, resolveCtx, logger),
Expand All @@ -9279,6 +9309,14 @@ function buildAccountHandlers<P extends DecisioningPlatform<any, any>>(
...(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