diff --git a/.changeset/typed-proposal-negotiation-storyboard.md b/.changeset/typed-proposal-negotiation-storyboard.md new file mode 100644 index 0000000000..bff5040831 --- /dev/null +++ b/.changeset/typed-proposal-negotiation-storyboard.md @@ -0,0 +1,15 @@ +--- +"adcontextprotocol": minor +--- + +Add typed proposal negotiation compliance storyboard. + +- Add `typed_proposal_negotiation.yaml` exercising the AdCP 3.2 typed + negotiation lifecycle through `refine_proposals`: capability-gated + constraint satisfaction (total_budget, product_changes, alternatives), + partial invariant, unsupported dimension rejection, finalize atomicity, + idempotent replay, immutable lineage, digest-verified acceptance, + amendment, cancellation, double-finalize rejection, and multi-source batch. +- Register the scenario in the media-buy seller `index.yaml`. + +Refs #6559 diff --git a/docs/contributing/storyboard-authoring.md b/docs/contributing/storyboard-authoring.md index ab79a07ac0..78c68e9452 100644 --- a/docs/contributing/storyboard-authoring.md +++ b/docs/contributing/storyboard-authoring.md @@ -55,7 +55,7 @@ The lint still accepts a bare top-level `brand.domain` as a fallback because the The authoritative list lives in `scripts/lint-storyboard-scoping.cjs` as `TENANT_SCOPED_TASKS`. A parity test (`tests/lint-storyboard-scoping.test.cjs`) asserts every task registered in the training agent's `HANDLER_MAP` appears in either `TENANT_SCOPED_TASKS` or `EXEMPT_FROM_LINT`. If you add a new tool to the dispatch table and forget to classify it, the parity test fails — you won't get silent drift. -Rule of thumb: if the task's **request schema has a required globally-unique scope-ID** (`plan_id`, `rights_id`, `standards_id`, `list_id`, `event_source_id`), the seller can resolve the tenant from that ID alone — envelope identity is redundant and the lint does not require it (see `EXEMPT_FROM_LINT` bucket (c)). +Rule of thumb: if the task's **request schema has a required globally-unique scope-ID** (`plan_id`, `rights_id`, `standards_id`, `list_id`, `event_source_id`, or proposal IDs inside `refinements`/`declines`), the seller can resolve the tenant from that ID alone — envelope identity is redundant and the lint does not require it (see `EXEMPT_FROM_LINT` bucket (c)). Everything else falls into `TENANT_SCOPED_TASKS`: create/update mutations without a scope-ID, list/get operations that don't carry a single resource ID, resource-standards calls without `standards_id` in schema, etc. These must carry envelope `account { brand, operator }`. @@ -65,13 +65,13 @@ When a step captures a value into `$context` via `context_outputs` and a later s Other exempt categories: payload-array-keyed sync tasks (`sync_accounts`, `sync_governance`, `sync_catalogs`, `sync_event_sources`), global discovery (`get_adcp_capabilities`, plus deprecated `list_creative_formats` compatibility tests), global catalog reads (`get_brand_identity`, `get_rights`, `update_rights`), and the `comply_test_controller` sandbox primitive. -### Why ID-scoped tasks are exempt but storyboards still carry identity +### Why ID-scoped tasks are exempt -`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, and `validate_property_delivery` all require a globally-unique ID (`plan_id`, `rights_id`, `standards_id`, etc.) that was previously provisioned with brand context. At the spec level, a real seller resolves the ID → tenant via their own lookup; the envelope doesn't need to repeat the identity. +`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, `validate_property_delivery`, `refine_proposals`, and `decline_proposals` all require one or more globally-unique IDs that were previously provisioned with brand context. At the spec level, a real seller resolves each ID → tenant via its own lookup; the envelope doesn't need to repeat the identity. -The training agent's `sessionKeyFromArgs` routes by envelope identity. A storyboard that **drops** identity on an ID-scoped task lands in `open:default` and fails to find the plan/rights/standards — so storyboards carry envelope identity anyway, and the lint just won't enforce it. +Most legacy ID-scoped storyboards still carry envelope identity because the training agent's `sessionKeyFromArgs` uses it for sandbox routing. The split proposal-lifecycle schemas deliberately forbid `account` on `refine_proposals` and `decline_proposals`; for those tools, the training agent reverse-resolves the proposal IDs to the originating account scope. -This is a sandbox routing convention, not a spec claim. Production sellers resolve tenant from the authenticated principal (bearer/OAuth/HMAC), not from envelope payload — see [Tenant resolution](/docs/building/integration/authentication#tenant-resolution). They don't need envelope identity on ID-scoped tasks and wouldn't rely on it if present. Building a cross-session reverse index in the training agent just to move identity off the wire would be sandbox plumbing without spec meaning. +The remaining envelope identity is a sandbox routing convention, not a spec claim. Production sellers resolve tenant from the authenticated principal (bearer/OAuth/HMAC), not from envelope payload — see [Tenant resolution](/docs/building/integration/authentication#tenant-resolution). They don't need envelope identity on ID-scoped tasks and wouldn't rely on it if present. ## Intentionally cross-tenant probes diff --git a/scripts/build-compliance.cjs b/scripts/build-compliance.cjs index 18d1b746ab..7b67b13e46 100644 --- a/scripts/build-compliance.cjs +++ b/scripts/build-compliance.cjs @@ -262,6 +262,7 @@ function lintStoryboardIdempotency(sourceDir, schemasDir) { const duplicateGeneratedKeyAllowedReplayKeys = new Set([ 'protocols/media-buy/scenarios/get_products_async.yaml:$context.submitted_products_idempotency_key', 'protocols/media-buy/scenarios/proposal_finalize.yaml:$context.finalize_idempotency_key', + 'protocols/media-buy/scenarios/typed_proposal_negotiation.yaml:$context.finalize_idempotency_key', ]); function isGeneratedIdempotencyKey(value, generatedContextNames) { diff --git a/scripts/lint-storyboard-sample-request-schema.cjs b/scripts/lint-storyboard-sample-request-schema.cjs index 0ada72ca55..f5eafc4b1d 100644 --- a/scripts/lint-storyboard-sample-request-schema.cjs +++ b/scripts/lint-storyboard-sample-request-schema.cjs @@ -151,6 +151,7 @@ function discriminatorFor(payload, schema) { // idempotency-key patterns in the schemas. Substitutions typically resolve // to ids at runtime, so a UUID is a safer default than a short sentinel. const STRING_PLACEHOLDER = '00000000-0000-4000-8000-000000000000'; +const SHA256_DIGEST_PLACEHOLDER = `sha256:${'A'.repeat(43)}`; /** * Build a schema-typed placeholder for a substitution string. The placeholder @@ -177,6 +178,7 @@ function placeholderFor(schema, depth = 0) { if (resolved.format === 'email') return 'placeholder@example.com'; if (Array.isArray(resolved.enum) && resolved.enum.length > 0) return resolved.enum[0]; if (typeof resolved.const !== 'undefined') return resolved.const; + if (resolved.pattern === '^sha256:[A-Za-z0-9_-]{43}$') return SHA256_DIGEST_PLACEHOLDER; // Object variant nested inside a oneOf/anyOf at a location where the // author's substitution will resolve to that shape at runtime. Synthesize // the concrete shape instead of returning a string that fails required. diff --git a/scripts/lint-storyboard-scoping.cjs b/scripts/lint-storyboard-scoping.cjs index a732bb4434..b98d1aeeda 100644 --- a/scripts/lint-storyboard-scoping.cjs +++ b/scripts/lint-storyboard-scoping.cjs @@ -48,8 +48,6 @@ const TENANT_SCOPED_TASKS = new Set([ 'get_products', 'list_products', 'request_proposals', - 'refine_proposals', - 'decline_proposals', 'get_signals', 'activate_signal', 'sync_audiences', @@ -106,13 +104,15 @@ const TENANT_SCOPED_TASKS = new Set([ * - `validate_content_delivery` — required `standards_id` * - `validate_property_delivery` — required `list_id` (schema also * has optional `account`) + * - `refine_proposals` — required proposal IDs in `refinements` + * - `decline_proposals` — required proposal IDs in `declines` * - * Storyboard authors still carry envelope identity on these tasks as a - * sandbox routing convention — `sessionKeyFromArgs` routes by envelope - * identity, so a storyboard that drops it would land in `open:default`. - * The lint simply doesn't require it. Production sellers resolve the ID - * via the authenticated principal, not the envelope payload, so there is - * no spec-level gap to close. See docs/contributing/storyboard-authoring.md. + * Most legacy storyboards still carry envelope identity on these tasks as + * a sandbox routing convention. `refine_proposals` and `decline_proposals` + * deliberately forbid it; the training agent reverse-resolves their + * proposal IDs. Production sellers resolve every such ID via the + * authenticated principal, not an envelope payload. See + * docs/contributing/storyboard-authoring.md. */ const EXEMPT_FROM_LINT = new Set([ // (a) Payload-array-keyed sync tasks @@ -139,6 +139,8 @@ const EXEMPT_FROM_LINT = new Set([ 'calibrate_content', 'validate_content_delivery', 'validate_property_delivery', + 'refine_proposals', + 'decline_proposals', ]); // Compatibility-only handlers that are not AdCP tasks. creative_approval is diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 7b50a50830..47b10be1ef 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -36,6 +36,7 @@ requires_scenarios: - media_buy_seller/bidding_policy_placement - media_buy_seller/bidding_cost_binding - media_buy_seller/package_automatic_bidding + - media_buy_seller/typed_proposal_negotiation narrative: | You run a sell-side platform — a publisher, SSP, retail media network, or any system that diff --git a/static/compliance/source/protocols/media-buy/scenarios/typed_proposal_negotiation.yaml b/static/compliance/source/protocols/media-buy/scenarios/typed_proposal_negotiation.yaml new file mode 100644 index 0000000000..3f338b48ae --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/typed_proposal_negotiation.yaml @@ -0,0 +1,771 @@ +id: media_buy_seller/typed_proposal_negotiation +version: "1.0.0" +title: "Seller handles typed proposal negotiation with constraints, product changes, and alternatives" +category: media_buy_seller +summary: "Verifies the AdCP 3.2 typed negotiation lifecycle: capability-gated constraint satisfaction, product changes, digest-verified alternatives, partial invariant, immutable lineage, finalize atomicity, and idempotent replay through refine_proposals." +track: media_buy +required_tools: + - refine_proposals + - accept_proposal + +requires_capability: + path: media_buy.proposal_refinement.supported_dimensions + present: true + +narrative: | + AdCP 3.2 introduces typed proposal negotiation through refine_proposals. + Buyers declare hard constraints (total_budget, cpm, impressions, flight), + product changes, requested alternatives, and structured criteria alongside + the free-text ask. Sellers return immutable successor proposals with + deterministic outcomes — revised, partial, unable, or finalized — and + machine-readable reason codes. + + This storyboard exercises the complete typed negotiation surface: + + 1. Capability discovery — the seller declares proposal_refinement with + supported_dimensions and optional max_alternatives. + 2. Typed budget constraint — satisfied (revised) and unsatisfied (partial + with constraint_unsatisfiable and unsatisfied_constraints). + 3. Product changes — include/omit actions checked against purchases. + 4. Alternatives — exact count with unique terms_digest values. + 5. Partial invariant — drafts on a partial satisfy every constraint absent + from unsatisfied_constraints. + 6. Unsupported dimension — task-level UNSUPPORTED_FEATURE rejection with + the registered error-details shape. + 7. Finalize — committed snapshot with inventory hold and expires_at. + 8. Idempotent replay — same idempotency_key returns cached response. + 9. Immutable lineage — parent_proposal_id equals source on every successor. + 10. Accept — proposal_terms_digest verified acceptance creating a MediaBuy. + + Legacy sellers that do not declare proposal_refinement are not tested by + this storyboard; their unstructured proposal path is covered by + proposal_finalize.yaml. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - accepts_briefs + - generates_proposals + examples: + - "Full-service publisher with typed negotiation support" + - "Retail media network with structured constraint handling" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The caller needs a brand identity and operator credentials. The seller + must declare proposal_refinement.supported_dimensions in capabilities. + test_kit: "test-kits/acme-outdoor.yaml" + +fixtures: + products: + - product_id: "premium_video_q4" + delivery_type: "guaranteed" + channels: ["video"] + format_options: + - format_option_id: "video_30s" + format_kind: "video_hosted" + params: + duration_ms_exact: 30000 + - product_id: "display_standard_q4" + delivery_type: "guaranteed" + channels: ["display"] + format_options: + - format_option_id: "display_300x250" + format_kind: "image" + params: + width: 300 + height: 250 + - product_id: "native_feed_q4" + delivery_type: "guaranteed" + channels: ["native"] + format_options: + - format_option_id: "native_article" + format_kind: "native" + pricing_options: + - product_id: "premium_video_q4" + pricing_option_id: "cpm_guaranteed" + pricing_model: "cpm" + currency: "USD" + floor_price: 28.0 + - product_id: "display_standard_q4" + pricing_option_id: "cpm_display" + pricing_model: "cpm" + currency: "USD" + floor_price: 12.0 + - product_id: "native_feed_q4" + pricing_option_id: "cpm_native" + pricing_model: "cpm" + currency: "USD" + floor_price: 18.0 + +phases: + - id: capability_discovery + title: "Discover typed negotiation capabilities" + narrative: | + Confirm the seller declares proposal_refinement with explicit + supported_dimensions. The buyer uses these to decide which typed + fields to send on revise requests. + + steps: + - id: get_capabilities + title: "Check proposal refinement capabilities" + task: get_adcp_capabilities + schema_ref: "protocol/get-adcp-capabilities-request.json" + response_schema_ref: "protocol/get-adcp-capabilities-response.json" + doc_ref: "/protocol/get_adcp_capabilities" + comply_scenario: capability_discovery + stateful: false + expected: | + Return capabilities with proposal_refinement.supported_dimensions + declaring at least one typed dimension. If alternatives is declared, + max_alternatives may limit the count ceiling below the protocol + maximum of 10. + sample_request: + context: + correlation_id: "typed_negotiation--get_capabilities" + context_outputs: + - path: "media_buy.proposal_refinement.supported_dimensions" + key: "supported_dimensions" + - path: "media_buy.proposal_refinement.max_alternatives" + key: "max_alternatives" + validations: + - check: response_schema + description: "Response matches get-adcp-capabilities-response.json schema" + - check: field_present + path: "media_buy.proposal_refinement.supported_dimensions" + description: "Seller declares supported typed dimensions" + + - id: account_setup + title: "Account setup" + steps: + - id: sync_accounts + title: "Establish account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + expected: | + Return the account with account_id and status active. + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + idempotency_key: "$generate:uuid_v4#typed_negotiation_setup_sync_accounts" + + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json schema" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a platform-assigned ID" + + - id: seed_proposals + title: "Brief to seed initial proposals" + narrative: | + Send a brief to obtain draft proposals for subsequent typed revision. + The buyer needs at least one proposal_id to target with refine_proposals. + + steps: + - id: get_products_brief + title: "Send a brief and receive proposals" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: full_sales_flow + stateful: false + expected: | + Return products and proposals matching the brief. At least one + proposal with proposal_id is required for the typed revision phases. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_seed_get_products_brief" + buying_mode: "brief" + brief: "Premium video and display across sports and outdoor lifestyle. Q4 flight, $75K budget. Adults 25-54, US." + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + + context_outputs: + - path: "proposals[0].proposal_id" + key: "seed_proposal_id" + - name: finalize_idempotency_key + generate: uuid_v4 + + validations: + - check: response_schema + description: "Response matches get-products-response.json schema" + - check: field_present + path: "proposals" + description: "Response contains proposals" + - check: field_present + path: "proposals[0].proposal_id" + description: "Proposals have IDs" + - check: field_present + path: "proposals[0].proposal_id" + description: "Proposals have unique IDs" + + - id: typed_budget_satisfied + title: "Typed budget constraint — satisfied" + requires_capability: + path: media_buy.proposal_refinement.supported_dimensions + contains: "total_budget" + narrative: | + Send a revise request with a total_budget constraint that the seller + can satisfy. The result must be outcome revised with the returned draft + meeting the budget bounds. + + steps: + - id: revise_with_budget + title: "Revise with satisfiable total_budget constraint" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome revised with proposals[] containing one draft. + The draft's commercial_terms.total_budget.amount must fall within + the requested bounds. parent_proposal_id must equal the source. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_budget_satisfied" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + constraints: + total_budget: + max: 80000 + currency: "USD" + ask: "Optimize for reach within the budget ceiling." + + context_outputs: + - path: "results[0].proposals[0].proposal_id" + key: "budget_revised_proposal_id" + - path: "results[0].proposals[0].terms_digest" + key: "budget_revised_terms_digest" + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + value: "revised" + description: "Outcome is revised when budget constraint is satisfied" + - check: field_present + path: "results[0].proposals[0].proposal_id" + description: "Revised draft has a new proposal_id" + - check: field_present + path: "results[0].proposals[0].parent_proposal_id" + description: "Revised draft carries parent_proposal_id" + - check: field_value + path: "results[0].source_proposal_id" + value: "$context.seed_proposal_id" + description: "Result echoes the source proposal_id" + - check: field_value + path: "results[0].proposals[0].parent_proposal_id" + value: "$context.seed_proposal_id" + description: "parent_proposal_id equals source_proposal_id for lineage" + - check: field_absent + path: "results[0].reason_code" + description: "No reason_code on a fully satisfied revision" + - check: field_absent + path: "results[0].unsatisfied_constraints" + description: "No unsatisfied_constraints on a fully satisfied revision" + + - id: typed_budget_unsatisfied + title: "Typed budget constraint — unsatisfied (partial)" + requires_capability: + path: media_buy.proposal_refinement.supported_dimensions + contains: "total_budget" + narrative: | + Send a revise request with a total_budget constraint the seller cannot + fully satisfy. The seller must return partial or unable with + reason_code constraint_unsatisfiable and total_budget echoed in + unsatisfied_constraints. A partial result may still carry honest drafts + that satisfy every other constraint absent from unsatisfied_constraints. + + steps: + - id: revise_with_tight_budget + title: "Revise with unsatisfiable total_budget constraint" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome partial or unable. reason_code must be + constraint_unsatisfiable. unsatisfied_constraints must include + total_budget. If partial, returned drafts must satisfy every + constraint not listed in unsatisfied_constraints. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_budget_unsatisfied" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + constraints: + total_budget: + max: 100 + currency: "USD" + ask: "Best effort within this ceiling." + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + allowed_values: ["partial", "unable"] + description: "Outcome is partial or unable when budget is unsatisfiable" + - check: field_value + path: "results[0].reason_code" + value: "constraint_unsatisfiable" + description: "reason_code is constraint_unsatisfiable for a typed failure" + - check: field_contains + path: "results[0].unsatisfied_constraints" + value: "total_budget" + description: "unsatisfied_constraints echoes the failing constraint key" + + - id: product_changes + title: "Product changes — include and omit" + requires_capability: + path: media_buy.proposal_refinement.supported_dimensions + contains: "product_changes" + narrative: | + Send a revise request with product_changes requesting inclusion and + omission of specific products. The seller evaluates each action against + the resulting commercial_terms.purchases. + + steps: + - id: revise_with_product_changes + title: "Revise with product include/omit map" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome revised (all product actions met) or partial + (some unmet, echoed in unsatisfied_product_changes). + parent_proposal_id equals source on every returned draft. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_product_changes" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + product_changes: + premium_video_q4: "include" + display_standard_q4: "omit" + ask: "Replace display spend with additional video inventory." + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + allowed_values: ["revised", "partial"] + description: "Outcome is revised or partial" + - check: field_value + path: "results[0].source_proposal_id" + value: "$context.seed_proposal_id" + description: "Result echoes the source proposal_id" + - check: field_present + path: "results[0].proposals[0].parent_proposal_id" + description: "Successor carries parent_proposal_id for lineage" + + - id: alternatives_requested + title: "Alternatives — multiple drafts with unique digests" + requires_capability: + path: media_buy.proposal_refinement.supported_dimensions + contains: "alternatives" + narrative: | + Request multiple alternative drafts. Revised requires exactly the + requested count with unique terms_digest on each. Fewer alternatives + or commercially duplicate digests require partial with + alternatives_unavailable. + + steps: + - id: revise_with_alternatives + title: "Request alternative drafts" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome revised with exactly the requested number of drafts, + each with a unique terms_digest. Or partial with + alternatives_unavailable if fewer distinct drafts are possible. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_alternatives" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + alternatives: + count: 3 + ask: "Use materially different product mixes while preserving reach." + + context_outputs: + - path: "results[0].proposals[0].proposal_id" + key: "alt_proposal_id_0" + - path: "results[0].proposals[0].terms_digest" + key: "alt_digest_0" + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + allowed_values: ["revised", "partial"] + description: "Outcome is revised or partial" + - check: field_present + path: "results[0].proposals" + description: "Result carries proposals array" + - check: field_present + path: "results[0].proposals[0].terms_digest" + description: "Each draft carries a terms_digest" + + - id: unsupported_dimension_rejection + title: "Unsupported dimension — task-level UNSUPPORTED_FEATURE" + narrative: | + Send a revise request using a typed dimension the seller has omitted + from supported_dimensions. The seller MUST reject the entire request + at task level with UNSUPPORTED_FEATURE before creating any proposal. + The error details should match the unsupported-refinement-dimension + shape, echoing the offending dimension and the seller's declaration. + + This phase sends a constraints.flight dimension; sellers that include + flight in supported_dimensions skip this phase. + + skip_if: + context_key: "supported_dimensions" + contains: "flight" + + steps: + - id: revise_unsupported_dimension + title: "Reject unsupported typed dimension at task level" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: | + Reject with UNSUPPORTED_FEATURE at task level. No proposal is + created. Error details should include unsupported_dimension and + supported_dimensions per the registered details shape. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_unsupported_dimension" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + constraints: + flight: + start_no_later_than: "2026-10-01T00:00:00Z" + ask: "Must start by October." + + validations: + - check: error_code + value: "UNSUPPORTED_FEATURE" + description: "Unsupported dimension is rejected with UNSUPPORTED_FEATURE" + - check: field_present + path: "adcp_error.details.unsupported_dimension" + description: "Error details identify the unsupported dimension" + - check: field_present + path: "adcp_error.details.supported_dimensions" + description: "Error details echo the seller's supported dimensions" + + - id: finalize_proposal + title: "Finalize draft into committed snapshot" + narrative: | + Finalize the initial seed draft. The seller creates a committed + successor with firm pricing and an inventory hold. The committed + proposal carries expires_at and its proposal_status is committed. This + lifecycle path is independent of every optional typed dimension. + + steps: + - id: finalize_draft + title: "Finalize the revised draft" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome finalized with a single committed proposal. + The proposal has proposal_status committed, expires_at timestamp, + and parent_proposal_id equal to the source draft. + sample_request: + idempotency_key: "$context.finalize_idempotency_key" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "finalize" + + context_outputs: + - path: "results[0].proposal.proposal_id" + key: "committed_proposal_id" + - path: "results[0].proposal.terms_digest" + key: "committed_terms_digest" + - path: "results[0].proposal.expires_at" + key: "committed_expires_at" + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + value: "finalized" + description: "Outcome is finalized" + - check: field_value + path: "results[0].proposal.proposal_status" + value: "committed" + description: "Committed proposal has proposal_status committed" + - check: field_present + path: "results[0].proposal.expires_at" + description: "Committed proposal has expires_at for the hold window" + - check: field_value + path: "results[0].proposal.parent_proposal_id" + value: "$context.seed_proposal_id" + description: "Committed proposal parent_proposal_id equals source draft" + - check: field_value + path: "results[0].source_proposal_id" + value: "$context.seed_proposal_id" + description: "Result echoes the source proposal_id" + + - id: finalize_idempotent_replay + title: "Idempotent finalize replay" + narrative: | + Replay the exact finalize request with the same idempotency_key. The + seller must return the cached committed proposal with replayed: true. + The original inventory-hold expiry and proposal_id must be preserved. + + steps: + - id: replay_finalize + title: "Replay finalize with same idempotency_key" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + sample_request: + idempotency_key: "$context.finalize_idempotency_key" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "finalize" + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "replayed" + value: true + description: "Replay is served from idempotency cache" + - check: field_value + path: "results[0].proposal.proposal_id" + value: "$context.committed_proposal_id" + description: "Replay preserves the original committed proposal_id" + - check: field_value + path: "results[0].proposal.expires_at" + value: "$context.committed_expires_at" + description: "Replay preserves the original hold expiry" + + - id: accept_committed + title: "Accept committed proposal to create MediaBuy" + narrative: | + The buyer accepts the committed proposal by passing the proposal_id + and proposal_terms_digest. The seller atomically verifies both before + creating the MediaBuy. + + steps: + - id: accept_proposal + title: "Accept the committed proposal" + task: accept_proposal + schema_ref: "media-buy/accept-proposal-request.json" + response_schema_ref: "media-buy/accept-proposal-response.json" + doc_ref: "/media-buy/task-reference/accept_proposal" + stateful: true + expected: | + Create a MediaBuy from the committed proposal. The response + includes a media_buy_id and confirms the proposal was accepted. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_accept_committed" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + proposal_id: "$context.committed_proposal_id" + proposal_terms_digest: "$context.committed_terms_digest" + io_acceptance: + io_id: "IO-2026-ACME-Q4" + accepted_at: "2026-09-15T10:00:00Z" + signatory: "ops@pinnacle-agency.example" + + context_outputs: + - path: "media_buy_id" + key: "media_buy_id" + + validations: + - check: response_schema + description: "Response matches accept-proposal-response.json schema" + + - id: amendment_from_accepted + title: "Amendment proposal from accepted source" + narrative: | + The buyer creates an amendment proposal against the accepted source. + This exercises the change_kind: amendment path. The source remains + accepted and unchanged; the result is a new draft amendment successor. + + steps: + - id: revise_amendment + title: "Create amendment draft from accepted proposal" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome revised or partial with a draft amendment proposal. + parent_proposal_id equals the accepted source. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_amendment" + refinements: + - proposal_id: "$context.committed_proposal_id" + action: "revise" + change_kind: "amendment" + ask: "Extend flight by two weeks and redistribute budget proportionally." + + context_outputs: + - path: "results[0].proposals[0].proposal_id" + key: "amendment_proposal_id" + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + allowed_values: ["revised", "partial"] + description: "Amendment produces a draft successor" + - check: field_present + path: "results[0].proposals[0].parent_proposal_id" + description: "Amendment draft carries parent_proposal_id" + + - id: cancellation_from_accepted + title: "Cancellation proposal from accepted source" + narrative: | + The buyer creates a cancellation proposal against the accepted source. + The source remains accepted and unchanged; the result is a new draft + cancellation successor that, once finalized and accepted, cancels the + MediaBuy. + + steps: + - id: revise_cancellation + title: "Create cancellation draft from accepted proposal" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return outcome revised or partial with a draft cancellation proposal. + parent_proposal_id equals the accepted source. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_cancellation" + refinements: + - proposal_id: "$context.committed_proposal_id" + action: "revise" + change_kind: "cancellation" + ask: "Campaign objectives met early; requesting negotiated wind-down." + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_value + path: "results[0].outcome" + allowed_values: ["revised", "partial", "unable"] + description: "Cancellation attempt produces an outcome" + - check: field_value + path: "results[0].source_proposal_id" + value: "$context.committed_proposal_id" + description: "Result echoes the accepted source" + + - id: double_finalize_rejected + title: "Double-finalize of committed proposal" + narrative: | + Attempt to finalize a proposal that is already committed (not a draft). + The seller must reject with INVALID_STATE at task level because + finalize targets drafts only. + + steps: + - id: finalize_committed_again + title: "Reject double-finalize of committed proposal" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: | + Reject with INVALID_STATE. Finalize targets drafts; a committed + proposal is not eligible. No new proposal is created. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_double_finalize" + refinements: + - proposal_id: "$context.committed_proposal_id" + action: "finalize" + + validations: + - check: error_code + value: "INVALID_STATE" + description: "Double-finalize of committed proposal returns INVALID_STATE" + + - id: multi_source_batch + title: "Multi-source batch with mixed outcomes" + narrative: | + Send a batch revise request targeting two sources in one call. Each + entry gets its own outcome — one may be revised while another is + partial or unable. Results preserve request order. + + steps: + - id: revise_multi_source + title: "Batch revise across two source proposals" + task: refine_proposals + schema_ref: "media-buy/refine-proposals-request.json" + response_schema_ref: "media-buy/refine-proposals-response.json" + doc_ref: "/media-buy/task-reference/refine_proposals" + stateful: true + expected: | + Return results[] with one entry per source, preserving request + order. Each entry has its own outcome and source_proposal_id. + sample_request: + idempotency_key: "$generate:uuid_v4#typed_negotiation_multi_source_batch" + refinements: + - proposal_id: "$context.seed_proposal_id" + action: "revise" + ask: "Maximize video allocation." + - proposal_id: "$context.amendment_proposal_id" + action: "revise" + ask: "Add native inventory alongside existing products." + + validations: + - check: response_schema + description: "Response matches refine-proposals-response.json schema" + - check: field_present + path: "results[1]" + description: "Results contain one entry per source proposal" + - check: field_value + path: "results[0].source_proposal_id" + value: "$context.seed_proposal_id" + description: "First result echoes first source" + - check: field_value + path: "results[1].source_proposal_id" + value: "$context.amendment_proposal_id" + description: "Second result echoes second source" diff --git a/tests/lint-storyboard-sample-request-schema.test.cjs b/tests/lint-storyboard-sample-request-schema.test.cjs index 69508fa4a9..90ec22a19c 100644 --- a/tests/lint-storyboard-sample-request-schema.test.cjs +++ b/tests/lint-storyboard-sample-request-schema.test.cjs @@ -267,6 +267,14 @@ test('normalizeSubstitutions replaces every live substitution dialect', () => { assert.equal(normalizeSubstitutions('plain value', stringSchema), 'plain value'); }); +test('normalizeSubstitutions produces a schema-valid digest placeholder', () => { + const digest = normalizeSubstitutions('$context.terms_digest', { + type: 'string', + pattern: '^sha256:[A-Za-z0-9_-]{43}$', + }); + assert.match(digest, /^sha256:[A-Za-z0-9_-]{43}$/); +}); + // 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