From 3f91bfe878529f8ca0bfe8d43967724322e3e50e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 18 May 2026 07:40:10 -0400 Subject: [PATCH] fix(training-agent): derive media-buy health/impairments from creative status (unblocks dependency_impairment storyboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `dependency_impairment` storyboard (adcp#4677 closing #2860) exercises `impairment.coherence` on /sales by forcing a referenced creative to `rejected` and asserting the buy snapshot flips to `health: impaired`. Three pieces were missing from the training agent: - `task-handlers.ts:deriveImpairments()` walks each buy's `creativeAssignments` and surfaces `impairments[]` entries from session creative status — `resource_type: creative`, `transition: approved → rejected`, materially-scoped `package_ids[]` — wired into `handleGetMediaBuys` + `handleCreateMediaBuy` responses. Creative-track only; audience and catalog_item families remain forward-only on the spec today. - `tenants/comply.ts`: `force.creative_status` adapter added to `buildSalesComplyConfig` so storyboards can drive the creative through approved → rejected → processing without depending on real seller-side review timing. `/sales` already advertises `syncCreatives` + `listCreatives` at the platform layer, so the SDK runner can observe the offline status transition through normal task responses. - `task-handlers.ts:handleSyncCreatives`: `assignments[]` entries now fall back to walking `session.mediaBuys` when `media_buy_id` is omitted — per `sync-creatives-request.json` which marks only `creative_id` + `package_id` as required. `@adcp/sdk` bumped `^7.6.0 → ^7.7.0` for the `creative_approvals[]` extractor on the SDK runner's `impairment.coherence` buy-snapshot walk (adcontextprotocol/adcp-client#1819). Moves the official scenario from `3P / 1F / 7S` to `6P / 1F / 4S` on `/sales` (three previously-skipped steps now pass; same one failure). The remaining `get_buy_impaired` failure is blocked on adcontextprotocol/adcp-client#1846 — the v6 SalesPlatform.syncCreatives strips `assignments[]` before reaching the platform handler, so the buy never gets the creative assigned and the derivation has nothing to surface. Once #1846 lands, the scenario fully clears without further training-agent changes. Follow-up audience/catalog tracks tracked in adcp#4674. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 +- package.json | 2 +- server/src/training-agent/task-handlers.ts | 82 ++++++++++++++++++++- server/src/training-agent/tenants/comply.ts | 7 ++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 732959cb94..41c56ccbd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "adcontextprotocol", "version": "3.0.3", "dependencies": { - "@adcp/sdk": "^7.6.0", + "@adcp/sdk": "^7.7.0", "@anthropic-ai/sdk": "^0.96.0", "@asteasolutions/zod-to-openapi": "^8.5.0", "@contentauth/c2pa-node": "^0.5.4", @@ -123,9 +123,9 @@ } }, "node_modules/@adcp/sdk": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-7.6.0.tgz", - "integrity": "sha512-B14uD0l+hwpeGcxsteu7bhaSvk2dJCMVC2atZW23eGcMUZ52EodmmDTbgSjomUfLcQa0FVDyquzEkKL1dVR5FQ==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-7.7.0.tgz", + "integrity": "sha512-OPlt8I2ZSV87LmYjapKMsvRzTyhzGAmfOE1xzNKVpRp6mkiuqTByHeNojHzFHNXprXH8Dw9svzmkaMpU3IPPkg==", "license": "Apache-2.0", "workspaces": [ ".", diff --git a/package.json b/package.json index 6f1d38f9e5..d6dda87cda 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "dev:docs": "node scripts/dev-docs.mjs" }, "dependencies": { - "@adcp/sdk": "^7.6.0", + "@adcp/sdk": "^7.7.0", "@anthropic-ai/sdk": "^0.96.0", "@asteasolutions/zod-to-openapi": "^8.5.0", "@contentauth/c2pa-node": "^0.5.4", diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 7e05536c19..d1eae3c20b 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -512,6 +512,58 @@ export function deriveStatus(mb: MediaBuyState): string { return mb.status; } +/** + * Per-buy snapshot derivation for `media_buy.health` + `media_buy.impairments[]` + * (adcp#2860). Walks a buy's assigned creatives and surfaces an impairment + * entry for each one whose session status is in the spec's creative offline + * set (today: `rejected` — see `enums/impairment-offline-state.json`). + * + * Returns `null` when no impairments are open so the caller can emit + * `health: 'ok'` without an `impairments` field. + * + * Materiality (per impairment.json schema): each entry's `package_ids[]` lists + * the subset of the buy's packages that actually reference the offline + * creative, satisfying the spec's "cosmetic effects MUST NOT be reported" + * carve-out without requiring buyer-side dedup. + * + * Scope: creative-track only. Audience / catalog_item / event_source + * resource families are forward-only on the spec today (the SDK runner's + * `INVERSE_DEFERRED_FAMILIES` still includes them); adding training-agent + * support for those tracks is gated on adcp#4674 + adcp-client follow-ups. + */ +function deriveImpairments( + mb: MediaBuyState, + session: import('./types.js').SessionState, + observedAt: string, +): Array> | null { + const buyOpenImpairments: Array> = []; + // Collect creative_id → packages-that-reference-it for materiality. + const refs = new Map(); + for (const pkg of mb.packages) { + for (const creativeId of pkg.creativeAssignments) { + const existing = refs.get(creativeId); + if (existing) existing.push(pkg.packageId); + else refs.set(creativeId, [pkg.packageId]); + } + } + for (const [creativeId, packageIds] of refs) { + const creative = session.creatives.get(creativeId); + if (!creative || creative.status !== 'rejected') continue; + buyOpenImpairments.push({ + // Stable across re-emissions for the same open impairment — the runner + // correlates webhook fires to impairments[] entries by this id. + impairment_id: `imp_${mb.mediaBuyId}_creative_${creativeId}`, + resource_type: 'creative', + resource_id: creativeId, + package_ids: packageIds, + transition: { from: 'approved', to: 'rejected' }, + reason_code: 'content_rejected', + observed_at: observedAt, + }); + } + return buyOpenImpairments.length > 0 ? buyOpenImpairments : null; +} + /** Map lifecycle status to valid buyer actions. */ function validActionsForStatus(status: string): string[] { switch (status) { @@ -2186,9 +2238,13 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) session.mediaBuys.set(mediaBuyId, mediaBuy); const status = deriveStatus(mediaBuy); + const openImpairments = deriveImpairments(mediaBuy, session, new Date().toISOString()); + const health = openImpairments ? 'impaired' : 'ok'; return { media_buy_id: mediaBuyId, status, + health, + ...(openImpairments && { impairments: openImpairments }), revision: mediaBuy.revision, confirmed_at: mediaBuy.confirmedAt, valid_actions: validActionsForStatus(status), @@ -2271,9 +2327,13 @@ export async function handleGetMediaBuys(args: ToolArgs, ctx: TrainingContext) { media_buys: pageBuys.map(mb => { const status = deriveStatus(mb); const totalBudget = mb.packages.reduce((sum, pkg) => sum + (pkg.budget || 0), 0); + const openImpairments = deriveImpairments(mb, session, new Date().toISOString()); + const health = openImpairments ? 'impaired' : 'ok'; const buy = { media_buy_id: mb.mediaBuyId, status, + health, + ...(openImpairments && { impairments: openImpairments }), revision: mb.revision, confirmed_at: mb.confirmedAt, created_at: mb.createdAt, @@ -2635,13 +2695,29 @@ export async function handleSyncCreatives(args: ToolArgs, ctx: TrainingContext) const assignmentResults: AssignmentResult[] = []; if (req.assignments?.length && !isDryRun) { for (const assignment of req.assignments) { - const mediaBuyId = (assignment as unknown as CreativeAssignmentInput).media_buy_id; + const explicitBuyId = (assignment as unknown as CreativeAssignmentInput).media_buy_id; const packageId = assignment.package_id; const creativeId = assignment.creative_id; - const mb = session.mediaBuys.get(mediaBuyId); + // Per `creative/sync-creatives-request.json`, `assignments[]` items + // require only creative_id + package_id — media_buy_id is optional. + // When missing, locate the buy whose packages contain `package_id`. + // package_ids are seller-assigned within a session so the resolution + // is deterministic in the sandbox; ambiguity (two buys with the same + // package_id) falls back to first match. + let mb: MediaBuyState | undefined; + if (explicitBuyId) { + mb = session.mediaBuys.get(explicitBuyId); + } else { + for (const candidate of session.mediaBuys.values()) { + if (candidate.packages.some(p => p.packageId === packageId)) { + mb = candidate; + break; + } + } + } if (!mb) { - assignmentResults.push({ creative_id: creativeId, package_id: packageId, status: 'error', message: `Media buy not found: ${mediaBuyId}` }); + assignmentResults.push({ creative_id: creativeId, package_id: packageId, status: 'error', message: explicitBuyId ? `Media buy not found: ${explicitBuyId}` : `Could not locate buy containing package: ${packageId}` }); continue; } const pkg = mb.packages.find(p => p.packageId === packageId); diff --git a/server/src/training-agent/tenants/comply.ts b/server/src/training-agent/tenants/comply.ts index e79a362309..46dea602b2 100644 --- a/server/src/training-agent/tenants/comply.ts +++ b/server/src/training-agent/tenants/comply.ts @@ -207,6 +207,13 @@ export function buildSalesComplyConfig(): ComplyControllerConfig { creative_format: cast(seedAdapter('seed_creative_format')), }, force: { + // /sales advertises syncCreatives + listCreatives at the platform layer, + // so storyboards that need to flip a creative's status to drive the + // impairment.coherence cross-resource invariant (adcp#2860) reach the + // creative store the same way they do on /creative — added here in + // sales-non-guaranteed's footprint so the dependency_impairment_flow + // scenario can fire on this tenant. + creative_status: cast(forceAdapter('force_creative_status')), media_buy_status: cast(forceAdapter('force_media_buy_status')), create_media_buy_arm: cast(forceAdapter('force_create_media_buy_arm')), task_completion: cast(forceAdapter('force_task_completion')),