diff --git a/.changeset/schema-driven-validation.md b/.changeset/schema-driven-validation.md new file mode 100644 index 000000000..cef9599f6 --- /dev/null +++ b/.changeset/schema-driven-validation.md @@ -0,0 +1,34 @@ +--- +'@adcp/client': minor +--- + +Add schema-driven validation against the bundled AdCP JSON schemas on both +the client and the server (closes adcp-client#688). + +**Client hooks** (on the `AdcpClient` / `SingleAgentClient` `validation` +config, applied automatically via `TaskExecutor`): + +- `validation.requests: 'strict' | 'warn' | 'off'` — validate outgoing + payloads before dispatch. `strict` throws `ValidationError` + (`code: 'VALIDATION_ERROR'`) with a JSON Pointer to the offending field; + `warn` logs to debug logs and continues. Default: `warn`. +- `validation.responses: 'strict' | 'warn' | 'off'` — validate incoming + payloads on receive. `strict` fails the task; `warn` logs and continues. + Default: strict in dev/test, warn in production. Overrides the legacy + `strictSchemaValidation` flag when set. + +**Server middleware** (opt-in on `createAdcpServer`'s `validation` config): + +- `validation.requests: 'strict'` — dispatcher returns + `adcpError('VALIDATION_ERROR', …)` before the handler runs. +- `validation.responses: 'strict'` — handler-returned drift surfaces as a + `VALIDATION_ERROR` envelope; `warn` logs to the configured logger and + returns the response unchanged. + +Validation uses the bundled JSON schemas shipped at +`dist/lib/schemas-data//` — async response variants +(`-submitted`, `-working`, `-input-required`) are selected by payload shape +(`status` field), matching issue #688's spec. `additionalProperties` is +left permissive so vendor extensions don't trip the validator. The +`VALIDATION_ERROR` envelope carries the full issue list (pointer, message, +keyword, schema path) under `details.issues` for programmatic indexing. diff --git a/docs/guides/BUILD-AN-AGENT.md b/docs/guides/BUILD-AN-AGENT.md index 896d4eaf1..00e9e3879 100644 --- a/docs/guides/BUILD-AN-AGENT.md +++ b/docs/guides/BUILD-AN-AGENT.md @@ -201,6 +201,26 @@ Scoping is per-principal — `resolveSessionKey` doubles as the idempotency prin **Only successful responses are cached.** Handler errors re-execute on retry rather than replaying — so a transient 5xx doesn't lock a failure into the cache. +### Schema-Driven Validation (opt-in) + +`createAdcpServer` can validate every inbound request and handler response against the bundled AdCP JSON schemas for the SDK's declared version. Catches field-name drift (e.g. a handler emits `targeting_overlay` where the spec expects `targeting`) before the response leaves your agent. + +```typescript +createAdcpServer({ + name: 'my-seller', + version: '1.0.0', + validation: { + requests: 'strict', // reject malformed requests with VALIDATION_ERROR + responses: 'warn', // log handler drift, return response unchanged + }, + mediaBuy: { /* … */ }, +}); +``` + +Modes per side: `'strict' | 'warn' | 'off'`. Default is `'off'` — enable explicitly. `VALIDATION_ERROR` envelopes carry the full issue list (pointer, message, keyword, schema path) under `details.issues` so buyers can surface each offending field. + +The same validator runs on the `AdcpClient` side — storyboards and third-party clients configure it via `validation: { requests, responses }` on the client config. Request default is `warn` (so existing callers that send partial payloads still work); response default is `strict` in dev/test, `warn` in production. Set either side to `'off'` for zero overhead. + ### createTaskCapableServer (Low-Level) For advanced cases where you need direct control over MCP tool registration, schema wiring, and response formatting. `createAdcpServer` uses this internally. diff --git a/docs/llms.txt b/docs/llms.txt index ccb28b1a4..76e6ff684 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -643,7 +643,7 @@ Flow: `get_adcp_capabilities → sync_accounts → list_creative_formats → get Flow: `get_adcp_capabilities → get_products → create_media_buy → get_media_buys → list_creative_formats → sync_creatives → expect_webhook → get_media_buy_delivery` **Catalog-driven creative and conversion tracking** — Seller that renders dynamic ads from product catalogs, tracks conversions, and optimizes delivery based on performance feedback. -Flow: `get_adcp_capabilities → sync_accounts → list_creative_formats → sync_catalogs → get_products → create_media_buy → sync_event_sources → log_event → provide_performance_feedback → get_media_buy_delivery` +Flow: `get_adcp_capabilities → sync_accounts → list_creative_formats → sync_catalogs → build_creative → expect_substitution_safe → get_products → create_media_buy → sync_event_sources → log_event → provide_performance_feedback → get_media_buy_delivery` **Guaranteed media buy with human IO approval** — Seller agent that requires human-in-the-loop IO signing before guaranteed media buys go live. Flow: `get_adcp_capabilities → sync_accounts → get_products → create_media_buy → get_media_buys → sync_creatives → get_media_buy_delivery` @@ -684,7 +684,7 @@ Flow: `get_adcp_capabilities → si_get_offering → si_initiate_session → si_ Flow: `get_adcp_capabilities → list_accounts → sync_audiences` **Social platform** — Social media platform that accepts audience segments, native creatives, and conversion events from buyer agents. -Flow: `get_adcp_capabilities → sync_accounts → list_accounts → sync_audiences → sync_creatives → log_event → get_account_financials` +Flow: `get_adcp_capabilities → sync_accounts → list_accounts → sync_audiences → sync_creatives → sync_catalogs → sync_creatives → log_event → get_account_financials` ### Core @@ -722,7 +722,7 @@ Flow: `get_adcp_capabilities → create_property_list → list_property_lists ### **Generative creative agent** — Agent that takes a brief and generates finished creatives from scratch — no input assets required. -Flow: `get_adcp_capabilities → list_creative_formats → build_creative` +Flow: `get_adcp_capabilities → list_creative_formats → build_creative → sync_catalogs → build_creative` **Runner output contract** — Required failure-detail shape that AdCP storyboard runners MUST emit so implementors can self-diagnose validation failures. @@ -731,6 +731,7 @@ Flow: `get_adcp_capabilities → list_creative_formats → build_creative` **Programmatic exchange sales** — Programmatic exchange or SSP — auction-based inventory access with real-time bidding semantics and multi-seller inventory pooling. **Retail media sales** — Retail media network seller — onsite, offsite, and in-store inventory tied to commerce signals and SKU-level reporting. +Flow: `sync_catalogs` **Streaming TV sales** — Connected TV and streaming sellers — ad-supported video inventory, audience-based buying, and frequency management across CTV apps. diff --git a/package.json b/package.json index e4f210ae1..74c49c2a5 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "prepare": "node scripts/install-hooks.js 2>/dev/null || true", "prepublishOnly": "npm run clean && npm run sync-schemas && (test -f src/lib/types/tools.generated.ts || npm run generate-types) && npm run build:lib && node --test-timeout=60000 --test test/lib/adcp-client.test.js test/lib/validation.test.js test/lib/zod-schemas.test.js", "build": "npm run build:lib", - "build:lib": "npm run sync-version && tsc --project tsconfig.lib.json", + "build:lib": "npm run sync-version && tsc --project tsconfig.lib.json && tsx scripts/copy-schemas-to-dist.ts", "build:test-agents": "npm run build:lib && tsc -p test-agents/tsconfig.json --rootDir test-agents", "test": "node --test-timeout=60000 --test-force-exit --test test/*.test.js test/lib/*.test.js", "test:lib": "node --test-timeout=60000 --test-force-exit --test test/lib/*.test.js", diff --git a/scripts/copy-schemas-to-dist.ts b/scripts/copy-schemas-to-dist.ts new file mode 100644 index 000000000..834b5de61 --- /dev/null +++ b/scripts/copy-schemas-to-dist.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env tsx +/** + * Copy the bundled + core JSON schemas into the built package so the + * runtime validator (src/lib/validation/schema-loader.ts) can read them + * without a dependency on the source tree. + * + * Source: schemas/cache//{bundled,core,}/ + * Dest: dist/lib/schemas-data//{bundled,core,}/ + * + * Invoked by the `build:lib` npm script after tsc emits JS. + */ + +import { cpSync, existsSync, readFileSync, mkdirSync } from 'fs'; +import path from 'path'; + +function main(): void { + const repoRoot = path.resolve(__dirname, '..'); + const versionFile = path.join(repoRoot, 'ADCP_VERSION'); + if (!existsSync(versionFile)) { + throw new Error(`ADCP_VERSION file not found at ${versionFile}`); + } + const version = readFileSync(versionFile, 'utf-8').trim(); + if (!version) throw new Error('ADCP_VERSION file is empty.'); + + const srcRoot = path.join(repoRoot, 'schemas', 'cache', version); + if (!existsSync(srcRoot)) { + // The schema cache is fetched by `sync-schemas`. CI jobs that don't run + // the full toolchain (e.g., the code-quality integrity check that does + // `npm clean && build:lib` without a prior sync-schemas) would otherwise + // break here. Skip quietly — the loader falls back to the same source + // path, and any job that actually needs the schemas at runtime will get + // a clear error at first use. + console.warn( + `[copy-schemas-to-dist] schema cache missing at ${srcRoot}; skipping. ` + + `Run \`npm run sync-schemas\` to populate it before shipping.` + ); + return; + } + + const destRoot = path.join(repoRoot, 'dist', 'lib', 'schemas-data', version); + mkdirSync(destRoot, { recursive: true }); + + // Copy bundled/ and every per-domain directory except registry extras. + // The async response variants live in the flat per-domain tree; we need + // them plus bundled/ (sync) and core/ (ref targets for async). + cpSync(srcRoot, destRoot, { + recursive: true, + // Skip heavy subtrees that the runtime validator doesn't read. + filter: src => { + const rel = path.relative(srcRoot, src); + if (!rel) return true; + const top = rel.split(path.sep)[0]; + if (top === 'tmp' || top === 'compliance') return false; + return true; + }, + }); + + console.log(`[copy-schemas-to-dist] copied ${srcRoot} → ${destRoot}`); +} + +main(); diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 728314af8..c34cf15c9 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -254,10 +254,33 @@ export interface SingleAgentClientConfig extends ConversationConfig { */ validation?: { /** - * Fail tasks when response schema validation fails (default: true) + * Validate outgoing requests against the bundled AdCP JSON schema before + * dispatch. Catches field-name drift at call-time instead of at + * storyboard-time. * - * When true: Invalid responses cause task to fail with error - * When false: Schema violations are logged but task continues + * - `strict`: throw `ValidationError` with a JSON Pointer to the bad field + * - `warn`: log to debug logs and continue + * - `off`: skip the validator entirely (no overhead) + * + * @default `strict` in dev/test, `warn` in production + */ + requests?: import('../validation/client-hooks').ValidationMode; + /** + * Validate incoming responses against the bundled AdCP JSON schema. + * + * - `strict`: fail the task with `VALIDATION_ERROR` + * - `warn`: log to debug logs and surface the task as successful + * - `off`: skip the validator entirely + * + * Overrides `strictSchemaValidation` when set. + * + * @default `strict` in dev/test, `warn` in production + */ + responses?: import('../validation/client-hooks').ValidationMode; + /** + * Legacy: fail tasks when response schema validation fails (default: true). + * Superseded by `responses` above — retained for backward compat. + * `false` maps to `responses: 'warn'` when `responses` isn't set. * * @default true */ @@ -336,6 +359,10 @@ export class SingleAgentClient { strictSchemaValidation: config.validation?.strictSchemaValidation !== false, // Default: true logSchemaViolations: config.validation?.logSchemaViolations !== false, // Default: true filterInvalidProducts: config.validation?.filterInvalidProducts === true, // Default: false + validation: { + ...(config.validation?.requests != null && { requests: config.validation.requests }), + ...(config.validation?.responses != null && { responses: config.validation.responses }), + }, onActivity: config.onActivity, governance: config.governance, }); diff --git a/src/lib/core/TaskExecutor.ts b/src/lib/core/TaskExecutor.ts index b7948534e..dce7526f9 100644 --- a/src/lib/core/TaskExecutor.ts +++ b/src/lib/core/TaskExecutor.ts @@ -9,7 +9,14 @@ import { getAuthToken } from '../auth'; import { is401Error, adcpErrorToTypedError } from '../errors'; import type { ADCPError } from '../errors'; import type { Storage } from '../storage/interfaces'; -import { responseValidator } from './ResponseValidator'; +import { + validateOutgoingRequest, + validateIncomingResponse, + resolveValidationModes, + type ValidationHookConfig, + type ValidationMode, +} from '../validation/client-hooks'; +import { formatIssues } from '../validation/schema-validator'; import { unwrapProtocolResponse, isAdcpError } from '../utils/response-unwrapper'; import { extractAdcpErrorInfo, extractCorrelationId } from '../utils/error-extraction'; import { generateIdempotencyKey, isMutatingTask, redactIdempotencyKeyInArgs } from '../utils/idempotency'; @@ -110,6 +117,8 @@ export class TaskExecutor { private conversationStorage?: Map; private governanceMiddleware?: GovernanceMiddleware; private lastKnownServerVersion?: 'v2' | 'v3'; + private requestValidationMode!: ValidationMode; + private responseValidationMode!: ValidationMode; constructor( private config: { @@ -135,6 +144,13 @@ export class TaskExecutor { strictSchemaValidation?: boolean; /** Log all schema validation violations to debug logs (default: true) */ logSchemaViolations?: boolean; + /** + * Schema-driven validation using the bundled AdCP JSON schemas. + * Controls outgoing request and incoming response checks independently. + * Defaults: strict in dev/test, warn in prod. Set a mode to `off` to + * skip the validator entirely on that side (zero overhead). + */ + validation?: ValidationHookConfig; /** Filter out invalid products from get_products responses instead of rejecting the entire response (default: false) */ filterInvalidProducts?: boolean; /** Global activity callback for observability */ @@ -150,6 +166,18 @@ export class TaskExecutor { if (config.governance) { this.governanceMiddleware = new GovernanceMiddleware(config.governance, config.onActivity); } + const modes = resolveValidationModes(config.validation); + this.requestValidationMode = modes.requests; + // Legacy `strictSchemaValidation: false` flips response-side enforcement + // to warn mode — preserve that behaviour unless `validation.responses` + // was set explicitly. + if (config.validation?.responses !== undefined) { + this.responseValidationMode = config.validation.responses; + } else if (config.strictSchemaValidation === false) { + this.responseValidationMode = 'warn'; + } else { + this.responseValidationMode = modes.responses; + } } /** @@ -342,6 +370,9 @@ export class TaskExecutor { metadata: { toolName: taskName, type: 'request' }, }; + // Pre-send schema check — throws in strict, logs in warn, skips in off. + validateOutgoingRequest(taskName, effectiveParams, this.requestValidationMode, debugLogs); + // Send initial request and get streaming response with webhook URL const response = await ProtocolClient.callTool( agent, @@ -1485,77 +1516,52 @@ export class TaskExecutor { } /** - * Validate response against AdCP schema and log any violations - * - * Respects config.strictSchemaValidation (default: true): - * - true: Validation failures cause task to fail - * - false: Validation failures are logged only + * Validate an incoming response against the bundled AdCP JSON schema for + * `taskName`. Strict mode fails the task; warn mode logs and returns + * valid so the caller can continue. Off mode short-circuits before + * AJV runs. */ private validateResponseSchema( response: any, taskName: string, debugLogs: any[] ): { valid: boolean; errors: string[] } { - const strictMode = this.config.strictSchemaValidation !== false; // Default: true - const logViolations = this.config.logSchemaViolations !== false; // Default: true + const mode = this.responseValidationMode; + const logViolations = this.config.logSchemaViolations !== false; try { - // Normalize response to v3 format before validation - // This ensures v2 server responses pass validation against v3 schemas const normalizedResponse = this.normalizeResponseForValidation(response, taskName); + const outcome = validateIncomingResponse(taskName, normalizedResponse, mode, debugLogs); + if (outcome.valid) return { valid: true, errors: [] }; + + const errorStrings = outcome.issues.map(i => `${i.pointer}: ${i.message}`); + + if (logViolations) { + debugLogs.push({ + timestamp: new Date().toISOString(), + type: mode === 'strict' ? 'error' : 'warning', + message: `Schema validation ${mode === 'strict' ? 'failed' : 'warning'} for ${taskName}: ${formatIssues( + outcome.issues + )}`, + errors: errorStrings, + schemaVariant: outcome.variant, + mode, + }); + } - const validationResult = responseValidator.validate(normalizedResponse, taskName, { - validateSchema: true, - strict: false, - }); - - if (!validationResult.valid) { - // Log to debug logs if enabled - if (logViolations) { - const errorSummary = validationResult.errors.slice(0, 3).join('; '); - const moreErrors = - validationResult.errors.length > 3 ? ` (and ${validationResult.errors.length - 3} more)` : ''; - - debugLogs.push({ - timestamp: new Date().toISOString(), - type: strictMode ? 'error' : 'warning', - message: `Schema validation ${ - strictMode ? 'failed' : 'warning' - } for ${taskName}: ${errorSummary}${moreErrors}`, - errors: validationResult.errors, - schemaErrors: validationResult.schemaErrors, - strictMode, - }); - } - - // Console output based on strict mode - if (strictMode) { - console.error(`Schema validation failed for ${taskName}:`, validationResult.errors); - } else { - console.warn(`Schema validation failed for ${taskName} (non-blocking):`, validationResult.errors); - } - - // In strict mode, validation failures are treated as invalid - if (strictMode) { - return { - valid: false, - errors: validationResult.errors, - }; - } + if (mode === 'warn') { + console.warn(`Schema validation failed for ${taskName} (non-blocking):`, errorStrings); + return { valid: true, errors: [] }; } - // Non-strict mode or validation passed - return { - valid: true, - errors: [], - }; + console.error(`Schema validation failed for ${taskName}:`, errorStrings); + return { valid: false, errors: errorStrings }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'unknown error'; console.error('Error during schema validation:', errorMessage); - // On validation error, fail safe based on strict mode return { - valid: !strictMode, // In strict mode, treat validation errors as failures - errors: strictMode ? [`Validation error: ${errorMessage}`] : [], + valid: mode !== 'strict', + errors: mode === 'strict' ? [`Validation error: ${errorMessage}`] : [], }; } } diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 3125dad29..96c298fa8 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -71,6 +71,8 @@ function hasIdempotencyClearAll(store: IdempotencyStore): boolean { return typeof store.clearAll === 'function'; } import { isMutatingTask, IDEMPOTENCY_KEY_PATTERN, MUTATING_TASKS } from '../utils/idempotency'; +import { validateRequest, validateResponse, formatIssues } from '../validation/schema-validator'; +import { buildAdcpValidationErrorPayload } from '../validation/schema-errors'; import type { IdempotencyStore } from './idempotency'; import { createWebhookEmitter, @@ -838,6 +840,29 @@ export interface AdcpServerConfig { */ signedRequests?: SignedRequestsConfig; + /** + * Opt-in schema-driven validation of requests and responses against the + * bundled AdCP JSON schemas. When enabled, the dispatcher rejects bad + * requests with `VALIDATION_ERROR` before the handler runs and catches + * drift in handler-returned responses before they leave the server. + * + * Defaults to off — enabling it costs one AJV compile per tool on cold + * start and one validator invocation per call. Recommended in dev/test; + * optional in production depending on tolerance for extra CPU. + * + * Per-side modes: + * - `requests: 'strict'` — reject malformed requests with VALIDATION_ERROR. + * `'warn'` — log a warning, allow the handler to run. + * `'off'` (default) — skip. + * - `responses: 'strict'` — handler-returned drift throws (dev/test canary). + * `'warn'` — log a warning, return the response unchanged. + * `'off'` (default) — skip. + */ + validation?: { + requests?: import('../validation/client-hooks').ValidationMode; + responses?: import('../validation/client-hooks').ValidationMode; + }; + /** * Register tools outside {@link AdcpToolMap}. Keys are the public tool * names; values follow {@link AdcpCustomToolConfig}. @@ -1468,8 +1493,12 @@ export function createAdcpServer(config: AdcpServerConfig(config: AdcpServerConfig(config: AdcpServerConfig= this value. + * Progress information for long-running tasks */ - min_width?: number; + progress?: { + /** + * Completion percentage (0-100) + */ + percentage?: number; + /** + * Current step or phase of the operation + */ + current_step?: string; + /** + * Total number of steps in the operation + */ + total_steps?: number; + /** + * Current step number + */ + step_number?: number; + }; /** - * Minimum height in pixels (inclusive). Returns formats with height >= this value. + * Error details for failed tasks */ - min_height?: number; + error?: { + /** + * Error code for programmatic handling + */ + code: string; + /** + * Detailed error message + */ + message: string; + /** + * Additional error context + */ + details?: { + protocol?: AdCPProtocol; + /** + * Specific operation that failed + */ + operation?: string; + /** + * Domain-specific error context + */ + specific_context?: {}; + }; + }; /** - * Filter for responsive formats that adapt to container size. When true, returns formats without fixed dimensions. + * Complete conversation history for this task (only included if include_history was true in request) */ - is_responsive?: boolean; + history?: { + /** + * When this exchange occurred (ISO 8601) + */ + timestamp: string; + /** + * Whether this was a request from client or response from server + */ + type: 'request' | 'response'; + /** + * The full request or response payload + */ + data: {}; + }[]; + context?: ContextObject; + ext?: ExtensionObject; +} + +// bundled/core/tasks-list-request.json +/** + * Sort direction + */ +export type SortDirection = 'asc' | 'desc'; + +/** + * Request parameters for listing and filtering async tasks across all AdCP protocols with state reconciliation capabilities + */ +export interface TasksListRequest { /** - * Search for formats by name (case-insensitive partial match) + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - name_search?: string; - wcag_level?: WCAGLevel; + adcp_major_version?: number; /** - * Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements. + * Filter criteria for querying tasks */ - disclosure_positions?: DisclosurePosition[]; + filters?: { + protocol?: AdCPProtocol; + /** + * Filter by multiple AdCP protocols + */ + protocols?: AdCPProtocol[]; + status?: TaskStatus; + /** + * Filter by multiple task statuses + */ + statuses?: TaskStatus[]; + task_type?: TaskType; + /** + * Filter by multiple task types + */ + task_types?: TaskType[]; + /** + * Filter tasks created after this date (ISO 8601) + */ + created_after?: string; + /** + * Filter tasks created before this date (ISO 8601) + */ + created_before?: string; + /** + * Filter tasks last updated after this date (ISO 8601) + */ + updated_after?: string; + /** + * Filter tasks last updated before this date (ISO 8601) + */ + updated_before?: string; + /** + * Filter by specific task IDs + */ + task_ids?: string[]; + /** + * Filter tasks where context contains this text (searches media_buy_id, signal_id, etc.) + */ + context_contains?: string; + /** + * Filter tasks that have webhook configuration when true + */ + has_webhook?: boolean; + }; /** - * Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act). + * Sorting parameters */ - disclosure_persistence?: DisclosurePersistence[]; + sort?: { + /** + * Field to sort by + */ + field?: 'created_at' | 'updated_at' | 'status' | 'task_type' | 'protocol'; + direction?: SortDirection; + }; + pagination?: PaginationRequest; /** - * Filter to formats whose output_format_ids includes any of these format IDs. Returns formats that can produce these outputs — inspect each result's input_format_ids to see what inputs they accept. + * Include full conversation history for each task (may significantly increase response size) */ - output_format_ids?: FormatID[]; + include_history?: boolean; + context?: ContextObject; + ext?: ExtensionObject; +} + +// bundled/core/tasks-list-response.json +/** + * Response from task listing query with filtered results and state reconciliation data across all AdCP domains + */ +export interface TasksListResponse { /** - * Filter to formats whose input_format_ids includes any of these format IDs. Returns formats that accept these creatives as input — inspect each result's output_format_ids to see what they can produce. + * Summary of the query that was executed */ - input_format_ids?: FormatID[]; + query_summary: { + /** + * Total number of tasks matching filters (across all pages) + */ + total_matching?: number; + /** + * Number of tasks returned in this response + */ + returned?: number; + /** + * Count of tasks by domain + */ + domain_breakdown?: { + /** + * Number of media-buy tasks in results + */ + 'media-buy'?: number; + /** + * Number of signals tasks in results + */ + signals?: number; + }; + /** + * Count of tasks by status + */ + status_breakdown?: { + [k: string]: number | undefined; + }; + /** + * List of filters that were applied to the query + */ + filters_applied?: string[]; + /** + * Sort order that was applied + */ + sort_applied?: { + field: string; + direction: 'asc' | 'desc'; + }; + }; /** - * Include pricing_options on each format. Used by transformation and generation agents that charge per format or per unit of work. Requires account. When false or omitted, pricing is not computed. + * Array of tasks matching the query criteria */ - include_pricing?: boolean; - account?: AccountReference; - pagination?: PaginationRequest; + tasks: { + /** + * Unique identifier for this task + */ + task_id: string; + task_type: TaskType; + /** + * AdCP domain this task belongs to + */ + domain: 'media-buy' | 'signals'; + status: TaskStatus; + /** + * When the task was initially created (ISO 8601) + */ + created_at: string; + /** + * When the task was last updated (ISO 8601) + */ + updated_at: string; + /** + * When the task completed (ISO 8601, only for completed/failed/canceled tasks) + */ + completed_at?: string; + /** + * Whether this task has webhook configuration + */ + has_webhook?: boolean; + }[]; + pagination: PaginationResponse; context?: ContextObject; ext?: ExtensionObject; -}; -/** - * Filter to formats that meet at least this WCAG conformance level (A < AA < AAA) - */ -export type WCAGLevel = 'A' | 'AA' | 'AAA'; +} -// bundled/creative/list-creatives-request.json +// bundled/creative/get-creative-delivery-request.json /** - * Request parameters for querying creative assets from a creative library with filtering, sorting, and pagination. Implemented by any agent that hosts a creative library — creative agents (ad servers, creative platforms) and sales agents that manage creatives. + * Request parameters for retrieving creative delivery data including variant-level metrics from a creative agent. At least one scoping filter (media_buy_ids or creative_ids) is required. */ -export type ListCreativesRequest = { +export type GetCreativeDeliveryRequest = { [k: string]: unknown | undefined; } & { /** * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ adcp_major_version?: number; - filters?: CreativeFilters; - /** - * Sorting parameters - */ - sort?: { - field?: CreativeSortField; - direction?: SortDirection; - }; - pagination?: PaginationRequest; - /** - * Include package assignment information in response - */ - include_assignments?: boolean; + account?: AccountReference; /** - * Include a lightweight delivery snapshot per creative (lifetime impressions and last-served date). For detailed performance analytics, use get_creative_delivery. + * Filter to specific media buys by publisher ID. If omitted, returns creative delivery across all matching media buys. */ - include_snapshot?: boolean; + media_buy_ids?: string[]; /** - * Include items for multi-asset formats like carousels and native ads + * Filter to specific creatives by ID. If omitted, returns delivery for all creatives matching the other filters. */ - include_items?: boolean; + creative_ids?: string[]; /** - * Include dynamic content variable definitions (DCO slots) for each creative + * Start date for delivery period (YYYY-MM-DD). Interpreted in the platform's reporting timezone. */ - include_variables?: boolean; + start_date?: string; /** - * Include pricing_options on each creative. Requires account to be provided. When false or omitted, pricing is not computed. + * End date for delivery period (YYYY-MM-DD). Interpreted in the platform's reporting timezone. */ - include_pricing?: boolean; - account?: AccountReference; + end_date?: string; /** - * Specific fields to include in response (omit for all fields). The 'concept' value returns both concept_id and concept_name. + * Maximum number of variants to return per creative. When omitted, the agent returns all variants. Use this to limit response size for generative creatives that may produce large numbers of variants. */ - fields?: ( - | 'creative_id' - | 'name' - | 'format_id' - | 'status' - | 'created_date' - | 'updated_date' - | 'tags' - | 'assignments' - | 'snapshot' - | 'items' - | 'variables' - | 'concept' - | 'pricing_options' - )[]; + max_variants?: number; + pagination?: PaginationRequest; context?: ContextObject; ext?: ExtensionObject; }; + +// bundled/creative/get-creative-delivery-response.json /** - * Field to sort by + * A specific execution variant of a creative with delivery metrics. For catalog-driven packages, each catalog item rendered as a distinct ad execution is a variant — the variant's manifest includes the catalog reference with the specific item rendered. For asset group optimization, represents one combination of assets the platform selected. For generative creative, represents a platform-generated variant. For standard creatives, maps 1:1 with the creative itself. */ -export type CreativeSortField = 'created_date' | 'updated_date' | 'name' | 'status' | 'assignment_count'; +export type CreativeVariant = DeliveryMetrics & { + /** + * Platform-assigned identifier for this variant + */ + variant_id: string; + manifest?: CreativeManifest; + /** + * Input signals that triggered generation of this variant (Tier 3). Describes why the platform created this specific variant. Platforms should provide summarized or anonymized signals rather than raw user input. For web contexts, may include page topic or URL. For conversational contexts, an anonymized content signal. For search, query category or intent. When the content context is managed through AdCP content standards, reference the artifact directly via the artifact field. + */ + generation_context?: { + /** + * Type of context that triggered generation (e.g., 'web_page', 'conversational', 'search', 'app', 'dooh') + */ + context_type?: string; + /** + * Reference to the content-standards artifact that provided the generation context. Links this variant to the specific piece of content (article, video, podcast segment, etc.) where the ad was placed. + */ + artifact?: { + property_id: Identifier; + /** + * Artifact identifier within the property + */ + artifact_id: string; + }; + ext?: ExtensionObject; + }; +}; /** - * Filter criteria for querying creatives from a creative library. By default, archived creatives are excluded from results. To include archived creatives, explicitly filter by status='archived' or include 'archived' in the statuses array. + * Response payload for get_creative_delivery task. Returns creative delivery data with variant-level breakdowns including manifests and metrics. */ -export interface CreativeFilters { +export interface GetCreativeDeliveryResponse { /** - * Filter creatives by owning accounts. Useful for agencies managing multiple client accounts. + * Account identifier. Present when the response spans or is scoped to a specific account. */ - accounts?: AccountReference[]; + account_id?: string; /** - * Filter by creative approval statuses + * Publisher's media buy identifier. Present when the request was scoped to a single media buy. */ - statuses?: CreativeStatus[]; + media_buy_id?: string; /** - * Filter by creative tags (all tags must match) + * ISO 4217 currency code for monetary values in this response (e.g., 'USD', 'EUR') */ - tags?: string[]; + currency: string; /** - * Filter by creative tags (any tag must match) + * Date range for the report. */ - tags_any?: string[]; + reporting_period: { + /** + * ISO 8601 start timestamp + */ + start: string; + /** + * ISO 8601 end timestamp + */ + end: string; + /** + * IANA timezone identifier for the reporting period (e.g., 'America/New_York', 'UTC'). Platforms report in their native timezone. + */ + timezone?: string; + }; /** - * Filter by creative names containing this text (case-insensitive) + * Creative delivery data with variant breakdowns */ - name_contains?: string; + creatives: { + /** + * Creative identifier + */ + creative_id: string; + /** + * Publisher's media buy identifier for this creative. Present when the request spanned multiple media buys, so the buyer can correlate each creative to its media buy. + */ + media_buy_id?: string; + format_id?: FormatID; + totals?: DeliveryMetrics; + /** + * Total number of variants for this creative. When max_variants was specified in the request, this may exceed the number of items in the variants array. + */ + variant_count?: number; + /** + * Variant-level delivery breakdown. Each variant includes the rendered manifest and delivery metrics. For standard creatives, contains a single variant. For asset group optimization, one per combination. For generative creative, one per generated execution. Empty when a creative has no variants yet. + */ + variants: CreativeVariant[]; + }[]; /** - * Filter by specific creative IDs + * Pagination information. Present when the request included pagination parameters. */ - creative_ids?: string[]; + pagination?: { + /** + * Maximum number of creatives requested + */ + limit: number; + /** + * Number of creatives skipped + */ + offset: number; + /** + * Whether more creatives are available beyond this page + */ + has_more: boolean; + /** + * Total number of creatives matching the request filters + */ + total?: number; + }; /** - * Filter creatives created after this date (ISO 8601) + * Task-specific errors and warnings */ - created_after?: string; + errors?: Error[]; + context?: ContextObject; + ext?: ExtensionObject; +} +/** + * Aggregate delivery metrics across all variants of this creative + */ +export interface DeliveryMetrics { /** - * Filter creatives created before this date (ISO 8601) + * Impressions delivered */ - created_before?: string; + impressions?: number; /** - * Filter creatives last updated after this date (ISO 8601) + * Amount spent */ - updated_after?: string; + spend?: number; /** - * Filter creatives last updated before this date (ISO 8601) + * Total clicks */ - updated_before?: string; + clicks?: number; /** - * Filter creatives assigned to any of these packages. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. + * Click-through rate (clicks/impressions) */ - assigned_to_packages?: string[]; + ctr?: number; /** - * Filter creatives assigned to any of these media buys. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. + * Content engagements counted toward the billable view threshold. For video this is a platform-defined view event (e.g., 30 seconds or video midpoint); for audio/podcast it is a stream start; for other formats it follows the pricing model's view definition. When the package uses CPV pricing, spend = views × rate. */ - media_buy_ids?: string[]; + views?: number; /** - * Filter for unassigned creatives when true, assigned creatives when false. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. + * Video/audio completions. When the package has a completed_views optimization goal with view_duration_seconds, completions are counted at that threshold rather than 100% completion. */ - unassigned?: boolean; + completed_views?: number; /** - * When true, return only creatives that have served at least one impression. When false, return only creatives that have never served. + * Completion rate (completed_views/impressions) */ - has_served?: boolean; + completion_rate?: number; /** - * Filter by creative concept IDs. Concepts group related creatives across sizes and formats (e.g., Flashtalking concepts, Celtra campaign folders, CM360 creative groups). + * Total conversions attributed to this delivery. When by_event_type is present, this equals the sum of all by_event_type[].count entries. */ - concept_ids?: string[]; + conversions?: number; /** - * Filter by structured format IDs. Returns creatives that match any of these formats. + * Total monetary value of attributed conversions (in the reporting currency) */ - format_ids?: FormatID[]; + conversion_value?: number; /** - * When true, return only creatives with dynamic variables (DCO). When false, return only static creatives. + * Return on ad spend (conversion_value / spend) */ - has_variables?: boolean; -} - -// bundled/creative/list-creatives-response.json -/** - * Item within a multi-asset creative format. Used for carousel products, native ad components, and other formats composed of multiple distinct elements. - */ -export type CreativeItem = - | { - /** - * Discriminator indicating this is a media asset with content_uri - */ - asset_kind: 'media'; - /** - * Type of asset. Common types: thumbnail_image, product_image, featured_image, logo - */ - asset_type: string; - /** - * Unique identifier for the asset within the creative - */ - asset_id: string; - /** - * URL for media assets (images, videos, etc.) - */ - content_uri: string; - } - | { - /** - * Discriminator indicating this is a text asset with content - */ - asset_kind: 'text'; - /** - * Type of asset. Common types: headline, body_text, cta_text, price_text, sponsor_name, author_name, click_url - */ - asset_type: string; - /** - * Unique identifier for the asset within the creative - */ - asset_id: string; - /** - * Text content for text-based assets like headlines, body text, CTA text, etc. - */ - content: string | string[]; - }; -/** - * A pricing option offered by a vendor agent (signals, creative, governance). Combines pricing_option_id with the pricing model fields. Pass pricing_option_id in report_usage for billing verification. All vendor discovery responses return pricing_options as an array — vendors may offer multiple options (volume tiers, context-specific rates, different models per product line). - */ -export type VendorPricingOption = { + roas?: number; /** - * Opaque identifier for this pricing option, unique within the vendor agent. Pass this in report_usage to identify which pricing option was applied. + * Cost per conversion (spend / conversions) */ - pricing_option_id: string; -} & VendorPricing; -/** - * Pricing model for a vendor service. Discriminated by model: 'cpm' (fixed CPM), 'percent_of_media' (percentage of spend with optional CPM cap), 'flat_fee' (fixed charge per reporting period), 'per_unit' (fixed price per unit of work), or 'custom' (escape hatch for models not covered by the enumerated forms — requires a description and structured metadata). - */ -export type VendorPricing = CpmPricing | PercentOfMediaPricing | FlatFeePricing | PerUnitPricing | CustomPricing; - -/** - * Response from creative library query with filtered results, metadata, and optional enriched data - */ -export interface ListCreativesResponse { + cost_per_acquisition?: number; /** - * Summary of the query that was executed + * Fraction of conversions from first-time brand buyers (0 = none, 1 = all) */ - query_summary: { + new_to_brand_rate?: number; + /** + * Leads generated (convenience alias for by_event_type where event_type='lead') + */ + leads?: number; + /** + * Conversion metrics broken down by event type. Spend-derived metrics (ROAS, CPA) are only available at the package/totals level since spend cannot be attributed to individual event types. + */ + by_event_type?: { + event_type: EventType; /** - * Total number of creatives matching filters (across all pages) + * Event source that produced these conversions (for disambiguation when multiple event sources are configured) */ - total_matching: number; + event_source_id?: string; /** - * Number of creatives returned in this response + * Number of events of this type */ - returned: number; - /** - * List of filters that were applied to the query - */ - filters_applied?: string[]; + count: number; /** - * Sort order that was applied + * Total monetary value of events of this type */ - sort_applied?: { - field?: string; - direction?: SortDirection; - }; - }; - pagination: PaginationResponse; + value?: number; + }[]; /** - * Array of creative assets matching the query + * Gross Rating Points delivered (for CPP) */ - creatives: { + grps?: number; + /** + * Unique reach in the units specified by reach_unit. When reach_unit is omitted, units are unspecified — do not compare reach values across packages or media buys without a common reach_unit. + */ + reach?: number; + /** + * Unit of measurement for the reach field. Aligns with the reach_unit declared on optimization goals and delivery forecasts. Required when reach is present to enable cross-platform comparison. + */ + reach_unit?: ReachUnit; + /** + * Average frequency per reach unit (typically measured over campaign duration, but can vary by measurement provider). When reach_unit is 'households', this is average exposures per household; when 'accounts', per logged-in account; etc. + */ + frequency?: number; + /** + * Audio/video quartile completion data + */ + quartile_data?: { /** - * Unique identifier for the creative + * 25% completion views */ - creative_id: string; - account?: Account; + q1_views?: number; /** - * Human-readable creative name + * 50% completion views */ - name: string; - format_id: FormatID; - status: CreativeStatus; + q2_views?: number; /** - * When the creative was created + * 75% completion views */ - created_date: string; + q3_views?: number; /** - * When the creative was last modified + * 100% completion views */ - updated_date: string; + q4_views?: number; + }; + /** + * DOOH-specific metrics (only included for DOOH campaigns) + */ + dooh_metrics?: { /** - * Assets for this creative, keyed by asset_id + * Number of times ad played in rotation */ - assets?: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^[a-z0-9_]+$". - */ - [k: string]: - | ImageAsset - | VideoAsset - | AudioAsset - | VASTAsset - | TextAsset - | URLAsset - | HTMLAsset - | JavaScriptAsset - | WebhookAsset - | CSSAsset - | DAASTAsset - | MarkdownAsset - | BriefAsset - | CatalogAsset; - }; + loop_plays?: number; /** - * User-defined tags for organization and searchability + * Number of unique screens displaying the ad */ - tags?: string[]; + screens_used?: number; /** - * Creative concept this creative belongs to. Concepts group related creatives across sizes and formats. + * Total display time in seconds */ - concept_id?: string; + screen_time_seconds?: number; /** - * Human-readable concept name + * Actual share of voice delivered (0.0 to 1.0) */ - concept_name?: string; + sov_achieved?: number; /** - * Dynamic content variables (DCO slots) for this creative. Included when include_variables=true. + * Explanation of how DOOH impressions were calculated */ - variables?: CreativeVariable[]; + calculation_notes?: string; /** - * Current package assignments (included when include_assignments=true) + * Per-venue performance breakdown */ - assignments?: { + venue_breakdown?: { /** - * Total number of active package assignments + * Venue identifier */ - assignment_count: number; + venue_id: string; /** - * List of packages this creative is assigned to + * Human-readable venue name */ - assigned_packages?: { - /** - * Package identifier - */ - package_id: string; - /** - * When this assignment was created - */ - assigned_date: string; - }[]; - }; - /** - * Lightweight delivery snapshot (included when include_snapshot=true). For detailed performance analytics, use get_creative_delivery. - */ - snapshot?: { + venue_name?: string; /** - * When this snapshot was captured by the platform + * Venue type (e.g., 'airport', 'transit', 'retail', 'billboard') */ - as_of: string; + venue_type?: string; /** - * Maximum age of this data in seconds. For example, 3600 means the data may be up to 1 hour old. + * Impressions delivered at this venue */ - staleness_seconds: number; + impressions: number; /** - * Lifetime impressions across all assignments. Not scoped to any date range. + * Loop plays at this venue */ - impressions: number; + loop_plays?: number; /** - * Last time this creative served an impression. Absent when the creative has never served. + * Number of screens used at this venue */ - last_served?: string; - }; - /** - * Machine-readable reason the snapshot is omitted. Present only when include_snapshot was true and snapshot data is unavailable for this creative. - */ - snapshot_unavailable_reason?: - | 'SNAPSHOT_UNSUPPORTED' - | 'SNAPSHOT_TEMPORARILY_UNAVAILABLE' - | 'SNAPSHOT_PERMISSION_DENIED'; - /** - * Items for multi-asset formats like carousels and native ads (included when include_items=true) - */ - items?: CreativeItem[]; - /** - * Pricing options for using this creative (serving, delivery). Used by ad servers and library agents. Transformation agents expose format-level pricing on list_creative_formats instead. Present when include_pricing=true and account provided. The buyer passes the applied pricing_option_id in report_usage. - */ - pricing_options?: VendorPricingOption[]; - }[]; - /** - * Breakdown of creatives by format. Keys are agent-defined format identifiers, optionally including dimensions (e.g., 'display_static_300x250', 'video_30s_vast'). Key construction is platform-specific — there is no required format. - */ - format_summary?: { - /** - * Number of creatives with this format - * - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z0-9_-]+$". - */ - [k: string]: number | undefined; + screens_used?: number; + }[]; }; /** - * Breakdown of creatives by status + * Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. */ - status_summary?: { - /** - * Number of creatives being processed - */ - processing?: number; - /** - * Number of approved creatives - */ - approved?: number; + viewability?: { /** - * Number of creatives pending review + * Impressions where viewability could be measured. Excludes environments without measurement capability (e.g., non-Intersection Observer browsers, certain app environments). */ - pending_review?: number; + measurable_impressions?: number; /** - * Number of rejected creatives + * Impressions that met the viewability threshold defined by the measurement standard. */ - rejected?: number; + viewable_impressions?: number; /** - * Number of archived creatives + * Viewable impression rate (viewable_impressions / measurable_impressions). Range 0.0 to 1.0. */ - archived?: number; + viewable_rate?: number; + standard?: ViewabilityStandard; }; /** - * Task-specific errors (e.g., invalid filters, account not found) + * Total engagements — direct interactions with the ad beyond viewing. Includes social reactions/comments/shares, story/unit opens, interactive overlay taps on CTV, companion banner interactions on audio. Platform-specific; corresponds to the 'engagements' optimization metric. */ - errors?: Error[]; + engagements?: number; /** - * When true, this response contains simulated data from sandbox mode. + * New followers, page likes, artist/podcast/channel subscribes attributed to this delivery. */ - sandbox?: boolean; - context?: ContextObject; - ext?: ExtensionObject; -} -/** - * A dynamic content variable (DCO slot) on a creative. Variables represent content that can change at serve time — headlines, images, product data, etc. - */ -export interface CreativeVariable { + follows?: number; /** - * Variable identifier on the creative platform + * Saves, bookmarks, playlist adds, pins attributed to this delivery. */ - variable_id: string; + saves?: number; /** - * Human-readable variable name + * Visits to the brand's in-platform page (profile, artist page, channel, or storefront) attributed to this delivery. Does not include external website clicks. */ - name: string; + profile_visits?: number; /** - * Data type of the variable. Each type represents a semantic content slot: text (headlines, body copy), image/video/audio (media URLs), url (clickthrough or tracking URLs), number (prices, counts), boolean (conditional flags like show_discount or is_raining), color (hex color values), date (ISO 8601 date-time for countdowns and offer expirations). + * Platform-specific engagement rate (0.0 to 1.0). Typically engagements/impressions, but definition varies by platform. */ - variable_type: 'text' | 'image' | 'video' | 'audio' | 'url' | 'number' | 'boolean' | 'color' | 'date'; + engagement_rate?: number; /** - * Default value used when no dynamic value is provided at serve time. All types are string-encoded: text/image/video/audio/url as literal strings, number as decimal (e.g., "42.99"), boolean as "true"/"false", color as "#RRGGBB", date as ISO 8601 (e.g., "2026-12-25T00:00:00Z"). + * Cost per click (spend / clicks) */ - default_value?: string; + cost_per_click?: number; /** - * Whether this variable must have a value for the creative to serve + * Conversion metrics broken down by action source (website, app, in_store, etc.). Useful for omnichannel sellers where conversions occur across digital and physical channels. */ - required?: boolean; + by_action_source?: { + action_source: ActionSource; + /** + * Event source that produced these conversions (for disambiguation when multiple event sources are configured) + */ + event_source_id?: string; + /** + * Number of conversions from this action source + */ + count: number; + /** + * Total monetary value of conversions from this action source + */ + value?: number; + }[]; } /** - * Fixed cost per thousand impressions + * Property where the artifact appears */ -export interface CpmPricing { - model: 'cpm'; - /** - * Cost per thousand impressions - */ - cpm: number; +export interface Identifier { + type: PropertyIdentifierTypes; /** - * ISO 4217 currency code + * The identifier value. For domain type: 'example.com' matches base domain plus www and m subdomains; 'edition.example.com' matches that specific subdomain; '*.example.com' matches ALL subdomains but NOT base domain */ - currency: string; - ext?: ExtensionObject; + value: string; } + +// bundled/creative/get-creative-features-request.json /** - * Percentage of media spend charged for this signal. When max_cpm is set, the effective rate is capped at that CPM — useful for platforms like The Trade Desk that use percent-of-media pricing with a CPM ceiling. + * Request payload for the get_creative_features task. Submits a creative manifest for evaluation by a governance agent, which analyzes the creative and returns scored feature values (brand safety, content categorization, quality metrics, etc.). */ -export interface PercentOfMediaPricing { - model: 'percent_of_media'; - /** - * Percentage of media spend, e.g. 15 = 15% - */ - percent: number; +export interface GetCreativeFeaturesRequest { /** - * Optional CPM cap. When set, the effective charge is min(percent × media_spend_per_mille, max_cpm). + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - max_cpm?: number; + adcp_major_version?: number; + creative_manifest: CreativeManifest; /** - * ISO 4217 currency code for the resulting charge + * Optional filter to specific features. If omitted, returns all available features. */ - currency: string; + feature_ids?: string[]; + account?: AccountReference; + context?: ContextObject; ext?: ExtensionObject; } + +// bundled/creative/get-creative-features-response.json /** - * Fixed charge per billing period, regardless of impressions or spend. Used for licensed data bundles and audience subscriptions. + * Response payload for the get_creative_features task. Returns scored feature values from the governance agent's evaluation of the submitted creative manifest. */ -export interface FlatFeePricing { - model: 'flat_fee'; +export type GetCreativeFeaturesResponse = + | { + /** + * Feature values for the evaluated creative + */ + results: CreativeFeatureResult[]; + /** + * URL to the vendor's full assessment report. The vendor controls what information is disclosed and access control. + */ + detail_url?: string; + /** + * Which rate card pricing option was applied for this evaluation. Present when the governance agent charges for evaluations and account was provided in the request. + */ + pricing_option_id?: string; + /** + * Cost incurred for this evaluation, denominated in currency. + */ + vendor_cost?: number; + /** + * ISO 4217 currency code for vendor_cost. + */ + currency?: string; + consumption?: CreativeConsumption; + context?: ContextObject; + ext?: ExtensionObject; + } + | { + errors: Error[]; + context?: ContextObject; + ext?: ExtensionObject; + }; + +/** + * A single feature evaluation result for a creative. Uses the same value structure as property-feature-value (value, confidence, expires_at, etc.). + */ +export interface CreativeFeatureResult { /** - * Fixed charge for the billing period + * The feature that was evaluated (e.g., 'auto_redirect', 'brand_consistency'). Features prefixed with 'registry:' reference standardized policies from the shared policy registry (e.g., 'registry:eu_ai_act_article_50'). Unprefixed feature IDs are agent-defined. */ - amount: number; + feature_id: string; /** - * Billing period for the flat fee. + * The feature value. Type depends on feature definition: boolean for binary, number for quantitative, string for categorical. */ - period: 'monthly' | 'quarterly' | 'annual' | 'campaign'; + value: boolean | number | string; /** - * ISO 4217 currency code + * Unit of measurement for quantitative values (e.g., 'percentage', 'score') */ - currency: string; - ext?: ExtensionObject; -} -/** - * Fixed price per unit of work. Used for creative transformation (per format), AI generation (per image, per token), and rendering (per variant). The unit field describes what is counted; unit_price is the cost per one unit. - */ -export interface PerUnitPricing { - model: 'per_unit'; + unit?: string; /** - * What is counted — e.g. 'format', 'image', 'token', 'variant', 'render', 'evaluation'. + * Confidence score for this value (0-1) */ - unit: string; + confidence?: number; /** - * Cost per one unit + * When this feature was evaluated */ - unit_price: number; + measured_at?: string; /** - * ISO 4217 currency code + * When this evaluation expires and should be refreshed */ - currency: string; - ext?: ExtensionObject; -} -/** - * Escape hatch for pricing constructs that do not fit cpm, percent_of_media, flat_fee, or per_unit. Use when a vendor prices via performance kickers, tiered volume, hybrid formulas, outcome-sharing, or any other model the standard forms cannot express. Requires a human-readable description and a structured metadata object that captures the parameters a buyer needs to reason about the charge. Buyers SHOULD route custom pricing through operator review before commitment — automatic selection is not recommended. - */ -export interface CustomPricing { - model: 'custom'; + expires_at?: string; /** - * Human-readable description of the custom pricing model. Buyers display this to the operator when requesting approval. + * Version of the methodology used to evaluate this feature */ - description: string; + methodology_version?: string; /** - * Structured parameters for the custom model. Keys follow lowercase_snake_case. Values may be primitives, arrays, or nested objects. Must be sufficient for a human to understand the pricing basis and for a downstream system to reconstruct the charge. Vendors SHOULD include a `summary_for_operator` string (one or two sentences, suitable for display in a buyer's operator-review UI) so reviewers across vendors see a consistent prompt. Required operator-review fields (approver role, dollar threshold for automatic approval, escalation contact) MAY be surfaced via additional keys the buyer's review surface recognizes. + * Additional vendor-specific details about this evaluation */ - metadata: { - /** - * One or two sentences describing the pricing construct in plain language, displayed to the buyer's operator when requesting approval. Should not repeat the top-level `description` verbatim — summarize the charge mechanic instead (e.g., 'Base $12 CPM plus $0.50 per qualifying post-view conversion, capped at $45 CPM'). - */ - summary_for_operator?: string; - }; + details?: {}; /** - * ISO 4217 currency code. Present when the pricing resolves to a monetary charge in a specific currency. + * Optional attribution — when this feature was evaluated to satisfy a specific policy, policy_id references the authorizing PolicyEntry. Reserved field; populated by producers in 3.1 and later (see issue #2303). Governance agents MAY ignore in 3.0. */ - currency?: string; + policy_id?: string; ext?: ExtensionObject; } -// bundled/creative/preview-creative-request.json +// bundled/creative/list-creative-formats-request.json /** - * Request to generate previews of creative manifests. Uses request_type to select single, batch, or variant mode. + * Request parameters for discovering creative formats provided by this creative agent */ -export type PreviewCreativeRequest = { +export type ListCreativeFormatsRequestCreativeAgent = { [k: string]: unknown | undefined; } & { /** @@ -8102,341 +8384,825 @@ export type PreviewCreativeRequest = { */ adcp_major_version?: number; /** - * Preview mode. 'single' previews one creative manifest. 'batch' previews multiple creatives in one call. 'variant' replays a post-flight variant by ID. + * Return only these specific format IDs */ - request_type: 'single' | 'batch' | 'variant'; - creative_manifest?: CreativeManifest; - format_id?: FormatID; + format_ids?: FormatID[]; /** - * Array of input sets for generating multiple preview variants. Each input set defines macros and context values for one preview rendering. Used in single mode. + * Filter by format type (technical categories with distinct requirements) */ - inputs?: { - /** - * Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad', 'Desktop dark mode') - */ - name: string; - /** - * Macro values to use for this preview. Supports all universal macros from the format's supported_macros list. - */ - macros?: { - [k: string]: string | undefined; - }; - /** - * Natural language description of the context for AI-generated content (e.g., 'User just searched for running shoes', 'Podcast discussing weather patterns') - */ - context_description?: string; - }[]; + type?: 'audio' | 'video' | 'display' | 'dooh'; /** - * Specific template ID for custom format rendering. Used in single mode. + * Filter to formats that include these asset types. For third-party tags, search for 'html' or 'javascript'. E.g., ['image', 'text'] returns formats with images and text, ['javascript'] returns formats accepting JavaScript tags. */ - template_id?: string; - quality?: CreativeQuality; - output_format?: PreviewOutputFormat; + asset_types?: ('image' | 'video' | 'audio' | 'text' | 'html' | 'javascript' | 'url')[]; /** - * Maximum number of catalog items to render per preview variant. Used in single mode. Creative agents SHOULD default to a reasonable sample when omitted and the catalog is large. + * Maximum width in pixels (inclusive). Returns formats with width <= this value. Omit for responsive/fluid formats. */ - item_limit?: number; + max_width?: number; /** - * Array of preview requests (1-50 items). Required when request_type is 'batch'. Each item follows the single request structure. + * Maximum height in pixels (inclusive). Returns formats with height <= this value. Omit for responsive/fluid formats. */ - requests?: { - format_id?: FormatID; - creative_manifest: CreativeManifest; - /** - * Array of input sets for generating multiple preview variants - */ - inputs?: { - /** - * Human-readable name for this input set - */ - name: string; - /** - * Macro values to use for this preview - */ - macros?: { - [k: string]: string | undefined; - }; - /** - * Natural language description of the context for AI-generated content - */ - context_description?: string; - }[]; - /** - * Specific template ID for custom format rendering - */ - template_id?: string; - quality?: CreativeQuality; - output_format?: PreviewOutputFormat; - /** - * Maximum number of catalog items to render in this preview. - */ - item_limit?: number; - }[]; + max_height?: number; /** - * Platform-assigned variant identifier from get_creative_delivery response. Required when request_type is 'variant'. + * Minimum width in pixels (inclusive). Returns formats with width >= this value. */ - variant_id?: string; + min_width?: number; /** - * Creative identifier for context. Used in variant mode. + * Minimum height in pixels (inclusive). Returns formats with height >= this value. */ - creative_id?: string; + min_height?: number; + /** + * Filter for responsive formats that adapt to container size. When true, returns formats without fixed dimensions. + */ + is_responsive?: boolean; + /** + * Search for formats by name (case-insensitive partial match) + */ + name_search?: string; + wcag_level?: WCAGLevel; + /** + * Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements. + */ + disclosure_positions?: DisclosurePosition[]; + /** + * Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act). + */ + disclosure_persistence?: DisclosurePersistence[]; + /** + * Filter to formats whose output_format_ids includes any of these format IDs. Returns formats that can produce these outputs — inspect each result's input_format_ids to see what inputs they accept. + */ + output_format_ids?: FormatID[]; + /** + * Filter to formats whose input_format_ids includes any of these format IDs. Returns formats that accept these creatives as input — inspect each result's output_format_ids to see what they can produce. + */ + input_format_ids?: FormatID[]; + /** + * Include pricing_options on each format. Used by transformation and generation agents that charge per format or per unit of work. Requires account. When false or omitted, pricing is not computed. + */ + include_pricing?: boolean; + account?: AccountReference; + pagination?: PaginationRequest; context?: ContextObject; ext?: ExtensionObject; }; /** - * Render quality. 'draft' produces fast, lower-fidelity renderings. 'production' produces full-quality renderings. In batch mode, sets the default for all requests (individual items can override). - */ -export type CreativeQuality = 'draft' | 'production'; -/** - * Output format. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML). In batch mode, sets the default for all requests (individual items can override). Default: 'url'. + * Filter to formats that meet at least this WCAG conformance level (A < AA < AAA) */ -export type PreviewOutputFormat = 'url' | 'html'; +export type WCAGLevel = 'A' | 'AA' | 'AAA'; -// bundled/creative/preview-creative-response.json +// bundled/creative/list-creatives-request.json /** - * Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests. + * Request parameters for querying creative assets from a creative library with filtering, sorting, and pagination. Implemented by any agent that hosts a creative library — creative agents (ad servers, creative platforms) and sales agents that manage creatives. */ -export type PreviewCreativeResponse = - | PreviewCreativeSingleResponse - | PreviewCreativeBatchResponse - | PreviewCreativeVariantResponse; -/** - * Single preview response - each preview URL returns an HTML page that can be embedded in an iframe - */ -export interface PreviewCreativeSingleResponse { - /** - * Discriminator indicating this is a single preview response - */ - response_type: 'single'; - /** - * Array of preview variants. Each preview corresponds to an input set from the request. If no inputs were provided, returns a single default preview. - */ - previews: { - /** - * Unique identifier for this preview variant - */ - preview_id: string; - /** - * Array of rendered pieces for this preview variant. Most formats render as a single piece. Companion ad formats (video + banner), multi-placement formats, and adaptive formats render as multiple pieces. - */ - renders: PreviewRender[]; - /** - * The input parameters that generated this preview variant. Echoes back the request input or shows defaults used. - */ - input: { - /** - * Human-readable name for this variant - */ - name: string; - /** - * Macro values applied to this variant - */ - macros?: { - [k: string]: string | undefined; - }; - /** - * Context description applied to this variant - */ - context_description?: string; - }; - }[]; - /** - * Optional URL to an interactive testing page that shows all preview variants with controls to switch between them, modify macro values, and test different scenarios. - */ - interactive_url?: string; - /** - * ISO 8601 timestamp when preview links expire - */ - expires_at: string; - context?: ContextObject; - ext?: ExtensionObject; -} -/** - * Batch preview response - contains results for multiple creative requests - */ -export interface PreviewCreativeBatchResponse { - /** - * Discriminator indicating this is a batch preview response - */ - response_type: 'batch'; +export type ListCreativesRequest = { + [k: string]: unknown | undefined; +} & { /** - * Array of preview results corresponding to each request in the same order. results[0] is the result for requests[0], results[1] for requests[1], etc. Order is guaranteed even when some requests fail. Each result contains either a successful preview response or an error. + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - results: (PreviewBatchResultSuccess | PreviewBatchResultError)[]; - context?: ContextObject; - ext?: ExtensionObject; -} -export interface PreviewBatchResultSuccess { + adcp_major_version?: number; + filters?: CreativeFilters; /** - * Indicates this preview request succeeded + * Sorting parameters */ - success?: true; -} -export interface PreviewBatchResultError { + sort?: { + field?: CreativeSortField; + direction?: SortDirection; + }; + pagination?: PaginationRequest; /** - * Indicates this preview request failed + * Include package assignment information in response */ - success?: false; -} -/** - * Variant preview response - shows what a specific creative variant looked like when served during delivery - */ -export interface PreviewCreativeVariantResponse { + include_assignments?: boolean; /** - * Discriminator indicating this is a variant preview response + * Include a lightweight delivery snapshot per creative (lifetime impressions and last-served date). For detailed performance analytics, use get_creative_delivery. */ - response_type: 'variant'; + include_snapshot?: boolean; /** - * Platform-assigned variant identifier + * Include items for multi-asset formats like carousels and native ads */ - variant_id: string; + include_items?: boolean; /** - * Creative identifier this variant belongs to + * Include dynamic content variable definitions (DCO slots) for each creative */ - creative_id?: string; + include_variables?: boolean; /** - * Array of rendered pieces for this variant. Most formats render as a single piece. + * Include pricing_options on each creative. Requires account to be provided. When false or omitted, pricing is not computed. */ - previews: { - /** - * Unique identifier for this preview - */ - preview_id: string; - /** - * Rendered pieces for this variant - */ - renders: PreviewRender[]; - }[]; - manifest?: CreativeManifest; + include_pricing?: boolean; + account?: AccountReference; /** - * ISO 8601 timestamp when preview links expire + * Specific fields to include in response (omit for all fields). The 'concept' value returns both concept_id and concept_name. */ - expires_at?: string; + fields?: ( + | 'creative_id' + | 'name' + | 'format_id' + | 'status' + | 'created_date' + | 'updated_date' + | 'tags' + | 'assignments' + | 'snapshot' + | 'items' + | 'variables' + | 'concept' + | 'pricing_options' + )[]; context?: ContextObject; ext?: ExtensionObject; -} +}; /** - * Validation strictness. 'strict' fails entire sync on any validation error. 'lenient' processes valid creatives and reports errors. + * Field to sort by */ -export type ValidationMode = 'strict' | 'lenient'; +export type CreativeSortField = 'created_date' | 'updated_date' | 'name' | 'status' | 'assignment_count'; /** - * Request parameters for syncing creative assets with upsert semantics - supports bulk operations, scoped updates, and assignment management + * Filter criteria for querying creatives from a creative library. By default, archived creatives are excluded from results. To include archived creatives, explicitly filter by status='archived' or include 'archived' in the statuses array. */ -export interface SyncCreativesRequest { +export interface CreativeFilters { /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + * Filter creatives by owning accounts. Useful for agencies managing multiple client accounts. */ - adcp_major_version?: number; - account: AccountReference; + accounts?: AccountReference[]; /** - * Array of creative assets to sync (create or update) + * Filter by creative approval statuses */ - creatives: CreativeAsset[]; + statuses?: CreativeStatus[]; /** - * Optional filter to limit sync scope to specific creative IDs. When provided, only these creatives will be created/updated. Other creatives in the library are unaffected. Useful for partial updates and error recovery. + * Filter by creative tags (all tags must match) */ - creative_ids?: string[]; + tags?: string[]; /** - * Optional bulk assignment of creatives to packages. Each entry maps one creative to one package with optional weight and placement targeting. Standalone creative agents that do not manage media buys ignore this field. + * Filter by creative tags (any tag must match) */ - assignments?: { - /** - * ID of the creative to assign - */ - creative_id: string; - /** - * ID of the package to assign the creative to - */ - package_id: string; - /** - * Relative delivery weight (0-100). When multiple creatives are assigned to the same package, weights determine impression distribution proportionally. When omitted, the creative receives equal rotation with other unweighted creatives. A weight of 0 means the creative is assigned but paused (receives no delivery). - */ - weight?: number; - /** - * Restrict this creative to specific placements within the package. When omitted, the creative is eligible for all placements. - */ - placement_ids?: string[]; - }[]; + tags_any?: string[]; /** - * Client-generated idempotency key for safe retries. If a sync fails without a response, resending with the same idempotency_key guarantees at-most-once execution. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request. + * Filter by creative names containing this text (case-insensitive) */ - idempotency_key: string; + name_contains?: string; /** - * When true, creatives not included in this sync will be archived. Use with caution for full library replacement. Invalid when creative_ids is provided — delete_missing applies to the entire library scope, not a filtered subset. + * Filter by specific creative IDs */ - delete_missing?: boolean; + creative_ids?: string[]; /** - * When true, preview changes without applying them. Returns what would be created/updated/deleted. + * Filter creatives created after this date (ISO 8601) */ - dry_run?: boolean; - validation_mode?: ValidationMode; - push_notification_config?: PushNotificationConfig; - context?: ContextObject; - ext?: ExtensionObject; -} - -// bundled/media-buy/build-creative-request.json -/** - * Request to transform, generate, or retrieve a creative manifest. Supports three modes: (1) generation from a brief or seed assets, (2) transformation of an existing manifest, (3) retrieval from a creative library by creative_id. Produces target manifest(s) in the specified format(s). Provide either target_format_id for a single format or target_format_ids for multiple formats. - */ -export interface BuildCreativeRequest { + created_after?: string; /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + * Filter creatives created before this date (ISO 8601) */ - adcp_major_version?: number; + created_before?: string; /** - * Natural language instructions for the transformation or generation. For pure generation, this is the creative brief. For transformation, this provides guidance on how to adapt the creative. For refinement, this describes the desired changes. + * Filter creatives last updated after this date (ISO 8601) */ - message?: string; - creative_manifest?: CreativeManifest; + updated_after?: string; /** - * Reference to a creative in the agent's library. The creative agent resolves this to a manifest from its library. Use this instead of creative_manifest when retrieving an existing creative for tag generation or format adaptation. + * Filter creatives last updated before this date (ISO 8601) */ - creative_id?: string; + updated_before?: string; /** - * Creative concept containing the creative. Creative agents SHOULD assign globally unique creative_id values; when they cannot guarantee uniqueness, concept_id is REQUIRED to disambiguate. + * Filter creatives assigned to any of these packages. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. */ - concept_id?: string; + assigned_to_packages?: string[]; /** - * Media buy identifier for tag generation context. When the creative agent is also the ad server, this provides the trafficking context needed to generate placement-specific tags (e.g., CM360 placement ID). Not needed when tags are generated at the creative level (most creative platforms). + * Filter creatives assigned to any of these media buys. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. */ - media_buy_id?: string; + media_buy_ids?: string[]; /** - * Package identifier within the media buy. Used with media_buy_id when the creative agent needs line-item-level context for tag generation. Omit to get a tag not scoped to a specific package. + * Filter for unassigned creatives when true, assigned creatives when false. Sales-agent-specific — standalone creative agents SHOULD ignore this filter. */ - package_id?: string; - target_format_id?: FormatID; + unassigned?: boolean; /** - * Array of format IDs to generate in a single call. Mutually exclusive with target_format_id. The creative agent produces one manifest per format. Each format definition specifies its own required input assets and output structure. + * When true, return only creatives that have served at least one impression. When false, return only creatives that have never served. */ - target_format_ids?: FormatID[]; - account?: AccountReference; - brand?: BrandReference; - quality?: CreativeQuality; + has_served?: boolean; /** - * Maximum number of catalog items to use when generating. When a catalog asset contains more items than this limit, the creative agent selects the top items based on relevance or catalog ordering. When item_limit exceeds the format's max_items, the creative agent SHOULD use the lesser of the two. Ignored when the manifest contains no catalog assets. + * Filter by creative concept IDs. Concepts group related creatives across sizes and formats (e.g., Flashtalking concepts, Celtra campaign folders, CM360 creative groups). */ - item_limit?: number; + concept_ids?: string[]; /** - * When true, requests the creative agent to include preview renders in the response alongside the manifest. Agents that support this return a 'preview' object in the response using the same structure as preview_creative. Agents that do not support inline preview simply omit the field. This avoids a separate preview_creative round trip for platforms that generate previews as a byproduct of building. + * Filter by structured format IDs. Returns creatives that match any of these formats. */ - include_preview?: boolean; + format_ids?: FormatID[]; /** - * Input sets for preview generation when include_preview is true. Each input set defines macros and context values for one preview variant. If include_preview is true but this is omitted, the agent generates a single default preview. Only supported with target_format_id (single-format requests). Ignored when using target_format_ids — multi-format requests generate one default preview per format. Ignored when include_preview is false or omitted. + * When true, return only creatives with dynamic variables (DCO). When false, return only static creatives. */ - preview_inputs?: { - /** - * Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad') - */ - name: string; - /** - * Macro values to use for this preview variant - */ - macros?: { - [k: string]: string | undefined; - }; - /** - * Natural language description of the context for AI-generated content + has_variables?: boolean; +} + +// bundled/creative/list-creatives-response.json +/** + * Item within a multi-asset creative format. Used for carousel products, native ad components, and other formats composed of multiple distinct elements. + */ +export type CreativeItem = + | { + /** + * Discriminator indicating this is a media asset with content_uri + */ + asset_kind: 'media'; + /** + * Type of asset. Common types: thumbnail_image, product_image, featured_image, logo + */ + asset_type: string; + /** + * Unique identifier for the asset within the creative + */ + asset_id: string; + /** + * URL for media assets (images, videos, etc.) + */ + content_uri: string; + } + | { + /** + * Discriminator indicating this is a text asset with content + */ + asset_kind: 'text'; + /** + * Type of asset. Common types: headline, body_text, cta_text, price_text, sponsor_name, author_name, click_url + */ + asset_type: string; + /** + * Unique identifier for the asset within the creative + */ + asset_id: string; + /** + * Text content for text-based assets like headlines, body text, CTA text, etc. + */ + content: string | string[]; + }; +/** + * Response from creative library query with filtered results, metadata, and optional enriched data + */ +export interface ListCreativesResponse { + /** + * Summary of the query that was executed + */ + query_summary: { + /** + * Total number of creatives matching filters (across all pages) + */ + total_matching: number; + /** + * Number of creatives returned in this response + */ + returned: number; + /** + * List of filters that were applied to the query + */ + filters_applied?: string[]; + /** + * Sort order that was applied + */ + sort_applied?: { + field?: string; + direction?: SortDirection; + }; + }; + pagination: PaginationResponse; + /** + * Array of creative assets matching the query + */ + creatives: { + /** + * Unique identifier for the creative + */ + creative_id: string; + account?: Account; + /** + * Human-readable creative name + */ + name: string; + format_id: FormatID; + status: CreativeStatus; + /** + * When the creative was created + */ + created_date: string; + /** + * When the creative was last modified + */ + updated_date: string; + /** + * Assets for this creative, keyed by asset_id + */ + assets?: { + /** + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^[a-z0-9_]+$". + */ + [k: string]: + | ImageAsset + | VideoAsset + | AudioAsset + | VASTAsset + | TextAsset + | URLAsset + | HTMLAsset + | JavaScriptAsset + | WebhookAsset + | CSSAsset + | DAASTAsset + | MarkdownAsset + | BriefAsset + | CatalogAsset; + }; + /** + * User-defined tags for organization and searchability + */ + tags?: string[]; + /** + * Creative concept this creative belongs to. Concepts group related creatives across sizes and formats. + */ + concept_id?: string; + /** + * Human-readable concept name + */ + concept_name?: string; + /** + * Dynamic content variables (DCO slots) for this creative. Included when include_variables=true. + */ + variables?: CreativeVariable[]; + /** + * Current package assignments (included when include_assignments=true) + */ + assignments?: { + /** + * Total number of active package assignments + */ + assignment_count: number; + /** + * List of packages this creative is assigned to + */ + assigned_packages?: { + /** + * Package identifier + */ + package_id: string; + /** + * When this assignment was created + */ + assigned_date: string; + }[]; + }; + /** + * Lightweight delivery snapshot (included when include_snapshot=true). For detailed performance analytics, use get_creative_delivery. + */ + snapshot?: { + /** + * When this snapshot was captured by the platform + */ + as_of: string; + /** + * Maximum age of this data in seconds. For example, 3600 means the data may be up to 1 hour old. + */ + staleness_seconds: number; + /** + * Lifetime impressions across all assignments. Not scoped to any date range. + */ + impressions: number; + /** + * Last time this creative served an impression. Absent when the creative has never served. + */ + last_served?: string; + }; + /** + * Machine-readable reason the snapshot is omitted. Present only when include_snapshot was true and snapshot data is unavailable for this creative. + */ + snapshot_unavailable_reason?: + | 'SNAPSHOT_UNSUPPORTED' + | 'SNAPSHOT_TEMPORARILY_UNAVAILABLE' + | 'SNAPSHOT_PERMISSION_DENIED'; + /** + * Items for multi-asset formats like carousels and native ads (included when include_items=true) + */ + items?: CreativeItem[]; + /** + * Pricing options for using this creative (serving, delivery). Used by ad servers and library agents. Transformation agents expose format-level pricing on list_creative_formats instead. Present when include_pricing=true and account provided. The buyer passes the applied pricing_option_id in report_usage. + */ + pricing_options?: VendorPricingOption[]; + }[]; + /** + * Breakdown of creatives by format. Keys are agent-defined format identifiers, optionally including dimensions (e.g., 'display_static_300x250', 'video_30s_vast'). Key construction is platform-specific — there is no required format. + */ + format_summary?: { + /** + * Number of creatives with this format + * + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z0-9_-]+$". + */ + [k: string]: number | undefined; + }; + /** + * Breakdown of creatives by status + */ + status_summary?: { + /** + * Number of creatives being processed + */ + processing?: number; + /** + * Number of approved creatives + */ + approved?: number; + /** + * Number of creatives pending review + */ + pending_review?: number; + /** + * Number of rejected creatives + */ + rejected?: number; + /** + * Number of archived creatives + */ + archived?: number; + }; + /** + * Task-specific errors (e.g., invalid filters, account not found) + */ + errors?: Error[]; + /** + * When true, this response contains simulated data from sandbox mode. + */ + sandbox?: boolean; + context?: ContextObject; + ext?: ExtensionObject; +} +/** + * A dynamic content variable (DCO slot) on a creative. Variables represent content that can change at serve time — headlines, images, product data, etc. + */ +export interface CreativeVariable { + /** + * Variable identifier on the creative platform + */ + variable_id: string; + /** + * Human-readable variable name + */ + name: string; + /** + * Data type of the variable. Each type represents a semantic content slot: text (headlines, body copy), image/video/audio (media URLs), url (clickthrough or tracking URLs), number (prices, counts), boolean (conditional flags like show_discount or is_raining), color (hex color values), date (ISO 8601 date-time for countdowns and offer expirations). + */ + variable_type: 'text' | 'image' | 'video' | 'audio' | 'url' | 'number' | 'boolean' | 'color' | 'date'; + /** + * Default value used when no dynamic value is provided at serve time. All types are string-encoded: text/image/video/audio/url as literal strings, number as decimal (e.g., "42.99"), boolean as "true"/"false", color as "#RRGGBB", date as ISO 8601 (e.g., "2026-12-25T00:00:00Z"). + */ + default_value?: string; + /** + * Whether this variable must have a value for the creative to serve + */ + required?: boolean; +} + +// bundled/creative/preview-creative-request.json +/** + * Request to generate previews of creative manifests. Uses request_type to select single, batch, or variant mode. + */ +export type PreviewCreativeRequest = { + [k: string]: unknown | undefined; +} & { + /** + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + */ + adcp_major_version?: number; + /** + * Preview mode. 'single' previews one creative manifest. 'batch' previews multiple creatives in one call. 'variant' replays a post-flight variant by ID. + */ + request_type: 'single' | 'batch' | 'variant'; + creative_manifest?: CreativeManifest; + format_id?: FormatID; + /** + * Array of input sets for generating multiple preview variants. Each input set defines macros and context values for one preview rendering. Used in single mode. + */ + inputs?: { + /** + * Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad', 'Desktop dark mode') + */ + name: string; + /** + * Macro values to use for this preview. Supports all universal macros from the format's supported_macros list. + */ + macros?: { + [k: string]: string | undefined; + }; + /** + * Natural language description of the context for AI-generated content (e.g., 'User just searched for running shoes', 'Podcast discussing weather patterns') + */ + context_description?: string; + }[]; + /** + * Specific template ID for custom format rendering. Used in single mode. + */ + template_id?: string; + quality?: CreativeQuality; + output_format?: PreviewOutputFormat; + /** + * Maximum number of catalog items to render per preview variant. Used in single mode. Creative agents SHOULD default to a reasonable sample when omitted and the catalog is large. + */ + item_limit?: number; + /** + * Array of preview requests (1-50 items). Required when request_type is 'batch'. Each item follows the single request structure. + */ + requests?: { + format_id?: FormatID; + creative_manifest: CreativeManifest; + /** + * Array of input sets for generating multiple preview variants + */ + inputs?: { + /** + * Human-readable name for this input set + */ + name: string; + /** + * Macro values to use for this preview + */ + macros?: { + [k: string]: string | undefined; + }; + /** + * Natural language description of the context for AI-generated content + */ + context_description?: string; + }[]; + /** + * Specific template ID for custom format rendering + */ + template_id?: string; + quality?: CreativeQuality; + output_format?: PreviewOutputFormat; + /** + * Maximum number of catalog items to render in this preview. + */ + item_limit?: number; + }[]; + /** + * Platform-assigned variant identifier from get_creative_delivery response. Required when request_type is 'variant'. + */ + variant_id?: string; + /** + * Creative identifier for context. Used in variant mode. + */ + creative_id?: string; + context?: ContextObject; + ext?: ExtensionObject; +}; +/** + * Render quality. 'draft' produces fast, lower-fidelity renderings. 'production' produces full-quality renderings. In batch mode, sets the default for all requests (individual items can override). + */ +export type CreativeQuality = 'draft' | 'production'; +/** + * Output format. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML). In batch mode, sets the default for all requests (individual items can override). Default: 'url'. + */ +export type PreviewOutputFormat = 'url' | 'html'; + +// bundled/creative/preview-creative-response.json +/** + * Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests. + */ +export type PreviewCreativeResponse = + | PreviewCreativeSingleResponse + | PreviewCreativeBatchResponse + | PreviewCreativeVariantResponse; +/** + * Single preview response - each preview URL returns an HTML page that can be embedded in an iframe + */ +export interface PreviewCreativeSingleResponse { + /** + * Discriminator indicating this is a single preview response + */ + response_type: 'single'; + /** + * Array of preview variants. Each preview corresponds to an input set from the request. If no inputs were provided, returns a single default preview. + */ + previews: { + /** + * Unique identifier for this preview variant + */ + preview_id: string; + /** + * Array of rendered pieces for this preview variant. Most formats render as a single piece. Companion ad formats (video + banner), multi-placement formats, and adaptive formats render as multiple pieces. + */ + renders: PreviewRender[]; + /** + * The input parameters that generated this preview variant. Echoes back the request input or shows defaults used. + */ + input: { + /** + * Human-readable name for this variant + */ + name: string; + /** + * Macro values applied to this variant + */ + macros?: { + [k: string]: string | undefined; + }; + /** + * Context description applied to this variant + */ + context_description?: string; + }; + }[]; + /** + * Optional URL to an interactive testing page that shows all preview variants with controls to switch between them, modify macro values, and test different scenarios. + */ + interactive_url?: string; + /** + * ISO 8601 timestamp when preview links expire + */ + expires_at: string; + context?: ContextObject; + ext?: ExtensionObject; +} +/** + * Batch preview response - contains results for multiple creative requests + */ +export interface PreviewCreativeBatchResponse { + /** + * Discriminator indicating this is a batch preview response + */ + response_type: 'batch'; + /** + * Array of preview results corresponding to each request in the same order. results[0] is the result for requests[0], results[1] for requests[1], etc. Order is guaranteed even when some requests fail. Each result contains either a successful preview response or an error. + */ + results: (PreviewBatchResultSuccess | PreviewBatchResultError)[]; + context?: ContextObject; + ext?: ExtensionObject; +} +export interface PreviewBatchResultSuccess { + /** + * Indicates this preview request succeeded + */ + success?: true; +} +export interface PreviewBatchResultError { + /** + * Indicates this preview request failed + */ + success?: false; +} +/** + * Variant preview response - shows what a specific creative variant looked like when served during delivery + */ +export interface PreviewCreativeVariantResponse { + /** + * Discriminator indicating this is a variant preview response + */ + response_type: 'variant'; + /** + * Platform-assigned variant identifier + */ + variant_id: string; + /** + * Creative identifier this variant belongs to + */ + creative_id?: string; + /** + * Array of rendered pieces for this variant. Most formats render as a single piece. + */ + previews: { + /** + * Unique identifier for this preview + */ + preview_id: string; + /** + * Rendered pieces for this variant + */ + renders: PreviewRender[]; + }[]; + manifest?: CreativeManifest; + /** + * ISO 8601 timestamp when preview links expire + */ + expires_at?: string; + context?: ContextObject; + ext?: ExtensionObject; +} +/** + * Validation strictness. 'strict' fails entire sync on any validation error. 'lenient' processes valid creatives and reports errors. + */ +export type ValidationMode = 'strict' | 'lenient'; +/** + * Request parameters for syncing creative assets with upsert semantics - supports bulk operations, scoped updates, and assignment management + */ +export interface SyncCreativesRequest { + /** + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + */ + adcp_major_version?: number; + account: AccountReference; + /** + * Array of creative assets to sync (create or update) + */ + creatives: CreativeAsset[]; + /** + * Optional filter to limit sync scope to specific creative IDs. When provided, only these creatives will be created/updated. Other creatives in the library are unaffected. Useful for partial updates and error recovery. + */ + creative_ids?: string[]; + /** + * Optional bulk assignment of creatives to packages. Each entry maps one creative to one package with optional weight and placement targeting. Standalone creative agents that do not manage media buys ignore this field. + */ + assignments?: { + /** + * ID of the creative to assign + */ + creative_id: string; + /** + * ID of the package to assign the creative to + */ + package_id: string; + /** + * Relative delivery weight (0-100). When multiple creatives are assigned to the same package, weights determine impression distribution proportionally. When omitted, the creative receives equal rotation with other unweighted creatives. A weight of 0 means the creative is assigned but paused (receives no delivery). + */ + weight?: number; + /** + * Restrict this creative to specific placements within the package. When omitted, the creative is eligible for all placements. + */ + placement_ids?: string[]; + }[]; + /** + * Client-generated idempotency key for safe retries. If a sync fails without a response, resending with the same idempotency_key guarantees at-most-once execution. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request. + */ + idempotency_key: string; + /** + * When true, creatives not included in this sync will be archived. Use with caution for full library replacement. Invalid when creative_ids is provided — delete_missing applies to the entire library scope, not a filtered subset. + */ + delete_missing?: boolean; + /** + * When true, preview changes without applying them. Returns what would be created/updated/deleted. + */ + dry_run?: boolean; + validation_mode?: ValidationMode; + push_notification_config?: PushNotificationConfig; + context?: ContextObject; + ext?: ExtensionObject; +} + +// bundled/media-buy/build-creative-request.json +/** + * Request to transform, generate, or retrieve a creative manifest. Supports three modes: (1) generation from a brief or seed assets, (2) transformation of an existing manifest, (3) retrieval from a creative library by creative_id. Produces target manifest(s) in the specified format(s). Provide either target_format_id for a single format or target_format_ids for multiple formats. + */ +export interface BuildCreativeRequest { + /** + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + */ + adcp_major_version?: number; + /** + * Natural language instructions for the transformation or generation. For pure generation, this is the creative brief. For transformation, this provides guidance on how to adapt the creative. For refinement, this describes the desired changes. + */ + message?: string; + creative_manifest?: CreativeManifest; + /** + * Reference to a creative in the agent's library. The creative agent resolves this to a manifest from its library. Use this instead of creative_manifest when retrieving an existing creative for tag generation or format adaptation. + */ + creative_id?: string; + /** + * Creative concept containing the creative. Creative agents SHOULD assign globally unique creative_id values; when they cannot guarantee uniqueness, concept_id is REQUIRED to disambiguate. + */ + concept_id?: string; + /** + * Media buy identifier for tag generation context. When the creative agent is also the ad server, this provides the trafficking context needed to generate placement-specific tags (e.g., CM360 placement ID). Not needed when tags are generated at the creative level (most creative platforms). + */ + media_buy_id?: string; + /** + * Package identifier within the media buy. Used with media_buy_id when the creative agent needs line-item-level context for tag generation. Omit to get a tag not scoped to a specific package. + */ + package_id?: string; + target_format_id?: FormatID; + /** + * Array of format IDs to generate in a single call. Mutually exclusive with target_format_id. The creative agent produces one manifest per format. Each format definition specifies its own required input assets and output structure. + */ + target_format_ids?: FormatID[]; + account?: AccountReference; + brand?: BrandReference; + quality?: CreativeQuality; + /** + * Maximum number of catalog items to use when generating. When a catalog asset contains more items than this limit, the creative agent selects the top items based on relevance or catalog ordering. When item_limit exceeds the format's max_items, the creative agent SHOULD use the lesser of the two. Ignored when the manifest contains no catalog assets. + */ + item_limit?: number; + /** + * When true, requests the creative agent to include preview renders in the response alongside the manifest. Agents that support this return a 'preview' object in the response using the same structure as preview_creative. Agents that do not support inline preview simply omit the field. This avoids a separate preview_creative round trip for platforms that generate previews as a byproduct of building. + */ + include_preview?: boolean; + /** + * Input sets for preview generation when include_preview is true. Each input set defines macros and context values for one preview variant. If include_preview is true but this is omitted, the agent generates a single default preview. Only supported with target_format_id (single-format requests). Ignored when using target_format_ids — multi-format requests generate one default preview per format. Ignored when include_preview is false or omitted. + */ + preview_inputs?: { + /** + * Human-readable name for this input set (e.g., 'Sunny morning on mobile', 'Evening podcast ad') + */ + name: string; + /** + * Macro values to use for this preview variant + */ + macros?: { + [k: string]: string | undefined; + }; + /** + * Natural language description of the context for AI-generated content */ context_description?: string; }[]; @@ -12571,1237 +13337,857 @@ export interface GetSignalsResponse { description: string; value_type?: SignalValueType; /** - * Valid values for categorical signals. Present when value_type is 'categorical'. Buyers must use one of these values in SignalTargeting.values. - */ - categories?: string[]; - /** - * Valid range for numeric signals. Present when value_type is 'numeric'. - */ - range?: { - /** - * Minimum value (inclusive) - */ - min: number; - /** - * Maximum value (inclusive) - */ - max: number; - }; - signal_type: SignalCatalogType; - /** - * Human-readable name of the data provider - */ - data_provider: string; - /** - * Percentage of audience coverage - */ - coverage_percentage: number; - /** - * Array of deployment targets - */ - deployments: Deployment[]; - /** - * Pricing options available for this signal. The buyer selects one and passes its pricing_option_id in report_usage for billing verification. - */ - pricing_options: VendorPricingOption[]; - }[]; - /** - * Task-specific errors and warnings (e.g., signal discovery or pricing issues) - */ - errors?: Error[]; - pagination?: PaginationResponse; - /** - * When true, this response contains simulated data from sandbox mode. - */ - sandbox?: boolean; - context?: ContextObject; - ext?: ExtensionObject; -} - -// bundled/sponsored-intelligence/si-get-offering-request.json -/** - * Get offering details and availability before session handoff. Returns offering information, availability status, and optionally matching products based on context. - */ -export interface SIGetOfferingRequest { - /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. - */ - adcp_major_version?: number; - /** - * Offering identifier from the catalog to get details for - */ - offering_id: string; - /** - * Optional natural language context about user intent for personalized results (e.g., 'mens size 14 near Cincinnati'). Must be anonymous - no PII. - */ - context?: string; - /** - * Whether to include matching products in the response - */ - include_products?: boolean; - /** - * Maximum number of matching products to return - */ - product_limit?: number; - ext?: ExtensionObject; -} - -// bundled/sponsored-intelligence/si-get-offering-response.json -/** - * Offering details, availability status, and optionally matching products. Use the offering_token in si_initiate_session for correlation. - */ -export interface SIGetOfferingResponse { - /** - * Whether the offering is currently available - */ - available: boolean; - /** - * Token to pass to si_initiate_session for session continuity. Brand stores the full query context server-side (products shown, order, context) so they can resolve references like 'the second one' when the session starts. - */ - offering_token?: string; - /** - * How long this offering information is valid (seconds). Host should re-fetch after TTL expires. - */ - ttl_seconds?: number; - /** - * When this offering information was retrieved - */ - checked_at?: string; - /** - * Offering details - */ - offering?: { - /** - * Offering identifier - */ - offering_id?: string; - /** - * Offering title - */ - title?: string; - /** - * Brief summary of the offering - */ - summary?: string; - /** - * Short promotional tagline - */ - tagline?: string; - /** - * When this offering expires - */ - expires_at?: string; - /** - * Price indication (e.g., 'from $199', '50% off') - */ - price_hint?: string; - /** - * Hero image for the offering - */ - image_url?: string; - /** - * Landing page URL - */ - landing_url?: string; - }; - /** - * Products matching the request context. Only included if include_products was true. - */ - matching_products?: { - /** - * Product identifier - */ - product_id: string; - /** - * Product name - */ - name: string; - /** - * Display price (e.g., '$129', '$89.99') - */ - price?: string; - /** - * Original price if on sale - */ - original_price?: string; - /** - * Product image - */ - image_url?: string; - /** - * Brief availability info (e.g., 'In stock', 'Size 14 available', '3 left') - */ - availability_summary?: string; - /** - * Product detail page URL - */ - url?: string; - }[]; - /** - * Total number of products matching the context (may be more than returned in matching_products) - */ - total_matching?: number; - /** - * If not available, why (e.g., 'expired', 'sold_out', 'region_restricted') - */ - unavailable_reason?: string; - /** - * Alternative offerings to consider if this one is unavailable - */ - alternative_offering_ids?: string[]; - /** - * Errors during offering lookup - */ - errors?: Error[]; - context?: ContextObject; - ext?: ExtensionObject; -} - -// bundled/sponsored-intelligence/si-initiate-session-request.json -/** - * Host initiates a session with a brand agent - */ -export interface SIInitiateSessionRequest { - /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. - */ - adcp_major_version?: number; - /** - * Conversation handoff from the host describing what the user needs - */ - context: string; - identity: SIIdentity; - /** - * AdCP media buy ID if session was triggered by advertising - */ - media_buy_id?: string; - /** - * Where this session was triggered (e.g., 'chatgpt_search', 'claude_chat') - */ - placement?: string; - /** - * Brand-specific offering identifier to apply - */ - offering_id?: string; - supported_capabilities?: SICapabilities; - /** - * Token from si_get_offering response for session continuity. Brand uses this to recall what products were shown to the user, enabling natural references like 'the second one' or 'that blue shoe'. - */ - offering_token?: string; - /** - * Client-generated unique key for this request. Prevents duplicate session creation on retries. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request. - */ - idempotency_key: string; - ext?: ExtensionObject; -} -/** - * User identity shared with brand agent (with explicit consent) - */ -export interface SIIdentity { - /** - * Whether user consented to share identity - */ - consent_granted: boolean; - /** - * When consent was granted (ISO 8601) - */ - consent_timestamp?: string; - /** - * What data was consented to share - */ - consent_scope?: ('name' | 'email' | 'shipping_address' | 'phone' | 'locale')[]; - /** - * Brand privacy policy acknowledgment - */ - privacy_policy_acknowledged?: { - /** - * URL to brand's privacy policy - */ - brand_policy_url?: string; - /** - * Version of policy acknowledged - */ - brand_policy_version?: string; - }; - /** - * User data (only present if consent_granted is true) - */ - user?: { + * Valid values for categorical signals. Present when value_type is 'categorical'. Buyers must use one of these values in SignalTargeting.values. + */ + categories?: string[]; /** - * User's email address + * Valid range for numeric signals. Present when value_type is 'numeric'. */ - email?: string; + range?: { + /** + * Minimum value (inclusive) + */ + min: number; + /** + * Maximum value (inclusive) + */ + max: number; + }; + signal_type: SignalCatalogType; /** - * User's display name + * Human-readable name of the data provider */ - name?: string; + data_provider: string; /** - * User's locale (e.g., en-US) + * Percentage of audience coverage */ - locale?: string; + coverage_percentage: number; /** - * User's phone number + * Array of deployment targets */ - phone?: string; + deployments: Deployment[]; /** - * User's shipping address for accurate pricing + * Pricing options available for this signal. The buyer selects one and passes its pricing_option_id in report_usage for billing verification. */ - shipping_address?: { - street?: string; - city?: string; - state?: string; - postal_code?: string; - country?: string; - }; - }; + pricing_options: VendorPricingOption[]; + }[]; /** - * Session ID for anonymous users (when consent_granted is false) + * Task-specific errors and warnings (e.g., signal discovery or pricing issues) */ - anonymous_session_id?: string; + errors?: Error[]; + pagination?: PaginationResponse; + /** + * When true, this response contains simulated data from sandbox mode. + */ + sandbox?: boolean; + context?: ContextObject; + ext?: ExtensionObject; } -// bundled/sponsored-intelligence/si-initiate-session-response.json -/** - * Standard visual component that brand returns and host renders - */ -export type SIUIElement = { - [k: string]: unknown | undefined; -}; -/** - * Current session lifecycle state. Returned in initiation, message, and termination responses. - */ -export type SISessionStatus = 'active' | 'pending_handoff' | 'complete' | 'terminated'; - +// bundled/sponsored-intelligence/si-get-offering-request.json /** - * Brand agent's response to session initiation + * Get offering details and availability before session handoff. Returns offering information, availability status, and optionally matching products based on context. */ -export interface SIInitiateSessionResponse { +export interface SIGetOfferingRequest { /** - * Unique session identifier for subsequent messages + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - session_id: string; + adcp_major_version?: number; /** - * Brand agent's initial response + * Offering identifier from the catalog to get details for */ - response?: { - /** - * Conversational message from brand agent - */ - message?: string; - /** - * Visual components to render - */ - ui_elements?: SIUIElement[]; - }; - negotiated_capabilities?: SICapabilities; - session_status: SISessionStatus; + offering_id: string; /** - * Session inactivity timeout in seconds. After this duration without a message, the brand agent may terminate the session. Hosts SHOULD warn users before timeout when possible. + * Optional natural language context about user intent for personalized results (e.g., 'mens size 14 near Cincinnati'). Must be anonymous - no PII. */ - session_ttl_seconds?: number; + context?: string; /** - * Errors during session initiation + * Whether to include matching products in the response */ - errors?: Error[]; - context?: ContextObject; + include_products?: boolean; + /** + * Maximum number of matching products to return + */ + product_limit?: number; ext?: ExtensionObject; } -// bundled/sponsored-intelligence/si-send-message-request.json +// bundled/sponsored-intelligence/si-get-offering-response.json /** - * Send a message to the brand agent within an active session + * Offering details, availability status, and optionally matching products. Use the offering_token in si_initiate_session for correlation. */ -export type SISendMessageRequest = { - [k: string]: unknown | undefined; -} & { +export interface SIGetOfferingResponse { /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + * Whether the offering is currently available */ - adcp_major_version?: number; + available: boolean; /** - * Client-generated unique key for at-most-once execution. Each conversational turn is a distinct mutation of session transcript — without this key, a timeout-and-retry produces a duplicate turn and a duplicate model response. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each user turn. + * Token to pass to si_initiate_session for session continuity. Brand stores the full query context server-side (products shown, order, context) so they can resolve references like 'the second one' when the session starts. */ - idempotency_key: string; + offering_token?: string; /** - * Active session identifier + * How long this offering information is valid (seconds). Host should re-fetch after TTL expires. */ - session_id: string; + ttl_seconds?: number; /** - * User's message to the brand agent + * When this offering information was retrieved */ - message?: string; + checked_at?: string; /** - * Response to a previous action_button (e.g., user clicked checkout) + * Offering details */ - action_response?: { + offering?: { /** - * The action that was triggered + * Offering identifier */ - action?: string; + offering_id?: string; /** - * Action-specific response data + * Offering title */ - payload?: {}; - }; - context?: ContextObject; - ext?: ExtensionObject; -}; - - -// bundled/sponsored-intelligence/si-send-message-response.json -/** - * Brand agent's response to a user message - */ -export interface SISendMessageResponse { - /** - * Session identifier - */ - session_id: string; - /** - * Brand agent's response - */ - response?: { + title?: string; /** - * Conversational message from brand agent + * Brief summary of the offering */ - message?: string; - surface?: A2UISurface; + summary?: string; /** - * @deprecated - * Visual components to render (DEPRECATED: use surface instead) + * Short promotional tagline */ - ui_elements?: SIUIElement[]; - }; - /** - * MCP resource URI for hosts with MCP Apps support (e.g., ui://si/session-abc123) - */ - mcp_resource_uri?: string; - session_status: SISessionStatus; - /** - * Handoff request when session_status is pending_handoff - */ - handoff?: { + tagline?: string; /** - * Type of handoff: transaction (ready for ACP checkout) or complete (conversation done) + * When this offering expires */ - type?: 'transaction' | 'complete'; + expires_at?: string; /** - * For transaction handoffs: what the user wants to purchase + * Price indication (e.g., 'from $199', '50% off') */ - intent?: { - /** - * The commerce action (e.g., 'purchase') - */ - action?: string; - /** - * Product details for checkout - */ - product?: {}; - /** - * Price information - */ - price?: { - amount?: number; - currency?: string; - }; - }; + price_hint?: string; /** - * Context to pass to ACP for seamless checkout + * Hero image for the offering */ - context_for_checkout?: { - /** - * Summary of the conversation leading to purchase - */ - conversation_summary?: string; - /** - * Offer IDs that were applied during the conversation - */ - applied_offers?: string[]; - }; + image_url?: string; + /** + * Landing page URL + */ + landing_url?: string; }; - errors?: Error[]; - context?: ContextObject; - ext?: ExtensionObject; -} - -// bundled/sponsored-intelligence/si-terminate-session-request.json -/** - * Request to terminate an SI session. Naturally idempotent — `session_id` is the dedup boundary, and terminating an already-terminated session is a no-op that returns the same terminal state. No `idempotency_key` is needed on this request. - */ -export interface SITerminateSessionRequest { - /** - * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. - */ - adcp_major_version?: number; - /** - * Session identifier to terminate - */ - session_id: string; - /** - * Reason for termination - */ - reason: 'handoff_transaction' | 'handoff_complete' | 'user_exit' | 'session_timeout' | 'host_terminated'; /** - * Context for the termination + * Products matching the request context. Only included if include_products was true. */ - termination_context?: { + matching_products?: { + /** + * Product identifier + */ + product_id: string; + /** + * Product name + */ + name: string; + /** + * Display price (e.g., '$129', '$89.99') + */ + price?: string; + /** + * Original price if on sale + */ + original_price?: string; /** - * Summary of the conversation + * Product image */ - summary?: string; + image_url?: string; /** - * For handoff_transaction - what user wants to buy + * Brief availability info (e.g., 'In stock', 'Size 14 available', '3 left') */ - transaction_intent?: { - action?: 'purchase' | 'subscribe'; - /** - * Product/service details - */ - product?: {}; - }; + availability_summary?: string; /** - * For host_terminated - why host ended session + * Product detail page URL */ - cause?: string; - }; - context?: ContextObject; - ext?: ExtensionObject; -} - -// bundled/sponsored-intelligence/si-terminate-session-response.json -/** - * Confirmation of session termination - */ -export interface SITerminateSessionResponse { + url?: string; + }[]; /** - * Terminated session identifier + * Total number of products matching the context (may be more than returned in matching_products) */ - session_id: string; + total_matching?: number; /** - * Whether session was successfully terminated + * If not available, why (e.g., 'expired', 'sold_out', 'region_restricted') */ - terminated: boolean; - session_status?: SISessionStatus; + unavailable_reason?: string; /** - * ACP checkout handoff data. Present when reason is handoff_transaction. + * Alternative offerings to consider if this one is unavailable */ - acp_handoff?: { - /** - * Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS before opening. - */ - checkout_url?: string; - /** - * Opaque token for the checkout flow. The host passes this to the checkout endpoint to correlate the SI session with the transaction. - */ - checkout_token?: string; - /** - * Rich checkout context to pass to the ACP endpoint (product details, applied offers, pricing). Alternative to checkout_token for integrations that need structured data. - */ - payload?: {}; - /** - * When this handoff data expires. Hosts should initiate checkout before this time. - */ - expires_at?: string; - }; + alternative_offering_ids?: string[]; /** - * Suggested follow-up actions + * Errors during offering lookup */ - follow_up?: { - action?: 'save_for_later' | 'set_reminder' | 'subscribe_updates' | 'none'; - /** - * Data for follow-up action - */ - data?: {}; - }; errors?: Error[]; context?: ContextObject; ext?: ExtensionObject; } -// collection/base-collection-source.json -/** - * A source of collections for a collection list. Supports three selection patterns: distribution identifiers (cross-publisher), publisher-specific collection IDs, or publisher-specific genres. - */ -export type BaseCollectionSource = DistributionIDsSource | PublisherCollectionsSource | PublisherGenresSource; -/** - * Type of distribution identifier - */ -export type DistributionIdentifierType = - | 'apple_podcast_id' - | 'spotify_collection_id' - | 'rss_url' - | 'podcast_guid' - | 'amazon_music_id' - | 'iheart_id' - | 'podcast_index_id' - | 'youtube_channel_id' - | 'youtube_playlist_id' - | 'amazon_title_id' - | 'roku_channel_id' - | 'pluto_channel_id' - | 'tubi_id' - | 'peacock_id' - | 'tiktok_id' - | 'twitch_channel' - | 'imdb_id' - | 'gracenote_id' - | 'eidr_id' - | 'domain' - | 'substack_id'; -/** - * Taxonomy for the genre values. Required so sellers can interpret genre strings unambiguously. Use 'custom' for free-form values negotiated out of band. - */ -export type GenreTaxonomy = - | 'iab_content_3.0' - | 'iab_content_2.2' - | 'gracenote' - | 'eidr' - | 'apple_genres' - | 'google_genres' - | 'roku' - | 'amazon_genres' - | 'custom'; - +// bundled/sponsored-intelligence/si-initiate-session-request.json /** - * Select collections by platform-independent distribution identifiers. The primary mechanism for cross-publisher collection matching. + * Host initiates a session with a brand agent */ -export interface DistributionIDsSource { - /** - * Discriminator indicating selection by platform-independent distribution identifiers - */ - selection_type: 'distribution_ids'; +export interface SIInitiateSessionRequest { /** - * Platform-independent identifiers (imdb_id, gracenote_id, eidr_id, etc.). Each identifier uniquely identifies a collection across all publishers. + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - identifiers: { - type: DistributionIdentifierType; - /** - * The identifier value - */ - value: string; - }[]; -} -/** - * Select specific collections within a publisher's adagents.json by collection ID - */ -export interface PublisherCollectionsSource { + adcp_major_version?: number; /** - * Discriminator indicating selection by specific collection IDs within a publisher + * Conversation handoff from the host describing what the user needs */ - selection_type: 'publisher_collections'; + context: string; + identity: SIIdentity; /** - * Domain where publisher's adagents.json is hosted + * AdCP media buy ID if session was triggered by advertising */ - publisher_domain: string; + media_buy_id?: string; /** - * Specific collection IDs from the publisher's adagents.json + * Where this session was triggered (e.g., 'chatgpt_search', 'claude_chat') */ - collection_ids: string[]; -} -/** - * Select all collections from a publisher matching genre criteria. Use when excluding entire content categories from a specific publisher. - */ -export interface PublisherGenresSource { + placement?: string; /** - * Discriminator indicating selection by genre within a publisher + * Brand-specific offering identifier to apply */ - selection_type: 'publisher_genres'; + offering_id?: string; + supported_capabilities?: SICapabilities; /** - * Domain where publisher's adagents.json is hosted + * Token from si_get_offering response for session continuity. Brand uses this to recall what products were shown to the user, enabling natural references like 'the second one' or 'that blue shoe'. */ - publisher_domain: string; + offering_token?: string; /** - * Genre values to match against the publisher's collections + * Client-generated unique key for this request. Prevents duplicate session creation on retries. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request. */ - genres: string[]; - genre_taxonomy: GenreTaxonomy; + idempotency_key: string; + ext?: ExtensionObject; } - - -// collection/collection-list-changed-webhook.json /** - * Webhook notification sent when a collection list's resolved collections change. Contains a summary only — recipients must call get_collection_list to retrieve the updated collections. + * User identity shared with brand agent (with explicit consent) */ -export interface CollectionListChangedWebhook { +export interface SIIdentity { /** - * Sender-generated key stable across retries of the same webhook event. Governance agents MUST generate a cryptographically random value (UUID v4 recommended) per distinct list-change event and reuse the same key on every retry. Recipients MUST dedupe by this key, scoped to the authenticated sender identity (HMAC secret or Bearer credential) — keys from different governance agents are independent. + * Whether user consented to share identity */ - idempotency_key: string; + consent_granted: boolean; /** - * The event type + * When consent was granted (ISO 8601) */ - event: 'collection_list_changed'; + consent_timestamp?: string; /** - * ID of the collection list that changed + * What data was consented to share */ - list_id: string; + consent_scope?: ('name' | 'email' | 'shipping_address' | 'phone' | 'locale')[]; /** - * Name of the collection list + * Brand privacy policy acknowledgment */ - list_name?: string; + privacy_policy_acknowledged?: { + /** + * URL to brand's privacy policy + */ + brand_policy_url?: string; + /** + * Version of policy acknowledged + */ + brand_policy_version?: string; + }; /** - * Summary of changes to the resolved list + * User data (only present if consent_granted is true) */ - change_summary?: { + user?: { /** - * Number of collections added since last resolution + * User's email address */ - collections_added?: number; + email?: string; /** - * Number of collections removed since last resolution + * User's display name */ - collections_removed?: number; + name?: string; /** - * Total collections in the resolved list + * User's locale (e.g., en-US) */ - total_collections?: number; + locale?: string; + /** + * User's phone number + */ + phone?: string; + /** + * User's shipping address for accurate pricing + */ + shipping_address?: { + street?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; + }; }; /** - * When the list was re-resolved - */ - resolved_at: string; - /** - * When the consumer should refresh from the governance agent - */ - cache_valid_until?: string; - /** - * HMAC-SHA256 webhook signature over {unix_timestamp}.{raw_http_body_bytes} using the secret exchanged out-of-band when the seller registered with the governance agent. Recipients MUST verify against the X-ADCP-Signature and X-ADCP-Timestamp headers using timing-safe comparison and MUST reject requests where |now - timestamp| > 300 seconds. The body copy of this field is a convenience only — the headers are authoritative. See docs/building/implementation/security#webhook-security. + * Session ID for anonymous users (when consent_granted is false) */ - signature: string; - ext?: ExtensionObject; + anonymous_session_id?: string; } -// collection/collection-list-filters.json +// bundled/sponsored-intelligence/si-initiate-session-response.json /** - * Production quality tier for collection content. Maps to OpenRTB content.prodq: professional=1, prosumer=2, ugc=3. Seller-declared — no external validation. + * Standard visual component that brand returns and host renders + */ +export type SIUIElement = { + [k: string]: unknown | undefined; +}; +/** + * Current session lifecycle state. Returned in initiation, message, and termination responses. */ -export type ProductionQuality = 'professional' | 'prosumer' | 'ugc'; +export type SISessionStatus = 'active' | 'pending_handoff' | 'complete' | 'terminated'; /** - * Filters that dynamically modify a collection list when resolved. Include filters are allowlists (only matching collections pass). Exclude filters are blocklists (matching collections are removed). When both are present for the same dimension, include is applied first, then exclude narrows further. + * Brand agent's response to session initiation */ -export interface CollectionListFilters { - /** - * Exclude collections with any of these content ratings (OR logic). This is a metadata filter on the collection's declared content_rating field — it does not evaluate episode content. - */ - content_ratings_exclude?: ContentRating[]; - /** - * Include only collections with any of these content ratings (OR logic). Collections without a declared content_rating are excluded. - */ - content_ratings_include?: ContentRating[]; - /** - * Exclude collections tagged with any of these genres (OR logic). Values are interpreted against genre_taxonomy when present. - */ - genres_exclude?: string[]; - /** - * Include only collections with any of these genres (OR logic). Collections without genre metadata are excluded. Values are interpreted against genre_taxonomy when present. - */ - genres_include?: string[]; - genre_taxonomy?: GenreTaxonomy; +export interface SIInitiateSessionResponse { /** - * Filter to these collection kinds + * Unique session identifier for subsequent messages */ - kinds?: ('series' | 'publication' | 'event_series' | 'rotation')[]; + session_id: string; /** - * Always exclude collections with these distribution identifiers + * Brand agent's initial response */ - exclude_distribution_ids?: { - type: DistributionIdentifierType; + response?: { /** - * The identifier value + * Conversational message from brand agent */ - value: string; - }[]; + message?: string; + /** + * Visual components to render + */ + ui_elements?: SIUIElement[]; + }; + negotiated_capabilities?: SICapabilities; + session_status: SISessionStatus; /** - * Filter by production quality tier + * Session inactivity timeout in seconds. After this duration without a message, the brand agent may terminate the session. Hosts SHOULD warn users before timeout when possible. */ - production_quality?: ProductionQuality[]; + session_ttl_seconds?: number; + /** + * Errors during session initiation + */ + errors?: Error[]; + context?: ContextObject; + ext?: ExtensionObject; } -// collection/collection-list.json +// bundled/sponsored-intelligence/si-send-message-request.json /** - * A managed collection list with optional filters for dynamic evaluation. Lists are resolved at setup time and cached by orchestrators/sellers for real-time use. Collections represent programs, shows, and other content entities independent of which properties carry them. + * Send a message to the brand agent within an active session */ -export interface CollectionList { +export type SISendMessageRequest = { + [k: string]: unknown | undefined; +} & { /** - * Unique identifier for this collection list + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - list_id: string; + adcp_major_version?: number; /** - * Human-readable name for the list + * Client-generated unique key for at-most-once execution. Each conversational turn is a distinct mutation of session transcript — without this key, a timeout-and-retry produces a duplicate turn and a duplicate model response. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each user turn. */ - name: string; + idempotency_key: string; /** - * Description of the list's purpose + * Active session identifier */ - description?: string; - account?: AccountReference; + session_id: string; /** - * Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database. + * User's message to the brand agent */ - base_collections?: BaseCollectionSource[]; - filters?: CollectionListFilters; - brand?: BrandReference; + message?: string; /** - * URL to receive notifications when the resolved list changes + * Response to a previous action_button (e.g., user clicked checkout) */ - webhook_url?: string; + action_response?: { + /** + * The action that was triggered + */ + action?: string; + /** + * Action-specific response data + */ + payload?: {}; + }; + context?: ContextObject; + ext?: ExtensionObject; +}; + + +// bundled/sponsored-intelligence/si-send-message-response.json +/** + * Brand agent's response to a user message + */ +export interface SISendMessageResponse { /** - * Recommended cache duration for resolved list. Consumers should re-fetch after this period. Defaults to 168 (one week) because collection metadata changes less frequently than property metadata. + * Session identifier */ - cache_duration_hours?: number; + session_id: string; /** - * When the list was created + * Brand agent's response */ - created_at?: string; + response?: { + /** + * Conversational message from brand agent + */ + message?: string; + surface?: A2UISurface; + /** + * @deprecated + * Visual components to render (DEPRECATED: use surface instead) + */ + ui_elements?: SIUIElement[]; + }; /** - * When the list was last modified + * MCP resource URI for hosts with MCP Apps support (e.g., ui://si/session-abc123) */ - updated_at?: string; + mcp_resource_uri?: string; + session_status: SISessionStatus; /** - * Number of collections in the resolved list (at time of last resolution) + * Handoff request when session_status is pending_handoff */ - collection_count?: number; -} - -// content-standards/artifact-webhook-payload.json -/** - * Authentication for secured URLs - */ -export type AssetAccess = - | { - method: 'bearer_token'; + handoff?: { + /** + * Type of handoff: transaction (ready for ACP checkout) or complete (conversation done) + */ + type?: 'transaction' | 'complete'; + /** + * For transaction handoffs: what the user wants to purchase + */ + intent?: { /** - * OAuth2 bearer token for Authorization header + * The commerce action (e.g., 'purchase') */ - token: string; - } - | { - method: 'service_account'; + action?: string; /** - * Cloud provider + * Product details for checkout */ - provider: 'gcp' | 'aws'; + product?: {}; /** - * Service account credentials + * Price information */ - credentials?: {}; - } - | { - method: 'signed_url'; + price?: { + amount?: number; + currency?: string; + }; + }; + /** + * Context to pass to ACP for seamless checkout + */ + context_for_checkout?: { + /** + * Summary of the conversation leading to purchase + */ + conversation_summary?: string; + /** + * Offer IDs that were applied during the conversation + */ + applied_offers?: string[]; }; + }; + errors?: Error[]; + context?: ContextObject; + ext?: ExtensionObject; +} + +// bundled/sponsored-intelligence/si-terminate-session-request.json /** - * Payload sent by sales agents to orchestrators when pushing content artifacts for governance validation. Complements get_media_buy_artifacts for push-based artifact delivery. + * Request to terminate an SI session. Naturally idempotent — `session_id` is the dedup boundary, and terminating an already-terminated session is a no-op that returns the same terminal state. No `idempotency_key` is needed on this request. */ -export interface ArtifactWebhookPayload { - /** - * Sender-generated key stable across retries of the same webhook event. Sales agents MUST generate a cryptographically random value (UUID v4 recommended) per distinct emission of a batch and reuse the same key on every retry. Recipients MUST dedupe by this key, scoped to the authenticated sender identity (HMAC secret or Bearer credential) — keys from different sales agents are independent. Distinct from `batch_id`, which identifies the logical batch: `idempotency_key` identifies this specific emission event, so a re-emission of the same `batch_id` (e.g., after a correction) is a different event and MUST carry a fresh `idempotency_key`. - */ - idempotency_key: string; +export interface SITerminateSessionRequest { /** - * Media buy identifier these artifacts belong to + * The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. */ - media_buy_id: string; + adcp_major_version?: number; /** - * Unique identifier for this batch of artifacts. Use for deduplication and acknowledgment. + * Session identifier to terminate */ - batch_id: string; + session_id: string; /** - * When this batch was generated (ISO 8601) + * Reason for termination */ - timestamp: string; + reason: 'handoff_transaction' | 'handoff_complete' | 'user_exit' | 'session_timeout' | 'host_terminated'; /** - * Content artifacts from delivered impressions + * Context for the termination */ - artifacts: { - artifact: Artifact; + termination_context?: { /** - * When the impression was delivered (ISO 8601) + * Summary of the conversation */ - delivered_at: string; + summary?: string; + /** + * For handoff_transaction - what user wants to buy + */ + transaction_intent?: { + action?: 'purchase' | 'subscribe'; + /** + * Product/service details + */ + product?: {}; + }; + /** + * For host_terminated - why host ended session + */ + cause?: string; + }; + context?: ContextObject; + ext?: ExtensionObject; +} + +// bundled/sponsored-intelligence/si-terminate-session-response.json +/** + * Confirmation of session termination + */ +export interface SITerminateSessionResponse { + /** + * Terminated session identifier + */ + session_id: string; + /** + * Whether session was successfully terminated + */ + terminated: boolean; + session_status?: SISessionStatus; + /** + * ACP checkout handoff data. Present when reason is handoff_transaction. + */ + acp_handoff?: { /** - * Optional impression identifier for correlation with delivery reports + * Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS before opening. */ - impression_id?: string; + checkout_url?: string; /** - * Package within the media buy this artifact relates to + * Opaque token for the checkout flow. The host passes this to the checkout endpoint to correlate the SI session with the transaction. */ - package_id?: string; - }[]; - /** - * Pagination info when batching large artifact sets - */ - pagination?: { + checkout_token?: string; /** - * Total artifacts in the delivery period + * Rich checkout context to pass to the ACP endpoint (product details, applied offers, pricing). Alternative to checkout_token for integrations that need structured data. */ - total_artifacts?: number; + payload?: {}; /** - * Current batch number (1-indexed) + * When this handoff data expires. Hosts should initiate checkout before this time. */ - batch_number?: number; + expires_at?: string; + }; + /** + * Suggested follow-up actions + */ + follow_up?: { + action?: 'save_for_later' | 'set_reminder' | 'subscribe_updates' | 'none'; /** - * Total batches for this delivery period + * Data for follow-up action */ - total_batches?: number; + data?: {}; }; + errors?: Error[]; + context?: ContextObject; ext?: ExtensionObject; } + +// collection/base-collection-source.json /** - * The content artifact + * A source of collections for a collection list. Supports three selection patterns: distribution identifiers (cross-publisher), publisher-specific collection IDs, or publisher-specific genres. */ -export interface Artifact { +export type BaseCollectionSource = DistributionIDsSource | PublisherCollectionsSource | PublisherGenresSource; +/** + * Type of distribution identifier + */ +export type DistributionIdentifierType = + | 'apple_podcast_id' + | 'spotify_collection_id' + | 'rss_url' + | 'podcast_guid' + | 'amazon_music_id' + | 'iheart_id' + | 'podcast_index_id' + | 'youtube_channel_id' + | 'youtube_playlist_id' + | 'amazon_title_id' + | 'roku_channel_id' + | 'pluto_channel_id' + | 'tubi_id' + | 'peacock_id' + | 'tiktok_id' + | 'twitch_channel' + | 'imdb_id' + | 'gracenote_id' + | 'eidr_id' + | 'domain' + | 'substack_id'; +/** + * Taxonomy for the genre values. Required so sellers can interpret genre strings unambiguously. Use 'custom' for free-form values negotiated out of band. + */ +export type GenreTaxonomy = + | 'iab_content_3.0' + | 'iab_content_2.2' + | 'gracenote' + | 'eidr' + | 'apple_genres' + | 'google_genres' + | 'roku' + | 'amazon_genres' + | 'custom'; + +/** + * Select collections by platform-independent distribution identifiers. The primary mechanism for cross-publisher collection matching. + */ +export interface DistributionIDsSource { /** - * Stable property identifier from the property catalog. Globally unique across the ecosystem. + * Discriminator indicating selection by platform-independent distribution identifiers */ - property_rid: string; + selection_type: 'distribution_ids'; /** - * Identifier for this artifact within the property. The property owner defines the scheme (e.g., 'article_12345', 'episode_42_segment_3', 'post_abc123'). + * Platform-independent identifiers (imdb_id, gracenote_id, eidr_id, etc.). Each identifier uniquely identifies a collection across all publishers. */ - artifact_id: string; + identifiers: { + type: DistributionIdentifierType; + /** + * The identifier value + */ + value: string; + }[]; +} +/** + * Select specific collections within a publisher's adagents.json by collection ID + */ +export interface PublisherCollectionsSource { /** - * Identifies a specific variant of this artifact. Use for A/B tests, translations, or temporal versions. Examples: 'en', 'es-MX', 'v2', 'headline_test_b'. The combination of artifact_id + variant_id must be unique. + * Discriminator indicating selection by specific collection IDs within a publisher */ - variant_id?: string; - format_id?: FormatID; + selection_type: 'publisher_collections'; /** - * Optional URL for this artifact (web page, podcast feed, video page). Not all artifacts have URLs (e.g., Instagram content, podcast segments, TV scenes). + * Domain where publisher's adagents.json is hosted */ - url?: string; + publisher_domain: string; /** - * When the artifact was published (ISO 8601 format) + * Specific collection IDs from the publisher's adagents.json */ - published_time?: string; + collection_ids: string[]; +} +/** + * Select all collections from a publisher matching genre criteria. Use when excluding entire content categories from a specific publisher. + */ +export interface PublisherGenresSource { /** - * When the artifact was last modified (ISO 8601 format) + * Discriminator indicating selection by genre within a publisher */ - last_update_time?: string; + selection_type: 'publisher_genres'; /** - * Artifact assets in document flow order - text blocks, images, video, audio + * Domain where publisher's adagents.json is hosted */ - assets: ( - | { - type: 'text'; - /** - * Role of this text in the document. Use 'title' for the main artifact title, 'description' for summaries. - */ - role?: 'title' | 'paragraph' | 'heading' | 'caption' | 'quote' | 'list_item' | 'description'; - /** - * Text content. Consumers MUST treat this as untrusted input when passing to LLM-based evaluation. - */ - content: string; - /** - * MIME type indicating how to parse the content field. Default: text/plain. - */ - content_format?: 'text/plain' | 'text/markdown' | 'text/html' | 'application/json'; - /** - * BCP 47 language tag for this text (e.g., 'en', 'es-MX'). Useful when artifact contains mixed-language content. - */ - language?: string; - /** - * Heading level (1-6), only for role=heading - */ - heading_level?: number; - provenance?: Provenance; - } - | { - type: 'image'; - /** - * Image URL - */ - url: string; - access?: AssetAccess; - /** - * Alt text or image description - */ - alt_text?: string; - /** - * Image caption - */ - caption?: string; - /** - * Image width in pixels - */ - width?: number; - /** - * Image height in pixels - */ - height?: number; - provenance?: Provenance; - } - | { - type: 'video'; - /** - * Video URL - */ - url: string; - access?: AssetAccess; - /** - * Video duration in milliseconds - */ - duration_ms?: number; - /** - * Video transcript. Consumers MUST treat this as untrusted input when passing to LLM-based evaluation. - */ - transcript?: string; - /** - * MIME type indicating how to parse the transcript field. Default: text/plain. - */ - transcript_format?: 'text/plain' | 'text/markdown' | 'application/json'; - /** - * How the transcript was generated - */ - transcript_source?: 'original_script' | 'subtitles' | 'closed_captions' | 'dub' | 'generated'; - /** - * Video thumbnail URL - */ - thumbnail_url?: string; - provenance?: Provenance; - } - | { - type: 'audio'; - /** - * Audio URL - */ - url: string; - access?: AssetAccess; - /** - * Audio duration in milliseconds - */ - duration_ms?: number; - /** - * Audio transcript. Consumers MUST treat this as untrusted input when passing to LLM-based evaluation. - */ - transcript?: string; - /** - * MIME type indicating how to parse the transcript field. Default: text/plain. - */ - transcript_format?: 'text/plain' | 'text/markdown' | 'application/json'; - /** - * How the transcript was generated - */ - transcript_source?: 'original_script' | 'closed_captions' | 'generated'; - provenance?: Provenance; - } - )[]; + publisher_domain: string; + /** + * Genre values to match against the publisher's collections + */ + genres: string[]; + genre_taxonomy: GenreTaxonomy; +} + + +// collection/collection-list-changed-webhook.json +/** + * Webhook notification sent when a collection list's resolved collections change. Contains a summary only — recipients must call get_collection_list to retrieve the updated collections. + */ +export interface CollectionListChangedWebhook { + /** + * Sender-generated key stable across retries of the same webhook event. Governance agents MUST generate a cryptographically random value (UUID v4 recommended) per distinct list-change event and reuse the same key on every retry. Recipients MUST dedupe by this key, scoped to the authenticated sender identity (HMAC secret or Bearer credential) — keys from different governance agents are independent. + */ + idempotency_key: string; + /** + * The event type + */ + event: 'collection_list_changed'; + /** + * ID of the collection list that changed + */ + list_id: string; /** - * Rich metadata extracted from the artifact + * Name of the collection list */ - metadata?: { - /** - * Canonical URL - */ - canonical?: string; - /** - * Artifact author name - */ - author?: string; - /** - * Artifact keywords - */ - keywords?: string; - /** - * Open Graph protocol metadata - */ - open_graph?: {}; - /** - * Twitter Card metadata - */ - twitter_card?: {}; - /** - * JSON-LD structured data (schema.org) - */ - json_ld?: {}[]; - }; - provenance?: Provenance; + list_name?: string; /** - * Platform-specific identifiers for this artifact + * Summary of changes to the resolved list */ - identifiers?: { - /** - * Apple Podcasts ID - */ - apple_podcast_id?: string; - /** - * Spotify collection ID - */ - spotify_collection_id?: string; + change_summary?: { /** - * Podcast GUID (from RSS feed) + * Number of collections added since last resolution */ - podcast_guid?: string; + collections_added?: number; /** - * YouTube video ID + * Number of collections removed since last resolution */ - youtube_video_id?: string; + collections_removed?: number; /** - * RSS feed URL + * Total collections in the resolved list */ - rss_url?: string; + total_collections?: number; }; + /** + * When the list was re-resolved + */ + resolved_at: string; + /** + * When the consumer should refresh from the governance agent + */ + cache_valid_until?: string; + /** + * HMAC-SHA256 webhook signature over {unix_timestamp}.{raw_http_body_bytes} using the secret exchanged out-of-band when the seller registered with the governance agent. Recipients MUST verify against the X-ADCP-Signature and X-ADCP-Timestamp headers using timing-safe comparison and MUST reject requests where |now - timestamp| > 300 seconds. The body copy of this field is a convenience only — the headers are authoritative. See docs/building/implementation/security#webhook-security. + */ + signature: string; + ext?: ExtensionObject; } -// content-standards/content-standards.json -/** - * The nature of the obligation: regulation (legal requirement) or standard (best practice). Optional for inline bespoke policies — defaults to "standard". - */ -export type PolicyCategory = 'regulation' | 'standard'; -/** - * How governance agents treat violations. Regulations are typically "must"; standards are typically "should". - */ -export type PolicyEnforcementLevel = 'must' | 'should' | 'may'; +// collection/collection-list-filters.json /** - * Governance sub-domains that a registry policy applies to. Used to indicate which types of governance agents can evaluate this policy. + * Production quality tier for collection content. Maps to OpenRTB content.prodq: professional=1, prosumer=2, ugc=3. Seller-declared — no external validation. */ -export type GovernanceDomain = 'campaign' | 'property' | 'creative' | 'content_standards'; +export type ProductionQuality = 'professional' | 'prosumer' | 'ugc'; + /** - * A content standards configuration defining brand safety and suitability policies. Standards are scoped by brand, geography, and channel. Multiple standards can be active simultaneously for different scopes. + * Filters that dynamically modify a collection list when resolved. Include filters are allowlists (only matching collections pass). Exclude filters are blocklists (matching collections are removed). When both are present for the same dimension, include is applied first, then exclude narrows further. */ -export interface ContentStandards { - /** - * Unique identifier for this standards configuration - */ - standards_id: string; +export interface CollectionListFilters { /** - * Human-readable name for this standards configuration + * Exclude collections with any of these content ratings (OR logic). This is a metadata filter on the collection's declared content_rating field — it does not evaluate episode content. */ - name?: string; + content_ratings_exclude?: ContentRating[]; /** - * ISO 3166-1 alpha-2 country codes. Standards apply in ALL listed countries (AND logic). + * Include only collections with any of these content ratings (OR logic). Collections without a declared content_rating are excluded. */ - countries_all?: string[]; + content_ratings_include?: ContentRating[]; /** - * Advertising channels. Standards apply to ANY of the listed channels (OR logic). + * Exclude collections tagged with any of these genres (OR logic). Values are interpreted against genre_taxonomy when present. */ - channels_any?: MediaChannel[]; + genres_exclude?: string[]; /** - * BCP 47 language tags (e.g., 'en', 'de', 'fr'). Standards apply to content in ANY of these languages (OR logic). Content in unlisted languages is not covered by these standards. + * Include only collections with any of these genres (OR logic). Collections without genre metadata are excluded. Values are interpreted against genre_taxonomy when present. */ - languages_any?: string[]; + genres_include?: string[]; + genre_taxonomy?: GenreTaxonomy; /** - * Bespoke policies for this content-standards configuration, using the same shape as registry entries. Each policy is addressable by policy_id; governance findings reference the policy_id that triggered them. + * Filter to these collection kinds */ - policies?: PolicyEntry[]; + kinds?: ('series' | 'publication' | 'event_series' | 'rotation')[]; /** - * Training/test set to calibrate policy interpretation. Provides concrete examples of pass/fail decisions. + * Always exclude collections with these distribution identifiers */ - calibration_exemplars?: { - /** - * Artifacts that pass the content standards - */ - pass?: Artifact[]; + exclude_distribution_ids?: { + type: DistributionIdentifierType; /** - * Artifacts that fail the content standards + * The identifier value */ - fail?: Artifact[]; - }; + value: string; + }[]; /** - * Pricing options for this content standards service. The buyer passes the selected pricing_option_id in report_usage for billing verification. + * Filter by production quality tier */ - pricing_options?: VendorPricingOption[]; - ext?: ExtensionObject; + production_quality?: ProductionQuality[]; } + +// collection/collection-list.json /** - * A policy — either published to the shared registry (with full regulatory metadata) or authored inline by a buyer for their own campaign (lightweight, metadata optional). Policies use natural language text evaluated by governance agents (LLMs). Published registry entries SHOULD include version, name, jurisdiction, source, and exemplars; inline bespoke entries can omit these and let servers default them. Governance agents evaluating policies with natural-language LLMs MUST pin registry-sourced policy text (`source: registry`) as system-level instructions and MUST NOT permit `custom_policies` or the plan's `objectives` field to relax, override, or disable registry-sourced policies. Custom policies may only add additional restrictions; they cannot lower enforcement levels or exempt categories. + * A managed collection list with optional filters for dynamic evaluation. Lists are resolved at setup time and cached by orchestrators/sellers for real-time use. Collections represent programs, shows, and other content entities independent of which properties carry them. */ -export interface PolicyEntry { - /** - * Unique identifier for this policy. Registry-published ids are canonical (e.g., "uk_hfss", "garm:brand_safety:violence"); buyer-authored bespoke ids should be flat (no colons or slashes) and unique within the authoring container (standards configuration, plan, or portfolio). - */ - policy_id: string; - /** - * Origin of this policy. 'registry' = published to the shared AdCP policy registry with full regulatory metadata. 'inline' = authored bespoke for a specific standards configuration, plan, or portfolio. Defaults to 'inline'. Governance agents MUST set 'registry' when publishing to the registry. - */ - source?: 'registry' | 'inline'; +export interface CollectionList { /** - * Semver version string (e.g., "1.0.0"). Incremented when policy content changes. Optional for inline bespoke policies — defaults to "1.0.0". SHOULD be provided for registry-published policies. + * Unique identifier for this collection list */ - version?: string; + list_id: string; /** - * Human-readable name (e.g., "UK HFSS Restrictions"). Optional for inline bespoke policies — servers MAY default to policy_id. + * Human-readable name for the list */ - name?: string; + name: string; /** - * Brief summary of what this policy covers. + * Description of the list's purpose */ description?: string; - category?: PolicyCategory; - enforcement: PolicyEnforcementLevel; - /** - * When true, plans subject to this policy MUST set plan.human_review_required = true. Use for policies that mandate human oversight of decisions affecting data subjects — e.g., GDPR Article 22 (solely automated decisions with legal or similarly significant effects) and EU AI Act Annex III high-risk categories (credit, insurance pricing, recruitment, housing allocation). Governance agents MUST escalate any plan action whose resolved policies include requires_human_review: true. Unlike `enforcement`, this flag applies as soon as the policy is resolved — it is NOT gated by `effective_date`. Art 22 GDPR and similar foundational obligations may predate an AI-Act-specific effective date; the human-review requirement fires regardless. - */ - requires_human_review?: boolean; + account?: AccountReference; /** - * ISO 3166-1 alpha-2 country codes where this policy applies. Empty array means the policy is not jurisdiction-specific. + * Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database. */ - jurisdictions?: string[]; + base_collections?: BaseCollectionSource[]; + filters?: CollectionListFilters; + brand?: BrandReference; /** - * Named groups of jurisdictions for convenience (e.g., {"EU": ["AT","BE","BG",...]}). Governance agents expand aliases when matching against a plan's target jurisdictions. + * URL to receive notifications when the resolved list changes */ - region_aliases?: { - [k: string]: string[] | undefined; - }; + webhook_url?: string; /** - * Regulatory categories this policy belongs to (e.g., ["children_directed", "age_restricted"]). Used for automatic matching against a campaign plan's declared policy_categories. A single policy can belong to multiple categories. + * Recommended cache duration for resolved list. Consumers should re-fetch after this period. Defaults to 168 (one week) because collection metadata changes less frequently than property metadata. */ - policy_categories?: string[]; + cache_duration_hours?: number; /** - * Advertising channels this policy applies to. If omitted or null, the policy applies to all channels. + * When the list was created */ - channels?: MediaChannel[]; + created_at?: string; /** - * Governance sub-domains this policy applies to. Determines which types of governance agents can declare registry:{policy_id} features. For example, a policy with domains ["creative", "property"] can be declared as a feature by both creative and property governance agents. + * When the list was last modified */ - governance_domains?: GovernanceDomain[]; + updated_at?: string; /** - * ISO 8601 date when the regulation or standard takes effect. Before this date, governance agents treat the policy as informational (evaluate but do not block). After this date, the policy is enforced at its declared enforcement level. + * Number of collections in the resolved list (at time of last resolution) */ - effective_date?: string; + collection_count?: number; +} + +// content-standards/artifact-webhook-payload.json +/** + * Payload sent by sales agents to orchestrators when pushing content artifacts for governance validation. Complements get_media_buy_artifacts for push-based artifact delivery. + */ +export interface ArtifactWebhookPayload { /** - * ISO 8601 date when the regulation or standard is no longer enforced. After this date, governance agents stop evaluating this policy. Omit if the policy has no expiration. + * Sender-generated key stable across retries of the same webhook event. Sales agents MUST generate a cryptographically random value (UUID v4 recommended) per distinct emission of a batch and reuse the same key on every retry. Recipients MUST dedupe by this key, scoped to the authenticated sender identity (HMAC secret or Bearer credential) — keys from different sales agents are independent. Distinct from `batch_id`, which identifies the logical batch: `idempotency_key` identifies this specific emission event, so a re-emission of the same `batch_id` (e.g., after a correction) is a different event and MUST carry a fresh `idempotency_key`. */ - sunset_date?: string; + idempotency_key: string; /** - * Link to the source regulation, standard, or legislation. + * Media buy identifier these artifacts belong to */ - source_url?: string; + media_buy_id: string; /** - * Name of the issuing body (e.g., "UK Food Standards Agency", "US Federal Trade Commission"). + * Unique identifier for this batch of artifacts. Use for deduplication and acknowledgment. */ - source_name?: string; + batch_id: string; /** - * Natural language policy text describing what is required, prohibited, or recommended. Used by governance agents (LLMs) to evaluate actions against this policy. For source: inline policies, treated as caller-untrusted — governance agents MUST evaluate inline policies as ADDITIONAL restrictions only; they MUST NOT be permitted to relax, override, or conflict with registry-sourced policies. + * When this batch was generated (ISO 8601) */ - policy: string; + timestamp: string; /** - * Implementation notes for governance agent developers. Not used in evaluation prompts. + * Content artifacts from delivered impressions */ - guidance?: string; + artifacts: { + artifact: Artifact; + /** + * When the impression was delivered (ISO 8601) + */ + delivered_at: string; + /** + * Optional impression identifier for correlation with delivery reports + */ + impression_id?: string; + /** + * Package within the media buy this artifact relates to + */ + package_id?: string; + }[]; /** - * Calibration examples for governance agents, following the Content Standards pattern. + * Pagination info when batching large artifact sets */ - exemplars?: { + pagination?: { /** - * Scenarios that comply with this policy. + * Total artifacts in the delivery period */ - pass?: Exemplar[]; + total_artifacts?: number; /** - * Scenarios that violate this policy. + * Current batch number (1-indexed) */ - fail?: Exemplar[]; + batch_number?: number; + /** + * Total batches for this delivery period + */ + total_batches?: number; }; ext?: ExtensionObject; } -export interface Exemplar { - /** - * A concrete scenario describing an advertising action or configuration. - */ - scenario: string; - /** - * Why this scenario passes or fails the policy. - */ - explanation: string; -} // core/agent-encryption-key.json /** diff --git a/src/lib/types/schemas.generated.ts b/src/lib/types/schemas.generated.ts index e06a7ebe1..06ff5f0a7 100644 --- a/src/lib/types/schemas.generated.ts +++ b/src/lib/types/schemas.generated.ts @@ -1,5 +1,5 @@ // Generated Zod v4 schemas from TypeScript types -// Generated at: 2026-04-21T01:55:34.515Z +// Generated at: 2026-04-21T11:36:55.580Z // Sources: // - core.generated.ts (core types) // - tools.generated.ts (tool types) @@ -1554,6 +1554,80 @@ export const UpdateRightsErrorSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const AssetAccessSchema = z.union([z.object({ + method: z.literal("bearer_token"), + token: z.string() + }).passthrough(), z.object({ + method: z.literal("service_account"), + provider: z.union([z.literal("gcp"), z.literal("aws")]), + credentials: z.object({}).passthrough().optional() + }).passthrough(), z.object({ + method: z.literal("signed_url") + }).passthrough()]); + +export const ArtifactSchema = z.object({ + property_rid: z.string(), + artifact_id: z.string(), + variant_id: z.string().optional(), + format_id: FormatIDSchema.optional(), + url: z.string().optional(), + published_time: z.string().optional(), + last_update_time: z.string().optional(), + assets: z.array(z.union([z.object({ + type: z.literal("text"), + role: z.union([z.literal("title"), z.literal("paragraph"), z.literal("heading"), z.literal("caption"), z.literal("quote"), z.literal("list_item"), z.literal("description")]).optional(), + content: z.string(), + content_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("text/html"), z.literal("application/json")]).optional(), + language: z.string().optional(), + heading_level: z.number().optional(), + provenance: ProvenanceSchema.optional() + }).passthrough(), z.object({ + type: z.literal("image"), + url: z.string(), + access: AssetAccessSchema.optional(), + alt_text: z.string().optional(), + caption: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + provenance: ProvenanceSchema.optional() + }).passthrough(), z.object({ + type: z.literal("video"), + url: z.string(), + access: AssetAccessSchema.optional(), + duration_ms: z.number().optional(), + transcript: z.string().optional(), + transcript_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("application/json")]).optional(), + transcript_source: z.union([z.literal("original_script"), z.literal("subtitles"), z.literal("closed_captions"), z.literal("dub"), z.literal("generated")]).optional(), + thumbnail_url: z.string().optional(), + provenance: ProvenanceSchema.optional() + }).passthrough(), z.object({ + type: z.literal("audio"), + url: z.string(), + access: AssetAccessSchema.optional(), + duration_ms: z.number().optional(), + transcript: z.string().optional(), + transcript_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("application/json")]).optional(), + transcript_source: z.union([z.literal("original_script"), z.literal("closed_captions"), z.literal("generated")]).optional(), + provenance: ProvenanceSchema.optional() + }).passthrough()])), + metadata: z.object({ + canonical: z.string().optional(), + author: z.string().optional(), + keywords: z.string().optional(), + open_graph: z.object({}).passthrough().optional(), + twitter_card: z.object({}).passthrough().optional(), + json_ld: z.array(z.object({}).passthrough()).optional() + }).passthrough().optional(), + provenance: ProvenanceSchema.optional(), + identifiers: z.object({ + apple_podcast_id: z.string().optional(), + spotify_collection_id: z.string().optional(), + podcast_guid: z.string().optional(), + youtube_video_id: z.string().optional(), + rss_url: z.string().optional() + }).passthrough().optional() +}).passthrough(); + export const CalibrateContentResponseSchema = z.union([z.object({ verdict: z.union([z.literal("pass"), z.literal("fail")]), confidence: z.number().optional(), @@ -1573,6 +1647,17 @@ export const CalibrateContentResponseSchema = z.union([z.object({ ext: ExtensionObjectSchema.optional() }).passthrough()]); +export const PolicyCategorySchema = z.union([z.literal("regulation"), z.literal("standard")]); + +export const PolicyEnforcementLevelSchema = z.union([z.literal("must"), z.literal("should"), z.literal("may")]); + +export const GovernanceDomainSchema = z.union([z.literal("campaign"), z.literal("property"), z.literal("creative"), z.literal("content_standards")]); + +export const ExemplarSchema = z.object({ + scenario: z.string(), + explanation: z.string() +}).passthrough(); + export const CreateContentStandardsResponseSchema = z.union([z.object({ standards_id: z.string(), context: ContextObjectSchema.optional(), @@ -1591,6 +1676,74 @@ export const GetContentStandardsRequestSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const CpmPricingSchema = z.object({ + model: z.literal("cpm"), + cpm: z.number(), + currency: z.string(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const PercentOfMediaPricingSchema = z.object({ + model: z.literal("percent_of_media"), + percent: z.number(), + max_cpm: z.number().optional(), + currency: z.string(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const FlatFeePricingSchema = z.object({ + model: z.literal("flat_fee"), + amount: z.number(), + period: z.union([z.literal("monthly"), z.literal("quarterly"), z.literal("annual"), z.literal("campaign")]), + currency: z.string(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const PerUnitPricingSchema = z.object({ + model: z.literal("per_unit"), + unit: z.string(), + unit_price: z.number(), + currency: z.string(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const CustomPricingSchema = z.object({ + model: z.literal("custom"), + description: z.string(), + metadata: z.object({ + summary_for_operator: z.string().optional() + }).passthrough(), + currency: z.string().optional(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const PolicyEntrySchema = z.object({ + policy_id: z.string(), + source: z.union([z.literal("registry"), z.literal("inline")]).optional(), + version: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + category: PolicyCategorySchema.optional(), + enforcement: PolicyEnforcementLevelSchema, + requires_human_review: z.boolean().optional(), + jurisdictions: z.array(z.string()).optional(), + region_aliases: z.record(z.string(), z.array(z.string())).optional(), + policy_categories: z.array(z.string()).optional(), + channels: z.array(MediaChannelSchema).optional(), + governance_domains: z.array(GovernanceDomainSchema).optional(), + effective_date: z.string().optional(), + sunset_date: z.string().optional(), + source_url: z.string().optional(), + source_name: z.string().optional(), + policy: z.string(), + guidance: z.string().optional(), + exemplars: z.object({ + pass: z.array(ExemplarSchema).optional(), + fail: z.array(ExemplarSchema).optional() + }).passthrough().optional(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + export const AccountReferenceSchema = z.union([z.object({ account_id: z.string() }).passthrough(), z.object({ @@ -1617,6 +1770,36 @@ export const GetMediaBuyArtifactsRequestSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const GetMediaBuyArtifactsResponseSchema = z.union([z.object({ + media_buy_id: z.string(), + artifacts: z.array(z.object({ + record_id: z.string(), + timestamp: z.string().optional(), + package_id: z.string().optional(), + artifact: ArtifactSchema, + country: z.string().optional(), + channel: z.string().optional(), + brand_context: z.object({ + brand_id: z.string().optional(), + sku_id: z.string().optional() + }).passthrough().optional(), + local_verdict: z.union([z.literal("pass"), z.literal("fail"), z.literal("unevaluated")]).optional() + }).passthrough()), + collection_info: z.object({ + total_deliveries: z.number().optional(), + total_collected: z.number().optional(), + returned_count: z.number().optional(), + effective_rate: z.number().optional() + }).passthrough().optional(), + pagination: PaginationResponseSchema.optional(), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() + }).passthrough(), z.object({ + errors: z.array(ErrorSchema), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() + }).passthrough()]); + export const ListContentStandardsRequestSchema = z.object({ adcp_major_version: z.number().optional(), channels: z.array(MediaChannelSchema).optional(), @@ -1627,6 +1810,34 @@ export const ListContentStandardsRequestSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const UpdateContentStandardsRequestSchema = z.object({ + adcp_major_version: z.number().optional(), + standards_id: z.string(), + scope: z.object({ + countries_all: z.array(z.string()).optional(), + channels_any: z.array(MediaChannelSchema).optional(), + languages_any: z.array(z.string()).optional(), + description: z.string().optional() + }).passthrough().optional(), + registry_policy_ids: z.array(z.string()).optional(), + policies: z.array(PolicyEntrySchema).optional(), + calibration_exemplars: z.object({ + pass: z.array(z.union([z.object({ + type: z.literal("url"), + value: z.string(), + language: z.string().optional() + }).passthrough(), ArtifactSchema])).optional(), + fail: z.array(z.union([z.object({ + type: z.literal("url"), + value: z.string(), + language: z.string().optional() + }).passthrough(), ArtifactSchema])).optional() + }).passthrough().optional(), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional(), + idempotency_key: z.string() +}).passthrough(); + export const UpdateContentStandardsSuccessSchema = z.object({ success: z.literal(true), standards_id: z.string(), @@ -1642,6 +1853,27 @@ export const UpdateContentStandardsErrorSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const ValidateContentDeliveryRequestSchema = z.object({ + adcp_major_version: z.number().optional(), + standards_id: z.string(), + records: z.array(z.object({ + record_id: z.string(), + media_buy_id: z.string().optional(), + timestamp: z.string().optional(), + artifact: ArtifactSchema, + country: z.string().optional(), + channel: z.string().optional(), + brand_context: z.object({ + brand_id: z.string().optional(), + sku_id: z.string().optional() + }).passthrough().optional() + }).passthrough()), + feature_ids: z.array(z.string()).optional(), + include_passed: z.boolean().optional(), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + export const ValidateContentDeliveryResponseSchema = z.union([z.object({ summary: z.object({ total_records: z.number(), @@ -1899,47 +2131,6 @@ export const CreativeItemSchema = z.union([z.object({ content: z.union([z.string(), z.array(z.string())]) }).passthrough()]); -export const CpmPricingSchema = z.object({ - model: z.literal("cpm"), - cpm: z.number(), - currency: z.string(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const PercentOfMediaPricingSchema = z.object({ - model: z.literal("percent_of_media"), - percent: z.number(), - max_cpm: z.number().optional(), - currency: z.string(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const FlatFeePricingSchema = z.object({ - model: z.literal("flat_fee"), - amount: z.number(), - period: z.union([z.literal("monthly"), z.literal("quarterly"), z.literal("annual"), z.literal("campaign")]), - currency: z.string(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const PerUnitPricingSchema = z.object({ - model: z.literal("per_unit"), - unit: z.string(), - unit_price: z.number(), - currency: z.string(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const CustomPricingSchema = z.object({ - model: z.literal("custom"), - description: z.string(), - metadata: z.object({ - summary_for_operator: z.string().optional() - }).passthrough(), - currency: z.string().optional(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - export const CreativeVariableSchema = z.object({ variable_id: z.string(), name: z.string(), @@ -2768,161 +2959,95 @@ export const SITerminateSessionResponseSchema = z.object({ session_status: SISessionStatusSchema.optional(), acp_handoff: z.object({ checkout_url: z.string().optional(), - checkout_token: z.string().optional(), - payload: z.object({}).passthrough().optional(), - expires_at: z.string().optional() - }).passthrough().optional(), - follow_up: z.object({ - action: z.union([z.literal("save_for_later"), z.literal("set_reminder"), z.literal("subscribe_updates"), z.literal("none")]).optional(), - data: z.object({}).passthrough().optional() - }).passthrough().optional(), - errors: z.array(ErrorSchema).optional(), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const PublisherCollectionsSourceSchema = z.object({ - selection_type: z.literal("publisher_collections"), - publisher_domain: z.string(), - collection_ids: z.array(z.string()) -}).passthrough(); - -export const DistributionIdentifierTypeSchema = z.union([z.literal("apple_podcast_id"), z.literal("spotify_collection_id"), z.literal("rss_url"), z.literal("podcast_guid"), z.literal("amazon_music_id"), z.literal("iheart_id"), z.literal("podcast_index_id"), z.literal("youtube_channel_id"), z.literal("youtube_playlist_id"), z.literal("amazon_title_id"), z.literal("roku_channel_id"), z.literal("pluto_channel_id"), z.literal("tubi_id"), z.literal("peacock_id"), z.literal("tiktok_id"), z.literal("twitch_channel"), z.literal("imdb_id"), z.literal("gracenote_id"), z.literal("eidr_id"), z.literal("domain"), z.literal("substack_id")]); - -export const GenreTaxonomySchema = z.union([z.literal("iab_content_3.0"), z.literal("iab_content_2.2"), z.literal("gracenote"), z.literal("eidr"), z.literal("apple_genres"), z.literal("google_genres"), z.literal("roku"), z.literal("amazon_genres"), z.literal("custom")]); - -export const DistributionIDsSourceSchema = z.object({ - selection_type: z.literal("distribution_ids"), - identifiers: z.array(z.object({ - type: DistributionIdentifierTypeSchema, - value: z.string() - }).passthrough()) -}).passthrough(); - -export const PublisherGenresSourceSchema = z.object({ - selection_type: z.literal("publisher_genres"), - publisher_domain: z.string(), - genres: z.array(z.string()), - genre_taxonomy: GenreTaxonomySchema -}).passthrough(); - -export const CollectionListChangedWebhookSchema = z.object({ - idempotency_key: z.string(), - event: z.literal("collection_list_changed"), - list_id: z.string(), - list_name: z.string().optional(), - change_summary: z.object({ - collections_added: z.number().optional(), - collections_removed: z.number().optional(), - total_collections: z.number().optional() - }).passthrough().optional(), - resolved_at: z.string(), - cache_valid_until: z.string().optional(), - signature: z.string(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const ProductionQualitySchema = z.union([z.literal("professional"), z.literal("prosumer"), z.literal("ugc")]); - -export const CollectionListFiltersSchema = z.object({ - content_ratings_exclude: z.array(ContentRatingSchema).optional(), - content_ratings_include: z.array(ContentRatingSchema).optional(), - genres_exclude: z.array(z.string()).optional(), - genres_include: z.array(z.string()).optional(), - genre_taxonomy: GenreTaxonomySchema.optional(), - kinds: z.array(z.union([z.literal("series"), z.literal("publication"), z.literal("event_series"), z.literal("rotation")])).optional(), - exclude_distribution_ids: z.array(z.object({ - type: DistributionIdentifierTypeSchema, - value: z.string() - }).passthrough()).optional(), - production_quality: z.array(ProductionQualitySchema).optional() -}).passthrough(); - -export const BaseCollectionSourceSchema = z.union([DistributionIDsSourceSchema, PublisherCollectionsSourceSchema, PublisherGenresSourceSchema]); - -export const AssetAccessSchema = z.union([z.object({ - method: z.literal("bearer_token"), - token: z.string() - }).passthrough(), z.object({ - method: z.literal("service_account"), - provider: z.union([z.literal("gcp"), z.literal("aws")]), - credentials: z.object({}).passthrough().optional() - }).passthrough(), z.object({ - method: z.literal("signed_url") - }).passthrough()]); - -export const ArtifactSchema = z.object({ - property_rid: z.string(), - artifact_id: z.string(), - variant_id: z.string().optional(), - format_id: FormatIDSchema.optional(), - url: z.string().optional(), - published_time: z.string().optional(), - last_update_time: z.string().optional(), - assets: z.array(z.union([z.object({ - type: z.literal("text"), - role: z.union([z.literal("title"), z.literal("paragraph"), z.literal("heading"), z.literal("caption"), z.literal("quote"), z.literal("list_item"), z.literal("description")]).optional(), - content: z.string(), - content_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("text/html"), z.literal("application/json")]).optional(), - language: z.string().optional(), - heading_level: z.number().optional(), - provenance: ProvenanceSchema.optional() - }).passthrough(), z.object({ - type: z.literal("image"), - url: z.string(), - access: AssetAccessSchema.optional(), - alt_text: z.string().optional(), - caption: z.string().optional(), - width: z.number().optional(), - height: z.number().optional(), - provenance: ProvenanceSchema.optional() - }).passthrough(), z.object({ - type: z.literal("video"), - url: z.string(), - access: AssetAccessSchema.optional(), - duration_ms: z.number().optional(), - transcript: z.string().optional(), - transcript_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("application/json")]).optional(), - transcript_source: z.union([z.literal("original_script"), z.literal("subtitles"), z.literal("closed_captions"), z.literal("dub"), z.literal("generated")]).optional(), - thumbnail_url: z.string().optional(), - provenance: ProvenanceSchema.optional() - }).passthrough(), z.object({ - type: z.literal("audio"), - url: z.string(), - access: AssetAccessSchema.optional(), - duration_ms: z.number().optional(), - transcript: z.string().optional(), - transcript_format: z.union([z.literal("text/plain"), z.literal("text/markdown"), z.literal("application/json")]).optional(), - transcript_source: z.union([z.literal("original_script"), z.literal("closed_captions"), z.literal("generated")]).optional(), - provenance: ProvenanceSchema.optional() - }).passthrough()])), - metadata: z.object({ - canonical: z.string().optional(), - author: z.string().optional(), - keywords: z.string().optional(), - open_graph: z.object({}).passthrough().optional(), - twitter_card: z.object({}).passthrough().optional(), - json_ld: z.array(z.object({}).passthrough()).optional() + checkout_token: z.string().optional(), + payload: z.object({}).passthrough().optional(), + expires_at: z.string().optional() }).passthrough().optional(), - provenance: ProvenanceSchema.optional(), - identifiers: z.object({ - apple_podcast_id: z.string().optional(), - spotify_collection_id: z.string().optional(), - podcast_guid: z.string().optional(), - youtube_video_id: z.string().optional(), - rss_url: z.string().optional() - }).passthrough().optional() + follow_up: z.object({ + action: z.union([z.literal("save_for_later"), z.literal("set_reminder"), z.literal("subscribe_updates"), z.literal("none")]).optional(), + data: z.object({}).passthrough().optional() + }).passthrough().optional(), + errors: z.array(ErrorSchema).optional(), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() }).passthrough(); -export const PolicyCategorySchema = z.union([z.literal("regulation"), z.literal("standard")]); +export const PublisherCollectionsSourceSchema = z.object({ + selection_type: z.literal("publisher_collections"), + publisher_domain: z.string(), + collection_ids: z.array(z.string()) +}).passthrough(); -export const PolicyEnforcementLevelSchema = z.union([z.literal("must"), z.literal("should"), z.literal("may")]); +export const DistributionIdentifierTypeSchema = z.union([z.literal("apple_podcast_id"), z.literal("spotify_collection_id"), z.literal("rss_url"), z.literal("podcast_guid"), z.literal("amazon_music_id"), z.literal("iheart_id"), z.literal("podcast_index_id"), z.literal("youtube_channel_id"), z.literal("youtube_playlist_id"), z.literal("amazon_title_id"), z.literal("roku_channel_id"), z.literal("pluto_channel_id"), z.literal("tubi_id"), z.literal("peacock_id"), z.literal("tiktok_id"), z.literal("twitch_channel"), z.literal("imdb_id"), z.literal("gracenote_id"), z.literal("eidr_id"), z.literal("domain"), z.literal("substack_id")]); -export const GovernanceDomainSchema = z.union([z.literal("campaign"), z.literal("property"), z.literal("creative"), z.literal("content_standards")]); +export const GenreTaxonomySchema = z.union([z.literal("iab_content_3.0"), z.literal("iab_content_2.2"), z.literal("gracenote"), z.literal("eidr"), z.literal("apple_genres"), z.literal("google_genres"), z.literal("roku"), z.literal("amazon_genres"), z.literal("custom")]); -export const ExemplarSchema = z.object({ - scenario: z.string(), - explanation: z.string() +export const DistributionIDsSourceSchema = z.object({ + selection_type: z.literal("distribution_ids"), + identifiers: z.array(z.object({ + type: DistributionIdentifierTypeSchema, + value: z.string() + }).passthrough()) +}).passthrough(); + +export const PublisherGenresSourceSchema = z.object({ + selection_type: z.literal("publisher_genres"), + publisher_domain: z.string(), + genres: z.array(z.string()), + genre_taxonomy: GenreTaxonomySchema +}).passthrough(); + +export const CollectionListChangedWebhookSchema = z.object({ + idempotency_key: z.string(), + event: z.literal("collection_list_changed"), + list_id: z.string(), + list_name: z.string().optional(), + change_summary: z.object({ + collections_added: z.number().optional(), + collections_removed: z.number().optional(), + total_collections: z.number().optional() + }).passthrough().optional(), + resolved_at: z.string(), + cache_valid_until: z.string().optional(), + signature: z.string(), + ext: ExtensionObjectSchema.optional() +}).passthrough(); + +export const ProductionQualitySchema = z.union([z.literal("professional"), z.literal("prosumer"), z.literal("ugc")]); + +export const CollectionListFiltersSchema = z.object({ + content_ratings_exclude: z.array(ContentRatingSchema).optional(), + content_ratings_include: z.array(ContentRatingSchema).optional(), + genres_exclude: z.array(z.string()).optional(), + genres_include: z.array(z.string()).optional(), + genre_taxonomy: GenreTaxonomySchema.optional(), + kinds: z.array(z.union([z.literal("series"), z.literal("publication"), z.literal("event_series"), z.literal("rotation")])).optional(), + exclude_distribution_ids: z.array(z.object({ + type: DistributionIdentifierTypeSchema, + value: z.string() + }).passthrough()).optional(), + production_quality: z.array(ProductionQualitySchema).optional() +}).passthrough(); + +export const BaseCollectionSourceSchema = z.union([DistributionIDsSourceSchema, PublisherCollectionsSourceSchema, PublisherGenresSourceSchema]); + +export const ArtifactWebhookPayloadSchema = z.object({ + idempotency_key: z.string(), + media_buy_id: z.string(), + batch_id: z.string(), + timestamp: z.string(), + artifacts: z.array(z.object({ + artifact: ArtifactSchema, + delivered_at: z.string(), + impression_id: z.string().optional(), + package_id: z.string().optional() + }).passthrough()), + pagination: z.object({ + total_artifacts: z.number().optional(), + batch_number: z.number().optional(), + total_batches: z.number().optional() + }).passthrough().optional(), + ext: ExtensionObjectSchema.optional() }).passthrough(); export const AgentEncryptionKeySchema = z.object({ @@ -4580,33 +4705,6 @@ export const DeleteCollectionListResponseSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const PolicyEntrySchema = z.object({ - policy_id: z.string(), - source: z.union([z.literal("registry"), z.literal("inline")]).optional(), - version: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - category: PolicyCategorySchema.optional(), - enforcement: PolicyEnforcementLevelSchema, - requires_human_review: z.boolean().optional(), - jurisdictions: z.array(z.string()).optional(), - region_aliases: z.record(z.string(), z.array(z.string())).optional(), - policy_categories: z.array(z.string()).optional(), - channels: z.array(MediaChannelSchema).optional(), - governance_domains: z.array(GovernanceDomainSchema).optional(), - effective_date: z.string().optional(), - sunset_date: z.string().optional(), - source_url: z.string().optional(), - source_name: z.string().optional(), - policy: z.string(), - guidance: z.string().optional(), - exemplars: z.object({ - pass: z.array(ExemplarSchema).optional(), - fail: z.array(ExemplarSchema).optional() - }).passthrough().optional(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - export const ContentStandardsSchema = z.object({ standards_id: z.string(), name: z.string().optional(), @@ -4622,6 +4720,12 @@ export const ContentStandardsSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); +export const GetContentStandardsResponseSchema = z.union([ContentStandardsSchema, z.object({ + errors: z.array(ErrorSchema), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() + }).passthrough()]); + export const CreateContentStandardsRequestSchema = z.object({ adcp_major_version: z.number().optional(), scope: z.object({ @@ -4649,34 +4753,6 @@ export const CreateContentStandardsRequestSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const UpdateContentStandardsRequestSchema = z.object({ - adcp_major_version: z.number().optional(), - standards_id: z.string(), - scope: z.object({ - countries_all: z.array(z.string()).optional(), - channels_any: z.array(MediaChannelSchema).optional(), - languages_any: z.array(z.string()).optional(), - description: z.string().optional() - }).passthrough().optional(), - registry_policy_ids: z.array(z.string()).optional(), - policies: z.array(PolicyEntrySchema).optional(), - calibration_exemplars: z.object({ - pass: z.array(z.union([z.object({ - type: z.literal("url"), - value: z.string(), - language: z.string().optional() - }).passthrough(), ArtifactSchema])).optional(), - fail: z.array(z.union([z.object({ - type: z.literal("url"), - value: z.string(), - language: z.string().optional() - }).passthrough(), ArtifactSchema])).optional() - }).passthrough().optional(), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional(), - idempotency_key: z.string() -}).passthrough(); - export const UpdateContentStandardsResponseSchema = z.union([UpdateContentStandardsSuccessSchema, UpdateContentStandardsErrorSchema]); export const CalibrateContentRequestSchema = z.object({ @@ -4688,57 +4764,6 @@ export const CalibrateContentRequestSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const ValidateContentDeliveryRequestSchema = z.object({ - adcp_major_version: z.number().optional(), - standards_id: z.string(), - records: z.array(z.object({ - record_id: z.string(), - media_buy_id: z.string().optional(), - timestamp: z.string().optional(), - artifact: ArtifactSchema, - country: z.string().optional(), - channel: z.string().optional(), - brand_context: z.object({ - brand_id: z.string().optional(), - sku_id: z.string().optional() - }).passthrough().optional() - }).passthrough()), - feature_ids: z.array(z.string()).optional(), - include_passed: z.boolean().optional(), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - -export const GetMediaBuyArtifactsResponseSchema = z.union([z.object({ - media_buy_id: z.string(), - artifacts: z.array(z.object({ - record_id: z.string(), - timestamp: z.string().optional(), - package_id: z.string().optional(), - artifact: ArtifactSchema, - country: z.string().optional(), - channel: z.string().optional(), - brand_context: z.object({ - brand_id: z.string().optional(), - sku_id: z.string().optional() - }).passthrough().optional(), - local_verdict: z.union([z.literal("pass"), z.literal("fail"), z.literal("unevaluated")]).optional() - }).passthrough()), - collection_info: z.object({ - total_deliveries: z.number().optional(), - total_collected: z.number().optional(), - returned_count: z.number().optional(), - effective_rate: z.number().optional() - }).passthrough().optional(), - pagination: PaginationResponseSchema.optional(), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() - }).passthrough(), z.object({ - errors: z.array(ErrorSchema), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() - }).passthrough()]); - export const GetCreativeFeaturesRequestSchema = z.object({ adcp_major_version: z.number().optional(), creative_manifest: CreativeManifestSchema, @@ -5714,6 +5739,17 @@ export const GetRightsSuccessSchema = z.object({ export const UpdateRightsResponseSchema = z.union([UpdateRightsSuccessSchema, UpdateRightsErrorSchema]); +export const ListContentStandardsResponseSchema = z.union([z.object({ + standards: z.array(ContentStandardsSchema), + pagination: PaginationResponseSchema.optional(), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() + }).passthrough(), z.object({ + errors: z.array(ErrorSchema), + context: ContextObjectSchema.optional(), + ext: ExtensionObjectSchema.optional() + }).passthrough()]); + export const ListCreativeFormatsRequestCreativeAgentSchema = z.object({ adcp_major_version: z.number().optional(), format_ids: z.array(FormatIDSchema).optional(), @@ -5912,25 +5948,6 @@ export const ValidationResultSchema = z.object({ export const ActivateSignalResponseSchema = z.union([ActivateSignalSuccessSchema, ActivateSignalErrorSchema]); -export const ArtifactWebhookPayloadSchema = z.object({ - idempotency_key: z.string(), - media_buy_id: z.string(), - batch_id: z.string(), - timestamp: z.string(), - artifacts: z.array(z.object({ - artifact: ArtifactSchema, - delivered_at: z.string(), - impression_id: z.string().optional(), - package_id: z.string().optional() - }).passthrough()), - pagination: z.object({ - total_artifacts: z.number().optional(), - batch_number: z.number().optional(), - total_batches: z.number().optional() - }).passthrough().optional(), - ext: ExtensionObjectSchema.optional() -}).passthrough(); - export const AppItemSchema = z.object({ app_id: z.string(), name: z.string(), @@ -6173,23 +6190,6 @@ export const CreateCollectionListResponseSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const ListContentStandardsResponseSchema = z.union([z.object({ - standards: z.array(ContentStandardsSchema), - pagination: PaginationResponseSchema.optional(), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() - }).passthrough(), z.object({ - errors: z.array(ErrorSchema), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() - }).passthrough()]); - -export const GetContentStandardsResponseSchema = z.union([ContentStandardsSchema, z.object({ - errors: z.array(ErrorSchema), - context: ContextObjectSchema.optional(), - ext: ExtensionObjectSchema.optional() - }).passthrough()]); - export const SyncAccountsResponseSchema = z.union([SyncAccountsSuccessSchema, SyncAccountsErrorSchema]); export const SyncGovernanceResponseSchema = z.union([SyncGovernanceSuccessSchema, SyncGovernanceErrorSchema]); diff --git a/src/lib/validation/client-hooks.ts b/src/lib/validation/client-hooks.ts new file mode 100644 index 000000000..e46e9a184 --- /dev/null +++ b/src/lib/validation/client-hooks.ts @@ -0,0 +1,104 @@ +/** + * Client-side hooks that run the schema validator around every AdCP tool + * call. Pre-send validation blocks malformed requests; post-receive + * validation catches field-name drift from agents (issue #688). + */ + +import { validateRequest, validateResponse, formatIssues, type ValidationOutcome } from './schema-validator'; +import { buildValidationError } from './schema-errors'; + +export type ValidationMode = 'strict' | 'warn' | 'off'; + +export interface ValidationHookConfig { + /** Validate outgoing requests. Default: strict in dev/test, warn in prod. */ + requests?: ValidationMode; + /** Validate incoming responses. Default: strict in dev/test, warn in prod. */ + responses?: ValidationMode; +} + +function defaultResponseMode(): ValidationMode { + // Responses have been validated strictly by default since the SDK shipped + // Zod validation; preserve that in dev/test and soften to warn in prod. + return process.env.NODE_ENV === 'production' ? 'warn' : 'strict'; +} + +/** + * Resolve the effective request/response modes. + * + * Response default: strict in dev/test, warn in prod (preserves the + * existing `strictSchemaValidation` contract). + * + * Request default: `warn` everywhere. Strict-by-default would break + * existing callers that intentionally send partial payloads (error-path + * tests, exploratory probes) — storyboards and third-party clients that + * want hard-stop enforcement should set `requests: 'strict'` explicitly. + */ +export function resolveValidationModes(config?: ValidationHookConfig): Required { + return { + requests: config?.requests ?? 'warn', + responses: config?.responses ?? defaultResponseMode(), + }; +} + +export interface DebugLogEntry { + type: 'info' | 'success' | 'warning' | 'error'; + message: string; + timestamp: string; + [k: string]: unknown; +} + +function logWarning(debugLogs: DebugLogEntry[] | undefined, taskName: string, outcome: ValidationOutcome): void { + if (!debugLogs) return; + debugLogs.push({ + type: 'warning', + message: `Schema validation warning for ${taskName}: ${formatIssues(outcome.issues)}`, + timestamp: new Date().toISOString(), + schemaVariant: outcome.variant, + issues: outcome.issues, + }); +} + +/** + * Run request validation per the configured mode. + * - `off`: no-op (true). + * - `warn`: log to debug + continue (true). + * - `strict`: throw `ValidationError` with JSON Pointer to the first bad field. + */ +export function validateOutgoingRequest( + taskName: string, + params: unknown, + mode: ValidationMode, + debugLogs?: DebugLogEntry[] +): ValidationOutcome | undefined { + if (mode === 'off') return undefined; + const outcome = validateRequest(taskName, params); + if (outcome.valid) return outcome; + if (mode === 'warn') { + logWarning(debugLogs, taskName, outcome); + return outcome; + } + throw buildValidationError(taskName, 'request', outcome.issues); +} + +/** + * Run response validation per the configured mode. + * - `off`: no-op (valid). + * - `warn`: log + return invalid outcome so the caller can surface details. + * - `strict`: return the invalid outcome so the caller fails the task. + * Does NOT throw — matches the existing response-side contract where a + * validation failure turns a task into `status: 'failed'` rather than + * raising out of the SDK. + */ +export function validateIncomingResponse( + taskName: string, + data: unknown, + mode: ValidationMode, + debugLogs?: DebugLogEntry[] +): ValidationOutcome { + if (mode === 'off') return { valid: true, issues: [], variant: 'sync' }; + const outcome = validateResponse(taskName, data); + if (!outcome.valid && mode === 'warn') { + logWarning(debugLogs, taskName, outcome); + } + return outcome; +} diff --git a/src/lib/validation/index.ts b/src/lib/validation/index.ts index d51636af3..946481107 100644 --- a/src/lib/validation/index.ts +++ b/src/lib/validation/index.ts @@ -3,6 +3,15 @@ * Functions for validating URLs, responses, and schemas */ +export { validateRequest, validateResponse, formatIssues } from './schema-validator'; +export type { ValidationIssue, ValidationOutcome } from './schema-validator'; +export { buildValidationError, buildAdcpValidationErrorPayload } from './schema-errors'; +export type { ValidationErrorDetails, AdcpValidationErrorDetails } from './schema-errors'; +export { getValidator, listValidatorKeys } from './schema-loader'; +export type { Direction, ResponseVariant } from './schema-loader'; +export { validateOutgoingRequest, validateIncomingResponse, resolveValidationModes } from './client-hooks'; +export type { ValidationMode, ValidationHookConfig } from './client-hooks'; + /** * Get expected response schema type for a given tool */ diff --git a/src/lib/validation/schema-errors.ts b/src/lib/validation/schema-errors.ts new file mode 100644 index 000000000..41ff78503 --- /dev/null +++ b/src/lib/validation/schema-errors.ts @@ -0,0 +1,65 @@ +/** + * Convert schema validation failures into the AdCP L3 `VALIDATION_ERROR` + * envelope and the `ValidationError` thrown type. + */ + +import { ValidationError } from '../errors'; +import type { ValidationIssue } from './schema-validator'; + +export interface ValidationErrorDetails { + /** Tool that was being validated. */ + tool: string; + /** Which side of the exchange — request (outgoing) or response (incoming). */ + side: 'request' | 'response'; + /** All failures, each with a JSON Pointer to the bad field. */ + issues: ValidationIssue[]; +} + +/** + * Build a `ValidationError` thrown by strict-mode client hooks. Keeps the + * existing constructor signature (`field, value, constraint`) but threads + * the full issue list through `details` so callers can inspect every + * pointer, not just the first. + */ +export function buildValidationError( + tool: string, + side: 'request' | 'response', + issues: ValidationIssue[] +): ValidationError { + const first = issues[0]; + const field = first?.pointer ?? '/'; + const constraint = first ? `${first.keyword}: ${first.message}` : 'schema validation failed'; + const err = new ValidationError(field, undefined, `${tool} ${side}: ${constraint}`); + err.details = { tool, side, issues } satisfies ValidationErrorDetails; + return err; +} + +/** + * Shape of `adcp_error.details` inside a server-side `VALIDATION_ERROR` + * envelope. Shipped so buyers can index every pointer programmatically + * instead of parsing the free-text message. + */ +export interface AdcpValidationErrorDetails { + tool: string; + side: 'request' | 'response'; + issues: ValidationIssue[]; +} + +/** Serialize issues for the server-side `adcpError('VALIDATION_ERROR', ...)` call. */ +export function buildAdcpValidationErrorPayload( + tool: string, + side: 'request' | 'response', + issues: ValidationIssue[] +): { message: string; field?: string; details: Record } { + const first = issues[0]; + const message = + first != null + ? `${tool} ${side} failed schema validation at ${first.pointer}: ${first.message}` + : `${tool} ${side} failed schema validation`; + const payload: { message: string; field?: string; details: Record } = { + message, + details: { tool, side, issues } satisfies AdcpValidationErrorDetails as unknown as Record, + }; + if (first?.pointer) payload.field = first.pointer; + return payload; +} diff --git a/src/lib/validation/schema-loader.ts b/src/lib/validation/schema-loader.ts new file mode 100644 index 000000000..8d78063eb --- /dev/null +++ b/src/lib/validation/schema-loader.ts @@ -0,0 +1,199 @@ +/** + * JSON Schema loader for AdCP tool request/response validation. + * + * Loads the bundled per-tool schemas shipped with the SDK plus the + * `core/` schemas that async response variants `$ref`, then compiles + * AJV validators lazily by `(toolName, direction)`. + */ + +import Ajv, { type ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; +import { readdirSync, readFileSync, existsSync } from 'fs'; +import path from 'path'; +import { ADCP_VERSION } from '../version'; + +export type ResponseVariant = 'sync' | 'submitted' | 'working' | 'input-required'; +export type Direction = 'request' | ResponseVariant; + +interface LoadedSchema { + $id?: string; + [k: string]: unknown; +} + +const SCHEMA_FILENAME_SUFFIX: Record = { + request: 'request', + sync: 'response', + submitted: 'async-response-submitted', + working: 'async-response-working', + 'input-required': 'async-response-input-required', +}; + +/** + * Resolve the directory that holds the bundled + core schemas copied into + * the package at build time. Source layout (dev): + * schemas/cache//{bundled,core} + * Built layout (dist): + * dist/lib/schemas-data//{bundled,core} + */ +function resolveSchemaRoot(): string { + const distCandidate = path.join(__dirname, '..', 'schemas-data', ADCP_VERSION); + if (existsSync(distCandidate)) return distCandidate; + + const srcCandidate = path.join(__dirname, '..', '..', '..', 'schemas', 'cache', ADCP_VERSION); + if (existsSync(srcCandidate)) return srcCandidate; + + throw new Error( + `AdCP schema data not found. Looked in ${distCandidate} and ${srcCandidate}. ` + + `Run \`npm run sync-schemas\` and \`npm run build:lib\`.` + ); +} + +interface LoaderState { + ajv: Ajv; + fileIndex: Map; + validators: Map; + root: string; + coreLoaded: boolean; +} + +let state: LoaderState | undefined; + +function walkJsonFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkJsonFiles(full)); + else if (entry.isFile() && entry.name.endsWith('.json')) out.push(full); + } + return out; +} + +function loadJson(file: string): LoadedSchema { + return JSON.parse(readFileSync(file, 'utf-8')) as LoadedSchema; +} + +/** + * Build the (toolName, direction) → file path index by scanning the schema + * tree once. Runs eagerly at first validator lookup. + */ +function buildFileIndex(root: string): Map { + const index = new Map(); + const record = (toolName: string, direction: Direction, file: string): void => { + index.set(`${toolName}::${direction}`, file); + }; + + // Sync request/response live in the pre-resolved bundled/ tree. + const bundledRoot = path.join(root, 'bundled'); + for (const file of walkJsonFiles(bundledRoot)) { + const base = path.basename(file, '.json'); + if (base.endsWith('-request')) { + const tool = base.slice(0, -'-request'.length).replace(/-/g, '_'); + record(tool, 'request', file); + } else if (base.endsWith('-response')) { + const tool = base.slice(0, -'-response'.length).replace(/-/g, '_'); + record(tool, 'sync', file); + } + } + + // Async variants aren't bundled upstream — they live in the flat per-domain + // directory with $refs to core/context.json and core/ext.json. + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'bundled' || entry.name === 'core') continue; + const domainDir = path.join(root, entry.name); + for (const file of walkJsonFiles(domainDir)) { + const base = path.basename(file, '.json'); + if (base.endsWith('-async-response-submitted')) { + const tool = base.slice(0, -'-async-response-submitted'.length).replace(/-/g, '_'); + record(tool, 'submitted', file); + } else if (base.endsWith('-async-response-working')) { + const tool = base.slice(0, -'-async-response-working'.length).replace(/-/g, '_'); + record(tool, 'working', file); + } else if (base.endsWith('-async-response-input-required')) { + const tool = base.slice(0, -'-async-response-input-required'.length).replace(/-/g, '_'); + record(tool, 'input-required', file); + } + } + } + + return index; +} + +function ensureInit(): LoaderState { + if (state) return state; + + const root = resolveSchemaRoot(); + const ajv = new Ajv({ + strict: false, + allErrors: true, + allowUnionTypes: true, + }); + addFormats(ajv); + + state = { + ajv, + fileIndex: buildFileIndex(root), + validators: new Map(), + root, + coreLoaded: false, + }; + return state; +} + +/** + * Lazily load `core/` schemas on first compile of an async response variant. + * Deferring keeps cold-start cheap for the common case (sync request/response). + */ +function ensureCoreLoaded(s: LoaderState): void { + if (s.coreLoaded) return; + const coreDir = path.join(s.root, 'core'); + for (const file of walkJsonFiles(coreDir)) { + const schema = loadJson(file); + if (typeof schema.$id === 'string' && !s.ajv.getSchema(schema.$id)) { + s.ajv.addSchema(schema); + } + } + s.coreLoaded = true; +} + +/** + * Look up the compiled AJV validator for a given tool + direction. + * Returns `undefined` when no schema exists for the pair — callers can + * skip validation cleanly (e.g., custom tools outside the AdCP spec). + */ +export function getValidator(toolName: string, direction: Direction): ValidateFunction | undefined { + const s = ensureInit(); + const cacheKey = `${toolName}::${direction}`; + const cached = s.validators.get(cacheKey); + if (cached) return cached; + + const file = s.fileIndex.get(cacheKey); + if (!file) return undefined; + + // Async response variants $ref into core/ — only pay that load cost when + // we're actually about to compile one. + if (direction !== 'request' && direction !== 'sync') { + ensureCoreLoaded(s); + } + + const schema = loadJson(file); + const existing = typeof schema.$id === 'string' ? s.ajv.getSchema(schema.$id) : undefined; + const compiled = existing ?? s.ajv.compile(schema); + s.validators.set(cacheKey, compiled); + return compiled; +} + +/** List of (toolName, direction) pairs that have schemas. Used by tests. */ +export function listValidatorKeys(): string[] { + const s = ensureInit(); + return [...s.fileIndex.keys()].sort(); +} + +/** Suffix used in the suffix table — exported for testing. */ +export { SCHEMA_FILENAME_SUFFIX }; + +/** Test hook: reset cached state so a fresh init runs. */ +export function _resetValidationLoader(): void { + state = undefined; +} diff --git a/src/lib/validation/schema-validator.ts b/src/lib/validation/schema-validator.ts new file mode 100644 index 000000000..e969c04e1 --- /dev/null +++ b/src/lib/validation/schema-validator.ts @@ -0,0 +1,108 @@ +/** + * Schema-driven validation for AdCP tool requests and responses. + * + * The client uses this pre-send and post-receive; the opt-in server + * middleware uses the same core to reject drift at the dispatcher. + */ + +import type { ErrorObject } from 'ajv'; +import { getValidator, type Direction, type ResponseVariant } from './schema-loader'; + +/** + * A single validation failure with a JSON Pointer to the offending field, + * the AJV message, and the schema path that rejected it. Mirrors the + * format a `VALIDATION_ERROR` carries in its `details.issues`. + */ +export interface ValidationIssue { + /** RFC 6901 JSON Pointer to the offending field in the payload. */ + pointer: string; + /** Human-readable message from the schema. */ + message: string; + /** AJV keyword that rejected the payload (e.g., `required`, `type`). */ + keyword: string; + /** Path inside the schema that rejected the payload. */ + schemaPath: string; +} + +export interface ValidationOutcome { + valid: boolean; + issues: ValidationIssue[]; + /** Which schema variant was selected — useful for logging/debugging. */ + variant: Direction | 'skipped'; +} + +const OK: ValidationOutcome = Object.freeze({ valid: true, issues: [], variant: 'skipped' }); + +function formatIssue(err: ErrorObject): ValidationIssue { + const instancePath = err.instancePath || ''; + const missingProperty = + err.keyword === 'required' && + err.params && + typeof (err.params as { missingProperty?: string }).missingProperty === 'string' + ? `/${(err.params as { missingProperty: string }).missingProperty}` + : ''; + return { + pointer: `${instancePath}${missingProperty}` || '/', + message: err.message ?? 'validation failed', + keyword: err.keyword, + schemaPath: err.schemaPath, + }; +} + +/** Validate an outgoing request against `{tool}-request.json`. */ +export function validateRequest(toolName: string, payload: unknown): ValidationOutcome { + const validator = getValidator(toolName, 'request'); + if (!validator) return OK; + const valid = validator(payload) as boolean; + if (valid) return { valid: true, issues: [], variant: 'request' }; + return { + valid: false, + issues: (validator.errors ?? []).map(formatIssue), + variant: 'request', + }; +} + +/** + * Select the response variant by payload shape (per issue #688: choose by + * `status` field, not just the tool name). Matches the AdCP 3.0 async + * contract: `submitted`, `working`, `input-required`, and the sync + * terminal states (`completed` / no status). + */ +function selectResponseVariant(payload: unknown): ResponseVariant { + if (payload && typeof payload === 'object' && 'status' in (payload as Record)) { + const status = (payload as Record).status; + if (status === 'submitted') return 'submitted'; + if (status === 'working') return 'working'; + if (status === 'input-required') return 'input-required'; + } + return 'sync'; +} + +/** Validate an incoming response; picks the async variant by payload shape. */ +export function validateResponse(toolName: string, payload: unknown): ValidationOutcome { + const variant = selectResponseVariant(payload); + const validator = getValidator(toolName, variant); + // If an async variant schema is missing, fall back to the sync one — + // some tools declare `-response.json` only and use `status` as an + // in-band marker without a dedicated variant schema. + const effective = validator ?? (variant !== 'sync' ? getValidator(toolName, 'sync') : undefined); + if (!effective) return OK; + const valid = effective(payload) as boolean; + const usedVariant: Direction = validator ? variant : 'sync'; + if (valid) return { valid: true, issues: [], variant: usedVariant }; + return { + valid: false, + issues: (effective.errors ?? []).map(formatIssue), + variant: usedVariant, + }; +} + +/** Render a compact one-line summary of the failures — useful for logs. */ +export function formatIssues(issues: ValidationIssue[], limit = 3): string { + const head = issues + .slice(0, limit) + .map(i => `${i.pointer} ${i.message}`) + .join('; '); + const rest = issues.length - limit; + return rest > 0 ? `${head} (+${rest} more)` : head; +} diff --git a/test/lib/schema-validation-server.test.js b/test/lib/schema-validation-server.test.js new file mode 100644 index 000000000..f0bb553b6 --- /dev/null +++ b/test/lib/schema-validation-server.test.js @@ -0,0 +1,176 @@ +// Server-middleware integration tests for schema-driven validation (issue #688). +// Exercises createAdcpServer's opt-in request/response validator against real handlers. + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const { createAdcpServer, InMemoryStateStore } = require('../../dist/lib/index.js'); + +async function callTool(server, toolName, params) { + const raw = await server.dispatchTestRequest({ + method: 'tools/call', + params: { name: toolName, arguments: params ?? {} }, + }); + return raw; +} + +const VALID_GET_PRODUCTS = { + brief: 'test campaign', + promoted_offering: 'shoes', + buying_mode: 'brief', +}; + +describe('createAdcpServer validation middleware', () => { + describe('requests: "strict"', () => { + test('rejects malformed requests with VALIDATION_ERROR before dispatch', async () => { + let handlerCalled = false; + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + validation: { requests: 'strict' }, + mediaBuy: { + getProducts: async () => { + handlerCalled = true; + return { products: [] }; + }, + }, + }); + + const res = await callTool(server, 'get_products', {}); // missing required fields + assert.strictEqual(handlerCalled, false, 'handler must not run when request fails schema'); + assert.strictEqual(res.isError, true); + assert.strictEqual(res.structuredContent.adcp_error.code, 'VALIDATION_ERROR'); + assert.ok(res.structuredContent.adcp_error.field, 'expected field pointer on error'); + assert.strictEqual(res.structuredContent.adcp_error.details.side, 'request'); + }); + + test('accepts valid requests unchanged', async () => { + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + validation: { requests: 'strict' }, + mediaBuy: { + getProducts: async () => ({ products: [] }), + }, + }); + + const res = await callTool(server, 'get_products', VALID_GET_PRODUCTS); + assert.notStrictEqual(res.isError, true); + assert.ok(Array.isArray(res.structuredContent.products)); + }); + }); + + describe('requests: "warn"', () => { + test('logs warning but still dispatches', async () => { + const warnings = []; + const logger = { + info: () => {}, + warn: (msg, meta) => warnings.push({ msg, meta }), + error: () => {}, + debug: () => {}, + }; + let handlerCalled = false; + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + logger, + validation: { requests: 'warn' }, + mediaBuy: { + getProducts: async () => { + handlerCalled = true; + return { products: [] }; + }, + }, + }); + + await callTool(server, 'get_products', {}); + assert.strictEqual(handlerCalled, true, 'warn mode must not block dispatch'); + const validationWarnings = warnings.filter(w => w.msg.includes('Schema validation warning (request)')); + assert.ok(validationWarnings.length > 0, 'expected at least one validation warning in logger'); + }); + }); + + describe('requests: "off" (default)', () => { + test('does not validate when no validation config provided', async () => { + let handlerCalled = false; + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + mediaBuy: { + getProducts: async () => { + handlerCalled = true; + return { products: [] }; + }, + }, + }); + + await callTool(server, 'get_products', {}); + assert.strictEqual(handlerCalled, true, 'off mode must not block dispatch'); + }); + }); + + describe('responses', () => { + test('strict: drift in handler response surfaces VALIDATION_ERROR', async () => { + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + validation: { responses: 'strict' }, + mediaBuy: { + // Drift: products should be an array, not a string. + getProducts: async () => ({ products: 'oops' }), + }, + }); + + const res = await callTool(server, 'get_products', VALID_GET_PRODUCTS); + assert.strictEqual(res.isError, true); + assert.strictEqual(res.structuredContent.adcp_error.code, 'VALIDATION_ERROR'); + assert.strictEqual(res.structuredContent.adcp_error.details.side, 'response'); + }); + + test('warn: drift in response logs but returns response unchanged', async () => { + const warnings = []; + const logger = { + info: () => {}, + warn: (msg, meta) => warnings.push({ msg, meta }), + error: () => {}, + debug: () => {}, + }; + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + logger, + validation: { responses: 'warn' }, + mediaBuy: { + getProducts: async () => ({ products: 'oops' }), + }, + }); + + const res = await callTool(server, 'get_products', VALID_GET_PRODUCTS); + assert.notStrictEqual(res.isError, true, 'warn mode should not turn the response into an error'); + assert.strictEqual(res.structuredContent.products, 'oops', 'original response passes through'); + const validationWarnings = warnings.filter(w => w.msg.includes('Schema validation warning (response)')); + assert.ok(validationWarnings.length > 0); + }); + + test('valid handler responses pass strict mode', async () => { + const server = createAdcpServer({ + name: 'test', + version: '0.0.1', + stateStore: new InMemoryStateStore(), + validation: { responses: 'strict' }, + mediaBuy: { + getProducts: async () => ({ products: [] }), + }, + }); + + const res = await callTool(server, 'get_products', VALID_GET_PRODUCTS); + assert.notStrictEqual(res.isError, true); + }); + }); +}); diff --git a/test/lib/schema-validation.test.js b/test/lib/schema-validation.test.js new file mode 100644 index 000000000..243bc96de --- /dev/null +++ b/test/lib/schema-validation.test.js @@ -0,0 +1,211 @@ +// Unit tests for the schema-driven validator (issue #688). +// Exercises the real bundled schemas shipped with the SDK. + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const { + validateRequest, + validateResponse, + formatIssues, + buildValidationError, + buildAdcpValidationErrorPayload, + listValidatorKeys, + resolveValidationModes, +} = require('../../dist/lib/validation'); +const { validateOutgoingRequest, validateIncomingResponse } = require('../../dist/lib/validation/client-hooks.js'); +const { ValidationError } = require('../../dist/lib/errors'); + +describe('schema-driven validation', () => { + describe('validateRequest', () => { + test('flags missing required fields with a JSON Pointer', () => { + const outcome = validateRequest('get_products', {}); + assert.strictEqual(outcome.valid, false); + assert.ok(outcome.issues.length > 0); + const pointers = outcome.issues.map(i => i.pointer); + // `buying_mode` is declared required on the get_products request. + assert.ok(pointers.includes('/buying_mode'), `expected /buying_mode in ${pointers.join(', ')}`); + }); + + test('returns skipped for tools outside the AdCP catalog', () => { + const outcome = validateRequest('custom_seller_extension', { anything: true }); + assert.strictEqual(outcome.valid, true); + assert.strictEqual(outcome.variant, 'skipped'); + }); + + test('accepts extension fields without error (additionalProperties permissive)', () => { + const outcome = validateRequest('get_products', { + brief: 'campaign brief', + promoted_offering: 'product', + buying_mode: 'sponsorship', + // `ext` is a recognized extension hook; unknown vendor namespaces pass. + ext: { gam: { custom_field: 1 } }, + // A completely unknown top-level extension should also pass. + unknown_vendor_field: { ok: true }, + }); + // Response may or may not fully validate (other fields may be required); + // the test is about additionalProperties tolerance, so we only assert + // that the unknown extensions themselves don't show up in errors. + for (const issue of outcome.issues) { + assert.notStrictEqual(issue.pointer, '/unknown_vendor_field'); + assert.ok(!issue.pointer.startsWith('/ext')); + } + }); + }); + + describe('validateResponse', () => { + test('selects the submitted variant when status === submitted', () => { + const outcome = validateResponse('create_media_buy', { status: 'submitted', task_id: 't_1' }); + assert.strictEqual(outcome.valid, true); + assert.strictEqual(outcome.variant, 'submitted'); + }); + + test('selects the working variant when status === working', () => { + const outcome = validateResponse('create_media_buy', { status: 'working', task_id: 't_2' }); + assert.strictEqual(outcome.valid, true); + assert.strictEqual(outcome.variant, 'working'); + }); + + test('selects the input-required variant', () => { + const outcome = validateResponse('create_media_buy', { + status: 'input-required', + task_id: 't_3', + }); + assert.strictEqual(outcome.valid, true); + assert.strictEqual(outcome.variant, 'input-required'); + }); + + test('falls back to sync variant when no status field present', () => { + // Raw partial payload — triggers sync schema errors on missing fields. + const outcome = validateResponse('create_media_buy', { media_buy_id: 'mb_1' }); + assert.strictEqual(outcome.variant, 'sync'); + }); + + test('surfaces schema errors with pointer + keyword + schemaPath', () => { + const outcome = validateResponse('get_products', { products: 'not-an-array' }); + assert.strictEqual(outcome.valid, false); + const productsIssue = outcome.issues.find(i => i.pointer === '/products'); + assert.ok(productsIssue, 'expected an issue at /products'); + assert.strictEqual(productsIssue.keyword, 'type'); + assert.ok(productsIssue.schemaPath.length > 0); + }); + }); + + describe('formatIssues', () => { + test('caps verbose failures and notes how many more there are', () => { + const issues = [ + { pointer: '/a', message: 'oops', keyword: 'required', schemaPath: '#/required' }, + { pointer: '/b', message: 'oops', keyword: 'required', schemaPath: '#/required' }, + { pointer: '/c', message: 'oops', keyword: 'required', schemaPath: '#/required' }, + { pointer: '/d', message: 'oops', keyword: 'required', schemaPath: '#/required' }, + ]; + const summary = formatIssues(issues, 2); + assert.ok(summary.includes('/a')); + assert.ok(summary.includes('/b')); + assert.ok(summary.includes('(+2 more)')); + }); + }); + + describe('buildValidationError / buildAdcpValidationErrorPayload', () => { + test('wraps issues into a ValidationError carrying details', () => { + const issues = [{ pointer: '/foo/bar', message: 'bad', keyword: 'type', schemaPath: '#/properties/foo/bar' }]; + const err = buildValidationError('get_products', 'request', issues); + assert.ok(err instanceof ValidationError); + assert.strictEqual(err.code, 'VALIDATION_ERROR'); + assert.strictEqual(err.details.tool, 'get_products'); + assert.strictEqual(err.details.side, 'request'); + assert.deepStrictEqual(err.details.issues, issues); + }); + + test('builds an L3 error payload for adcpError()', () => { + const issues = [ + { pointer: '/media_buy_id', message: 'is required', keyword: 'required', schemaPath: '#/required' }, + ]; + const payload = buildAdcpValidationErrorPayload('create_media_buy', 'response', issues); + assert.ok(payload.message.includes('/media_buy_id')); + assert.strictEqual(payload.field, '/media_buy_id'); + assert.strictEqual(payload.details.tool, 'create_media_buy'); + assert.strictEqual(payload.details.side, 'response'); + }); + }); + + describe('listValidatorKeys', () => { + test('exposes every (tool, direction) pair with a shipped schema', () => { + const keys = listValidatorKeys(); + assert.ok(keys.length > 0); + // Spot-check a handful we know must be present. + for (const key of ['get_products::request', 'get_products::sync', 'create_media_buy::submitted']) { + assert.ok(keys.includes(key), `missing ${key}`); + } + }); + }); + + describe('client hooks', () => { + test('validateOutgoingRequest strict throws a ValidationError', () => { + assert.throws( + () => validateOutgoingRequest('create_media_buy', {}, 'strict'), + err => err instanceof ValidationError && err.code === 'VALIDATION_ERROR' + ); + }); + + test('validateOutgoingRequest warn logs and returns without throwing', () => { + const logs = []; + const outcome = validateOutgoingRequest('create_media_buy', {}, 'warn', logs); + assert.strictEqual(outcome.valid, false); + assert.ok(logs.length === 1); + assert.strictEqual(logs[0].type, 'warning'); + }); + + test('validateOutgoingRequest off short-circuits without consulting the validator', () => { + const outcome = validateOutgoingRequest('create_media_buy', {}, 'off'); + assert.strictEqual(outcome, undefined); + }); + + test('validateIncomingResponse off is a no-op valid', () => { + const outcome = validateIncomingResponse('get_products', { products: 'not-array' }, 'off'); + assert.strictEqual(outcome.valid, true); + }); + + test('validateIncomingResponse warn logs and returns invalid outcome', () => { + const logs = []; + const outcome = validateIncomingResponse('get_products', { products: 'not-array' }, 'warn', logs); + assert.strictEqual(outcome.valid, false); + assert.ok(logs.length === 1); + }); + }); + + describe('resolveValidationModes defaults', () => { + test('requests default to warn', () => { + const modes = resolveValidationModes(); + assert.strictEqual(modes.requests, 'warn'); + }); + + test('responses default to strict in non-production', () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + const modes = resolveValidationModes(); + assert.strictEqual(modes.responses, 'strict'); + } finally { + process.env.NODE_ENV = prev; + } + }); + + test('responses default to warn in production', () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const modes = resolveValidationModes(); + assert.strictEqual(modes.responses, 'warn'); + } finally { + process.env.NODE_ENV = prev; + } + }); + + test('explicit config overrides defaults', () => { + const modes = resolveValidationModes({ requests: 'strict', responses: 'off' }); + assert.strictEqual(modes.requests, 'strict'); + assert.strictEqual(modes.responses, 'off'); + }); + }); +}); diff --git a/test/lib/storyboard-completeness.test.js b/test/lib/storyboard-completeness.test.js index fb7abc6eb..3d9454281 100644 --- a/test/lib/storyboard-completeness.test.js +++ b/test/lib/storyboard-completeness.test.js @@ -40,6 +40,10 @@ const HARNESS_TASKS = new Set([ 'expect_webhook', 'expect_webhook_retry_keys_stable', 'expect_webhook_signature_valid', + // Substitution-safety observer for catalog-driven sellers. The runner + // inspects the previous step's preview artifact rather than issuing a + // tool call — no request or response schema applies. + 'expect_substitution_safe', ]); // Tasks that reference test-kit data (e.g. "$test_kit.auth.probe_task"). The