diff --git a/.changeset/catalog-optional-collection-features.md b/.changeset/catalog-optional-collection-features.md new file mode 100644 index 00000000000..19cddd56f97 --- /dev/null +++ b/.changeset/catalog-optional-collection-features.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": patch +--- + +Collection selection and owner-sold carriage checks now discover native wholesale sandbox products instead of seeding invented collection support. Six independent scopes cover selectable subsets, one-pair default materialization, fixed bundles (omitted or false targeting flag), and structural cross-publisher selection. A completed valid catalog with no qualifying product reports `not_applicable` (`fixture_unsatisfied`): the feature was not tested, not passed or proven unsupported. Discovery and selected-path errors still fail. + +Selectors, negative witnesses and commercial terms come from the same selected product/price graph. Exact echoes compare domain-qualified sets without assuming illustrative collection names, domains or prices. Two separately tool-gated companions retain persisted subset/default checks without making optional `get_media_buys` support a prerequisite for creation. Only non-guaranteed, fixed-price CPM catalog products are covered; seeded-only and brief-only support remains outside this evidence. + +Completes #8254 with the reporting correction in #8336. No seller capability field, controller seed-refusal rule or released artifact is changed. diff --git a/docs/reference/known-limitations.mdx b/docs/reference/known-limitations.mdx index 56556d384a8..4e5498d5188 100644 --- a/docs/reference/known-limitations.mdx +++ b/docs/reference/known-limitations.mdx @@ -84,7 +84,9 @@ Request-specific configured offers are not a gap: canonical products carry no `i - **Catalog reporting selection.** `advanced_delivery_reporting` selects a native wholesale sandbox product advertising format breakdown; `time_based_delivery_reporting` independently selects one listing `time_based_views`. - **Catalog reporting not applicable.** `fixture_unsatisfied`, projected `not_applicable`, means no qualifying product was found. The feature was not tested; this is neither a pass nor proof of nonsupport. Fixture discovery errors and broken selected support still fail. - **Catalog reporting pass scope.** A pass covers required response fields, metric narrowing and legal returned measurements. A pass does not attest that the seller returns time-based views; the controller cannot inject view observations. A one-row format request cannot prove multi-row ordering or selection from an unobserved population. Mature metric value-or-disclosure accountability remains [#8412](https://github.com/adcontextprotocol/adcp/issues/8412). -- **Catalog reporting untested paths.** Only non-guaranteed, fixed-price CPM products are graded. Guaranteed, auction-priced, brief-only and seeded-only support is not tested, and the card cannot yet distinguish this from nonsupport ([#8416](https://github.com/adcontextprotocol/adcp/issues/8416)). Collection scenarios are unchanged. The scheduled heartbeat runs bare `comply()` without selected-price budget or authored-brand derivation until [SDK #3381](https://github.com/adcontextprotocol/adcp-client/issues/3381) and [SDK #3382](https://github.com/adcontextprotocol/adcp-client/issues/3382) are addressed. Adapter and SDK details are in the [qualification memo](https://github.com/adcontextprotocol/adcp/blob/main/specs/optional-product-reporting-qualification.md). +- **Catalog reporting untested paths.** Only non-guaranteed, fixed-price CPM products are graded. Guaranteed, auction-priced, brief-only and seeded-only support is not tested, and the card cannot yet distinguish this from nonsupport ([#8416](https://github.com/adcontextprotocol/adcp/issues/8416)). The scheduled heartbeat runs bare `comply()` without selected-price budget or authored-brand derivation until [SDK #3381](https://github.com/adcontextprotocol/adcp-client/issues/3381) and [SDK #3382](https://github.com/adcontextprotocol/adcp-client/issues/3382) are addressed. Adapter and SDK details are in the [qualification memo](https://github.com/adcontextprotocol/adcp/blob/main/specs/optional-product-reporting-qualification.md). +- **Catalog collection coverage.** Six independently qualified scopes grade selectable subsets, one-pair default materialization, fixed-bundle enforcement and restatement, and structural cross-publisher carriage/full or subset selection. Fixed bundles accept literal false or an omitted flag. A valid complete no-match is `fixture_unsatisfied`: feature not tested, never a support pass or proof of nonsupport. Fixture catalog discovery errors and broken selected paths still fail. Two separately reader-gated companions check persisted subset/default echoes; absence of `get_media_buys` does not suppress core creation checks. +- **Collection coverage boundaries.** Only native wholesale sandbox, non-guaranteed fixed-price CPM products are exercised; seeded-only, brief-only, guaranteed and auction-priced support remains outside this catalog slice. Cross-publisher means a collection namespace outside every property namespace on the same product; it does not verify ownership, host authorization or a supply path. Standalone reads require trusted successful-create buy/package/product/committed-selector context and requalify the current catalog; no durable cross-request grant or catalog-retirement immunity is claimed. SDK 16.3 can falsely fail an active read after a legal create response omitting optional `media_buy_status` ([SDK #3395](https://github.com/adcontextprotocol/adcp-client/issues/3395)); that variation remains unqualified, and the optional field is not made mandatory. Widened sets still fail, but failed comparison evidence omits the authored context key ([SDK #3396](https://github.com/adcontextprotocol/adcp-client/issues/3396)). Wholesale feed changes between pages are not yet restarted by the buyer ([SDK #3397](https://github.com/adcontextprotocol/adcp-client/issues/3397)); mutable-feed qualification remains incomplete, not seller nonconformance. The scheduled bare `comply()` path still lacks the repository caller's commercial/selector projection until [SDK #3382](https://github.com/adcontextprotocol/adcp-client/issues/3382). Details and historical #8259 reconciliation are in the [collection qualification memo](https://github.com/adcontextprotocol/adcp/blob/main/specs/optional-product-collection-qualification.md). - **OOH/audio journey coverage remains bounded.** Expanded OOH/audio measurement identities still have schema and focused contract-test coverage rather than one complete discovery → purchase → delivery journey per metric family. - **Audience activation coverage.** Audience activation has capability-gated storyboards for both discovery and execution: `audience_activation_discovery` covers the catalog-wide union, per-product declarations, vendor matching, OR/wildcard filter semantics, and exclusion of undeclared products; `external_audience_source_binding` binds a declared shared dataset, preserves its source and match state through discovery, uses the resulting audience in a media buy, and rejects undeclared rails or cross-transport updates. The reference seller deliberately advertises `dataset_query`, not `platform_distribution`, so the latter remains not applicable rather than simulated as a capability it does not provide. - **No automated enforcement of platform agnosticism** beyond the `check:platform-agnostic` lint scanning property names. A richer check covering schema semantics is future work. diff --git a/package.json b/package.json index 08f7f3000d6..2d94ec2ea85 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "test:tenant-routing": "vitest run --config server/vitest.config.ts server/src/training-agent/tenants/tenant-smoke.test.ts", "test:format-identity-boundaries": "node --test --test-force-exit --test-timeout=30000 tests/lint-format-identity-boundaries.test.cjs", "test:sdk-runner-capability-gates": "node --test --test-force-exit --test-timeout=30000 tests/sdk-runner-capability-gates.test.cjs && node --test --test-timeout=30000 tests/evaluator-allowlist-runner.test.cjs && node --test --test-timeout=30000 tests/optional-product-reporting.test.cjs tests/optional-product-reporting-public.test.cjs tests/catalog-reporting-preflight.test.cjs tests/catalog-reporting-preflight-security.test.cjs", + "test:optional-product-collections": "node --test --test-concurrency=1 --test-timeout=30000 tests/optional-product-collection-discovery.test.cjs tests/optional-product-collection-echo.test.cjs tests/optional-product-collection-price.test.cjs tests/optional-product-collection-rejections.test.cjs tests/optional-product-collection-scope.test.cjs tests/optional-product-collections.test.cjs tests/optional-product-collection-history.test.cjs", "test:callapi-state-change": "node --test --test-force-exit --test-timeout=30000 tests/lint-callapi-state-change.test.cjs", "test:cdn-artifacts-worker": "node --test --test-force-exit --test-timeout=30000 tests/artifact-cdn-worker.test.cjs", "test:sign-protocol-tarball": "node --test --test-force-exit --test-timeout=30000 tests/sign-protocol-tarball.test.cjs", @@ -171,7 +172,7 @@ "audit:verification-profile-shadow": "tsx server/src/scripts/audit-verification-profile-shadow.ts", "test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check", "fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs", - "test": "npm run test:sdk-response-conformance && npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:security-profiles && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:fast-channel-collections && npm run test:collection-selection-echo && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:functional-request-signing-contract && npm run test:webhook-receiver-envelope && npm run test:webhook-emission-storyboard && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:wba-profile-vectors && npm run test:targeting-overlay-vectors && npm run test:targeting-null-clear && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:audience-activation-methods && npm run test:principal && npm run test:audience-external-sources && npm run test:language-targeting && npm run test:availability-horizon && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:governance-task-slices && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-buyer-fixture-lifecycle-tools && npm run test:storyboard-buyer-fixture-products && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-context-resolution && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-controller-requires && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:storyboard-generate-phase-scope && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:v4-compatibility-registry && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-hoist-by-identity && npm run test:build-schemas-async-response-refs && npm run test:build-schemas-vendor-metric-uniqueness && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck && npm run test:contracts && npm run test:example-validation && npm run test:test-coverage", + "test": "npm run test:sdk-response-conformance && npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:security-profiles && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:fast-channel-collections && npm run test:collection-selection-echo && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:functional-request-signing-contract && npm run test:webhook-receiver-envelope && npm run test:webhook-emission-storyboard && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:wba-profile-vectors && npm run test:targeting-overlay-vectors && npm run test:targeting-null-clear && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:audience-activation-methods && npm run test:principal && npm run test:audience-external-sources && npm run test:language-targeting && npm run test:availability-horizon && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:governance-task-slices && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-buyer-fixture-lifecycle-tools && npm run test:storyboard-buyer-fixture-products && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-context-resolution && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-controller-requires && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:storyboard-generate-phase-scope && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:v4-compatibility-registry && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:optional-product-collections && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-hoist-by-identity && npm run test:build-schemas-async-response-refs && npm run test:build-schemas-vendor-metric-uniqueness && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck && npm run test:contracts && npm run test:example-validation && npm run test:test-coverage", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", "precommit": "bash scripts/with-timeout.sh 300 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 1500 npm run precommit:server-unit && npm run typecheck", diff --git a/scripts/lint-storyboard-fixture-resolution.cjs b/scripts/lint-storyboard-fixture-resolution.cjs index dff099eb941..14271715402 100644 --- a/scripts/lint-storyboard-fixture-resolution.cjs +++ b/scripts/lint-storyboard-fixture-resolution.cjs @@ -15,6 +15,7 @@ const OPERATORS = new Set([ 'contains_all', 'any_match', 'canonical_format_satisfies', + 'collection_selection_satisfies', ]); const ID_FIELDS = { accounts: 'account_id', @@ -63,7 +64,7 @@ function lintMatchClause(clause, at, violations) { violations.push({ rule: 'match_clause_shape', at }); return; } - if (typeof clause.path !== 'string' || !clause.path.startsWith('/')) { + if (typeof clause.path !== 'string' || (clause.path !== '' && !clause.path.startsWith('/'))) { violations.push({ rule: 'match_path', at }); } if (!OPERATORS.has(clause.operator)) { @@ -87,6 +88,17 @@ function lintMatchClause(clause, at, violations) { if (!Object.hasOwn(clause, 'value') || Object.hasOwn(clause, 'where')) { violations.push({ rule: 'match_value_shape', at }); } + // Keep the merged #8376 operand closed, including provisional spellings. + if (clause.operator === 'collection_selection_satisfies') { + const value = clause.value; + if (!value || typeof value !== 'object' || Array.isArray(value) || + Object.keys(value).some(key => !['collection_targeting', 'minimum_pairs', 'require_cross_publisher_collection'].includes(key)) || + !['fixed', 'selectable', 'any'].includes(value.collection_targeting) || + (value.minimum_pairs !== undefined && ![1, 2].includes(value.minimum_pairs)) || + (value.require_cross_publisher_collection !== undefined && typeof value.require_cross_publisher_collection !== 'boolean')) { + violations.push({ rule: 'collection_selection_value', at }); + } + } if (clause.operator === 'contains_all' && !Array.isArray(clause.value)) { violations.push({ rule: 'contains_all_value', at }); } diff --git a/scripts/lint-storyboard-sample-request-schema.cjs b/scripts/lint-storyboard-sample-request-schema.cjs index 9bb68f214f8..c738f3d787b 100644 --- a/scripts/lint-storyboard-sample-request-schema.cjs +++ b/scripts/lint-storyboard-sample-request-schema.cjs @@ -167,6 +167,17 @@ const SHA256_DIGEST_PLACEHOLDER = `sha256:${'A'.repeat(43)}`; * The `depth` guard prevents infinite recursion on self-referential schemas. */ function placeholderFor(schema, depth = 0) { + // Whole-value witnesses can contain selectors composed with allOf. Merge + // required object fields for placeholders only; AJV validates the full schema. + if (Array.isArray(schema?.allOf)) { + const branches = schema.allOf.map(branch => resolveSchemaNode(branch)); + if (branches.every(branch => branch && (branch.type === 'object' || branch.properties || branch.required))) { + return synthesizeObject({ + required: [...new Set(branches.flatMap(branch => branch.required ?? []))], + properties: Object.assign({}, ...branches.map(branch => branch.properties ?? {})), + }, depth); + } + } const resolved = resolveSchemaNode(schema); if (!resolved) return STRING_PLACEHOLDER; const types = Array.isArray(resolved.type) ? resolved.type : [resolved.type]; @@ -193,7 +204,10 @@ function placeholderFor(schema, depth = 0) { if (types.includes('integer')) return resolved.minimum ?? 1; if (types.includes('number')) return resolved.minimum ?? 0; if (types.includes('boolean')) return true; - if (types.includes('array')) return []; + if (types.includes('array')) { + if (depth > 4) return []; + return Array.from({ length: resolved.minItems ?? 0 }, () => placeholderFor(resolved.items, depth + 1)); + } if (types.includes('object')) { // A discriminated union written as `{ type: "object", discriminator, oneOf: [...] }` // (e.g. core/format-option-ref.json) carries no top-level `properties`, so diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 71f56d94cb5..fa5102d6e54 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -5892,11 +5892,46 @@ export function createMemberToolHandlers( output += `**Agent:** ${resolved.resolvedUrl}\n`; output += `**Compliance target:** ${formatComplianceTarget(runTarget)}\n`; const coverageGaps = result.coverage_gaps ?? []; - const catalogReportingGap = ['media_buy_seller/advanced_delivery_reporting', 'media_buy_seller/time_based_delivery_reporting'].includes(storyboardId) - && coverageGaps.some(gap => gap.reason === 'fixture_unsatisfied'); - const resultLabel = !result.overall_passed || result.failed_count > 0 ? 'FAILED' - : catalogReportingGap ? 'NOT APPLICABLE — no qualifying product in your wholesale sandbox catalog (feature not tested)' - : coverageGaps.length > 0 ? 'INCOMPLETE — coverage gap' : 'PASSED'; + const catalogReportingGap = + [ + 'media_buy_seller/advanced_delivery_reporting', + 'media_buy_seller/time_based_delivery_reporting', + 'media_buy_seller/collection_selection', + 'media_buy_seller/collection_default_materialization', + 'media_buy_seller/collection_selection_readback', + 'media_buy_seller/collection_default_materialization_readback', + 'media_buy_seller/fixed_bundle_enforcement', + 'media_buy_seller/fixed_bundle_restatement', + 'media_buy_seller/owner_sold_channel_carriage', + 'media_buy_seller/owner_sold_channel_subset', + ].includes(storyboardId) && + coverageGaps.some((gap) => gap.reason === 'fixture_unsatisfied'); + const collectionReaderAbsent = + [ + 'media_buy_seller/collection_selection_readback', + 'media_buy_seller/collection_default_materialization_readback', + ].includes(storyboardId) && + result.passed_count === 0 && + result.phases.some( + (phase) => + phase.phase_id === 'missing_tool' && + phase.steps.some( + (step) => + step.step_id === 'missing_tool' && + step.skipped && + step.skip_reason === 'missing_tool', + ), + ); + const resultLabel = + !result.overall_passed || result.failed_count > 0 + ? 'FAILED' + : catalogReportingGap + ? 'NOT APPLICABLE — no qualifying product in your wholesale sandbox catalog (feature not tested)' + : collectionReaderAbsent + ? 'NOT APPLICABLE — get_media_buys is not advertised (persisted collection readback not tested)' + : coverageGaps.length > 0 + ? 'INCOMPLETE — coverage gap' + : 'PASSED'; output += `**Result:** ${resultLabel} — ${result.passed_count} passed, ${result.failed_count} failed, ${result.skipped_count} skipped, ${result.validations_advisory_failed ?? 0} advisory validation(s) failed\n`; output += `**Duration:** ${(result.total_duration_ms / 1000).toFixed(1)}s\n\n`; for (const gap of coverageGaps) { @@ -6005,6 +6040,13 @@ export function createMemberToolHandlers( if (readinessPrompt) return readinessPrompt; const contextRefMeta = storyboardContextRefMeta(memberContext, storyboardId, resolved.resolvedUrl); const context = resolveStoryboardInputContext(input.context, contextRefMeta); + // SDK captures may precede final validation. Preserve the last trusted + // continuation before the call, including when SDK code mutates its input. + const collectionReadback = [ + 'media_buy_seller/collection_selection_readback', + 'media_buy_seller/collection_default_materialization_readback', + ].includes(storyboardId); + const previousContext = collectionReadback ? structuredClone(context) : context; if (!hasExplicitComplianceTarget(input)) { runTarget = await selectComplianceTargetForAgent( @@ -6146,7 +6188,9 @@ export function createMemberToolHandlers( } } - if (result.next) { + if (collectionReadback && !result.passed && !result.skipped && result.step_id === 'create_selection') { + output += `\nThe create did not pass. Fix it and re-run \`create_selection\` before attempting persisted readback. The context below retains the prior successful continuation only.\n`; + } else if (result.next) { output += `\n### Next step\n`; output += `**${result.next.title}** (\`${result.next.step_id}\`) — \`${result.next.task}\`\n`; if (result.next.narrative) output += `${renderStoryboardDiagnostic(result.next.narrative, RUNNER_ERROR_MAX_LEN)}\n`; @@ -6157,7 +6201,9 @@ export function createMemberToolHandlers( // Include an opaque encrypted reference for the next step call so // agent-controlled context cannot break out of this handoff surface. - output += `\n\n${JSON.stringify(storeStoryboardContextRef(result.context, contextRefMeta), null, 2)}\n`; + const continuationContext = collectionReadback && (!result.passed || result.skipped) + ? previousContext : result.context; + output += `\n\n${JSON.stringify(storeStoryboardContextRef(continuationContext, contextRefMeta), null, 2)}\n`; return output; } catch (error) { diff --git a/server/src/compliance/catalog-reporting-runner.ts b/server/src/compliance/catalog-reporting-runner.ts index a0a46dfd28c..62f3c7159f0 100644 --- a/server/src/compliance/catalog-reporting-runner.ts +++ b/server/src/compliance/catalog-reporting-runner.ts @@ -1,11 +1,17 @@ /** - * Buyer-side construction for the two catalog reporting storyboards. + * Buyer-side construction for catalog-qualified optional-feature storyboards. * * Arithmetic belongs to the caller: the closed storyboard language only * substitutes whole context keys. Prepare the exact graph once, derive a * budget from its selected price, and reuse that state without rediscovery. * This is request-local preparation, not a cross-request grant/state store. */ +import { + createHmac, + randomBytes, + scryptSync, + timingSafeEqual, +} from "node:crypto"; import { PreparedFixturesDeniedError, redactOAuthUrlForOutput, @@ -17,6 +23,7 @@ import { withPreparedFixtures, withExternalSchemaRoot, withServedReleaseSchemaRoots, + normalizeCollectionSelectors, type PreparedStoryboardFixtures, type PreparedFixturesAuthorizer, type Storyboard, @@ -31,7 +38,120 @@ const REPORTING_IDS = new Set([ "media_buy_seller/advanced_delivery_reporting", "media_buy_seller/time_based_delivery_reporting", ]); +export const COLLECTION_PRICE_CAPTURE = "__adcp_fixture_collection_price"; +export const COLLECTION_DECLARED_CAPTURE = "__adcp_fixture_collection_declared"; +export const COLLECTION_RELATION_CAPTURE = "__adcp_fixture_collection_relation"; +export const COLLECTION_BUDGET_CONTEXT = "catalog_collection_budget"; +export const COLLECTION_FULL_CONTEXT = "catalog_collection_full_selectors"; +export const COLLECTION_BULK_CONTEXT = "catalog_collection_bulk_selectors"; +const COLLECTION_IDS = new Set([ + "media_buy_seller/collection_selection", + "media_buy_seller/collection_default_materialization", + "media_buy_seller/fixed_bundle_enforcement", + "media_buy_seller/fixed_bundle_restatement", + "media_buy_seller/owner_sold_channel_carriage", + "media_buy_seller/owner_sold_channel_subset", + "media_buy_seller/collection_selection_readback", + "media_buy_seller/collection_default_materialization_readback", +]); const BASELINE_BUDGET = 1000; // Buyer test intent, never a seller minimum. +const COLLECTION_COMMITMENT = "catalog_collection_commitment_proof"; +// Match the existing hosted envelope's deployment secret sources, with a +// separate purpose/key. Development continuation is process-local, as before. +const developmentCommitmentKey = randomBytes(32); +function collectionCommitmentKey(): Buffer { + const configured = + process.env.STORYBOARD_CONTEXT_REF_SECRET || + process.env.WORKOS_COOKIE_PASSWORD || + process.env.CONFORMANCE_JWT_SECRET; + return configured + ? scryptSync(configured, "adcp-collection-successful-create-v1", 32) + : developmentCommitmentKey; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, child: unknown) => { + if (!child || typeof child !== "object" || Array.isArray(child)) + return child; + return Object.fromEntries( + Object.entries(child).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + }); +} + +function collectionCommitment( + agentUrl: AgentUrl, + storyboard: Storyboard, + options: StoryboardRunOptions, + context: Record, +): string { + const ids = [ + "collection_media_buy_id", + "collection_package_id", + "collection_committed_product_id", + ]; + const normalized = normalizeCollectionSelectors( + context.collection_committed_selectors, + ); + if ( + ids.some( + (key) => !Object.hasOwn(context, key) || typeof context[key] !== "string", + ) || + !Object.hasOwn(context, "collection_committed_selectors") || + !normalized.ok || + normalized.value.pairs.length === 0 || + normalized.value.pairs.some((pair) => pair.collection_id === null) + ) { + throw new Error( + "Standalone collection readback requires trusted prior buy, package, product and concrete committed-selector context", + ); + } + const pairs = normalized.value.pairs.map((pair) => [ + pair.publisher_domain, + pair.collection_id, + ]); + pairs.sort((a, b) => + Buffer.compare( + Buffer.from(JSON.stringify(a)), + Buffer.from(JSON.stringify(b)), + ), + ); + // This proves that this caller actually graded the exact create successfully. + // It grants no access: every subsequent invocation still prepares and uses + // the SDK's current credentials/authorization. No signer is public/exported. + return createHmac("sha256", collectionCommitmentKey()) + .update("adcp-catalog-collection-successful-create-v1\0") + .update( + canonicalJson({ + seller: agentUrl, + storyboard, + adcpVersion: options.adcpVersion ?? null, + account: storyboard.context?.sandbox_account, + ids: ids.map((key) => context[key]), + pairs, + }), + ) + .digest("hex"); +} + +function assertCollectionReadHistory( + agentUrl: AgentUrl, + storyboard: Storyboard, + options: StoryboardRunOptions, +) { + const context = options.context ?? {}; + const expected = collectionCommitment(agentUrl, storyboard, options, context); + const proof = context[COLLECTION_COMMITMENT]; + if ( + typeof proof !== "string" || + !/^[a-f0-9]{64}$/.test(proof) || + !timingSafeEqual(Buffer.from(proof, "hex"), Buffer.from(expected, "hex")) + ) { + throw new Error( + "Standalone collection readback requires verified successful-create history for this exact seller, storyboard and committed selection", + ); + } +} type AgentUrl = Parameters[0]; // Supplemental adapter evidence on standalone results; the SDK's native @@ -40,15 +160,23 @@ type PreparationEvidence = { fixture_resolutions?: PreparedStoryboardFixtures["resolutions"]; }; -function isCatalogReporting(storyboard: Storyboard): boolean { - return ( - REPORTING_IDS.has(storyboard.id) && - storyboard.fixture_resolution?.pricing_options?.some((declaration) => - declaration.context_outputs?.some( - (output) => output.key === REPORTING_PRICE_CAPTURE, - ), - ) === true - ); +function catalogScope(storyboard: Storyboard) { + const collections = COLLECTION_IDS.has(storyboard.id); + if (!collections && !REPORTING_IDS.has(storyboard.id)) return undefined; + const priceCapture = collections + ? COLLECTION_PRICE_CAPTURE + : REPORTING_PRICE_CAPTURE; + return storyboard.fixture_resolution?.pricing_options?.some((declaration) => + declaration.context_outputs?.some((output) => output.key === priceCapture), + ) === true + ? { + priceCapture, + budgetContext: collections + ? COLLECTION_BUDGET_CONTEXT + : REPORTING_BUDGET_CONTEXT, + collections, + } + : undefined; } // Hosted policy only, not a protocol product-size limit or aggregate budget. @@ -57,7 +185,7 @@ export function withHostedReportingBudget( storyboard: Storyboard, options: StoryboardRunOptions, ): StoryboardRunOptions { - if (!isCatalogReporting(storyboard)) return options; + if (!catalogScope(storyboard)) return options; const requested = options.transport?.maxResponseBytes; // SDK zero disables its cap; nonpositive/nonfinite values are not tighter. const cap = @@ -105,7 +233,7 @@ async function refuseUncallablePreparationAuthorizer( ); const authored = phase?.steps.find((s) => s.id === stepId); const detail = - "Fresh reporting preparation cannot consult the supplied host authorizer before seller contact with SDK 16.3; see SDK #3389"; + "Fresh catalog fixture preparation cannot consult the supplied host authorizer before seller contact with SDK 16.3; see SDK #3389"; const step: StoryboardStepResult = { storyboard_id: storyboard.id, step_id: authored?.id ?? "prepared_fixtures_authorization", @@ -191,8 +319,38 @@ async function withReportingPreparation( options: StoryboardRunOptions, execute: (preparedOptions: StoryboardRunOptions) => Promise, stepId?: string, + executeRootGate?: (options: StoryboardRunOptions) => Promise, ): Promise { - if (!isCatalogReporting(storyboard)) return execute(options); + const scope = catalogScope(storyboard); + if (!scope) return execute(options); + const nativeExecute = execute; + // Every native fallback must obey the same precondition. Capability failures + // cannot turn a missing branch-safe comparison key into a successful read. + execute = async (executionOptions) => { + if (scope.collections && stepId === "read_selection") { + assertCollectionReadHistory(agentUrl, storyboard, executionOptions); + } + const result = await nativeExecute(executionOptions); + if ( + scope.collections && + storyboard.id.endsWith("_readback") && + stepId === "create_selection" + ) { + const step = result as unknown as StoryboardStepResult; + const context = { ...step.context }; + delete context[COLLECTION_COMMITMENT]; + if (step.passed && !step.skipped) { + context[COLLECTION_COMMITMENT] = collectionCommitment( + agentUrl, + storyboard, + executionOptions, + context, + ); + } + return { ...result, context }; + } + return result; + }; options.signal?.throwIfAborted(); // Delegate invalid caller maps to the SDK's canonical denial BEFORE our // capability discovery. Do not sanitize them or produce a substitute grade. @@ -212,13 +370,16 @@ async function withReportingPreparation( ), ) ) { - return execute(options); + // This route is a canonical SDK pre-contact metadata refusal, never an + // executable read fallback. Preserve its typed denial/private audit even + // when no successful-create history exists. + return nativeExecute(options); } // These entry points own one preparation for one authenticated invocation. // Never silently discard a supplied reuse grant, or re-resolve it instead. if (options.prepared_fixtures !== undefined) { throw new Error( - "Catalog reporting preparation must be owned by this invocation; use the SDK directly to reuse an existing authorized state.", + "Catalog fixture preparation must be owned by this invocation; use the SDK directly to reuse an existing authorized state.", ); } // The authored sandbox identity is trusted source, never a seller echo or @@ -228,10 +389,14 @@ async function withReportingPreparation( options.disable_sandbox === true || options.context?.sandbox === false ) { - throw new Error("Catalog reporting requires the authored sandbox scope"); + throw new Error( + "Catalog fixture preparation requires the authored sandbox scope", + ); } if (options.agents || (Array.isArray(agentUrl) && agentUrl.length !== 1)) { - throw new Error("Catalog reporting targets one seller per invocation"); + throw new Error( + "Catalog fixture preparation targets one seller per invocation", + ); } if (options.prepared_fixtures_authorizer !== undefined) { return refuseUncallablePreparationAuthorizer( @@ -251,7 +416,7 @@ async function withReportingPreparation( `Step "${stepId}" not found in storyboard "${storyboard.id}". Available steps: ${storyboard.phases.flatMap((phase) => phase.steps.map((step) => step.id)).join(", ")}`, ); } - // Ignore the legacy first-product cache ONLY in these two scopes. All + // Ignore the legacy first-product cache ONLY in the declared catalog scopes. All // identities/commercial terms come from the pinned graph. Otherwise the // SDK's generic create enricher invents an auction bid from an unrelated // cached CPM row, even for a selected fixed-price option. @@ -317,7 +482,13 @@ async function withReportingPreparation( ) { return execute(originalOptions); } - options = { ...options, profile }; + // In collection scopes the observed inventory is also the native runner's + // effective inventory; an obsolete caller cache must not override this gate. + options = { + ...options, + profile, + ...(scope.collections && { agentTools: [...profile.tools] }), + }; const capabilities = profile.raw_capabilities as Record | undefined; const mediaBuy = capabilities?.media_buy as @@ -334,32 +505,87 @@ async function withReportingPreparation( !mediaBuy.buying_modes.includes("wholesale") || !storyboard.required_tools?.some((tool) => tools.has(tool)) ) { + if (scope.collections && executeRootGate) { + // SDK16.3 standalone execution bypasses root applicability gates. Keep + // the observed profile stable and return the actual native full-run + // gate. Never rename an authored skip or relax a hosted failure grade. + return executeRootGate({ + ...options, + agentTools: [...tools], + profile: { ...profile, tools: [...tools] }, + }); + } return execute(options); // Let the unchanged SDK produce its canonical gate. } + if (scope.collections && stepId === "read_selection") { + // Branch-safe field_equals_context intentionally passes when a comparand is + // absent. Standalone readback has no preceding create in this invocation, + // so require its trusted prior identities before preparing or dispatching. + assertCollectionReadHistory(agentUrl, storyboard, options); + } const prepared = await prepareStoryboardFixtures( agentUrl, storyboard, options, ); options.signal?.throwIfAborted(); - let budget: number | undefined; + const derivedContext: Record = {}; if (prepared.disposition.status === "resolved") { - const price = prepared.captures[REPORTING_PRICE_CAPTURE] as Record< + const price = prepared.captures[scope.priceCapture] as Record< string, unknown >; if (!price || typeof price !== "object") - throw new Error( - "Resolved reporting graph has no selected price snapshot", - ); + throw new Error("Resolved catalog graph has no selected price snapshot"); const minimum = price.min_spend_per_package; if ( minimum !== undefined && (typeof minimum !== "number" || !Number.isFinite(minimum) || minimum < 0) ) { - throw new Error("Selected reporting price has an invalid minimum spend"); + throw new Error("Selected catalog price has an invalid minimum spend"); + } + derivedContext[scope.budgetContext] = Math.max( + BASELINE_BUDGET, + minimum ?? 0, + ); + if (scope.collections) { + // Product composition may legally repeat entries; selected wire arrays + // require unique entries. Project the SAME protected capture through the + // public normalizer without altering its immutable evidence snapshot. + const normalized = normalizeCollectionSelectors( + prepared.captures[COLLECTION_DECLARED_CAPTURE], + ); + if ( + !normalized.ok || + normalized.value.pairs.some((pair) => pair.collection_id === null) + ) { + throw new Error( + "Resolved collection graph has no concrete selector set", + ); + } + derivedContext[COLLECTION_FULL_CONTEXT] = normalized.value.pairs.map( + (pair) => ({ + publisher_domain: pair.publisher_domain, + collection_ids: [pair.collection_id], + }), + ); + const relation = prepared.captures[COLLECTION_RELATION_CAPTURE] as + { collection_publisher_domain?: unknown } | undefined; + if (relation !== undefined) { + if (typeof relation?.collection_publisher_domain !== "string") { + throw new Error( + "Resolved collection graph has no cross-publisher relation", + ); + } + // Intentionally malformed buyer selection for the schema-invalid + // negative. Its domain comes from the retained relation, not index 0. + derivedContext[COLLECTION_BULK_CONTEXT] = [ + { + publisher_domain: relation.collection_publisher_domain, + }, + ]; + } } - budget = Math.max(BASELINE_BUDGET, minimum ?? 0); } // The trusted caller authorized this invocation. The state is never shared // or serialized; close its local grant when the invocation ends. Supplied @@ -370,7 +596,7 @@ async function withReportingPreparation( ...options, context: { ...options.context, - ...(budget === undefined ? {} : { [REPORTING_BUDGET_CONTEXT]: budget }), + ...derivedContext, }, ...withPreparedFixtures(prepared, authorize), // The producer ran before consumer authorization. Link its real records @@ -447,5 +673,20 @@ export function runCatalogReportingStoryboardStep( (preparedOptions) => runSdkStoryboardStep(agentUrl, storyboard, stepId, preparedOptions), stepId, + async (gatedOptions) => { + const result = await runSdkStoryboard(agentUrl, storyboard, gatedOptions); + const steps = result.phases.flatMap((phase) => phase.steps); + if ( + result.passed_count !== 0 || + result.failed_count !== 0 || + steps.length !== 1 || + !steps[0].skipped + ) { + throw new Error( + "The native catalog applicability gate did not produce a single skipped gate", + ); + } + return steps[0]; + }, ); } diff --git a/server/tests/unit/addie/catalog-reporting-coverage-presentation.test.ts b/server/tests/unit/addie/catalog-reporting-coverage-presentation.test.ts index e2ca043ac36..02bf495be2a 100644 --- a/server/tests/unit/addie/catalog-reporting-coverage-presentation.test.ts +++ b/server/tests/unit/addie/catalog-reporting-coverage-presentation.test.ts @@ -57,4 +57,53 @@ describe('Addie reporting fixture coverage presentation', () => { mocks.result.passed_count = 5; expect(await render()).toContain('**Result:** PASSED'); }); + it.each([ + "collection_selection", + "collection_default_materialization", + "fixed_bundle_enforcement", + "fixed_bundle_restatement", + "owner_sold_channel_carriage", + "owner_sold_channel_subset", + "collection_selection_readback", + "collection_default_materialization_readback", + ])( + "presents %s catalog fixture gaps as untested without changing raw accounting", + async (id) => { + expect(await render(`media_buy_seller/${id}`)).toContain( + "NOT APPLICABLE — no qualifying product", + ); + expect(mocks.result.passed_count).toBe(0); + expect(mocks.result.overall_passed).toBe(true); + }, + ); + it.each([ + "collection_selection_readback", + "collection_default_materialization_readback", + ])( + "does not credit absent optional reader in %s as passed support", + async (id) => { + mocks.result.coverage_gaps = []; + mocks.result.skipped_count = 1; + mocks.result.phases = [ + { + phase_id: "missing_tool", + steps: [ + { + step_id: "missing_tool", + skipped: true, + passed: false, + skip_reason: "missing_tool", + }, + ], + }, + ]; + expect(await render(`media_buy_seller/${id}`)).toContain( + "NOT APPLICABLE — get_media_buys is not advertised", + ); + mocks.result.failed_count = 1; + expect(await render(`media_buy_seller/${id}`)).toContain( + "**Result:** FAILED", + ); + }, + ); }); diff --git a/specs/optional-product-collection-qualification.md b/specs/optional-product-collection-qualification.md new file mode 100644 index 00000000000..0976233f51e --- /dev/null +++ b/specs/optional-product-collection-qualification.md @@ -0,0 +1,163 @@ +# Catalog-qualified optional collection checks + +This is the implementation record for #8254's collection work. Reporting was +separately corrected in [#8336](https://github.com/adcontextprotocol/adcp/pull/8336). +It supersedes the unimplemented prerequisites in +[#8259](https://github.com/adcontextprotocol/adcp/pull/8259); that proposal's old +SDK15.9.5/pristine15.9.6 defect observations remain historical evidence at its +original head, not qualification of current production behavior. + +## Existing authority and release scope + +[DR-0010](../governance/decisions/DR-0010-fixture-handles-and-resolution.md) defines +fixture handles, scoped pricing identities, pinned whole-graph resolution, +terminal discovery failures and `fixture_unsatisfied` coverage gaps. The merged +[SDK foundation #3354](https://github.com/adcontextprotocol/adcp-client/pull/3354) +and [authoring/output contract #8376](https://github.com/adcontextprotocol/adcp/pull/8376) +supply the predicate, captures, pair-set comparison and authenticated preparation. +The implementation uses the already installed public npm SDK16.3.0. No dependency, +lockfile, installer, capability declaration or controller contract changes here. + +This is a protocol-package **patch** to existing compliance obligations on main, +with new scenario IDs introduced in the upcoming 3.3 line. It does not ratify +[future declarations #8333](https://github.com/adcontextprotocol/adcp/pull/8333), +qualify SDK14/9, prepare a maintenance backport or authorize publication. The +proposed DR-0028 specialism is not authority for these applicability rules. + +## Independent applicability scopes + +All IDs below have the `media_buy_seller/` prefix. Each resolves its own product +and SAME-parent fixed-price CPM option; no missing companion suppresses another. + +| Scenario | Product predicate | Graded behavior | +| --------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------- | +| `collection_selection` | selectable, 2 distinct pairs | Strict subset; undeclared ID and domain rejected | +| `collection_default_materialization` | any, 1 pair | Default materializes the complete concrete set | +| `fixed_bundle_enforcement` | fixed, 2 distinct pairs | Full set accepted; strict subset rejected | +| `fixed_bundle_restatement` | fixed, 1 pair | Exact full-set restatement accepted | +| `owner_sold_channel_carriage` | any, 1 pair, cross-publisher | Full set accepted; bulk-form and undeclared selectors rejected | +| `owner_sold_channel_subset` | selectable, 2 total pairs, cross-publisher | Strict subset retains the external collection pair | +| `collection_selection_readback` | selectable, 2 distinct pairs | Persisted subset, buy/package/product identity | +| `collection_default_materialization_readback` | any, 1 pair | Persisted materialized default and identities | + +Fixed means `collection_targeting_allowed` is omitted or literally false; null +is schema-invalid. One local and one external pair suffice for cross-publisher +subset coverage. A one-pair default or fixed-restatement product never needs a +two-pair companion. Cross-publisher is structural: the retained collection domain +lies outside **all** singular publisher-property domains of the same product. +It attests neither beneficial ownership, AOR, host authorization nor supply-path +verification. The historical carriage filename is retained for continuity. + +The six core scenarios require creation, not an optional reader. Only the two +read companions have the root `get_media_buys` tool gate. The gate is any-of by +existing contract, so the root list contains only that reader. An advertised +broken reader fails. An absent reader returns the actual native missing-tool +gate; it does not turn an authored skip into a pass. The repository's standalone +caller executes this same native root gate because SDK16.3's bare standalone +entry does not apply enclosing storyboard gates. Observed profile tools are +bound to the native `agentTools` inventory so a stale caller cache cannot bypass +it. Evaluator disposition, required-phase floors and global skip rules are unchanged. + +## Selection, construction and evidence + +Product and price declarations use only `discover`, never seed fallback. Account +setup remains in the existing sandbox controller contract. The discovery path +exhausts pagination and validates pages before selecting by deterministic order; +a later qualifying product wins over an earlier nonqualifying one. A complete +valid no-match is an explicit `fixture_unsatisfied` gap, projected +`not_applicable`: feature **not tested**, never a pass or proof of nonsupport. +Fixture catalog transport, protocol, schema, malformed/repeated-cursor and capture +errors remain failed. Native capability-discovery delegation has its separately +disclosed [SDK #3392](https://github.com/adcontextprotocol/adcp-client/issues/3392) limit. Missing controller/account setup has its existing distinct disposition. + +The protected selected graph supplies `declared_selectors`, `strict_subset`, the +two collision-checked `undeclared_*` selector arrays, structural relation and +selected product ID. Negatives use the complete selected product, including the +retained external relation when required. ID identity is the exact +(domain, collection ID) pair: legal empty/Unicode IDs, grouping, order and catalog +redundancy do not change comparison. The caller projects distinct canonical wire +entries using the public normalizer; it never rewrites protected raw captures. +An authorization-style domain-only selector is used only in the explicit +schema-invalid negative, whose domain comes from the retained relation. + +Whole `snapshot: selected_entity` pricing preserves absent minimum, explicit0 and 5000. The caller constructs budget `max(1000, min_spend_per_package ?? 0)` from +that SAME price; 1000 is buyer test intent, not a fabricated seller term. The +request has no invented top-level currency or auction bid. Required schema, +exact package cardinality, explicit product-ID echo and exact collection-set +checks remain independent assertions. Unknown references require +`REFERENCE_NOT_FOUND`, malformed bulk-form requires `INVALID_REQUEST`, and fixed +bundle narrowing requires `UNSUPPORTED_FEATURE` under the existing schemas. + +Full runs prepare once, then consume the authenticated state with invocation-local +lookup authority. Caller-supplied host authorizers still fail closed before HTTP +until [SDK #3389](https://github.com/adcontextprotocol/adcp-client/issues/3389); +the caller does not claim an uncallable hook ran. Denials stay typed exceptions +with private audit records. The invocation grant is closed in `finally` and no +portable or cross-request grant service is claimed. + +Read companions consume committed selectors and product ID only after create +passes its protected-witness assertion in a full run. SDK16.3 publishes step +captures before final validation, so failed-step context is not proof of a +successful commitment and must not be reused as trusted standalone history. +Reads compare that committed set rather than later catalog composition. +The caller issues an integrity-only HMAC commitment after the actual standalone +create passes. Its closed claims bind the exact authored storyboard, seller, +AdCP version, authored sandbox account, buy/package/product identities and +normalized concrete pair set. It uses a purpose-separated key derived from the +existing hosted context deployment-secret sources, or a process-private random +key for local development; no public signing API or durable grant store is added. +JSON context round-trips retain the proof, but modified identities/selectors or a +failed create cannot acquire one. This proof grants no seller access: fresh +preparation and all SDK authorization still run on each invocation. Hosted +opaque-reference handoff seals the pre-call continuation on failure/skips while +retaining the failed result's raw captures, validations and ordinary response. +Standalone reads require that successful-create proof and its buy, +package, product and concrete committed-selector context before preparation and +on **every** native discovery-error/malformed-profile fallback; +missing keys are local invocation errors, not fabricated seller failures. This +also prevents generic branch-safe absent-comparand behavior from certifying a +missing identity. Standalone currently requalifies the catalog; retirement of a +previously selected product is not handled as durable prepared-state reuse. + +## Executable qualification and bounded SDK gaps + +`npm run test:optional-product-collections` executes the actual eight YAMLs via +the public SDK MCP transport and production caller, with controlled HTTP and +seller-owned synthetic catalogs. It covers supporting/nonsupporting/independently +supporting catalogs, omitted/false fixed flags, one/two-pair separation, +later-page selection, same-parent commercial terms, optional minima, legal set +variations, collision controls, invalid catalogs, broken echoes/identities, +negative rejection codes, readback, root gates, private audit and zero-contact +host-hook refusal. The fake seller uses a separate domain-qualified set oracle, +so the production normalizer is not its own expected-answer implementation. +Actual reference-CI rows must be inspected separately; a fixture gap is not +native positive-path evidence. + +The following repaired-behavior probes are intentionally **not** passing CI +regressions. Run them manually from the repository root; each fails naturally on +SDK16.3.0. Their original failing assertions are retained, not changed into +success-by-observing-a-defect: + +| Public probe | Actual SDK16.3 result and qualification limit | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `node specs/optional-product-collection-qualification/sdk3395-resource-status.cjs` | Explicit lifecycle control passes; omitting optional `media_buy_status` falsely fails later active read. [SDK #3395](https://github.com/adcontextprotocol/adcp-client/issues/3395). The optional field remains optional. | +| `node specs/optional-product-collection-qualification/sdk3396-context-key.cjs` | Widened subset correctly fails, but failed output omits authored `context_key`. [SDK #3396](https://github.com/adcontextprotocol/adcp-client/issues/3396). The merged output MUST remains in force; this metadata path is unqualified. | +| `node specs/optional-product-collection-qualification/sdk3397-feed-restart.cjs` | Feed changes v1→v2 between pages; resolver makes two page calls and resolves instead of restarting and making four after stabilization. [SDK #3397](https://github.com/adcontextprotocol/adcp-client/issues/3397). Mutable-feed restart remains unqualified. | + +The first split matrix was75pass/6fail: four assertions mistakenly classified a +conforming feed-token change as seller failure, and two exposed the genuine +context-key omission. Protocol review corrected the former using the existing +get_products buyer-restart MUST; invalid `adcp_version` is now a separate true +schema-failure case. The latter's unchanged normative assertion is preserved in +the public probe. Initial minimized feed/status drafts with incorrect tool +profile/raw output key were setup errors; corrected probes exercise actual wire +behavior. These historical diagnostics are not final qualification counts. + +Only native wholesale sandbox, non-guaranteed fixed-price CPM support is tested. +Guaranteed, auction-priced, brief-only and seeded-only support is not tested. +Bare `comply()` and the scheduled heartbeat lack this caller's selector/budget +projection ([SDK #3382](https://github.com/adcontextprotocol/adcp-client/issues/3382)); +no heterogeneous bundle-branding parity is claimed. Hosted cards cannot yet +separate absent offerings from untested fixture slices +([#8416](https://github.com/adcontextprotocol/adcp/issues/8416)). These disclosed +limits do not waive broken selected-path failures or establish seller nonsupport. diff --git a/specs/optional-product-collection-qualification/sdk3395-resource-status.cjs b/specs/optional-product-collection-qualification/sdk3395-resource-status.cjs new file mode 100644 index 00000000000..9e12c746512 --- /dev/null +++ b/specs/optional-product-collection-qualification/sdk3395-resource-status.cjs @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const {runStoryboard}=require('@adcp/sdk/testing'); +const tools=['get_adcp_capabilities','create_media_buy','get_media_buys']; +const caps={status:'completed',adcp:{major_versions:[3],supported_versions:['3.2'],idempotency:{supported:false}},supported_protocols:['media_buy'],media_buy:{buying_modes:['wholesale']}}; +const account={brand:{domain:'acmeoutdoor.example'},operator:'pinnacle-agency.example',sandbox:true}; +const doc={id:'media_buy_seller/status_identity_probe',version:'1.0.0',title:'Task status is not resource lifecycle',category:'media_buy_seller',required_tools:['create_media_buy'],phases:[{id:'lifecycle',title:'Create then read',steps:[ + {id:'create',title:'Create',task:'create_media_buy',stateful:true,schema_ref:'media-buy/create-media-buy-request.json',response_schema_ref:'media-buy/create-media-buy-response.json',sample_request:{brand:account.brand,account,idempotency_key:'status-probe-create',start_time:'asap',end_time:'2099-09-30T23:59:59Z',packages:[{product_id:'seller-product',pricing_option_id:'seller-price',budget:1000}]},context_outputs:[{key:'buy_id',path:'media_buy_id'}],validations:[{check:'response_schema',description:'Canonical create schema'}]}, + {id:'read',title:'Read',task:'get_media_buys',stateful:true,schema_ref:'media-buy/get-media-buys-request.json',response_schema_ref:'media-buy/get-media-buys-response.json',sample_request:{account,media_buy_ids:['$context.buy_id']},validations:[{check:'response_schema',description:'Canonical read schema'}]}, +]}]}; +async function run(includeLifecycle){ + const exchanges=[]; + const trustedFetchFn=async(_url,init={})=>{ + if(!init.body)return new Response(null,{status:init.method==='DELETE'?202:405}); + const msg=JSON.parse(init.body);let result; + if(msg.method==='server/discover')return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32601,message:'Not supported'}}),{headers:{'content-type':'application/json'}}); + if(msg.method==='initialize')result={protocolVersion:'2025-03-26',capabilities:{tools:{}},serverInfo:{name:'status-control',version:'1'}}; + else if(msg.method.startsWith('notifications/'))return new Response(null,{status:202}); + else if(msg.method==='tools/list')result={tools:tools.map(name=>({name,description:name,inputSchema:{type:'object',additionalProperties:true}}))}; + else if(msg.method==='tools/call'){ + const task=msg.params.name;let data; + if(task==='get_adcp_capabilities')data=caps; + else if(task==='create_media_buy')data={status:'completed',...(includeLifecycle?{media_buy_status:'active'}:{}),media_buy_id:'seller-buy',confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package',product_id:'seller-product'}]}; + else if(task==='get_media_buys')data={status:'completed',media_buys:[{media_buy_id:'seller-buy',status:'active',currency:'EUR',total_budget:1000,confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package',product_id:'seller-product'}]}]}; + else throw Error('Unexpected task '+task); + exchanges.push({task,request:msg.params.arguments,response:data});result={content:[{type:'text',text:JSON.stringify(data)}]}; + }else throw Error('Unexpected method '+msg.method); + return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,result}),{headers:{'content-type':'application/json'}}); + }; + const result=await runStoryboard('https://status-control.example/mcp',doc,{adcpVersion:'3.2.5',brand:account.brand,sandbox:true,timeout_ms:3000,profile:{tools,raw_capabilities:caps,supported_protocols:['media_buy']},transport:{trustedFetchFn}}); + console.log(JSON.stringify({includeLifecycle,overall_passed:result.overall_passed,failed_count:result.failed_count,exchanges,steps:result.phases.flatMap(p=>p.steps.map(s=>({id:s.step_id,passed:s.passed,validations:s.validations})))})); + return result; +} +(async()=>{ + assert.equal((await run(true)).overall_passed,true,'explicit lifecycle positive control'); + assert.equal((await run(false)).overall_passed,true,'MAY omit lifecycle: task completed cannot establish terminal media-buy lifecycle'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/specs/optional-product-collection-qualification/sdk3396-context-key.cjs b/specs/optional-product-collection-qualification/sdk3396-context-key.cjs new file mode 100644 index 00000000000..1e5b11f33e4 --- /dev/null +++ b/specs/optional-product-collection-qualification/sdk3396-context-key.cjs @@ -0,0 +1,38 @@ +const assert = require('node:assert/strict'); +const {runStoryboard}=require('@adcp/sdk/testing'); +const tools=['get_adcp_capabilities','create_media_buy','get_media_buys']; +const caps={status:'completed',adcp:{major_versions:[3],supported_versions:['3.2'],idempotency:{supported:false}},supported_protocols:['media_buy'],media_buy:{buying_modes:['wholesale']}}; +const account={brand:{domain:'acmeoutdoor.example'},operator:'pinnacle-agency.example',sandbox:true}; +const doc={id:'media_buy_seller/collection_evidence_probe',version:'1.0.0',title:'Collection failure evidence',category:'media_buy_seller',required_tools:['create_media_buy'],context:{wanted:[{publisher_domain:'studio.example',collection_ids:['a']}]},phases:[{id:'selection',title:'Compare',steps:[ + {id:'create',title:'Create',task:'create_media_buy',stateful:true,schema_ref:'media-buy/create-media-buy-request.json',response_schema_ref:'media-buy/create-media-buy-response.json',sample_request:{brand:account.brand,account,idempotency_key:'evidence-probe-create',start_time:'asap',end_time:'2099-09-30T23:59:59Z',packages:[{product_id:'seller-product',pricing_option_id:'seller-price',budget:1000,targeting_overlay:{collection_selection:{mode:'selected',collections:'$context.wanted'}}}]},validations:[{check:'response_schema',description:'Canonical create schema'},{id:'exact',check:'collection_set_equals_context',path:'packages[0].targeting_overlay.collection_selection.collections',context_key:'wanted',description:'Do not widen the requested set'}]}, +]}]}; +async function run(includeLifecycle){ + const exchanges=[]; + const trustedFetchFn=async(_url,init={})=>{ + if(!init.body)return new Response(null,{status:init.method==='DELETE'?202:405}); + const msg=JSON.parse(init.body);let result; + if(msg.method==='server/discover')return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32601,message:'Not supported'}}),{headers:{'content-type':'application/json'}}); + if(msg.method==='initialize')result={protocolVersion:'2025-03-26',capabilities:{tools:{}},serverInfo:{name:'status-control',version:'1'}}; + else if(msg.method.startsWith('notifications/'))return new Response(null,{status:202}); + else if(msg.method==='tools/list')result={tools:tools.map(name=>({name,description:name,inputSchema:{type:'object',additionalProperties:true}}))}; + else if(msg.method==='tools/call'){ + const task=msg.params.name;let data; + if(task==='get_adcp_capabilities')data=caps; + else if(task==='create_media_buy')data={status:'completed',...(includeLifecycle?{media_buy_status:'active'}:{}),media_buy_id:'seller-buy',confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package',product_id:'seller-product',targeting_overlay:{collection_selection:{mode:'selected',collections:[{publisher_domain:'studio.example',collection_ids:['a','b']}]}}}]}; + else if(task==='get_media_buys')data={status:'completed',media_buys:[{media_buy_id:'seller-buy',status:'active',currency:'EUR',total_budget:1000,confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package',product_id:'seller-product'}]}]}; + else throw Error('Unexpected task '+task); + exchanges.push({task,request:msg.params.arguments,response:data});result={content:[{type:'text',text:JSON.stringify(data)}]}; + }else throw Error('Unexpected method '+msg.method); + return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,result}),{headers:{'content-type':'application/json'}}); + }; + const result=await runStoryboard('https://status-control.example/mcp',doc,{adcpVersion:'3.2.5',brand:account.brand,sandbox:true,timeout_ms:3000,profile:{tools,raw_capabilities:caps,supported_protocols:['media_buy']},transport:{trustedFetchFn}}); + console.log(JSON.stringify({includeLifecycle,overall_passed:result.overall_passed,failed_count:result.failed_count,exchanges,steps:result.phases.flatMap(p=>p.steps.map(s=>({id:s.step_id,passed:s.passed,validations:s.validations})))})); + return result; +} +(async()=>{ + const result=await run(true); + const failed=result.phases.flatMap(p=>p.steps.flatMap(s=>s.validations??[])).find(v=>v.check==='collection_set_equals_context'&&!v.passed); + assert.ok(failed,'broken seller widening must fail'); + console.log(JSON.stringify({failed})); + assert.equal(failed.context_key,'wanted','existing runner-output-contract MUST echo authored context_key'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/specs/optional-product-collection-qualification/sdk3397-feed-restart.cjs b/specs/optional-product-collection-qualification/sdk3397-feed-restart.cjs new file mode 100644 index 00000000000..86e467c9c2e --- /dev/null +++ b/specs/optional-product-collection-qualification/sdk3397-feed-restart.cjs @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const {prepareStoryboardFixtures:runStoryboard}=require('@adcp/sdk/testing'); +const tools=['get_adcp_capabilities','get_products','create_media_buy','get_media_buys']; +const caps={status:'completed',adcp:{major_versions:[3],supported_versions:['3.2'],idempotency:{supported:false}},supported_protocols:['media_buy'],media_buy:{buying_modes:['wholesale']}}; +const account={brand:{domain:'acmeoutdoor.example'},operator:'pinnacle-agency.example',sandbox:true}; +const offer={product_id:'seller-product',name:'StreamHaus offer',description:'Sandbox',delivery_type:'non_guaranteed',channels:['olv'],publisher_properties:[{publisher_domain:'studio.example',selection_type:'all'}],format_options:[{format_option_id:'image',format_kind:'image',params:{width:300,height:250}}],reporting_capabilities:{available_reporting_frequencies:['daily'],expected_delay_minutes:0,timezone:'UTC',supports_webhooks:false,available_metrics:['impressions'],date_range_support:'date_range'},collections:[{publisher_domain:'studio.example',collection_ids:['a','b']}],collection_targeting_allowed:true,pricing_options:[{pricing_option_id:'seller-price',pricing_model:'cpm',currency:'EUR',fixed_price:17}]}; +const doc={id:'media_buy_seller/feed_restart_probe',version:'1.0.0',title:'Restart changed wholesale feed',category:'media_buy_seller',fixtures:{products:[{product_id:'catalog-product'}]},fixture_resolution:{products:[{handle:'catalog-product',strategies:['discover'],match:[{path:'',operator:'collection_selection_satisfies',value:{collection_targeting:'selectable',minimum_pairs:2}}]}]},phases:[{id:'consume',title:'Consume',steps:[]}]}; +async function run(includeLifecycle){ + const exchanges=[];let version='v1'; + const trustedFetchFn=async(_url,init={})=>{ + if(!init.body)return new Response(null,{status:init.method==='DELETE'?202:405}); + const msg=JSON.parse(init.body);let result; + if(msg.method==='server/discover')return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32601,message:'Not supported'}}),{headers:{'content-type':'application/json'}}); + if(msg.method==='initialize')result={protocolVersion:'2025-03-26',capabilities:{tools:{}},serverInfo:{name:'status-control',version:'1'}}; + else if(msg.method.startsWith('notifications/'))return new Response(null,{status:202}); + else if(msg.method==='tools/list')result={tools:tools.map(name=>({name,description:name,inputSchema:{type:'object',additionalProperties:true}}))}; + else if(msg.method==='tools/call'){ + const task=msg.params.name;let data; + if(task==='get_adcp_capabilities')data=caps; + else if(task==='get_products'){const next=msg.params.arguments.pagination?.cursor;if(next)version='v2';data={status:'completed',products:next?[offer]:[],cache_scope:'account',wholesale_feed_version:version,pagination:{has_more:!next,...(!next?{cursor:'next'}:{})}};} + else if(task==='create_media_buy')data={status:'completed',...(includeLifecycle?{media_buy_status:'active'}:{}),media_buy_id:'seller-buy',confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package',targeting_overlay:{collection_selection:{mode:'selected',collections:[{publisher_domain:'studio.example',collection_ids:['a','b']}]}}}]}; + else if(task==='get_media_buys')data={status:'completed',media_buys:[{media_buy_id:'seller-buy',status:'active',currency:'EUR',total_budget:1000,confirmed_at:'2026-10-23T00:00:00Z',revision:1,packages:[{package_id:'seller-package'}]}]}; + else throw Error('Unexpected task '+task); + exchanges.push({task,request:msg.params.arguments,response:data});result={content:[{type:'text',text:JSON.stringify(data)}]}; + }else throw Error('Unexpected method '+msg.method); + return new Response(JSON.stringify({jsonrpc:'2.0',id:msg.id,result}),{headers:{'content-type':'application/json'}}); + }; + const result=await runStoryboard('https://status-control.example/mcp',doc,{adcpVersion:'3.2.5',brand:account.brand,sandbox:true,timeout_ms:3000,profile:{tools,raw_capabilities:caps,supported_protocols:['media_buy']},transport:{trustedFetchFn}}); + console.log(JSON.stringify({disposition:result.disposition,exchanges})); + return {disposition:result.disposition,exchanges}; +} +(async()=>{ + const result=await run(true); + const pages=result.exchanges.filter(e=>e.task==='get_products'); + assert.deepEqual(pages.map(e=>e.request.pagination?.cursor),[undefined,'next',undefined,'next'],'buyer MUST restart from cursor:null when feed version changes between pages'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 98ef029d7ec..e51fd1ef626 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -69,6 +69,12 @@ requires_scenarios: - media_buy_seller/budget_cap_timezone_override_rejected - media_buy_seller/owner_sold_channel_carriage - media_buy_seller/collection_selection + - media_buy_seller/collection_default_materialization + - media_buy_seller/collection_selection_readback + - media_buy_seller/collection_default_materialization_readback + - media_buy_seller/fixed_bundle_enforcement + - media_buy_seller/fixed_bundle_restatement + - media_buy_seller/owner_sold_channel_subset - media_buy_seller/outcome_target_list_products_inert narrative: | @@ -394,11 +400,11 @@ phases: context_outputs: - name: product_format_kind - path: 'products[0].format_options[0].format_kind' + path: "products[0].format_options[0].format_kind" - name: product_id - path: 'products[0].product_id' + path: "products[0].product_id" - name: pricing_option_id - path: 'products[0].pricing_options[0].pricing_option_id' + path: "products[0].pricing_options[0].pricing_option_id" validations: - check: response_schema description: "Response matches get-products-response.json schema" @@ -538,7 +544,7 @@ phases: context_outputs: - name: media_buy_id - path: 'media_buy_id' + path: "media_buy_id" validations: - check: response_schema description: "Response matches create-media-buy-response.json schema" @@ -709,11 +715,11 @@ phases: correlation_id: "media_buy_seller--get_products_brief" context_outputs: - name: product_format_kind - path: 'products[0].format_options[0].format_kind' + path: "products[0].format_options[0].format_kind" - name: product_id - path: 'products[0].product_id' + path: "products[0].product_id" - name: pricing_option_id - path: 'products[0].pricing_options[0].pricing_option_id' + path: "products[0].pricing_options[0].pricing_option_id" validations: - check: response_schema description: "Response matches get-products-response.json schema" @@ -797,7 +803,7 @@ phases: correlation_id: "media_buy_seller--create_media_buy" context_outputs: - name: media_buy_id - path: 'media_buy_id' + path: "media_buy_id" validations: - check: response_schema description: "Response matches create-media-buy-response.json schema" diff --git a/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization.yaml b/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization.yaml new file mode 100644 index 00000000000..66c41a7efea --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization.yaml @@ -0,0 +1,200 @@ +id: media_buy_seller/collection_default_materialization +version: 1.0.0 +introduced_in: "3.3" +title: Seller materializes a catalog product default as its complete concrete collection set +category: media_buy_seller +summary: Independently grades any collection support with at least 1 distinct domain-qualified pair. +track: media_buy +requires: + - controller +required_tools: + - create_media_buy +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_collection_default_materialization + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-2af6e9ef + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: any + minimum_pairs: 1 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-2af6e9ef + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-2af6e9ef + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Grade the independently selected collection product + steps: + - id: create_selection + title: Purchase the complete default bundle + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-2af6e9ef + sandbox: true + idempotency_key: $generate:uuid_v4#collection_default_materialization_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: default + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_declared + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections diff --git a/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization_readback.yaml b/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization_readback.yaml new file mode 100644 index 00000000000..af2a8e4e3fa --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/collection_default_materialization_readback.yaml @@ -0,0 +1,266 @@ +id: media_buy_seller/collection_default_materialization_readback +version: 1.0.0 +introduced_in: "3.3" +title: Seller persists the materialized default catalog collection set +category: media_buy_seller +summary: Independently catalog-qualifies persisted collection readback when get_media_buys is advertised. +track: media_buy +requires: + - controller +required_tools: + - get_media_buys +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + This companion grades creation followed by persisted readback under its own advertised + get_media_buys root gate; the six core scopes remain independent of this optional reader. Required arrays, exact buy/package identities and concrete echoes + remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. + + + This readback companion requires only get_media_buys in its root tool gate. A missing reader is a + genuine native storyboard-level coverage gate, not a relabeled authored failure. An advertised + reader must preserve the committed set. Callers of individual steps must honor the same root gate; + the repository caller returns the unchanged native root-gate step before preparation when the + reader is absent. Standalone reads need trusted prior buy, package and committed-selector context; + they requalify the current native catalog and do not provide durable cross-request prepared-state + authority. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_collection_default_materialization_readback + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-c806cfbc + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: any + minimum_pairs: 1 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-c806cfbc + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-c806cfbc + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Commit and validate the selection before readback + steps: + - id: create_selection + title: Purchase the complete default bundle + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-c806cfbc + sandbox: true + idempotency_key: $generate:uuid_v4#collection_default_materialization_readback_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: default + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_declared + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections + - id: persisted_readback + title: Grade the persisted committed set + steps: + - id: read_selection + title: Read the persisted exact collection selection when the seller offers readback + task: get_media_buys + requires_tool: 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: + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_default_-c806cfbc + sandbox: true + media_buy_ids: + - $context.collection_media_buy_id + expected: + Return exactly the requested buy and its committed package. Preserve the complete + domain-qualified collection set; an advertised broken readback remains a failure. + validations: + - check: response_schema + description: Readback matches the canonical get-media-buys schema + - check: array_length + path: media_buys + value: 1 + description: Only the requested buy is returned + - check: field_equals_context + path: media_buys[0].media_buy_id + context_key: collection_media_buy_id + description: Readback belongs to the actual committed buy + - check: field_equals_context + path: media_buys[0].packages[0].product_id + context_key: collection_committed_product_id + description: Readback echoes the explicit committed product identity + - check: array_length + path: media_buys[0].packages + value: 1 + description: The buy preserves exactly its one committed package + - check: field_equals_context + path: media_buys[0].packages[0].package_id + context_key: collection_package_id + description: Readback belongs to the actual committed package + - check: field_value + path: media_buys[0].packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Readback contains concrete selected-form selectors, including materialized defaults + - check: collection_set_equals_context + path: media_buys[0].packages[0].targeting_overlay.collection_selection.collections + context_key: collection_committed_selectors + description: + Persisted selectors equal the committed set from the preceding successful create; a failed + create blocks full-run readback, and grouping or ordering may vary diff --git a/static/compliance/source/protocols/media-buy/scenarios/collection_selection.yaml b/static/compliance/source/protocols/media-buy/scenarios/collection_selection.yaml index 7c7b247264b..020717e137c 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/collection_selection.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/collection_selection.yaml @@ -1,474 +1,285 @@ id: media_buy_seller/collection_selection -version: "1.0.0" +version: 1.0.1 introduced_in: "3.2" -title: "Buyer commits collection selection and the seller echoes it as concrete selectors on readback" +title: Seller accepts a catalog collection subset and preserves its exact selectors category: media_buy_seller -summary: "Verifies mode: selected accepts an explicit domain-qualified subset, mode: default materializes the product's full bundle on readback, package readback always echoes committed collection_selection as concrete selectors, and a fixed (non-selectable) bundle rejects partial selection while accepting an exact restatement." +summary: Independently grades selectable collection support with at least 2 distinct domain-qualified pairs. track: media_buy requires: - controller - required_tools: - - get_products - create_media_buy - - get_media_buys - - comply_test_controller +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + -narrative: | - Products with multiple collections default to bundles; sellers can set - collection_targeting_allowed: true to let buyers target a subset, the same - pattern as property_targeting_allowed for properties - (collections-and-installments.mdx, "Collection targeting"). Selection - travels in targeting_overlay.collection_selection, mirroring - placement_selection: mode: "selected" names the complete required set as - domain-qualified selectors with explicit collection_ids, and mode: - "default" accepts the product's full bundle. + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. - On package readback, collection_selection is the audit surface: sellers - MUST echo the committed collection selection there as concrete selectors — - including the materialized result of any collection_list composition — so - a buyer can verify what content programming a package runs against without - re-resolving mutable lists or re-fetching the product catalog - (collections-and-installments.mdx, "Collection targeting"; core/package.json - targeting_overlay). Because the schema's mode: "default" branch carries no - collections field, a default-mode purchase's readback necessarily - materializes as concrete "selected"-shaped selectors naming the product's - full bundle. + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. agent: interaction_model: media_buy_seller capabilities: - sells_media examples: - - "A CTV seller offering a multi-channel programmed-audio/video bundle" - + - A seller offering native collection-bearing sandbox products caller: role: buyer_agent - example: "Pinnacle Agency (buyer)" - + example: Pinnacle Agency (buyer) prerequisites: - description: | - The caller needs a brand identity for product discovery and buy creation. - The controller seeds a selectable two-collection channel bundle - (collection_targeting_allowed: true) and a companion fixed bundle of the - same two collections (collection_targeting_allowed: false, the default) - to exercise both the selectable and fixed-bundle behaviors. - test_kit: "test-kits/acme-outdoor.yaml" + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml controller_seeding: true - fixtures: accounts: - - account_id: "acct_acmeoutdoor_example_sandbox_8852d176d548" + - account_id: sandbox_collection_selection fixture: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" - billing: "operator" - status: "active" + id: compliance-media_buy_seller_collection_selectio-7e6aac4c sandbox: true + billing: operator + status: active products: - - product_id: "retro_bundle_ctv" - delivery_type: "non_guaranteed" - channels: ["ctv"] - format_options: - - format_option_id: "video_30s" - format_kind: "video_hosted" - params: - duration_ms_exact: 30000 - publisher_properties: - - publisher_domain: "hoststream.example" - selection_type: "by_id" - property_ids: ["hoststream_ctv"] - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news", "vintage_static"] - collection_targeting_allowed: true - placements: - - kind: "seller_inline" - seller_agent: - agent_url: "https://sales.channel-owner.example" - publisher_domain: "channel-owner.example" - placement_id: "owner_avails" - name: "Channel-owner avails" - mode: "included" - - product_id: "retro_bundle_ctv_fixed" - delivery_type: "non_guaranteed" - channels: ["ctv"] - format_options: - - format_option_id: "video_30s" - format_kind: "video_hosted" - params: - duration_ms_exact: 30000 - publisher_properties: - - publisher_domain: "hoststream.example" - selection_type: "by_id" - property_ids: ["hoststream_ctv"] - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news", "vintage_static"] - placements: - - kind: "seller_inline" - seller_agent: - agent_url: "https://sales.channel-owner.example" - publisher_domain: "channel-owner.example" - placement_id: "owner_avails" - name: "Channel-owner avails" - mode: "included" + - product_id: catalog_collection_product pricing_options: - - product_id: "retro_bundle_ctv" - pricing_option_id: "retro_bundle_cpm" - pricing_model: "cpm" - currency: "USD" - floor_price: 22.0 - - product_id: "retro_bundle_ctv_fixed" - pricing_option_id: "retro_bundle_fixed_cpm" - pricing_model: "cpm" - currency: "USD" - floor_price: 22.0 - + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: selectable + minimum_pairs: 2 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + - key: __adcp_fixture_collection_subset + witness: strict_subset + - key: __adcp_fixture_collection_unknown_id + witness: undeclared_collection_id_selectors + - key: __adcp_fixture_collection_unknown_domain + witness: undeclared_publisher_domain_selectors + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-7e6aac4c + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-7e6aac4c + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example phases: - - id: selected_subset_lifecycle - title: "Purchase and read back an explicit collection subset" + - id: commit_selection + title: Grade the independently selected collection product steps: - - id: get_products_retro_bundle - title: "Discover the selectable channel bundle" - task: get_products - schema_ref: "media-buy/get-products-request.json" - response_schema_ref: "media-buy/get-products-response.json" - doc_ref: "/media-buy/product-discovery/collections-and-installments" - stateful: true - sample_request: - buying_mode: "brief" - brief: "Pre-roll across the HostStream channel bundle: Acme Retro News and Vintage Static" - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" - sandbox: true - filters: - channels: ["ctv"] - context: - correlation_id: "collection_selection--discovery" - expected: | - Return the selectable two-collection bundle product, declaring - collection_targeting_allowed: true. - context_outputs: - - key: "bundle_product_id" - path: "products[0].product_id" - - key: "bundle_pricing_option_id" - path: "products[0].pricing_options[0].pricing_option_id" - validations: - - check: response_schema - description: "Response matches get-products-response.json" - - check: field_value - path: "products[0].collection_targeting_allowed" - value: true - description: "Product declares collection_targeting_allowed, the gate for targeting_overlay.collection_selection (core/product.json collection_targeting_allowed)" - - check: array_length - path: "products[0].collections[0].collection_ids" - value: 2 - description: "Product's full bundle has two collections, so a selected subset is a real narrowing" - - - id: create_media_buy_selected_subset - title: "Buy an explicit domain-qualified subset of the bundle" + - id: create_selection + title: Purchase the catalog-qualified concrete collection set task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" + id: compliance-media_buy_seller_collection_selectio-7e6aac4c sandbox: true - idempotency_key: "$generate:uuid_v4#collection_selection_selected_subset_create" - start_time: "asap" + idempotency_key: $generate:uuid_v4#collection_selection_create_selection + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.bundle_product_id" - pricing_option_id: "$context.bundle_pricing_option_id" - bid_price: 22.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news"] - expected: | - Accept the subset because collection_targeting_allowed: true - permits buyers to select fewer than the product's full bundle. The - response package echoes the committed selection as a concrete, - domain-qualified selector naming exactly the requested collection. - context_outputs: - - key: "selected_media_buy_id" - path: "media_buy_id" - - key: "selected_package_id" - path: "packages[0].package_id" - validations: - - check: response_schema - description: "Response matches create-media-buy-response.json" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.mode" - value: "selected" - description: "Sellers MUST echo collection selection on package readback (core/package.json targeting_overlay)" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.collections[0].publisher_domain" - value: "channel-owner.example" - description: "Echoed selector is domain-qualified (core/collection-selector.json requires publisher_domain)" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.collections[0].collection_ids[0]" - value: "retro_news" - description: "Echoed selector names the committed collection as a concrete selector (collections-and-installments.mdx, Collection targeting)" - - check: field_absent - path: "packages[0].targeting_overlay.collection_selection.collections[0].collection_ids[1]" - description: "Committed subset has exactly one collection_id; the seller must not silently widen it" - - check: field_absent - path: "packages[0].targeting_overlay.collection_selection.collections[1]" - description: "Committed subset has exactly one selector; the seller must not silently add the unselected collection" - - - id: read_selected_subset - title: "Verify the persisted subset selection" - 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: - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" - sandbox: true - media_buy_ids: ["$context.selected_media_buy_id"] - expected: | - Readback returns the same concrete, domain-qualified subset - selector committed at create time. + mode: selected + collections: $context.__adcp_fixture_collection_subset + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. validations: - check: response_schema - description: "Response matches get-media-buys-response.json" + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity - check: field_equals_context - path: "media_buys[0].packages[0].package_id" - context_key: "selected_package_id" - description: "Readback targets the package created earlier" - - check: field_value - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.mode" - value: "selected" - description: "Package readback MUST echo the committed collection selection (collections-and-installments.mdx, Collection targeting)" + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package - check: field_value - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.collections[0].collection_ids[0]" - value: "retro_news" - description: "Readback echoes the committed collection as a concrete selector, not a re-resolvable reference (collections-and-installments.mdx, Collection targeting)" - - check: field_absent - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.collections[0].collection_ids[1]" - description: "Readback does not widen the committed one-collection subset" - - - id: default_bundle_materialization - title: "mode: default materializes the full bundle as concrete selectors on readback" - steps: - - id: create_media_buy_default_bundle - title: "Buy the product's default (full-bundle) collection selection" - task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" - stateful: true - sample_request: - brand: - domain: "acmeoutdoor.example" - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" - sandbox: true - idempotency_key: "$generate:uuid_v4#collection_selection_default_bundle_create" - start_time: "asap" - end_time: "2099-09-30T23:59:59Z" - packages: - - product_id: "$context.bundle_product_id" - pricing_option_id: "$context.bundle_pricing_option_id" - bid_price: 22.0 - budget: 5000 - targeting_overlay: - collection_selection: - mode: "default" - expected: | - Accept the product's full bundle. Because the collection-selection - schema's mode: "default" branch carries no collections field, the - seller MUST echo the committed selection as concrete "selected"-shaped - selectors naming both bundle collections rather than echoing - mode: "default" back unmaterialized (core/collection-selection.json; - collections-and-installments.mdx, Collection targeting). + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_subset + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening context_outputs: - - key: "default_media_buy_id" - path: "media_buy_id" - - key: "default_package_id" - path: "packages[0].package_id" - validations: - - check: response_schema - description: "Response matches create-media-buy-response.json" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.mode" - value: "selected" - description: "Readback materializes mode: default into concrete selected-shaped selectors; the schema's default branch cannot carry a collections field (core/collection-selection.json), and sellers MUST echo the committed selection as concrete selectors (collections-and-installments.mdx, Collection targeting)" - - check: field_contains - path: "packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "retro_news" - description: "Materialized readback includes the full bundle's first collection" - - check: field_contains - path: "packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "vintage_static" - description: "Materialized readback includes the full bundle's second collection" - - - id: read_default_bundle - title: "Verify the persisted materialized bundle selection" - 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: - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" - sandbox: true - media_buy_ids: ["$context.default_media_buy_id"] - expected: | - Readback returns the same materialized full-bundle selection - committed at create time. - validations: - - check: response_schema - description: "Response matches get-media-buys-response.json" - - check: field_equals_context - path: "media_buys[0].packages[0].package_id" - context_key: "default_package_id" - description: "Readback targets the package created earlier" - - check: field_value - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.mode" - value: "selected" - description: "Package readback MUST echo the committed collection selection as concrete selectors even when the buyer purchased mode: default (collections-and-installments.mdx, Collection targeting)" - - check: field_contains - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "retro_news" - description: "Readback preserves the full bundle's first collection" - - check: field_contains - path: "media_buys[0].packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "vintage_static" - description: "Readback preserves the full bundle's second collection" - - - id: fixed_bundle_enforcement - title: "A fixed (non-selectable) bundle rejects partial selection but accepts exact restatement" - narrative: | - When collection_targeting_allowed is false (the default), the product - is a fixed bundle: a collection_selection that exactly restates the - complete bundle remains an inherent match, but a narrower subset - requires collection_targeting_allowed: true and must be rejected - (core/product.json collection_targeting_allowed). - steps: - - id: reject_partial_selection_on_fixed_bundle - title: "Reject a subset selection on the fixed bundle" + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections + - id: reject_unknown_collection_id + title: Reject a collision-checked undeclared domain-qualified collection ID task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true expect_error: true negative_path: payload_well_formed - stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" + id: compliance-media_buy_seller_collection_selectio-7e6aac4c sandbox: true - idempotency_key: "$generate:uuid_v4#collection_selection_fixed_partial_reject" - start_time: "asap" + idempotency_key: $generate:uuid_v4#collection_selection_reject_unknown_collection_id + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "retro_bundle_ctv_fixed" - pricing_option_id: "retro_bundle_fixed_cpm" - bid_price: 22.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news"] - expected: | - Reject with UNSUPPORTED_FEATURE. Partial collection selection - requires collection_targeting_allowed: true; the fixed product - cannot silently narrow to less than its complete bundle - (core/product.json collection_targeting_allowed). + mode: selected + collections: $context.__adcp_fixture_collection_unknown_id + expected: + Reject with REFERENCE_NOT_FOUND; preserve the requested selectors rather than substituting + a supported set. validations: - check: error_code - value: "UNSUPPORTED_FEATURE" - description: "A fixed (collection_targeting_allowed: false) product rejects a narrower collection subset (core/product.json collection_targeting_allowed)" - - - id: accept_full_restatement_on_fixed_bundle - title: "Accept an exact restatement of the fixed bundle's complete collections" + allowed_values: + - REFERENCE_NOT_FOUND + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing + - id: reject_unknown_publisher_domain + title: Reject a collection namespace absent from the SAME complete product task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy stateful: true + expect_error: true + negative_path: payload_well_formed sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_collection_selectio-7e6aac4c" + id: compliance-media_buy_seller_collection_selectio-7e6aac4c sandbox: true - idempotency_key: "$generate:uuid_v4#collection_selection_fixed_full_restatement_create" - start_time: "asap" + idempotency_key: $generate:uuid_v4#collection_selection_reject_unknown_publisher_domain + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "retro_bundle_ctv_fixed" - pricing_option_id: "retro_bundle_fixed_cpm" - bid_price: 22.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news", "vintage_static"] - expected: | - Accept the exact restatement of the complete fixed bundle. A - collection_selection that exactly restates the complete bundle - remains an inherent match even without collection_targeting_allowed - (core/product.json collection_targeting_allowed), and the response - echoes the complete committed set. + mode: selected + collections: $context.__adcp_fixture_collection_unknown_domain + expected: + Reject with REFERENCE_NOT_FOUND; preserve the requested selectors rather than substituting + a supported set. validations: - - check: response_schema - description: "Response matches create-media-buy-response.json" - - check: field_contains - path: "packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "retro_news" - description: "Exact restatement echoes the first bundle collection" - - check: field_contains - path: "packages[0].targeting_overlay.collection_selection.collections[*].collection_ids[*]" - value: "vintage_static" - description: "Exact restatement echoes the second bundle collection" + - check: error_code + allowed_values: + - REFERENCE_NOT_FOUND + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing diff --git a/static/compliance/source/protocols/media-buy/scenarios/collection_selection_readback.yaml b/static/compliance/source/protocols/media-buy/scenarios/collection_selection_readback.yaml new file mode 100644 index 00000000000..4c0faa62d08 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/collection_selection_readback.yaml @@ -0,0 +1,269 @@ +id: media_buy_seller/collection_selection_readback +version: 1.0.0 +introduced_in: "3.3" +title: Seller persists the exact catalog collection subset +category: media_buy_seller +summary: Independently catalog-qualifies persisted collection readback when get_media_buys is advertised. +track: media_buy +requires: + - controller +required_tools: + - get_media_buys +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + This companion grades creation followed by persisted readback under its own advertised + get_media_buys root gate; the six core scopes remain independent of this optional reader. Required arrays, exact buy/package identities and concrete echoes + remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. + + + This readback companion requires only get_media_buys in its root tool gate. A missing reader is a + genuine native storyboard-level coverage gate, not a relabeled authored failure. An advertised + reader must preserve the committed set. Callers of individual steps must honor the same root gate; + the repository caller returns the unchanged native root-gate step before preparation when the + reader is absent. Standalone reads need trusted prior buy, package and committed-selector context; + they requalify the current native catalog and do not provide durable cross-request prepared-state + authority. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_collection_selection_readback + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-1d7f802e + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: selectable + minimum_pairs: 2 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + - key: __adcp_fixture_collection_subset + witness: strict_subset + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-1d7f802e + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-1d7f802e + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Commit and validate the selection before readback + steps: + - id: create_selection + title: Purchase the catalog-qualified concrete collection set + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-1d7f802e + sandbox: true + idempotency_key: $generate:uuid_v4#collection_selection_readback_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: selected + collections: $context.__adcp_fixture_collection_subset + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_subset + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections + - id: persisted_readback + title: Grade the persisted committed set + steps: + - id: read_selection + title: Read the persisted exact collection selection when the seller offers readback + task: get_media_buys + requires_tool: 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: + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_collection_selectio-1d7f802e + sandbox: true + media_buy_ids: + - $context.collection_media_buy_id + expected: + Return exactly the requested buy and its committed package. Preserve the complete + domain-qualified collection set; an advertised broken readback remains a failure. + validations: + - check: response_schema + description: Readback matches the canonical get-media-buys schema + - check: array_length + path: media_buys + value: 1 + description: Only the requested buy is returned + - check: field_equals_context + path: media_buys[0].media_buy_id + context_key: collection_media_buy_id + description: Readback belongs to the actual committed buy + - check: field_equals_context + path: media_buys[0].packages[0].product_id + context_key: collection_committed_product_id + description: Readback echoes the explicit committed product identity + - check: array_length + path: media_buys[0].packages + value: 1 + description: The buy preserves exactly its one committed package + - check: field_equals_context + path: media_buys[0].packages[0].package_id + context_key: collection_package_id + description: Readback belongs to the actual committed package + - check: field_value + path: media_buys[0].packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Readback contains concrete selected-form selectors, including materialized defaults + - check: collection_set_equals_context + path: media_buys[0].packages[0].targeting_overlay.collection_selection.collections + context_key: collection_committed_selectors + description: + Persisted selectors equal the committed set from the preceding successful create; a failed + create blocks full-run readback, and grouping or ordering may vary diff --git a/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_enforcement.yaml b/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_enforcement.yaml new file mode 100644 index 00000000000..a7d000bf952 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_enforcement.yaml @@ -0,0 +1,242 @@ +id: media_buy_seller/fixed_bundle_enforcement +version: 1.0.0 +introduced_in: "3.3" +title: Seller accepts a fixed catalog bundle and rejects a strict subset +category: media_buy_seller +summary: Independently grades fixed collection support with at least 2 distinct domain-qualified pairs. +track: media_buy +requires: + - controller +required_tools: + - create_media_buy +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_fixed_bundle_enforcement + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_enforc-47911509 + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: fixed + minimum_pairs: 2 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + - key: __adcp_fixture_collection_subset + witness: strict_subset + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_enforc-47911509 + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_enforc-47911509 + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Grade the independently selected collection product + steps: + - id: create_selection + title: Purchase the catalog-qualified concrete collection set + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_enforc-47911509 + sandbox: true + idempotency_key: $generate:uuid_v4#fixed_bundle_enforcement_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: selected + collections: $context.catalog_collection_full_selectors + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_declared + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections + - id: reject_fixed_subset + title: Reject a strict subset of this SAME fixed product + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + expect_error: true + negative_path: payload_well_formed + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_enforc-47911509 + sandbox: true + idempotency_key: $generate:uuid_v4#fixed_bundle_enforcement_reject_fixed_subset + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: selected + collections: $context.__adcp_fixture_collection_subset + expected: + Reject with UNSUPPORTED_FEATURE; preserve the requested selectors rather than substituting + a supported set. + validations: + - check: error_code + allowed_values: + - UNSUPPORTED_FEATURE + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing diff --git a/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_restatement.yaml b/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_restatement.yaml new file mode 100644 index 00000000000..171091a642f --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/fixed_bundle_restatement.yaml @@ -0,0 +1,201 @@ +id: media_buy_seller/fixed_bundle_restatement +version: 1.0.0 +introduced_in: "3.3" +title: Seller accepts an exact restatement of a fixed catalog collection bundle +category: media_buy_seller +summary: Independently grades fixed collection support with at least 1 distinct domain-qualified pair. +track: media_buy +requires: + - controller +required_tools: + - create_media_buy +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_fixed_bundle_restatement + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_restat-2e84b25b + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: fixed + minimum_pairs: 1 + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_restat-2e84b25b + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_restat-2e84b25b + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Grade the independently selected collection product + steps: + - id: create_selection + title: Purchase the catalog-qualified concrete collection set + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_fixed_bundle_restat-2e84b25b + sandbox: true + idempotency_key: $generate:uuid_v4#fixed_bundle_restatement_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: selected + collections: $context.catalog_collection_full_selectors + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_declared + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections diff --git a/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_carriage.yaml b/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_carriage.yaml index f253629c35c..427bdfcf55a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_carriage.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_carriage.yaml @@ -1,388 +1,335 @@ id: media_buy_seller/owner_sold_channel_carriage -version: "1.0.0" +version: 1.0.1 introduced_in: "3.2" -title: "Seller declares owner-sold channel carriage and enforces collection selector boundaries" +title: Seller accepts declared cross-publisher collection selectors and rejects invalid selectors category: media_buy_seller -summary: "Verifies an owner-sold channel product combines host property scope with the owner's canonical collection selector, that buyer collection selection requires explicit collection_ids, and that unknown or mismatched collection selectors are rejected rather than silently substituted." +summary: + Independently grades any collection support with at least 1 distinct domain-qualified pair + and a structural cross-publisher relation. track: media_buy requires: - controller - required_tools: - - get_products - create_media_buy - - comply_test_controller +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. -narrative: | - A continuously programmed audio or video stream is modeled as a `kind: - "channel"` collection declared by the channel owner, carried on a host CTV - app property. When the owner sells its allocated avails, a verified - owner-sold supply path requires the host's adagents.json to authorize the - owner's sales agent for the host property, narrowed to the owner's - canonical collection (docs/governance/property/adagents.mdx, "Owner-sold - channel carriage"; docs/media-buy/product-discovery/collections-and-installments.mdx, - "Programmed channels and owner-sold carriage"). - Buyer agents distinguish three verification states: verified owner-sold - (host adagents.json authorizes the owner's agent, narrowed by an exact or - bulk-grant collection selector), host-delegated (only an ads.txt/app-ads.txt - `inventorypartnerdomain=` line corroborates the relationship), and - owner-attested (only the owner's own `distribution[]` entry asserts - carriage, with no host-side corroboration at all). Missing host - authorization, a host selector naming a different owner domain, a dangling - `distribution[].property_ids` value, and a typo'd collection_id are all - fail-closed conditions on that ladder. + Cross-publisher means a retained collection namespace outside ALL publisher_properties domains of + the SAME product. The subset and undeclared-selector witnesses retain that relation; one external + plus one local pair suffices for a two-pair scope. This structural fact proves neither beneficial + ownership, host authorization, channel kind nor a verified supply path. This storyboard does not + fetch adagents.json or evaluate the owner-sold verification ladder. - adagents.json itself is fetched directly by buyer SDKs over HTTPS and is - not carried by any AdCP task — the verification ladder is buyer-side - classification logic over two independently-hosted JSON documents. This - storyboard exercises the portion of that model that *is* wire-visible: the - seller's product declarations that a buyer's verification depends on, and - the seller's obligation to reject collection selectors it cannot honor - rather than silently substituting a different collection. It does not - simulate adagents.json content itself; see the compliance report for what - remains uncovered. + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. agent: interaction_model: media_buy_seller capabilities: - sells_media examples: - - "A channel owner's sales agent selling owner-sold avails on a host CTV app" - + - A seller offering native collection-bearing sandbox products caller: role: buyer_agent - example: "Pinnacle Agency (buyer)" - + example: Pinnacle Agency (buyer) prerequisites: - description: | - The caller needs a brand identity for product discovery and buy creation. - The controller seeds one owner-sold channel product that combines a host - property selector with the owner's canonical collection selector, per the - docs' worked example (hoststream.example / hoststream_ctv carrying - channel-owner.example's retro_news channel). - test_kit: "test-kits/acme-outdoor.yaml" + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml controller_seeding: true - fixtures: accounts: - - account_id: "acct_acmeoutdoor_example_sandbox_bd866a1fee90" + - account_id: sandbox_owner_sold_channel_carriage fixture: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" - billing: "operator" - status: "active" + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 sandbox: true + billing: operator + status: active products: - - product_id: "retro_news_owner_sold" - delivery_type: "non_guaranteed" - channels: ["ctv"] - format_options: - - format_option_id: "video_30s" - format_kind: "video_hosted" - params: - duration_ms_exact: 30000 - publisher_properties: - - publisher_domain: "hoststream.example" - selection_type: "by_id" - property_ids: ["hoststream_ctv"] - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news"] - collection_targeting_allowed: true - placements: - - kind: "seller_inline" - seller_agent: - agent_url: "https://sales.channel-owner.example" - publisher_domain: "channel-owner.example" - placement_id: "owner_avails" - name: "Channel-owner avails" - mode: "included" + - product_id: catalog_collection_product pricing_options: - - product_id: "retro_news_owner_sold" - pricing_option_id: "retro_news_cpm" - pricing_model: "cpm" - currency: "USD" - floor_price: 20.0 - + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: any + minimum_pairs: 1 + require_cross_publisher_collection: true + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + - key: __adcp_fixture_collection_relation + witness: cross_publisher_relation + - key: __adcp_fixture_collection_unknown_id + witness: undeclared_collection_id_selectors + - key: __adcp_fixture_collection_unknown_domain + witness: undeclared_publisher_domain_selectors + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + catalog_collection_bulk_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example phases: - - id: verified_owner_sold_discovery - title: "Discover the verified owner-sold channel product" - narrative: | - A verified owner-sold supply path requires the host property scope and - the owner's external collection selector to appear together on the - seller's product (collections-and-installments.mdx, "Programmed - channels and owner-sold carriage"). This phase confirms the seller - returns both halves of that combination on the same product. + - id: commit_selection + title: Grade the independently selected collection product steps: - - id: get_products_owner_sold_channel - title: "Discover the owner-sold channel product" - task: get_products - schema_ref: "media-buy/get-products-request.json" - response_schema_ref: "media-buy/get-products-response.json" - doc_ref: "/media-buy/product-discovery/collections-and-installments" - stateful: true - sample_request: - buying_mode: "brief" - brief: "Pre-roll on Acme Retro News, the syndicated retro news channel carried on the HostStream CTV app" - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" - sandbox: true - filters: - channels: ["ctv"] - context: - correlation_id: "owner_sold_channel_carriage--discovery" - expected: | - Return the owner-sold channel product. The product combines the - host property scope (hoststream.example / hoststream_ctv) with the - owner's canonical collection selector (channel-owner.example / - retro_news) on the same product, and the collection selector names - explicit collection_ids rather than the domain-only bulk-grant - form. - context_outputs: - - key: "owner_sold_product_id" - path: "products[0].product_id" - - key: "owner_sold_pricing_option_id" - path: "products[0].pricing_options[0].pricing_option_id" - validations: - - check: response_schema - description: "Response matches get-products-response.json" - - check: field_value - path: "products[0].publisher_properties[0].publisher_domain" - value: "hoststream.example" - description: "Product declares the host property scope a verified owner-sold supply path requires (collections-and-installments.mdx, Programmed channels and owner-sold carriage)" - - check: field_value - path: "products[0].collections[0].publisher_domain" - value: "channel-owner.example" - description: "Collection selector names the owner's adagents.json domain (core/collection-selector.json requires publisher_domain)" - - check: field_present - path: "products[0].collections[0].collection_ids" - description: "Product collection selectors MUST name explicit collection_ids; the domain-only bulk-grant form is authorization scoping only, not product composition (core/product.json collections[] requires collection_ids)" - - check: field_value - path: "products[0].collections[0].collection_ids[0]" - value: "retro_news" - description: "Product references the owner's canonical collection matching the verified-path worked example" - - check: field_value - path: "products[0].collection_targeting_allowed" - value: true - description: "Product advertises collection_targeting_allowed so buyers can exercise targeting_overlay.collection_selection (core/product.json collection_targeting_allowed)" - - - id: verified_selection_accepted - title: "Accept the correct owner collection selection and echo it back" - narrative: | - Before probing the fail-closed legs, prove the affirmative path: the - exact selector the product declares is accepted, and the package - readback echoes the committed selection as concrete selectors - (core/package.json targeting_overlay; core/collection-selection.json). - Without this leg, a seller rejecting every create would pass the - rejection phases vacuously. - steps: - - id: create_media_buy_correct_selector - title: "Buy the owner-sold channel with the declared collection selector" + - id: create_selection + title: Purchase the catalog-qualified concrete collection set task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 sandbox: true - idempotency_key: "$generate:uuid_v4#owner_sold_channel_carriage_accept" - start_time: "asap" + idempotency_key: $generate:uuid_v4#owner_sold_channel_carriage_create_selection + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.owner_sold_product_id" - pricing_option_id: "$context.owner_sold_pricing_option_id" - bid_price: 20.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["retro_news"] - expected: | - Accept the buy. The committed collection selection is echoed in the - package's targeting_overlay.collection_selection as the same - concrete domain-qualified selector, unchanged and unwidened. + mode: selected + collections: $context.catalog_collection_full_selectors + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. validations: - check: response_schema - description: "Response matches create-media-buy-response.json" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.mode" - value: "selected" - description: "Package readback MUST echo the committed collection selection as concrete selectors (core/package.json targeting_overlay)" - - check: field_value - path: "packages[0].targeting_overlay.collection_selection.collections[0].publisher_domain" - value: "channel-owner.example" - description: "Echo preserves the owner's publisher namespace (core/collection-selector.json)" + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package - check: field_value - path: "packages[0].targeting_overlay.collection_selection.collections[0].collection_ids[0]" - value: "retro_news" - description: "Echo names the committed collection exactly, not a substitute (core/collection-selection.json)" - - - id: explicit_selection_required - title: "Buyer collection selection requires explicit collection_ids and known selectors" - narrative: | - mode: selected names the complete required set as domain-qualified - selectors with explicit collection_ids; the domain-only bulk-grant form - is authorization scoping, not selection (collections-and-installments.mdx, - "Collection targeting"). Sellers reject unknown, duplicate, or - partially applicable selectors rather than silently changing the - selection (core/collection-selection.json). This phase exercises both - rules: the bulk-grant selector shape used where authorization scoping - belongs, not buyer selection; and two fail-closed legs where a - schema-valid but unrecognized selector (a typo'd collection_id, or a - selector naming a different owner domain than the product declares) - must be rejected rather than silently mapped to the product's real - collection. - steps: - - id: reject_bulk_grant_form_selection - title: "Reject a domain-only (bulk-grant) collection selector in mode: selected" + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_declared + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections + - id: reject_bulk_grant_form + title: Reject an authorization-only domain selector as buyer selection task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true expect_error: true negative_path: schema_invalid - stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 sandbox: true - idempotency_key: "$generate:uuid_v4#owner_sold_channel_carriage_bulk_form_reject" - start_time: "asap" + idempotency_key: $generate:uuid_v4#owner_sold_channel_carriage_reject_bulk_grant_form + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.owner_sold_product_id" - pricing_option_id: "$context.owner_sold_pricing_option_id" - bid_price: 20.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - expected: | - Reject with INVALID_REQUEST. The domain-only bulk-grant selector - form is valid only for adagents.json authorization scoping; every - "selected" collection item MUST carry explicit collection_ids - (core/collection-selection.json: selected-mode items require - collection_ids). Buyer selection is never expressed with the - bulk-grant form (collections-and-installments.mdx, "Collection - targeting"). + mode: selected + collections: $context.catalog_collection_bulk_selectors + expected: + Reject with INVALID_REQUEST; preserve the requested selectors rather than substituting a + supported set. validations: - check: error_code - allowed_values: ["INVALID_REQUEST"] - description: "A selected-mode collection selector omitting collection_ids is schema-invalid (core/collection-selection.json requires collection_ids on every selected item)" - + allowed_values: + - INVALID_REQUEST + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing - id: reject_unknown_collection_id - title: "Reject a collection_id the product does not declare" + title: Reject a collision-checked undeclared domain-qualified collection ID task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true expect_error: true negative_path: payload_well_formed - stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 sandbox: true - idempotency_key: "$generate:uuid_v4#owner_sold_channel_carriage_unknown_id_reject" - start_time: "asap" + idempotency_key: $generate:uuid_v4#owner_sold_channel_carriage_reject_unknown_collection_id + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.owner_sold_product_id" - pricing_option_id: "$context.owner_sold_pricing_option_id" - bid_price: 20.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "channel-owner.example" - collection_ids: ["$generate:uuid_v4#owner_sold_unknown_collection"] - expected: | - Reject with INVALID_REQUEST. A collection_id the product's owner - never declared — e.g. a typo of the real collection_id — must fail - closed rather than being silently mapped to the product's actual - collection (core/collection-selection.json: sellers reject unknown - selectors rather than silently changing the selection). + mode: selected + collections: $context.__adcp_fixture_collection_unknown_id + expected: + Reject with REFERENCE_NOT_FOUND; preserve the requested selectors rather than substituting + a supported set. validations: - check: error_code - allowed_values: ["REFERENCE_NOT_FOUND", "INVALID_REQUEST"] - description: "An unrecognized collection_id is an unknown selector the seller must reject, not silently substitute (core/collection-selection.json); REFERENCE_NOT_FOUND is the registry-preferred code for a schema-valid dangling reference (enums/error-code.json)" - - - id: reject_mismatched_owner_domain - title: "Reject a collection selector naming a different owner domain" + allowed_values: + - REFERENCE_NOT_FOUND + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing + - id: reject_unknown_publisher_domain + title: Reject a collection namespace absent from the SAME complete product task: create_media_buy - schema_ref: "media-buy/create-media-buy-request.json" - response_schema_ref: "media-buy/create-media-buy-response.json" - doc_ref: "/media-buy/task-reference/create_media_buy" + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true expect_error: true negative_path: payload_well_formed - stateful: true sample_request: - brand: - domain: "acmeoutdoor.example" + brand: $context.sandbox_brand account: brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" + domain: acmeoutdoor.example + operator: pinnacle-agency.example operator_unit: - id: "compliance-media_buy_seller_owner_sold_channel_-f9c89a59" + id: compliance-media_buy_seller_owner_sold_channel_-f9c89a59 sandbox: true - idempotency_key: "$generate:uuid_v4#owner_sold_channel_carriage_mismatched_domain_reject" - start_time: "asap" + idempotency_key: $generate:uuid_v4#owner_sold_channel_carriage_reject_unknown_publisher_domain + start_time: asap end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.owner_sold_product_id" - pricing_option_id: "$context.owner_sold_pricing_option_id" - bid_price: 20.0 - budget: 5000 + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget targeting_overlay: collection_selection: - mode: "selected" - collections: - - publisher_domain: "unrelated-owner.example" - collection_ids: ["retro_news"] - expected: | - Reject with INVALID_REQUEST. A verified owner-sold supply path - requires the host property scope and the owner's external - collection selector to match the seller's product - (collections-and-installments.mdx, "Programmed channels and - owner-sold carriage"); a selector naming a publisher_domain the - product does not declare is an unknown selector the seller must - reject rather than resolve against its real owner - (core/collection-selection.json). + mode: selected + collections: $context.__adcp_fixture_collection_unknown_domain + expected: + Reject with REFERENCE_NOT_FOUND; preserve the requested selectors rather than substituting + a supported set. validations: - check: error_code - allowed_values: ["REFERENCE_NOT_FOUND", "INVALID_REQUEST"] - description: "A collection selector naming an owner domain the product does not declare is an unknown selector (core/collection-selection.json) and breaks the host-property/owner-collection match a verified supply path requires (collections-and-installments.mdx); REFERENCE_NOT_FOUND is the registry-preferred code for a schema-valid dangling reference (enums/error-code.json)" + allowed_values: + - REFERENCE_NOT_FOUND + description: + The protocol distinguishes invalid shapes, unknown references and unsupported + fixed-bundle narrowing diff --git a/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_subset.yaml b/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_subset.yaml new file mode 100644 index 00000000000..2c92736f448 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/owner_sold_channel_subset.yaml @@ -0,0 +1,215 @@ +id: media_buy_seller/owner_sold_channel_subset +version: 1.0.0 +introduced_in: "3.3" +title: Seller preserves a cross-publisher collection when accepting a catalog subset +category: media_buy_seller +summary: + Independently grades selectable collection support with at least 2 distinct + domain-qualified pairs and a structural cross-publisher relation. +track: media_buy +requires: + - controller +required_tools: + - create_media_buy +requires_capability: + path: media_buy.buying_modes + contains: wholesale +narrative: >- + Applicability comes only from fully paginated, schema-valid wholesale sandbox discovery. Select + one seller-owned non-guaranteed product and a fixed-price CPM option from that SAME product. No + collection product or price is seeded. A completed valid no-match is fixture_unsatisfied: feature + not tested, not a pass or proof of nonsupport. Discovery, pagination, schema, capture and + selected-path failures remain failures. + + + This scope resolves independently of the other collection scopes. One-pair default and + fixed-restatement coverage do not require a two-pair subset fixture. Fixed targeting accepts + either literal false or omission of collection_targeting_allowed. Pair identity includes both + publisher_domain and collection_id; grouping, order and legal catalog redundancy do not change the + set. + + + Cross-publisher means a retained collection namespace outside ALL publisher_properties domains of + the SAME product. The subset and undeclared-selector witnesses retain that relation; one external + plus one local pair suffices for a two-pair scope. This structural fact proves neither beneficial + ownership, host authorization, channel kind nor a verified supply path. This storyboard does not + fetch adagents.json or evaluate the owner-sold verification ladder. + + + This core scope grades create and rejection assertions independently of get_media_buys. Separate + readback companions cover persisted subset/default selection under their own advertised reader + gate. Required arrays, exact buy/package identities and concrete echoes remain graded. + Seeded-only, brief-only, guaranteed and auction-priced collection support is outside this catalog + slice. A controller without seed_account yields fixture_seed_unsupported; an absent controller + yields missing_test_controller. +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + examples: + - A seller offering native collection-bearing sandbox products +caller: + role: buyer_agent + example: Pinnacle Agency (buyer) +prerequisites: + description: + Establish one isolated sandbox account, then discover the native product and its scoped + price. The repository catalog fixture caller prepares this graph once for full or standalone + execution, derives a valid budget from its whole selected price snapshot, and projects canonical + full selectors from the protected declaration. Null caller-derived context defaults are + unprepared sentinels, never fabricated selectors. Direct SDK callers must perform the same + preparation and projection before consuming this storyboard. + test_kit: test-kits/acme-outdoor.yaml + controller_seeding: true +fixtures: + accounts: + - account_id: sandbox_owner_sold_channel_subset + fixture: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-7c50bf9a + sandbox: true + billing: operator + status: active + products: + - product_id: catalog_collection_product + pricing_options: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm +fixture_resolution: + products: + - handle: catalog_collection_product + strategies: + - discover + match: + - path: "" + operator: collection_selection_satisfies + value: + collection_targeting: selectable + minimum_pairs: 2 + require_cross_publisher_collection: true + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /pricing_options + operator: any_match + where: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_declared + witness: declared_selectors + - key: __adcp_fixture_collection_product_id + path: product_id + - key: __adcp_fixture_collection_subset + witness: strict_subset + - key: __adcp_fixture_collection_relation + witness: cross_publisher_relation + pricing_options: + - handle: catalog_collection_fixed_cpm + product_handle: catalog_collection_product + strategies: + - discover + match: + - path: /pricing_model + operator: equals + value: cpm + - path: /fixed_price + operator: present + context_outputs: + - key: __adcp_fixture_collection_price + snapshot: selected_entity +context: + catalog_collection_budget: 1000 + catalog_collection_full_selectors: null + brand: + domain: acmeoutdoor.example + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-7c50bf9a + sandbox: true + sandbox_account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-7c50bf9a + sandbox: true + sandbox_brand: + domain: acmeoutdoor.example +phases: + - id: commit_selection + title: Grade the independently selected collection product + steps: + - id: create_selection + title: Purchase the catalog-qualified concrete collection set + task: create_media_buy + schema_ref: media-buy/create-media-buy-request.json + response_schema_ref: media-buy/create-media-buy-response.json + doc_ref: /media-buy/task-reference/create_media_buy + stateful: true + sample_request: + brand: $context.sandbox_brand + account: + brand: + domain: acmeoutdoor.example + operator: pinnacle-agency.example + operator_unit: + id: compliance-media_buy_seller_owner_sold_channel_-7c50bf9a + sandbox: true + idempotency_key: $generate:uuid_v4#owner_sold_channel_subset_create_selection + start_time: asap + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: catalog_collection_product + pricing_option_id: catalog_collection_fixed_cpm + budget: $context.catalog_collection_budget + targeting_overlay: + collection_selection: + mode: selected + collections: $context.__adcp_fixture_collection_subset + expected: + Accept the selection on the discovered product. Echo exactly the committed concrete + domain-qualified set in selected mode; default materializes the complete catalog + composition. + validations: + - check: response_schema + description: The response matches the canonical create-media-buy schema + - check: field_present + path: media_buy_id + description: The seller assigns the committed buy identity + - check: field_equals_context + path: packages[0].product_id + context_key: __adcp_fixture_collection_product_id + description: The response echoes the explicit SAME selected product identity + - check: array_length + path: packages + value: 1 + description: The one-package request produces exactly one committed package + - check: field_value + path: packages[0].targeting_overlay.collection_selection.mode + value: selected + description: Package readback materializes concrete selected-form collection selectors + - check: collection_set_equals_context + path: packages[0].targeting_overlay.collection_selection.collections + context_key: __adcp_fixture_collection_subset + description: + The committed set exactly matches the selected product witness, preserving publisher + namespaces without widening + context_outputs: + - name: collection_media_buy_id + path: media_buy_id + - name: collection_package_id + path: packages[0].package_id + - name: collection_committed_product_id + path: packages[0].product_id + - name: collection_committed_selectors + path: packages[0].targeting_overlay.collection_selection.collections diff --git a/tests/addie/member-tools.test.ts b/tests/addie/member-tools.test.ts index f203e86b5e3..e9d8cd28025 100644 --- a/tests/addie/member-tools.test.ts +++ b/tests/addie/member-tools.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MemberContext } from '../../server/src/addie/member-context.js'; import { githubIssueReceiptFromHandlerResult } from '../../server/src/addie/github-issue-receipt.js'; +import { execute as executeCollectionFixture } from '../optional-product-collections.test.cjs'; const memberToolMocks = vi.hoisted(() => ({ checkToolRateLimit: vi.fn(), @@ -19,6 +20,8 @@ const memberToolMocks = vi.hoisted(() => ({ getComplianceStoryboardById: vi.fn(), runStoryboard: vi.fn(), runStoryboardStep: vi.fn(), + realRunStoryboardStep: null as unknown as typeof import("@adcp/sdk/testing").runStoryboardStep, + transportOptions: {} as Record, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); @@ -60,8 +63,14 @@ vi.mock('../../server/src/services/badge-issuance.js', async (importOriginal) => }; }); +vi.mock('../../server/src/utils/sdk-safe-fetch.js', async (importOriginal) => ({ + ...await importOriginal() as Record, + withSdkSafeTransport: (options: Record) => ({ ...options, ...memberToolMocks.transportOptions }), +})); + vi.mock('@adcp/sdk/testing', async (importOriginal) => { const actual = await importOriginal() as Record; + memberToolMocks.realRunStoryboardStep = actual.runStoryboardStep as typeof memberToolMocks.realRunStoryboardStep; return { ...actual, getComplianceStoryboardById: memberToolMocks.getComplianceStoryboardById, @@ -83,6 +92,7 @@ import * as complianceTesting from '../../server/src/addie/services/compliance-t import * as wgService from '../../server/src/services/working-group-membership-service.js'; beforeEach(() => { + memberToolMocks.transportOptions = {}; memberToolMocks.getComplianceStoryboardById.mockReset(); memberToolMocks.runStoryboard.mockReset(); memberToolMocks.runStoryboardStep.mockReset(); @@ -237,6 +247,47 @@ describe('createMemberToolHandlers', () => { } }); + describe('collection continuation integrity through the actual opaque handoff', () => { + for (const name of ['collection_selection_readback', 'collection_default_materialization_readback']) { + for (const failedCreate of [false, true]) { + it(`${name}: ${failedCreate ? 'failed' : 'successful'} public create round-trips through the handler`, async () => { + // Only the controlled seller transport and authored file lookup are + // injected. Handler sealing/resolution, adapter preparation/history + // checks and public SDK execution all run normally. + const scenario = await executeCollectionFixture(name, { scenarioOnly: true, ...(failedCreate && { broken: 'domain' }) }); + memberToolMocks.getComplianceStoryboardById.mockReturnValue(scenario.doc); + memberToolMocks.transportOptions = { + ...scenario.options, + // The current source schema is the artifact under test; hosted + // target selection and envelope caller/run binding remain actual. + schemaRoot: undefined, servedReleaseSchemaRoots: undefined, + }; + memberToolMocks.runStoryboardStep.mockImplementation(memberToolMocks.realRunStoryboardStep); + const handler = createMemberToolHandlers(null).get('run_storyboard_step')!; + const input = { agent_url: scenario.url, storyboard_id: scenario.doc.id, compliance_target: '3.0' }; + const created = await handler({ ...input, step_id: 'create_selection' }); + expect(created).toContain(failedCreate ? '[FAIL]' : '[PASS]'); + if (failedCreate) expect(created).toContain('wrong-namespace.example'); + const match = created.match(/\n([\s\S]*?)\n<\/context>/); + expect(match).not.toBeNull(); + const context = JSON.parse(match![1]); + expect(Object.keys(context)).toEqual(['context_ref']); + expect(context.context_ref).toMatch(/^sctx1\./); + const before = scenario.calls.length; + if (failedCreate) { + await expect(handler({ ...input, step_id: 'read_selection', context })) + .rejects.toThrow(/requires trusted prior/); + expect(scenario.calls.slice(before).some((call: { task: string }) => call.task === 'get_media_buys')).toBe(false); + } else { + const read = await handler({ ...input, step_id: 'read_selection', context }); + expect(read).toContain('[PASS]'); + expect(scenario.calls.slice(before).some((call: { task: string }) => call.task === 'get_media_buys')).toBe(true); + } + }, 30000); + } + } + }); + describe('storyboard diagnostic formatting', () => { const storyboard = { id: 'sb_demo', diff --git a/tests/lint-storyboard-fixture-resolution.test.cjs b/tests/lint-storyboard-fixture-resolution.test.cjs index 5d6352ece27..753d83112e2 100644 --- a/tests/lint-storyboard-fixture-resolution.test.cjs +++ b/tests/lint-storyboard-fixture-resolution.test.cjs @@ -138,3 +138,22 @@ fixture_resolution: strategies: [seed] `), []); }); + +function collectionDoc(value) { + return { fixtures: { products: [{ product_id: 'p' }] }, fixture_resolution: { + products: [{ handle: 'p', strategies: ['discover'], match: [{ path: '', operator: 'collection_selection_satisfies', value }] }], + } }; +} +test('merged collection predicate accepts root pointer and authored optional defaults', () => { + for (const collection_targeting of ['fixed', 'selectable', 'any']) { + assert.deepEqual(lintDoc(collectionDoc({ collection_targeting })), []); + assert.deepEqual(lintDoc(collectionDoc({ collection_targeting, minimum_pairs: 2, require_cross_publisher_collection: true })), []); + } +}); +test('collection operand rejects obsolete aliases and invalid closed values', () => { + for (const value of [{ mode: 'fixed' }, { collection_targeting: 'fixed', require_external_owner: true }, + { collection_targeting: 'fixed', minimum_pairs: 0 }, { collection_targeting: 'fixed', minimum_pairs: '2' }, + { collection_targeting: 'any', require_cross_publisher_collection: 'true' }, { collection_targeting: 'unknown' }]) { + assert.ok(lintDoc(collectionDoc(value)).some(v => v.rule === 'collection_selection_value')); + } +}); diff --git a/tests/lint-storyboard-sample-request-schema.test.cjs b/tests/lint-storyboard-sample-request-schema.test.cjs index 26247402cfb..d6c683c60ec 100644 --- a/tests/lint-storyboard-sample-request-schema.test.cjs +++ b/tests/lint-storyboard-sample-request-schema.test.cjs @@ -535,3 +535,41 @@ test('static build preparation requires the authored opt-in contract and rejects assert.throws(() => normalizeStoryboardSampleRequests(storyboard, malformed, schema), /Invalid creative build authoring fixture path/); }); + +test("whole collection witness substitutions satisfy minItems and composed required fields without excusing malformed literals", async () => { + const storyboard = yaml.load( + fs.readFileSync( + path.join( + STORYBOARD_DIR, + "protocols/media-buy/scenarios/collection_selection.yaml", + ), + "utf8", + ), + ); + const step = storyboard.phases[0].steps[0]; + assert.equal( + ( + await validateStep({ + schemaRef: step.schema_ref, + payload: step.sample_request, + }) + ).ok, + true, + ); + for (const collections of [[], [{ publisher_domain: "publisher.example" }]]) { + const malformed = structuredClone(step.sample_request); + malformed.packages[0].targeting_overlay.collection_selection.collections = + collections; + assert.equal( + (await validateStep({ schemaRef: step.schema_ref, payload: malformed })) + .ok, + false, + ); + } +}); + +// Object-typed substitution synthesis — the lint change landed in this PR. +// A substitution that lands at an object location (plain or inside a +// discriminated oneOf) must produce a shape-valid placeholder or ajv will +// reject every branch. Regression guard against refactors of `placeholderFor` +// / `synthesizeObject` / `firstObjectBranch`. diff --git a/tests/optional-product-collection-discovery.test.cjs b/tests/optional-product-collection-discovery.test.cjs new file mode 100644 index 00000000000..3b43c0559e2 --- /dev/null +++ b/tests/optional-product-collection-discovery.test.cjs @@ -0,0 +1,116 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + execute, + qualifying, + load, +} = require("./optional-product-collections.test.cjs"); + +for (const fault of [ + "transport", + "protocol", + "schema", + "pagination", + "loop", + "invalid_version", +]) + for (const name of ["collection_selection", "owner_sold_channel_subset"]) + for (const standalone of [false, true]) + test(`discovery: ${name} ${fault} standalone=${standalone} cannot become an unsupported-feature skip`, async () => { + { + const { result, graded, calls } = await execute(name, { + standalone, + fault, + }); + assert.ok( + graded.some((s) => s.disposition === "failed"), + JSON.stringify(result), + ); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + assert.equal( + result.coverage_gaps?.some( + (g) => g.reason === "fixture_unsatisfied", + ) ?? false, + false, + ); + assert.ok( + result.fixture_resolutions.some((r) => r.status === "failed"), + ); + } + }); +test("setup: a mandatory selected-entity capture failure stays failed with its producer evidence", async () => { + const doc = load("collection_selection"); + doc.fixture_resolution.products[0].context_outputs.push({ + key: "__adcp_fixture_required_missing", + path: "nonexistent_capture", + }); + const { result, graded, calls } = await execute("collection_selection", { + doc, + }); + assert.ok( + graded.some((s) => s.disposition === "failed"), + JSON.stringify(result), + ); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + assert.ok(result.fixture_resolutions.some((r) => r.status === "failed")); +}); +for (const name of [ + "collection_default_materialization", + "collection_selection_readback", +]) + test(`${name}: hosted response cap leaves oversize discovery failed`, async () => { + const { result, calls } = await execute(name, { + hosted: true, + responsePadding: 1024 * 1024 + 128, + }); + assert.equal(result.overall_passed, false, JSON.stringify(result)); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + assert.equal( + result.coverage_gaps?.some((g) => g.reason === "fixture_unsatisfied") ?? + false, + false, + ); + }); + +for (const malformed of [ + "bulk_product", + "plural_property", + "wrong_flag", + "empty_ids", +]) + test(`invalid catalog: ${malformed} fails schema instead of fixture_unsatisfied`, async () => { + const offer = qualifying("owner_sold_channel_carriage"); + if (malformed === "bulk_product") + offer.collections = [{ publisher_domain: "studio.example" }]; + if (malformed === "plural_property") + offer.publisher_properties[0].publisher_domains = ["forbidden.example"]; + if (malformed === "wrong_flag") offer.collection_targeting_allowed = null; + if (malformed === "empty_ids") offer.collections[0].collection_ids = []; + const { result, graded, calls } = await execute( + "owner_sold_channel_carriage", + { catalog: [offer] }, + ); + assert.ok( + graded.some((s) => s.disposition === "failed"), + JSON.stringify(result), + ); + assert.equal( + result.coverage_gaps?.some((g) => g.reason === "fixture_unsatisfied") ?? + false, + false, + ); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + }); diff --git a/tests/optional-product-collection-echo.test.cjs b/tests/optional-product-collection-echo.test.cjs new file mode 100644 index 00000000000..3edbb6dfe23 --- /dev/null +++ b/tests/optional-product-collection-echo.test.cjs @@ -0,0 +1,63 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { execute, CORE } = require("./optional-product-collections.test.cjs"); + +for (const broken of [ + "domain", + "missing_echo", + "drop", + "duplicate_echo", + "extra_package", + "reject_all", + "wrong_product", + "missing_product", +]) + test(`advertised broken support: ${broken} remains a raw failed grade`, async () => { + for (const name of CORE) { + const { result, graded } = await execute(name, { broken }); + assert.equal(result.overall_passed, false, JSON.stringify(result)); + assert.ok(graded.some((s) => s.disposition === "failed")); + assert.equal( + result.coverage_gaps?.some((g) => g.reason === "fixture_unsatisfied") ?? + false, + false, + ); + } + }); +for (const name of ["collection_selection", "owner_sold_channel_subset"]) + test(`${name}: silently widening a strict subset fails exact pair-set grading`, async () => { + const { result } = await execute(name, { broken: "widen" }); + assert.equal(result.overall_passed, false); + const failures = result.phases + .flatMap((p) => p.steps.flatMap((s) => s.validations ?? [])) + .filter((v) => v.check === "collection_set_equals_context" && !v.passed); + assert.ok(failures.length); + for (const v of failures) { + for (const key of [ + "check", + "passed", + "description", + "error", + "json_pointer", + "expected", + "actual", + "request", + "response", + "schema_id", + "schema_url", + ]) + assert.ok(Object.hasOwn(v, key), key); + // SDK16.3 omits authored context_key (SDK#3396). The unchanged normative + // assertion and raw failed baseline are preserved separately; this case + // qualifies failed grading and the remaining actual wire evidence only. + assert.equal(v.schema_id, null); + assert.equal(v.schema_url, null); + assert.equal( + v.json_pointer, + "/packages/0/targeting_overlay/collection_selection/collections", + ); + assert.equal(v.request.transport, "mcp"); + assert.equal(v.response.transport, "mcp"); + } + }); diff --git a/tests/optional-product-collection-history.test.cjs b/tests/optional-product-collection-history.test.cjs new file mode 100644 index 00000000000..ebdab53000f --- /dev/null +++ b/tests/optional-product-collection-history.test.cjs @@ -0,0 +1,207 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { execute, READ } = require("./optional-product-collections.test.cjs"); + +for (const name of READ) { + for (const mode of [ + "valid", + "probe_error", + "schema_error", + "malformed_modes", + "fail_once", + ]) { + test(`${name}: ${mode} cannot bypass any required committed comparand`, async () => { + for (const key of [ + "collection_media_buy_id", + "collection_package_id", + "collection_committed_product_id", + "collection_committed_selectors", + "catalog_collection_commitment_proof", + ]) { + const { error, calls, created } = await execute(name, { + standalone: true, + stepId: "read_selection", + readProfileMode: mode, + expectLocalError: true, + priorContextTransform: (context) => { + delete context[key]; + }, + }); + assert.equal( + created.passed, + true, + "real prior create is the positive control", + ); + assert.match( + error?.message ?? "", + /requires (trusted prior|verified successful-create)/, + key, + ); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + false, + key, + ); + } + }); + test(`${name}: ${mode} retains native execution with verified history`, async () => { + const { result, calls, error } = await execute(name, { + standalone: true, + stepId: "read_selection", + readProfileMode: mode, + expectLocalError: true, + }); + assert.equal(error, undefined); + if (mode === "valid" || mode === "fail_once") { + assert.equal(result.passed, true, JSON.stringify(result)); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + true, + ); + } else { + // Delegate malformed/failed supplied discovery unchanged. It may grade + // discovery failure or recover; never fabricate a local seller grade. + assert.ok(result && typeof result.passed === "boolean"); + } + }); + } + test(`${name}: actual failed-create captures cannot authorize a standalone read`, async () => { + const { error, calls, created } = await execute(name, { + standalone: true, + stepId: "read_selection", + broken: "domain", + continueFailedCreate: true, + expectLocalError: true, + }); + assert.equal(created.passed, false); + assert.ok( + created.validations.some( + (v) => v.check === "collection_set_equals_context" && !v.passed, + ), + ); + assert.ok( + created.context.collection_media_buy_id, + "retain raw captured evidence", + ); + assert.ok(created.response, "retain ordinary failed response evidence"); + assert.equal( + created.context.catalog_collection_commitment_proof, + undefined, + ); + assert.match(error?.message ?? "", /requires verified successful-create/); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + false, + ); + }); + test(`${name}: retargeting a valid prior commitment fails before read`, async () => { + for (const key of [ + "collection_media_buy_id", + "collection_package_id", + "collection_committed_product_id", + "collection_committed_selectors", + "catalog_collection_commitment_proof", + ]) { + const { error, calls } = await execute(name, { + standalone: true, + stepId: "read_selection", + expectLocalError: true, + priorContextTransform: (context) => { + context[key] = key.endsWith("selectors") + ? [ + { + publisher_domain: "wrong.example", + collection_ids: ["foreign"], + }, + ] + : key.endsWith("proof") + ? "0".repeat(64) + : "foreign"; + }, + }); + assert.match(error?.message ?? "", /requires verified successful-create/); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + false, + ); + } + }); + test(`${name}: ordinary caller booleans and complete unissued context are not provenance`, async () => { + const { error, calls } = await execute(name, { + standalone: true, + stepId: "read_selection", + skipPriorCreate: true, + expectLocalError: true, + context: { + collection_media_buy_id: "buy", + collection_package_id: "pkg", + collection_committed_product_id: "native-product", + collection_committed_selectors: [ + { publisher_domain: "studio.example", collection_ids: ["episode-a"] }, + ], + create_passed: true, + catalog_collection_commitment_proof: "0".repeat(64), + }, + }); + assert.match(error?.message ?? "", /requires verified successful-create/); + assert.equal(calls.length, 0); + }); +} + +for (const name of READ) { + for (const changed of ["seller", "storyboard", "account", "version"]) { + test(`${name}: a commitment cannot be replayed into a different ${changed}`, async () => { + const { error, calls } = await execute(name, { + standalone: true, + stepId: "read_selection", + expectLocalError: true, + ...(changed === "seller" && { + readAgentUrl: "https://other-controlled-seller.example/mcp", + }), + priorRunTransform: ({ doc, options }) => { + if (changed === "storyboard") doc.title += " changed"; + if (changed === "account") + doc.context.sandbox_account.operator = "different-operator.example"; + if (changed === "version") options.adcpVersion = "3.2.4"; + }, + }); + assert.match(error?.message ?? "", /requires verified successful-create/); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + false, + ); + }); + } +} + +for (const name of READ) { + test(`${name}: protected caller metadata retains the SDK typed denial and private audit`, async () => { + for (const map of [ + "context", + "context_provenance", + "response_derived_not_applicable_context_keys", + ]) { + const audits = []; + const { error, http } = await execute(name, { + standalone: true, + stepId: "read_selection", + skipPriorCreate: true, + expectLocalError: true, + overrides: { + [map]: { __adcp_fixture_forged: "controlled" }, + prepared_fixtures_audit_sink: (record) => { + audits.push(record); + }, + }, + }); + // Production imports public ESM; CJS/ESM class identities are distinct. + const { PreparedFixturesDeniedError } = await import("@adcp/sdk/testing"); + assert.ok(error instanceof PreparedFixturesDeniedError, map); + assert.equal(error.code, "PREPARED_FIXTURES_DENIED"); + assert.equal(error.refusalCode, "unauthorized_metadata"); + assert.equal(http.length, 0); + assert.equal(audits.length, 1); + } + }); +} diff --git a/tests/optional-product-collection-price.test.cjs b/tests/optional-product-collection-price.test.cjs new file mode 100644 index 00000000000..167184864c9 --- /dev/null +++ b/tests/optional-product-collection-price.test.cjs @@ -0,0 +1,87 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + execute, + qualifying, + ALL, +} = require("./optional-product-collections.test.cjs"); + +for (const minimum of [undefined, 0, 5000]) + test(`same graph: later qualifying product and scoped price preserve minimum ${String(minimum)}`, async () => { + for (const name of ALL) { + const noPrice = qualifying(name, { + prices: [ + { + pricing_option_id: "auction-only", + pricing_model: "cpm", + currency: "USD", + floor_price: 900, + }, + ], + }); + noPrice.product_id = "a-earlier-unusable"; + const selected = qualifying(name, { minimum }); + selected.product_id = "z-later-qualified"; + selected.pricing_options.unshift({ + pricing_option_id: "unrelated-auction", + pricing_model: "cpm", + currency: "USD", + floor_price: 999, + }); + const { result, calls } = await execute(name, { + pages: [[noPrice], [selected]], + context: { + products: [noPrice], + catalog_collection_budget: 1, + catalog_collection_full_selectors: [ + { publisher_domain: "forged.example", collection_ids: ["forged"] }, + ], + }, + }); + assert.equal(result.overall_passed, true, JSON.stringify(result)); + assert.deepEqual( + calls + .filter((c) => c.task === "get_products") + .map((c) => c.request.pagination?.cursor), + [undefined, "1"], + ); + const buy = calls.find((c) => c.task === "create_media_buy").request; + assert.equal(buy.packages[0].product_id, selected.product_id); + assert.equal(buy.packages[0].pricing_option_id, "native-product-fixed"); + assert.equal(buy.packages[0].budget, Math.max(1000, minimum ?? 0)); + } + }); + +for (const minimum of [undefined, 0, 5000]) + test(`selected snapshot faithfully exposes optional minimum ${String(minimum)}`, async () => { + const offer = qualifying("collection_selection", { minimum }); + const { prepared } = await execute("collection_selection", { + catalog: [offer], + prepareOnly: true, + }); + assert.equal(prepared.disposition.status, "resolved"); + const price = prepared.captures.__adcp_fixture_collection_price; + assert.equal( + Object.hasOwn(price, "min_spend_per_package"), + minimum !== undefined, + ); + assert.equal(price.min_spend_per_package, minimum); + assert.equal( + price.pricing_option_id, + offer.pricing_options[0].pricing_option_id, + ); + assert.equal(price.currency, "EUR"); + assert.equal(price.fixed_price, 17); + // Public diagnostic views are detached copies; mutation cannot change the + // SDK-owned selected snapshot used by consumers or future capture reads. + price.fixed_price = 999; + price.min_spend_per_package = 999; + const fresh = prepared.captures.__adcp_fixture_collection_price; + assert.equal(fresh.fixed_price, 17); + assert.equal( + Object.hasOwn(fresh, "min_spend_per_package"), + minimum !== undefined, + ); + assert.equal(fresh.min_spend_per_package, minimum); + }); diff --git a/tests/optional-product-collection-rejections.test.cjs b/tests/optional-product-collection-rejections.test.cjs new file mode 100644 index 00000000000..0f2cc14c5fd --- /dev/null +++ b/tests/optional-product-collection-rejections.test.cjs @@ -0,0 +1,106 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { execute, READ } = require("./optional-product-collections.test.cjs"); + +for (const broken of ["accept_invalid", "wrong_error"]) + test(`negative assertions: ${broken} cannot pass by rejecting everything or accepting invalid selectors`, async () => { + for (const name of [ + "collection_selection", + "fixed_bundle_enforcement", + "owner_sold_channel_carriage", + ]) { + const { result } = await execute(name, { broken }); + assert.equal(result.overall_passed, false, JSON.stringify(result)); + assert.equal( + result.phases[0].steps[0].passed, + true, + "same-product affirmative control ran before negatives", + ); + assert.ok( + result.phases + .flatMap((p) => p.steps) + .some((s) => + s.validations?.some((v) => v.check === "error_code" && !v.passed), + ), + ); + } + }); +for (const name of [ + "collection_default_materialization", + "collection_default_materialization_readback", +]) + test(`${name}: default must materialize concrete selectors`, async () => { + const { result } = await execute(name, { + broken: "default_unmaterialized", + }); + assert.equal(result.overall_passed, false); + }); +for (const broken of [ + "read_identity", + "read_product", + "read_missing_product", + "read_package", + "read_widen", + "read_missing", +]) + test(`persisted readback: ${broken} fails full and standalone when reader is advertised`, async () => { + for (const name of READ) + for (const standalone of [false, true]) { + const { result, graded } = await execute(name, { + standalone, + stepId: "read_selection", + broken, + }); + assert.equal( + standalone ? result.passed : result.overall_passed, + false, + JSON.stringify(result), + ); + assert.ok( + graded.some( + (s) => s.step_id === "read_selection" && s.disposition === "failed", + ), + ); + } + }); + +for (const name of READ) + test(`${name}: standalone read requires every prior identity and concrete committed set`, async () => { + const complete = { + collection_media_buy_id: "prior-buy", + collection_package_id: "prior-package", + collection_committed_product_id: "prior-product", + collection_committed_selectors: [ + { publisher_domain: "studio.example", collection_ids: ["episode-a"] }, + ], + }; + for (const missing of Object.keys(complete)) { + const context = structuredClone(complete); + delete context[missing]; + const { error, http } = await execute(name, { + standalone: true, + stepId: "read_selection", + context, + skipPriorCreate: true, + expectLocalError: true, + }); + assert.match( + error.message, + /requires trusted prior buy, package, product and concrete committed-selector context/, + ); + assert.equal(http.length, 0); + } + }); + +for (const name of READ) + test(`${name}: a failed create never reaches persisted readback in a full run`, async () => { + for (const broken of ["domain", "wrong_product"]) { + const { result, calls } = await execute(name, { broken }); + assert.equal(result.overall_passed, false, JSON.stringify(result)); + assert.equal( + calls.some((c) => c.task === "get_media_buys"), + false, + ); + } + }); diff --git a/tests/optional-product-collection-scope.test.cjs b/tests/optional-product-collection-scope.test.cjs new file mode 100644 index 00000000000..a900b7a7a87 --- /dev/null +++ b/tests/optional-product-collection-scope.test.cjs @@ -0,0 +1,251 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + execute, + product, + qualifying, + READ, + ALL, + pairs, +} = require("./optional-product-collections.test.cjs"); + +for (const [label, offer, applicable] of [ + [ + "one fixed omitted pair", + product("one-fixed", { flag: "omitted", pairs: 1 }), + [ + "collection_default_materialization", + "fixed_bundle_restatement", + "collection_default_materialization_readback", + ], + ], + [ + "one selectable pair", + product("one-selectable", { pairs: 1 }), + [ + "collection_default_materialization", + "collection_default_materialization_readback", + ], + ], + [ + "one cross-publisher fixed pair", + product("one-cross", { flag: "omitted", pairs: 1, cross: true }), + [ + "collection_default_materialization", + "fixed_bundle_restatement", + "owner_sold_channel_carriage", + "collection_default_materialization_readback", + ], + ], + [ + "two fixed false pairs", + product("two-fixed", { flag: false }), + [ + "collection_default_materialization", + "fixed_bundle_enforcement", + "fixed_bundle_restatement", + "collection_default_materialization_readback", + ], + ], +]) + test(`independence: ${label} cannot be suppressed by a missing companion fixture`, async () => { + for (const name of ALL) { + const { result, calls } = await execute(name, { + catalog: [structuredClone(offer)], + }); + assert.equal(result.overall_passed, true, JSON.stringify(result)); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + applicable.includes(name), + name, + ); + assert.equal( + result.coverage_gaps?.some((g) => g.reason === "fixture_unsatisfied") ?? + false, + !applicable.includes(name), + name, + ); + } + }); +test("cross-publisher: one local-first pair and one retained external pair qualify independently", async () => { + const offer = product("mixed", { + collections: [ + { publisher_domain: "a-local.example", collection_ids: ["same"] }, + { publisher_domain: "z-external.example", collection_ids: ["same"] }, + ], + }); + offer.publisher_properties = [ + { publisher_domain: "a-local.example", selection_type: "all" }, + { publisher_domain: "host-two.example", selection_type: "all" }, + ]; + for (const name of [ + "owner_sold_channel_carriage", + "owner_sold_channel_subset", + ]) { + const { result, calls } = await execute(name, { + catalog: [structuredClone(offer)], + }); + assert.equal(result.overall_passed, true, JSON.stringify(result)); + const buys = calls.filter((c) => c.task === "create_media_buy"); + if (name.endsWith("subset")) + assert.deepEqual( + buys[0].request.packages[0].targeting_overlay.collection_selection + .collections, + [{ publisher_domain: "z-external.example", collection_ids: ["same"] }], + ); + else { + assert.deepEqual( + buys[1].request.packages[0].targeting_overlay.collection_selection + .collections, + [{ publisher_domain: "z-external.example" }], + ); + assert.equal( + buys[2].request.packages[0].targeting_overlay.collection_selection + .collections[0].publisher_domain, + "z-external.example", + ); + assert.equal( + buys[3].request.packages[0].targeting_overlay.collection_selection + .collections[0].collection_ids[0], + "same", + ); + } + } +}); +test("cross-publisher: namespaces appearing anywhere in product properties are not external", async () => { + const offer = product("all-local", { + collections: [ + { publisher_domain: "first.example", collection_ids: ["x"] }, + { publisher_domain: "second.example", collection_ids: ["y"] }, + ], + }); + offer.publisher_properties = [ + { publisher_domain: "first.example", selection_type: "all" }, + { publisher_domain: "second.example", selection_type: "all" }, + ]; + for (const name of [ + "owner_sold_channel_carriage", + "owner_sold_channel_subset", + ]) { + const { result, calls } = await execute(name, { catalog: [offer] }); + assert.ok( + result.coverage_gaps.some((g) => g.reason === "fixture_unsatisfied"), + ); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + } +}); +test("set semantics: legal catalog redundancy, empty and Unicode IDs stay concrete without duplicate wire entries", async () => { + const collections = [ + { publisher_domain: "studio.example", collection_ids: ["", "é", "é"] }, + { publisher_domain: "studio.example", collection_ids: ["", "é", "é"] }, + { publisher_domain: "other.example", collection_ids: ["é"] }, + ]; + for (const name of [ + "collection_default_materialization", + "fixed_bundle_restatement", + "collection_selection", + ]) { + const offer = qualifying(name, { collections }); + const { result, calls } = await execute(name, { catalog: [offer] }); + assert.equal(result.overall_passed, true, JSON.stringify(result)); + if (name === "fixed_bundle_restatement") { + const sent = calls.find((c) => c.task === "create_media_buy").request + .packages[0].targeting_overlay.collection_selection.collections; + assert.equal(sent.length, 3); + assert.equal( + new Set(sent.map((s) => JSON.stringify(s))).size, + sent.length, + ); + assert.deepEqual(pairs(sent), pairs(collections)); + } + } +}); + +test("negative witnesses collision-check the COMPLETE selected product rather than a reserved-looking prefix", async () => { + const { evaluateCollectionSelection } = require("@adcp/sdk/testing"); + const offer = product("collision-product", { cross: true }); + const initial = evaluateCollectionSelection(offer, { + collection_targeting: "any", + minimum_pairs: 1, + require_cross_publisher_collection: true, + }).witness; + offer.collections[0].collection_ids.push( + initial.undeclared_collection_id_selectors[0].collection_ids[0], + ); + offer.collections.push( + structuredClone(initial.undeclared_publisher_domain_selectors[0]), + ); + const { result, calls } = await execute("owner_sold_channel_carriage", { + catalog: [offer], + }); + assert.equal(result.overall_passed, true, JSON.stringify(result)); + const negative = calls.filter((c) => c.task === "create_media_buy").slice(2); + const declared = pairs(offer.collections); + for (const call of negative) + for (const pair of pairs( + call.request.packages[0].targeting_overlay.collection_selection + .collections, + )) + assert.equal(declared.has(pair), false); + const sentDomain = + negative[1].request.packages[0].targeting_overlay.collection_selection + .collections[0].publisher_domain; + assert.equal( + offer.collections.some((s) => s.publisher_domain === sentDomain), + false, + ); +}); + +test("root applicability: brief-only seller remains excluded in full and standalone without fixture contact", async () => { + for (const name of ALL) + for (const standalone of [false, true]) { + const { result, calls, graded } = await execute(name, { + standalone, + buyingModes: ["brief"], + }); + assert.equal(calls.length, 0, JSON.stringify(calls)); + assert.ok( + graded.every((s) => s.disposition === "not_applicable"), + JSON.stringify(result), + ); + } +}); + +for (const name of READ) + test(`${name}: stale caller tool caches cannot override the observed reader gate`, async () => { + for (const standalone of [false, true]) { + const absent = await execute(name, { + standalone, + reader: false, + stepId: "read_selection", + overrides: { + agentTools: [ + "get_media_buys", + "get_products", + "create_media_buy", + "comply_test_controller", + ], + }, + }); + assert.equal(absent.http.length, 0); + assert.equal(absent.calls.length, 0); + assert.ok( + absent.graded.every((s) => s.disposition === "not_applicable"), + JSON.stringify(absent.result), + ); + const present = await execute(name, { + standalone, + stepId: "read_selection", + overrides: { agentTools: [] }, + }); + assert.equal( + standalone ? present.result.passed : present.result.overall_passed, + true, + JSON.stringify(present.result), + ); + } + }); diff --git a/tests/optional-product-collections.test.cjs b/tests/optional-product-collections.test.cjs new file mode 100644 index 00000000000..2dc1cae6fa0 --- /dev/null +++ b/tests/optional-product-collections.test.cjs @@ -0,0 +1,628 @@ +"use strict"; + +// Executes the actual authored storyboards through the production caller and +// public SDK MCP transport. All seller data/credentials are controlled fixtures. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { + loadStoryboardFile, + prepareStoryboardFixtures, +} = require("@adcp/sdk/testing"); +const { + runCatalogReportingStoryboard: runStoryboard, + runCatalogReportingStoryboardStep: runStoryboardStep, + withHostedReportingBudget, +} = require("../server/src/compliance/catalog-reporting-runner.ts"); +const { + classifyComplianceStep, +} = require("../server/src/compliance/step-disposition.ts"); +const SOURCE = path.join( + __dirname, + "../static/compliance/source/protocols/media-buy/scenarios", +); +const CORE = [ + "collection_selection", + "collection_default_materialization", + "fixed_bundle_enforcement", + "fixed_bundle_restatement", + "owner_sold_channel_carriage", + "owner_sold_channel_subset", +]; +const READ = [ + "collection_selection_readback", + "collection_default_materialization_readback", +]; +const ALL = [...CORE, ...READ]; +const URL = "https://collection-regression.example/mcp"; +const load = (name) => loadStoryboardFile(path.join(SOURCE, `${name}.yaml`)); + +function product( + id = "native-product", + { flag = true, cross = false, pairs = 2, collections, minimum, prices } = {}, +) { + return { + product_id: id, + name: "StreamHaus collection offer", + description: "Native sandbox product", + delivery_type: "non_guaranteed", + channels: ["olv"], + publisher_properties: [ + { + publisher_domain: cross ? "host.example" : "studio.example", + selection_type: "all", + }, + ], + format_options: [ + { + format_option_id: "native-image", + format_kind: "image", + params: { width: 300, height: 250 }, + }, + ], + reporting_capabilities: { + available_reporting_frequencies: ["daily"], + expected_delay_minutes: 0, + timezone: "UTC", + supports_webhooks: false, + available_metrics: ["impressions", "clicks", "spend"], + date_range_support: "date_range", + }, + collections: collections ?? [ + { + publisher_domain: "studio.example", + collection_ids: ["episode-a", "episode-b"].slice(0, pairs), + }, + ], + ...(flag === "omitted" ? {} : { collection_targeting_allowed: flag }), + pricing_options: prices ?? [ + { + pricing_option_id: `${id}-fixed`, + pricing_model: "cpm", + currency: "EUR", + fixed_price: 17, + ...(minimum === undefined ? {} : { min_spend_per_package: minimum }), + }, + ], + }; +} +function qualifying(name, options = {}) { + const fixed = name.startsWith("fixed_bundle"); + const cross = name.startsWith("owner_sold"); + return product("native-product", { flag: !fixed, cross, ...options }); +} +function pairs(selectors) { + return new Set( + selectors.flatMap((s) => + (s.collection_ids ?? []).map((id) => + JSON.stringify([s.publisher_domain, id]), + ), + ), + ); +} +function concrete(selectors) { + const domains = new Map(); + for (const selector of selectors) + for (const id of selector.collection_ids ?? []) { + if (!domains.has(selector.publisher_domain)) + domains.set(selector.publisher_domain, new Set()); + domains.get(selector.publisher_domain).add(id); + } + return [...domains].reverse().map(([publisher_domain, ids]) => ({ + publisher_domain, + collection_ids: [...ids].reverse(), + })); +} + +async function execute( + name, + { + catalog = [qualifying(name)], + pages, + fault, + broken, + reader = true, + standalone = false, + stepId = "create_selection", + context, + overrides = {}, + noProfile = false, + hosted = false, + responsePadding = 0, + prepareOnly = false, + buyingModes = ["wholesale"], + doc = load(name), + mutateAfterCreate, + omitCreateLifecycle = false, + skipPriorCreate = false, + expectLocalError = false, + continueFailedCreate = false, + priorContextTransform, + readProfileMode, + readAgentUrl = URL, + priorRunTransform, + scenarioOnly = false, + } = {}, +) { + const calls = [], + wire = [], + http = [], + buys = new Map(); + const tools = [ + "get_adcp_capabilities", + "get_products", + "create_media_buy", + "comply_test_controller", + ...(reader ? ["get_media_buys"] : []), + ]; + const caps = { + status: "completed", + adcp: { + major_versions: [3], + supported_versions: ["3.2"], + idempotency: { supported: false }, + }, + supported_protocols: ["media_buy"], + media_buy: { buying_modes: buyingModes }, + compliance_testing: { scenarios: ["seed_account"] }, + }; + let buyIndex = 0; + let failReadCapabilities = false; + const trustedFetchFn = async (_url, init = {}) => { + http.push({ method: init.method ?? "GET", body: init.body }); + if (!init.body) + return new Response(null, { + status: init.method === "DELETE" ? 202 : 405, + }); + const msg = JSON.parse(init.body); + wire.push(msg.method); + const reply = (result) => + new Response(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }), { + headers: { "content-type": "application/json" }, + }); + if (msg.method === "server/discover") + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: "Not supported" }, + }), + { headers: { "content-type": "application/json" } }, + ); + if (msg.method === "initialize") + return reply({ + protocolVersion: "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "collection-regression", version: "1" }, + }); + if (msg.method.startsWith("notifications/")) + return new Response(null, { status: 202 }); + if (msg.method === "tools/list") + return reply({ + tools: tools.map((name) => ({ + name, + description: name, + inputSchema: { type: "object", additionalProperties: true }, + })), + }); + assert.equal(msg.method, "tools/call"); + const { name: task, arguments: request } = msg.params; + calls.push({ task, request: structuredClone(request) }); + let data; + if (task === "get_adcp_capabilities") { + if (failReadCapabilities) { + failReadCapabilities = false; + return new Response("Controlled first read capability failure", { + status: 503, + }); + } + data = caps; + } else if (task === "comply_test_controller") { + assert.equal(request.account.sandbox, true); + assert.ok( + ["list_scenarios", "seed_account"].includes(request.scenario), + "feature support cannot be manufactured by seeding", + ); + data = + request.scenario === "list_scenarios" + ? { success: true, scenarios: ["seed_account"] } + : { success: true }; + } else if (task === "get_products") { + assert.equal(request.buying_mode, "wholesale"); + assert.deepEqual(request.account, doc.context.sandbox_account); + if (fault === "transport") + throw new Error("Controlled catalog transport failure"); + if (fault === "protocol") + data = { + status: "error", + errors: [ + { + code: "INVALID_REQUEST", + message: "Controlled wholesale refusal", + }, + ], + }; + else { + const index = request.pagination?.cursor + ? Number(request.pagination.cursor) + : 0; + data = { + status: "completed", + products: + fault === "schema" + ? [{ product_id: "malformed" }] + : pages + ? pages[index] + : catalog, + cache_scope: "account", + wholesale_feed_version: "catalog-v1", + ...(fault === "invalid_version" && { adcp_version: "not-a-release" }), + ...(pages && { + pagination: { + has_more: index < pages.length - 1, + ...(index < pages.length - 1 && { cursor: String(index + 1) }), + }, + }), + ...(fault === "pagination" && { pagination: { has_more: true } }), + ...(fault === "loop" && { + pagination: { has_more: true, cursor: "0" }, + }), + }; + } + } else if (task === "create_media_buy") { + assert.deepEqual(request.account, doc.context.sandbox_account); + assert.deepEqual(request.brand, doc.context.sandbox_brand); + assert.equal(Object.hasOwn(request, "currency"), false); + const pkg = request.packages[0]; + const selected = (pages ? pages.flat() : catalog).find( + (p) => p.product_id === pkg.product_id, + ); + assert.ok(selected, "the request uses a real selected seller product"); + const price = selected.pricing_options.find( + (p) => p.pricing_option_id === pkg.pricing_option_id, + ); + assert.ok( + price && price.fixed_price !== undefined, + "the price belongs to that SAME selected parent", + ); + assert.equal(pkg.bid_price, undefined); + assert.equal( + pkg.budget, + Math.max(1000, price.min_spend_per_package ?? 0), + ); + const selection = pkg.targeting_overlay.collection_selection; + const full = pairs(selected.collections); + const wanted = + selection.mode === "default" ? full : pairs(selection.collections); + let error; + if ( + selection.mode === "selected" && + selection.collections.some((s) => !Array.isArray(s.collection_ids)) + ) + error = "INVALID_REQUEST"; + else if ([...wanted].some((p) => !full.has(p))) + error = "REFERENCE_NOT_FOUND"; + else if ( + selected.collection_targeting_allowed !== true && + wanted.size !== full.size + ) + error = "UNSUPPORTED_FEATURE"; + if (broken === "reject_all") error = "UNSUPPORTED_FEATURE"; + if (error && broken !== "accept_invalid") { + data = { + status: "error", + errors: [ + { + code: broken === "wrong_error" ? "INVALID_REQUEST" : error, + message: "Controlled selector refusal", + }, + ], + }; + } else { + const selectedSelectors = + selection.mode === "default" || broken === "widen" + ? concrete(selected.collections) + : concrete(selection.collections); + let echo = { mode: "selected", collections: selectedSelectors }; + if (broken === "domain") + echo.collections[0].publisher_domain = "wrong-namespace.example"; + if (broken === "drop") echo.collections = []; + if (broken === "duplicate_echo") + echo.collections.push(structuredClone(echo.collections[0])); + if (broken === "default_unmaterialized" && selection.mode === "default") + echo = { mode: "default" }; + const media_buy_id = `seller-buy-${++buyIndex}`, + package_id = `seller-package-${buyIndex}`; + const pack = { + package_id, + product_id: selected.product_id, + pricing_option_id: price.pricing_option_id, + ...(broken === "missing_echo" + ? {} + : { targeting_overlay: { collection_selection: echo } }), + }; + data = { + status: "completed", + ...(omitCreateLifecycle ? {} : { media_buy_status: "active" }), + media_buy_id, + confirmed_at: "2026-10-23T00:00:00Z", + revision: 1, + packages: [pack], + }; + if (broken === "wrong_product") + data.packages[0].product_id = "foreign-product"; + if (broken === "missing_product") delete data.packages[0].product_id; + if (broken === "extra_package") + data.packages.push({ ...pack, package_id: "unrequested-package" }); + buys.set(media_buy_id, { + media_buy_id, + status: "active", + currency: price.currency, + total_budget: pkg.budget, + confirmed_at: data.confirmed_at, + revision: 1, + packages: [structuredClone(pack)], + }); + if (mutateAfterCreate) mutateAfterCreate(selected); + } + } else if (task === "get_media_buys") { + assert.equal(reader, true, "an absent reader must never be dispatched"); + assert.deepEqual(request.account, doc.context.sandbox_account); + data = { + status: "completed", + media_buys: request.media_buy_ids.map((id) => + structuredClone(buys.get(id)), + ), + }; + assert.ok( + data.media_buys.every(Boolean), + "readback uses actual earlier committed buys", + ); + if (broken === "read_identity") + data.media_buys[0].media_buy_id = "foreign-buy"; + if (broken === "read_product") + data.media_buys[0].packages[0].product_id = "foreign-product"; + if (broken === "read_missing_product") + delete data.media_buys[0].packages[0].product_id; + if (broken === "read_package") + data.media_buys[0].packages[0].package_id = "foreign-package"; + if (broken === "read_widen") + data.media_buys[0].packages[0].targeting_overlay.collection_selection.collections = + [ + { + publisher_domain: "wrong.example", + collection_ids: ["uncommitted"], + }, + ]; + if (broken === "read_missing") + delete data.media_buys[0].packages[0].targeting_overlay; + } else assert.fail(`Unexpected tool ${task}`); + return reply({ + content: [{ type: "text", text: JSON.stringify(data) }], + ...(task === "get_products" && + responsePadding && { _meta: { padding: "x".repeat(responsePadding) } }), + }); + }; + let options = { + adcpVersion: "3.2.5", + timeout_ms: 3000, + ...(noProfile + ? {} + : { + profile: { + tools, + raw_capabilities: caps, + supported_protocols: ["media_buy"], + }, + }), + transport: { trustedFetchFn }, + ...(context && { context }), + ...overrides, + }; + if (scenarioOnly) return { options, calls, wire, http, doc, url: URL }; + if (hosted) options = withHostedReportingBudget(doc, options); + if (prepareOnly) + return { + prepared: await prepareStoryboardFixtures(URL, doc, { + ...options, + brand: doc.context.sandbox_brand, + sandbox: true, + }), + calls, + wire, + http, + }; + let result; + let created; + if ( + !skipPriorCreate && + standalone && + stepId === "read_selection" && + reader && + buyingModes.includes("wholesale") + ) { + created = await runStoryboardStep(URL, doc, "create_selection", options); + if (!continueFailedCreate) + assert.equal(created.passed, true, JSON.stringify(created)); + options = { ...options, context: structuredClone(created.context) }; + if (priorContextTransform) priorContextTransform(options.context); + if (priorRunTransform) priorRunTransform({ doc, options }); + if (readProfileMode === "probe_error") + options.profile = { + ...options.profile, + capabilities_probe_error: "Controlled prior discovery error", + }; + if (readProfileMode === "schema_error") + options.profile = { + ...options.profile, + capabilities_schema_issues: ["Controlled schema issue"], + }; + if (readProfileMode === "malformed_modes") + options.profile = { + ...options.profile, + raw_capabilities: { ...caps, media_buy: { buying_modes: "wholesale" } }, + }; + if (readProfileMode === "fail_once") { + delete options.profile; + failReadCapabilities = true; + } + } + try { + result = standalone + ? await runStoryboardStep(readAgentUrl, doc, stepId, options) + : await runStoryboard(URL, doc, options); + } catch (error) { + if ( + !expectLocalError && + overrides.prepared_fixtures_authorizer === undefined && + overrides.prepared_fixtures === undefined && + !overrides.signal?.aborted + ) + throw error; + return { error, calls, wire, http, created }; + } + const graded = ( + result.phases ?? [{ phase_id: result.phase_id, steps: [result] }] + ).flatMap((p) => + p.steps.map((s) => ({ + ...s, + disposition: classifyComplianceStep(s, `${doc.id}/${p.phase_id}`), + })), + ); + return { result, calls, wire, http, graded, created }; +} + +if (require.main === module) { + for (const name of ALL) { + test(`${name}: actual source full and standalone accepts native same-graph selectors and commercial terms`, async () => { + for (const standalone of [false, true]) { + const { result, calls } = await execute(name, { standalone }); + assert.equal( + standalone ? result.passed : result.overall_passed, + true, + JSON.stringify(result), + ); + assert.equal( + result.fixture_resolutions.every( + (r) => r.status === "resolved" && r.strategy === "discover", + ), + true, + ); + assert.ok(calls.some((c) => c.task === "create_media_buy")); + } + }); + test(`${name}: nonsupporting catalog is an honest no-match, with no product seeds or purchase`, async () => { + const plain = product(); + delete plain.collections; + delete plain.collection_targeting_allowed; + for (const standalone of [false, true]) { + const { result, calls, graded } = await execute(name, { + standalone, + catalog: [plain], + }); + assert.equal( + calls.some((c) => c.task === "create_media_buy"), + false, + ); + assert.equal( + standalone + ? classifyComplianceStep( + result, + `${load(name).id}/${result.phase_id}`, + ) + : classifyComplianceStep({ + skipped: true, + skip_reason: result.coverage_gaps?.[0]?.reason, + }), + "not_applicable", + JSON.stringify(result), + ); + assert.equal( + graded.some((s) => s.disposition === "failed"), + false, + ); + } + }); + } + for (const name of READ) { + test(`${name}: missing reader has the actual native root gate in full and standalone`, async () => { + for (const standalone of [false, true]) { + const { result, calls, http, graded } = await execute(name, { + reader: false, + standalone, + stepId: "read_selection", + }); + assert.equal(calls.length, 0, JSON.stringify(calls)); + assert.equal(http.length, 0); + assert.equal(graded.length, 1); + assert.equal( + graded[0].disposition, + "not_applicable", + JSON.stringify(result), + ); + assert.equal(graded[0].step_id, "missing_tool"); + assert.equal(graded[0].phase_id, "missing_tool"); + } + }); + test(`${name}: actual persisted read passes full and standalone with the committed selector capture`, async () => { + for (const standalone of [false, true]) { + const { result } = await execute(name, { + standalone, + stepId: "read_selection", + }); + assert.equal( + standalone ? result.passed : result.overall_passed, + true, + JSON.stringify(result), + ); + } + }); + } + + test("authority: supplied hooks stay fail-closed before every HTTP interaction, not falsely called", async () => { + const util = require("node:util"); + for (const name of ALL) + for (const standalone of [false, true]) { + let invoked = 0; + const audits = []; + const { error, http, calls } = await execute(name, { + standalone, + overrides: { + prepared_fixtures_authorizer: () => { + invoked++; + throw new Error("private-host-token"); + }, + prepared_fixtures_audit_sink: (r) => audits.push(r), + }, + }); + assert.equal(error.name, "PreparedFixturesDeniedError"); + assert.equal(invoked, 0); + assert.equal(http.length, 0); + assert.equal(calls.length, 0); + assert.equal(audits.length, 1); + assert.equal(util.inspect(error).includes("private-host-token"), false); + } + }); + test("committed readback: later catalog mutation cannot replace the captured committed set", async () => { + for (const standalone of [false, true]) { + const { result } = await execute("collection_selection_readback", { + standalone, + stepId: "read_selection", + mutateAfterCreate: (p) => { + p.collections = [ + { + publisher_domain: "studio.example", + collection_ids: ["later-a", "later-b"], + }, + ]; + }, + }); + assert.equal( + standalone ? result.passed : result.overall_passed, + true, + JSON.stringify(result), + ); + } + }); +} +module.exports = { execute, product, qualifying, CORE, READ, ALL, load, pairs };