diff --git a/.changeset/verify-deterministic-read-filters.md b/.changeset/verify-deterministic-read-filters.md new file mode 100644 index 0000000000..ca6ecd3a56 --- /dev/null +++ b/.changeset/verify-deterministic-read-filters.md @@ -0,0 +1,7 @@ +--- +"adcontextprotocol": patch +--- + +Add behavioral conformance scenarios for media-buy status and ID filters, +half-open delivery date ranges, and sales-agent creative status and assignment +filters. Update the training agent to honor `list_creatives` media-buy filters. diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index 1d9391933b..889dd24468 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -1306,6 +1306,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo const targetsControllerFixtureState = scenario === 'seed_product' || scenario === 'seed_pricing_option' || scenario === 'seed_measurement_catalog'; + const targetsPublicTaskState = scenario === 'seed_media_buy' + || scenario === 'seed_creative'; // The frozen 3.0 runner injects a synthetic natural account into controller // and fixture calls, sometimes without copying its brand to the top level. // Platform methods on that compatibility surface historically key by brand. @@ -1320,7 +1322,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo ? args.account.account_id : undefined; let staticFixtureAccount: ToolArgs['account'] | undefined; - if (targetsControllerFixtureState && ctx.principal?.startsWith('static:') && args.account) { + let staticTaskAccount: ToolArgs['account'] | undefined; + if ((targetsControllerFixtureState || targetsPublicTaskState) && ctx.principal?.startsWith('static:') && args.account) { try { const canonical = canonicalizeAccountRef(args.account); if (canonical.kind === 'natural' && canonical.sandbox) { @@ -1329,11 +1332,20 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo // task examples may name the buyer operator. Canonicalize only this // fixture projection to the brand-owned sandbox partition; real // principals keep the complete natural account identity. - staticFixtureAccount = { + const brandOwnedAccount = { brand: canonical.brand, operator: canonical.brand.domain, - sandbox: true, }; + if (targetsControllerFixtureState) { + staticFixtureAccount = { ...brandOwnedAccount, sandbox: true }; + } else { + // The SDK strips the controller-only sandbox assertion before + // ordinary media-buy reads. Store public demo entity fixtures in + // that exact brand-owned task partition so seed_media_buy and + // seed_creative remain observable without projecting entity state + // across the isolated controller-fixture boundary. + staticTaskAccount = { ...brandOwnedAccount, sandbox: false }; + } } } catch { staticFixtureAccount = undefined; @@ -1351,7 +1363,9 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo ? { ...args, account: { account_id: opaqueAccountId }, brand: undefined } : staticFixtureAccount ? { ...args, account: staticFixtureAccount } - : args; + : staticTaskAccount + ? { ...args, account: staticTaskAccount } + : args; let sessionKey = targetsGetProductsState ? getProductsSessionKeyFromArgs(sessionArgs, ctx.mode, ctx.userId, ctx.moduleId) : sessionKeyFromArgs( diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 0fea991fda..0cc1e34ac8 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -8753,8 +8753,10 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon const { model: pricingModel, rate } = derivePricing(pkg, productMap); const isRevenueShare = pricingModel === 'revenue_share'; + const useScopedSimulation = simDelivery !== undefined + && Boolean(req.start_date || req.end_date); const budget = pkg.budget; - const spend = isRevenueShare + const spend = isRevenueShare || useScopedSimulation ? (simDelivery?.reportedSpend.amount ?? 0) : Math.round(budget * elapsed * 100) / 100; @@ -8770,12 +8772,14 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon else if (channels?.some(c => ['print'].includes(c))) ctr = 0; else ctr = 0.001; - const impressions = isRevenueShare + const impressions = isRevenueShare || useScopedSimulation ? (simDelivery?.impressions ?? 0) : rate > 0 ? Math.round((spend / rate) * 1000) : 0; - const clicks = isRevenueShare ? (simDelivery?.clicks ?? 0) : Math.round(impressions * ctr); + const clicks = isRevenueShare || useScopedSimulation + ? (simDelivery?.clicks ?? 0) + : Math.round(impressions * ctr); - if (!isRevenueShare) { + if (!isRevenueShare && !useScopedSimulation) { totalImpressions += impressions; totalSpend += spend; totalClicks += clicks; @@ -9299,6 +9303,7 @@ function accountRefsOverlap(stored: AccountRef | undefined, requested: AccountRe type CreativeListFilters = { creative_ids?: string[]; statuses?: string[]; + media_buy_ids?: string[]; format_ids?: FormatID[]; asset_types?: string[]; }; @@ -9422,6 +9427,17 @@ export async function handleListCreatives(args: ToolArgs, ctx: TrainingContext) const statuses = new Set(filters.statuses); creatives = creatives.filter(c => statuses.has(c.status)); } + if (filters.media_buy_ids?.length) { + const requestedMediaBuyIds = new Set(filters.media_buy_ids); + const assignedCreativeIds = new Set(); + for (const mediaBuy of session.mediaBuys.values()) { + if (!requestedMediaBuyIds.has(mediaBuy.mediaBuyId)) continue; + for (const pkg of mediaBuy.packages) { + for (const creativeId of pkg.creativeAssignments) assignedCreativeIds.add(creativeId); + } + } + creatives = creatives.filter(c => assignedCreativeIds.has(c.creativeId)); + } const formatKinds = (req.filters as unknown as { format_kinds?: string[] } | undefined)?.format_kinds; const filterProjectionAdapters = formatKinds?.length || filters.format_ids?.length ? creativeProjectionAdapters() diff --git a/server/tests/unit/comply-test-controller.test.ts b/server/tests/unit/comply-test-controller.test.ts index fd708656e9..6c16b0bf6a 100644 --- a/server/tests/unit/comply-test-controller.test.ts +++ b/server/tests/unit/comply-test-controller.test.ts @@ -701,6 +701,56 @@ describe('comply_test_controller', () => { expect(found.some(b => b.media_buy_id === 'seeded_mb_1')).toBe(true); }); + it('keeps public controller entity seeds visible after the SDK strips sandbox', async () => { + const publicServer = createTrainingAgentServer({ mode: 'open', principal: 'static:public' }); + const sandboxAccount = { + brand: { domain: 'public-seed.example' }, + operator: 'public-seed.example', + sandbox: true, + }; + const taskAccount = { + brand: sandboxAccount.brand, + operator: sandboxAccount.operator, + }; + + await simulateCallTool(publicServer, 'comply_test_controller', { + scenario: 'seed_creative', + account: sandboxAccount, + params: { + creative_id: 'public_seed_creative', + fixture: { status: 'approved', format_id: { id: 'display_300x250' } }, + }, + }); + await simulateCallTool(publicServer, 'comply_test_controller', { + scenario: 'seed_media_buy', + account: sandboxAccount, + params: { + media_buy_id: 'public_seed_buy', + fixture: { + status: 'active', + packages: [{ + package_id: 'public_seed_package', + creative_assignments: ['public_seed_creative'], + }], + }, + }, + }); + + const { result: buys } = await simulateCallTool(publicServer, 'get_media_buys', { + account: taskAccount, + media_buy_ids: ['public_seed_buy'], + }); + expect((buys as any).media_buys.map((buy: any) => buy.media_buy_id)).toEqual(['public_seed_buy']); + + const { result: creatives } = await simulateCallTool(publicServer, 'list_creatives', { + account: taskAccount, + filters: { media_buy_ids: ['public_seed_buy'] }, + }); + expect((creatives as any).creatives.map((creative: any) => creative.creative_id)).toEqual([ + 'public_seed_creative', + ]); + }); + it('seed_media_buy preserves available_actions and enforces non-self-serve mode mismatch', async () => { const { result, isError } = await simulateCallTool(server, 'comply_test_controller', { scenario: 'seed_media_buy', diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 8574e8faf2..ae0a40be39 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -6909,6 +6909,65 @@ describe('list_creatives handler', () => { expect(pg.total_count).toBe(1); }); + it('filters creatives by status and media buy assignment with AND semantics', async () => { + const account = { + brand: { domain: 'creative-read-filters.example' }, + operator: 'creative-read-filters.example', + sandbox: true, + }; + const server = createTrainingAgentServer(DEFAULT_CTX); + + for (const [creativeId, status] of [ + ['cr_filter_match', 'rejected'], + ['cr_filter_wrong_status', 'approved'], + ['cr_filter_wrong_buy', 'rejected'], + ] as const) { + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_creative', + params: { + creative_id: creativeId, + fixture: { status, format_kind: 'image' }, + }, + }); + } + + for (const [mediaBuyId, creativeAssignments] of [ + ['mb_filter_target', ['cr_filter_match', 'cr_filter_wrong_status']], + ['mb_filter_other', ['cr_filter_wrong_buy']], + ] as const) { + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_media_buy', + params: { + media_buy_id: mediaBuyId, + fixture: { + status: 'active', + packages: [{ + package_id: `${mediaBuyId}_package`, + creative_assignments: creativeAssignments, + }], + }, + }, + }); + } + + const { result } = await simulateCallTool(server, 'list_creatives', { + account, + adcp_version: '3.1', + ext: { adcp: { creative_wire: 'legacy' } }, + filters: { + statuses: ['rejected'], + media_buy_ids: ['mb_filter_target'], + }, + }); + + expect((result.creatives as Array<{ creative_id: string; status: string }>)).toEqual([ + expect.objectContaining({ creative_id: 'cr_filter_match', status: 'rejected' }), + ]); + expect(result.query_summary).toEqual({ total_matching: 1, returned: 1 }); + }); + it('filters by top-level asset type and composes with format_ids', async () => { const account = { brand: { domain: 'assetfilters.example' }, operator: 'assetfilters.example' }; const server = createTrainingAgentServer(DEFAULT_CTX); diff --git a/static/compliance/source/protocols/creative/scenarios/list_creatives_filter_behavior.yaml b/static/compliance/source/protocols/creative/scenarios/list_creatives_filter_behavior.yaml new file mode 100644 index 0000000000..da6cdd16c8 --- /dev/null +++ b/static/compliance/source/protocols/creative/scenarios/list_creatives_filter_behavior.yaml @@ -0,0 +1,126 @@ +id: media_buy_seller/list_creatives_filter_behavior +version: "1.0.0" +introduced_in: "3.2" +title: "Sales agent applies deterministic creative filters" +category: media_buy_seller +summary: "Verifies a sales agent applies creative-status and media-buy-assignment filters conjunctively." +track: creative + +required_tools: + - list_creatives + - comply_test_controller + +narrative: | + A sales agent may expose its creative library through list_creatives, where + both approval status and media-buy assignment are deterministic predicates. + The controller seeds one matching creative and two independent negative + controls so ignoring either accepted filter produces an observable superset. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - has_creative_library + examples: + - "Publishers and SSPs that manage creative assignments for media buys" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + Three creatives are seeded across two media buys: a rejected target, an + approved status negative control on the target buy, and a rejected + assignment negative control on another buy. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + creatives: + - creative_id: "creative_filter_match" + name: "Rejected creative on requested buy" + status: "rejected" + format_id: + id: "display_300x250" + - creative_id: "creative_filter_wrong_status" + name: "Approved creative on requested buy" + status: "approved" + format_id: + id: "display_300x250" + - creative_id: "creative_filter_wrong_buy" + name: "Rejected creative on another buy" + status: "rejected" + format_id: + id: "display_300x250" + media_buys: + - media_buy_id: "creative_filter_target_buy" + account: &seed_account + brand: + domain: "acmeoutdoor.example" + operator: "acmeoutdoor.example" + sandbox: true + status: "active" + currency: "USD" + start_time: "2026-01-01T00:00:00Z" + end_time: "2099-12-31T00:00:00Z" + packages: + - package_id: "creative_filter_target_package" + product_id: "creative_filter_product" + pricing_option_id: "creative_filter_pricing" + budget: 1000 + creative_assignments: + - "creative_filter_match" + - "creative_filter_wrong_status" + - media_buy_id: "creative_filter_other_buy" + account: *seed_account + status: "active" + currency: "USD" + start_time: "2026-01-01T00:00:00Z" + end_time: "2099-12-31T00:00:00Z" + packages: + - package_id: "creative_filter_other_package" + product_id: "creative_filter_product" + pricing_option_id: "creative_filter_pricing" + budget: 1000 + creative_assignments: ["creative_filter_wrong_buy"] + +phases: + - id: creative_membership + title: "Apply status and assignment filters conjunctively" + steps: + - id: list_filtered_creatives + title: "Exclude both independent negative controls" + task: list_creatives + schema_ref: "creative/list-creatives-request.json" + response_schema_ref: "creative/list-creatives-response.json" + doc_ref: "/creative/task-reference/list_creatives" + stateful: true + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "acmeoutdoor.example" + filters: + statuses: ["rejected"] + media_buy_ids: ["creative_filter_target_buy"] + context: + correlation_id: "list_creatives_filter_behavior--combined" + expected: | + Return only the rejected creative assigned to the requested buy. + The approved creative catches a status no-op and the rejected + creative on the other buy catches an assignment-filter no-op. + validations: + - check: response_schema + description: "Response matches list-creatives-response.json" + - check: field_value + path: "creatives[0].creative_id" + value: "creative_filter_match" + description: "DR-0001 / list_creatives filter contract: the returned creative is assigned to the requested buy" + - check: field_value + path: "creatives[0].status" + value: "rejected" + description: "DR-0001 / list_creatives filter contract: the returned creative satisfies statuses" + - check: field_absent + path: "creatives[1]" + description: "Both independent negative-control creatives are excluded" diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 9b78803d0f..f8dd7cb21f 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -18,6 +18,8 @@ requires_scenarios: - media_buy_seller/product_signal_targeting - media_buy_seller/demographic_targeting - media_buy_seller/product_filter_behavior + - media_buy_seller/read_filter_behavior + - media_buy_seller/list_creatives_filter_behavior - media_buy_seller/targeting_aware_discovery - media_buy_seller/geo_place_targeting - media_buy_seller/available_actions diff --git a/static/compliance/source/protocols/media-buy/scenarios/read_filter_behavior.yaml b/static/compliance/source/protocols/media-buy/scenarios/read_filter_behavior.yaml new file mode 100644 index 0000000000..64faeea977 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/read_filter_behavior.yaml @@ -0,0 +1,259 @@ +id: media_buy_seller/read_filter_behavior +version: "1.0.0" +introduced_in: "3.2" +title: "Seller applies deterministic media-buy read filters" +category: media_buy_seller +summary: "Verifies get_media_buys membership filters and half-open get_media_buy_delivery date bounds instead of accepting them as no-ops." +track: reporting + +required_tools: + - get_media_buys + - get_media_buy_delivery + - comply_test_controller + +narrative: | + Deterministic read filters are protocol behavior, not request decoration. + The controller seeds positive, excluded, and boundary rows so an + implementation that accepts a filter but returns an unfiltered superset + fails an observable membership or boundary assertion. + + These checks implement the DR-0001 conformance contract with exact + membership and half-open range properties. They do not require two + arbitrary response payloads to differ. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - delivery_reporting + examples: + - "Any media-buy seller with deterministic status and delivery reads" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The controller seeds two differently-statused media buys and one creative + assignment per buy. Dated delivery rows fall before, on, and at the + exclusive end boundary of the requested interval. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "read_filter_product" + delivery_type: "non_guaranteed" + channels: ["display"] + format_options: + - format_option_id: "display_300x250" + format_kind: "image" + params: + width: 300 + height: 250 + pricing_options: + - product_id: "read_filter_product" + pricing_option_id: "read_filter_pricing" + pricing_model: "cpm" + currency: "USD" + fixed_price: 10 + creatives: + - creative_id: "read_filter_active_creative" + status: "approved" + format_kind: "image" + - creative_id: "read_filter_paused_creative" + status: "approved" + format_kind: "image" + media_buys: + - media_buy_id: "read_filter_active_buy" + account: &seed_account + brand: + domain: "acmeoutdoor.example" + operator: "acmeoutdoor.example" + sandbox: true + status: "active" + currency: "USD" + start_time: "2026-01-01T00:00:00Z" + end_time: "2099-12-31T00:00:00Z" + packages: + - package_id: "read_filter_active_package" + product_id: "read_filter_product" + pricing_option_id: "read_filter_pricing" + budget: 1000 + creative_assignments: ["read_filter_active_creative"] + - media_buy_id: "read_filter_paused_buy" + account: *seed_account + status: "paused" + currency: "USD" + start_time: "2026-01-01T00:00:00Z" + end_time: "2099-12-31T00:00:00Z" + packages: + - package_id: "read_filter_paused_package" + product_id: "read_filter_product" + pricing_option_id: "read_filter_pricing" + budget: 1000 + creative_assignments: ["read_filter_paused_creative"] + +phases: + - id: media_buy_membership + title: "Apply ID and status filters conjunctively" + steps: + - id: filter_media_buys + title: "Exclude an explicitly named buy with the wrong status" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + sample_request: + media_buy_ids: + - "read_filter_active_buy" + - "read_filter_paused_buy" + status_filter: ["paused"] + account: &buyer_account + brand: + domain: "acmeoutdoor.example" + operator: "acmeoutdoor.example" + sandbox: true + context: + correlation_id: "read_filter_behavior--media_buys" + expected: | + Return only the requested paused buy. The active buy is an explicit + negative control for status filtering, while an implementation that + ignores media_buy_ids cannot satisfy the exact one-row result. + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_value + path: "media_buys[0].media_buy_id" + value: "read_filter_paused_buy" + description: "DR-0001 / get_media_buys filter contract: the returned ID belongs to the requested set" + - check: field_value + path: "media_buys[0].status" + value: "paused" + description: "DR-0001 / get_media_buys filter contract: the returned row satisfies status_filter" + - check: field_absent + path: "media_buys[1]" + description: "The active negative-control row is excluded" + + - id: delivery_boundaries + title: "Apply a half-open delivery date range" + steps: + - id: seed_delivery_before_range + title: "Seed delivery before the requested range" + task: comply_test_controller + requires_tool: comply_test_controller + stateful: true + sample_request: + account: *buyer_account + scenario: "simulate_delivery" + params: + media_buy_id: "read_filter_active_buy" + delivery_date: "2026-04-01" + impressions: 100 + clicks: 10 + reported_spend: + amount: 10 + currency: "USD" + expected: "The controller stores the pre-range negative-control row." + validations: + - check: field_value + path: "success" + allowed_values: [true] + description: "Pre-range delivery simulation succeeds" + + - id: seed_delivery_at_start + title: "Seed delivery on the inclusive start boundary" + task: comply_test_controller + requires_tool: comply_test_controller + stateful: true + sample_request: + account: *buyer_account + scenario: "simulate_delivery" + params: + media_buy_id: "read_filter_active_buy" + delivery_date: "2026-04-15" + impressions: 200 + clicks: 20 + reported_spend: + amount: 20 + currency: "USD" + expected: "The controller stores the row that must be included." + validations: + - check: field_value + path: "success" + allowed_values: [true] + description: "Start-boundary delivery simulation succeeds" + + - id: seed_delivery_at_end + title: "Seed delivery on the exclusive end boundary" + task: comply_test_controller + requires_tool: comply_test_controller + stateful: true + sample_request: + account: *buyer_account + scenario: "simulate_delivery" + params: + media_buy_id: "read_filter_active_buy" + delivery_date: "2026-04-16" + impressions: 400 + clicks: 40 + reported_spend: + amount: 40 + currency: "USD" + expected: "The controller stores the end-boundary negative-control row." + validations: + - check: field_value + path: "success" + allowed_values: [true] + description: "End-boundary delivery simulation succeeds" + + - id: get_filtered_delivery + title: "Return only delivery inside the requested half-open range" + task: get_media_buy_delivery + schema_ref: "media-buy/get-media-buy-delivery-request.json" + response_schema_ref: "media-buy/get-media-buy-delivery-response.json" + doc_ref: "/media-buy/task-reference/get_media_buy_delivery" + stateful: true + sample_request: + account: *buyer_account + media_buy_ids: ["read_filter_active_buy"] + start_date: "2026-04-15" + end_date: "2026-04-16" + context: + correlation_id: "read_filter_behavior--delivery" + expected: | + Include the row dated April 15 and exclude both April 1 and the + April 16 exclusive boundary. The reporting period echoes the exact + UTC boundaries used for aggregation. + validations: + - check: response_schema + description: "Response matches get-media-buy-delivery-response.json" + - check: field_value + path: "reporting_period.start" + value: "2026-04-15T00:00:00.000Z" + description: "DR-0001 / delivery filter contract: reporting starts at the inclusive requested boundary" + - check: field_value + path: "reporting_period.end" + value: "2026-04-16T00:00:00.000Z" + description: "DR-0001 / delivery filter contract: reporting ends at the exclusive requested boundary" + - check: field_value + path: "media_buy_deliveries[0].media_buy_id" + value: "read_filter_active_buy" + description: "Only the requested media buy is reported" + - check: field_value + path: "media_buy_deliveries[0].totals.impressions" + value: 200 + description: "Only the start-boundary impressions contribute" + - check: field_value + path: "media_buy_deliveries[0].totals.clicks" + value: 20 + description: "Only the start-boundary clicks contribute" + - check: field_value + path: "media_buy_deliveries[0].totals.spend" + value: 20 + description: "Both out-of-range spend rows are excluded" + - check: field_absent + path: "media_buy_deliveries[1]" + description: "No unrequested media buy leaks into the result"