From ea1e2e23fb2ea59d081e1f10b2c0851e59f9fa47 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 8 May 2026 11:16:48 -0400 Subject: [PATCH] fix(storyboard): proposal-mode create_media_buy request shape (closes #1600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After AdCP 3.0.7 (#1595) landed adcp#4088, the proposal_finalize storyboard runs end-to-end through accept_proposal for the first time. The runner's create_media_buy enricher in request-builder.ts was unaware of the proposal-mode request shape — it always returned { account, brand, start_time, end_time, packages } even when the storyboard's sample_request carried `proposal_id: "$context.proposal_id"`. Two failures fell out: 1. Schema rejection. AdCP 3.0.7's create-media-buy-request.json declares `dependencies.proposal_id: ["total_budget"]` and disallows `packages` alongside `proposal_id`. Synthesising packages with the proposal_id elided made the request fail validation against the buyer-side strict gate. 2. Account resolution. create_media_buy is in FIXTURE_AWARE_ENRICHERS, so the enricher's output is used verbatim and the fixture's account does not flow through the generic merge. The non-proposal path always replaces account with resolveAccount(options) (default brand `test.example`); proposal-mode storyboards author a non-default brand (`acmeoutdoor.example`) that the adapter resolved end-to-end through brief/refine/finalize, so the override produced ACCOUNT_NOT_FOUND at the accept step. The enricher now detects proposal-mode (either step.sample_request.proposal_id resolving via $context.* or context.proposal_id set directly) and returns the fixture spread (with total_budget and other proposal-mode-required fields preserved) plus the harness-normalised start_time / end_time and proposal_id. account and brand prefer the fixture when supplied so non-default brands survive to the wire; otherwise the same context.account ?? resolveAccount(options) fallback applies. hello_seller_adapter_proposal_mode regression coverage updated: EXPECTED_FAILURES cleared (both get_products_refine — fixed by adcp#4088 — and create_media_buy — fixed here). expectedRoutes extended with POST /v1/orders and POST /v1/orders/{id}/lineitems so the façade gate now asserts the full proposal lifecycle reaches the upstream's order endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/floppy-dolls-fetch.md | 14 ++++++++ src/lib/testing/storyboard/request-builder.ts | 32 +++++++++++++++++++ ...hello-seller-adapter-proposal-mode.test.js | 30 +++++++++-------- 3 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 .changeset/floppy-dolls-fetch.md diff --git a/.changeset/floppy-dolls-fetch.md b/.changeset/floppy-dolls-fetch.md new file mode 100644 index 000000000..a843502c9 --- /dev/null +++ b/.changeset/floppy-dolls-fetch.md @@ -0,0 +1,14 @@ +--- +'@adcp/sdk': patch +--- + +fix(storyboard): proposal-mode `create_media_buy` request shape (closes adcp-client#1600) + +After AdCP 3.0.7 (#1595) landed adcp#4088, the `proposal_finalize` storyboard runs end-to-end through `accept_proposal` for the first time. The runner's `create_media_buy` enricher in `request-builder.ts` was unaware of the proposal-mode request shape — it always returned `{ account, brand, start_time, end_time, packages }` even when the storyboard's `sample_request` carried `proposal_id: "$context.proposal_id"`. Two failures fell out: + +1. **Schema rejection.** AdCP 3.0.7's `create-media-buy-request.json` declares `dependencies.proposal_id: ["total_budget"]` and disallows `packages` alongside `proposal_id`. Synthesising packages with the proposal_id elided made the request fail validation against the buyer-side strict gate. +2. **Account resolution.** `create_media_buy` is in `FIXTURE_AWARE_ENRICHERS`, so the enricher's output is used verbatim and the fixture's `account` does not flow through the generic merge. The non-proposal path always replaces `account` with `resolveAccount(options)` (default brand `test.example`); proposal-mode storyboards author a non-default brand (`acmeoutdoor.example`) that the adapter resolved end-to-end through brief/refine/finalize, so the override produced `ACCOUNT_NOT_FOUND` at the accept step. + +The enricher now detects proposal-mode (either `step.sample_request.proposal_id` resolving via `$context.*` or `context.proposal_id` set directly) and returns the fixture spread (with `total_budget` and other proposal-mode-required fields preserved) plus the harness-normalised `start_time` / `end_time` and `proposal_id`. `account` and `brand` prefer the fixture when supplied so non-default brands survive to the wire; otherwise the same `context.account ?? resolveAccount(options)` fallback applies. + +`hello_seller_adapter_proposal_mode` regression coverage updated: `EXPECTED_FAILURES` cleared (both `get_products_refine` — fixed by adcp#4088 — and `create_media_buy` — fixed here). `expectedRoutes` extended with `POST /v1/orders` and `POST /v1/orders/{id}/lineitems` so the façade gate now asserts the full proposal lifecycle reaches the upstream's order endpoints. diff --git a/src/lib/testing/storyboard/request-builder.ts b/src/lib/testing/storyboard/request-builder.ts index 8b32ae286..d8ed7ad99 100644 --- a/src/lib/testing/storyboard/request-builder.ts +++ b/src/lib/testing/storyboard/request-builder.ts @@ -280,6 +280,38 @@ const REQUEST_ENRICHERS: Record = { .slice(1) .map(p => injectContext({ ...p }, context) as Record); + // Proposal-mode: when context carries a proposal_id (captured from + // `proposals[0]` in a prior brief/refine/finalize step), forward it + // and omit `packages`. The schema disallows synthesising packages + // alongside `proposal_id` — `dependencies.proposal_id` requires + // `total_budget` and the seller derives packages from the committed + // allocation. + // + // Spread the fixture (after $context injection) so proposal-mode- + // required fields like `total_budget` flow through. Prefer the + // fixture's account/brand when supplied — proposal-mode storyboards + // author a non-default brand (e.g. `acmeoutdoor.example`) that the + // adapter resolves end-to-end through brief/refine/finalize, and + // the harness-default `test.example` would fail account resolution + // at the accept step (adcp-client#1600). Dates and proposal_id + // are still normalised by the enricher. + const fixture = + step.sample_request !== undefined + ? (injectContext({ ...(step.sample_request as Record) }, context) as Record) + : {}; + const proposalId: unknown = fixture.proposal_id !== undefined ? fixture.proposal_id : context.proposal_id; + if (typeof proposalId === 'string') { + const { packages: _droppedPackages, ...fixtureWithoutPackages } = fixture; + return { + ...fixtureWithoutPackages, + account: fixtureWithoutPackages.account ?? context.account ?? resolveAccount(options), + brand: fixtureWithoutPackages.brand ?? resolveBrand(options), + start_time: startTime, + end_time: endTime, + proposal_id: proposalId, + }; + } + return { account: context.account ?? resolveAccount(options), brand: resolveBrand(options), diff --git a/test/examples/hello-seller-adapter-proposal-mode.test.js b/test/examples/hello-seller-adapter-proposal-mode.test.js index 647774804..82d372f84 100644 --- a/test/examples/hello-seller-adapter-proposal-mode.test.js +++ b/test/examples/hello-seller-adapter-proposal-mode.test.js @@ -27,13 +27,12 @@ const { runHelloAdapterGates } = require('./_helpers/runHelloAdapterGates'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); const STORYBOARD_ID = 'media_buy_seller/proposal_finalize'; -// AdCP 3.0.7 (#1595) landed adcp#4088, which fixed `proposal_id` chaining -// in `proposal_finalize.yaml`. `get_products_refine` now passes — but the -// cascade-skip that previously hid `create_media_buy` is also gone, -// exposing a real adapter bug: the adapter's `create_media_buy` response -// doesn't satisfy the 3.0.7 `create-media-buy-response.json` schema. -// Tracked at #1600 — drop this allowlist entry when the adapter is fixed. -const EXPECTED_FAILURES = [{ storyboard_id: STORYBOARD_ID, step_id: 'create_media_buy' }]; +// adcp#4086 / PR #4088 fixed the storyboard's context chaining for the +// refine steps; the SDK fix in request-builder.ts (PR adcp-client#1600) +// forwards `context.proposal_id` into `create_media_buy` so the +// proposal-mode adapter's `ctx.recipes` is hydrated and the accept step +// passes end-to-end. No expected failures remain. +const EXPECTED_FAILURES = []; function isExpectedFailure(f) { return EXPECTED_FAILURES.some(e => e.storyboard_id === (f.storyboard_id || '') && e.step_id === (f.step_id || '')); @@ -121,11 +120,16 @@ runHelloAdapterGates({ extraEnv: { UPSTREAM_API_KEY: 'mock_sales_guaranteed_key_do_not_use_in_prod', }, - // Façade gate: only assert routes that the storyboard actually drives. - // refine + finalize + order routes are exercised by the primitives test - // suite end-to-end; the `proposal_finalize` storyboard stops at refine - // (per the adcp#4086 gap) so they don't appear in upstream traffic here. - expectedRoutes: ['GET /_lookup/network', 'GET /v1/products', 'POST /v1/proposals'], + // Façade gate: assert all routes the full proposal lifecycle drives. + // createOrder + createLineItem are now reachable since the SDK forwards + // proposal_id into create_media_buy (PR adcp-client#1600). + expectedRoutes: [ + 'GET /_lookup/network', + 'GET /v1/products', + 'POST /v1/proposals', + 'POST /v1/orders', + 'POST /v1/orders/{id}/lineitems', + ], filterFailures: grader => { // Issue-#1549 invariants run alongside the failure filter so the gate // fires on regressions even when the storyboard pass-count is clean. @@ -147,7 +151,7 @@ runHelloAdapterGates({ return failures.filter(f => !isExpectedFailure(f)); }, storyboardSummary: - 'sole-stateful exemption (sync_accounts skipped, downstream phases run) + proposal lifecycle through brief', + 'sole-stateful exemption (sync_accounts skipped, downstream phases run) + full proposal lifecycle through accept', }); // Surface assert is a no-op if `node --test` doesn't import it; placed at