Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/floppy-dolls-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions src/lib/testing/storyboard/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,38 @@ const REQUEST_ENRICHERS: Record<string, RequestEnricher> = {
.slice(1)
.map(p => injectContext({ ...p }, context) as Record<string, unknown>);

// 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<string, unknown>) }, context) as Record<string, unknown>)
: {};
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),
Expand Down
30 changes: 17 additions & 13 deletions test/examples/hello-seller-adapter-proposal-mode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''));
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading