diff --git a/.changeset/proposal-lifecycle.md b/.changeset/proposal-lifecycle.md new file mode 100644 index 0000000000..ddcc9b9198 --- /dev/null +++ b/.changeset/proposal-lifecycle.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add proposal lifecycle with draft/committed status, finalization via refine action, insertion order signing, and expiry enforcement on create_media_buy. Proposals containing guaranteed products now start as draft (indicative pricing) and must be finalized before purchase. Committed proposals include hold windows and optional insertion orders for formal agreements. diff --git a/server/src/addie/mcp/adcp-tools.ts b/server/src/addie/mcp/adcp-tools.ts index 0880f2b193..bc800bd887 100644 --- a/server/src/addie/mcp/adcp-tools.ts +++ b/server/src/addie/mcp/adcp-tools.ts @@ -68,7 +68,16 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ refine: { type: 'array', description: 'Change requests for iterating on a previous get_products response. Each entry has scope (request/product/proposal), an action, and an ask.', - items: { type: 'object' }, + items: { + type: 'object', + properties: { + scope: { type: 'string', enum: ['request', 'product', 'proposal'] }, + action: { type: 'string', enum: ['include', 'omit', 'adjust', 'finalize'] }, + id: { type: 'string', description: 'Product or proposal ID to act on' }, + ask: { type: 'string', description: 'Free-text instruction for the refinement' }, + }, + required: ['scope', 'action'], + }, }, catalog: { type: 'object', @@ -237,6 +246,17 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [ }, required: ['legal_name'], }, + io_acceptance: { + type: 'object', + description: 'Acceptance of an insertion order from a committed proposal. Required when the proposal has requires_signature: true.', + properties: { + io_id: { type: 'string', description: 'The io_id from the proposal insertion_order' }, + accepted_at: { type: 'string', description: 'ISO 8601 timestamp when the IO was accepted' }, + signatory: { type: 'string', description: 'Who accepted — agent identifier or human name' }, + signature_id: { type: 'string', description: 'Reference to electronic signature from signing service' }, + }, + required: ['io_id', 'accepted_at', 'signatory'], + }, po_number: { type: 'string', description: 'Purchase order number for tracking.', diff --git a/server/src/training-agent/product-factory.ts b/server/src/training-agent/product-factory.ts index b4adaf1473..ff2ed73e17 100644 --- a/server/src/training-agent/product-factory.ts +++ b/server/src/training-agent/product-factory.ts @@ -669,6 +669,12 @@ export function buildProposals(catalog: CatalogProduct[]): Proposal[] { if (!valid) continue; + // Proposals containing guaranteed products are drafts (indicative pricing, needs finalization) + const hasGuaranteed = allocations.some(alloc => { + const cp = catalog.find(c => c.product.product_id === alloc.product_id); + return cp?.product.delivery_type === 'guaranteed'; + }); + proposals.push({ proposal_id: def.proposalId, name: def.name, @@ -676,7 +682,11 @@ export function buildProposals(catalog: CatalogProduct[]): Proposal[] { brief_alignment: def.briefAlignment, total_budget_guidance: def.budgetGuidance, allocations, - }); + ...(hasGuaranteed && { + proposal_status: 'draft' as const, + expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2 hours indicative window + }), + } as Proposal); } return proposals; diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 2a870a0344..1b75359782 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -18,6 +18,7 @@ import { createLogger } from '../logger.js'; import type { TrainingContext, CatalogProduct, MediaBuyState, PackageState, SignalActivationState, CreativeState, CreativeManifest, ToolArgs } from './types.js'; import type { Product, + Proposal, FormatID, CreateMediaBuyRequest, UpdateMediaBuyRequest, @@ -80,6 +81,15 @@ interface CreativeAssignmentInput { media_buy_id: string; } +// Proposal lifecycle fields not yet in @adcp/client — remove after client update +interface ProposalLifecycle { + proposal_status?: 'draft' | 'committed'; + insertion_order?: { io_id: string; requires_signature: boolean; terms?: Record }; +} +function proposalLifecycle(proposal: Proposal): ProposalLifecycle { + return proposal as unknown as ProposalLifecycle; +} + import { buildCatalog, buildShowsForProducts, buildProposals } from './product-factory.js'; import { buildFormats, FORMAT_CHANNEL_MAP } from './formats.js'; import { getAllSignals, SIGNAL_PROVIDERS } from './signal-providers.js'; @@ -656,16 +666,19 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { } } - // Refine mode: apply include/omit/more_like_this + // Refine mode: apply include/omit/more_like_this/finalize + const refinementApplied: Array<{ status: string; notes?: string }> = []; + const proposalOmitIds = new Set(); if (buyingMode === 'refine' && req.refine) { const previousProducts = session.lastGetProductsContext?.products || products; + const previousProposals = session.lastGetProductsContext?.proposals || getProposals(); const omitIds = new Set(); const includeIds = new Set(); for (const op of req.refine) { if (op.scope === 'product') { - if (op.action === 'omit') omitIds.add(op.id); - else if (op.action === 'include') includeIds.add(op.id); + if (op.action === 'omit') { omitIds.add(op.id); refinementApplied.push({ status: 'applied' }); } + else if (op.action === 'include') { includeIds.add(op.id); refinementApplied.push({ status: 'applied' }); } // more_like_this: include the product plus similar channel products else if (op.action === 'more_like_this') { includeIds.add(op.id); @@ -678,11 +691,83 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { } } } + refinementApplied.push({ status: 'applied' }); + } + } else if (op.scope === 'proposal') { + const proposalOp = op as { scope: 'proposal'; id: string; action: string; ask?: string }; + const proposal = previousProposals.find(p => p.proposal_id === proposalOp.id); + if (!proposal) { + refinementApplied.push({ status: 'unable', notes: `Proposal not found: ${proposalOp.id}` }); + continue; + } + if (proposalOp.action === 'omit') { + proposalOmitIds.add(proposalOp.id); + refinementApplied.push({ status: 'applied' }); + } else if (proposalOp.action === 'include') { + // Include is a no-op for proposals already in the response + refinementApplied.push({ status: 'applied' }); + } else if (proposalOp.action === 'finalize') { + const status = proposalLifecycle(proposal).proposal_status; + if (status === 'committed') { + refinementApplied.push({ status: 'applied', notes: 'Proposal already committed' }); + } else if (status === 'draft') { + // Transition draft → committed: firm pricing, inventory hold, IO + const committed = { ...proposal } as Record & ProposalLifecycle; + committed.proposal_status = 'committed'; + (committed as Record).expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24h hold + + // Attach insertion order for proposals with guaranteed products + const hasGuaranteed = proposal.allocations.some(alloc => { + const cp = getCatalog().find(c => c.product.product_id === alloc.product_id); + return cp?.product.delivery_type === 'guaranteed'; + }); + if (hasGuaranteed) { + const publisherCp = getCatalog().find(c => c.product.product_id === proposal.allocations[0].product_id); + const accountBrand = (req as unknown as Record).account as Record | undefined; + const brandDomain = ((accountBrand?.brand as Record)?.domain as string) || 'advertiser.example'; + committed.insertion_order = { + io_id: `io_${randomUUID().replace(/-/g, '')}`, + terms: { + advertiser: brandDomain, + publisher: publisherCp?.publisherId || 'unknown', + total_budget: { + amount: proposal.total_budget_guidance?.recommended ?? 0, + currency: proposal.total_budget_guidance?.currency ?? 'USD', + }, + flight_start: new Date().toISOString(), + flight_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + payment_terms: 'net_30', + }, + requires_signature: true, + }; + } + + // Update proposal in session context + if (!session.lastGetProductsContext) { + session.lastGetProductsContext = { products: [...products], proposals: [] }; + } + const sessionProposals = session.lastGetProductsContext.proposals || []; + const idx = sessionProposals.findIndex(p => p.proposal_id === proposalOp.id); + const updatedProposal = committed as unknown as import('@adcp/client').Proposal; + if (idx >= 0) { + sessionProposals[idx] = updatedProposal; + } else { + sessionProposals.push(updatedProposal); + } + session.lastGetProductsContext.proposals = sessionProposals; + + refinementApplied.push({ status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); + } else { + // No proposal_status means already ready to buy — finalize is a no-op + refinementApplied.push({ status: 'applied', notes: 'Proposal is already ready to buy (no finalization needed)' }); + } } + } else if (op.scope === 'request') { + refinementApplied.push({ status: 'partial', notes: 'Request-level refinement acknowledged but not applied by training agent' }); } } - // Apply includes first (expand), then omits (filter) + // Apply includes first (expand), then omits (filter) for products if (includeIds.size > 0) { products = getCatalog() .filter(cp => includeIds.has(cp.product.product_id)) @@ -713,8 +798,14 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { } } - const proposals = getProposals().filter(proposal => - proposal.allocations.every(a => productIds.has(a.product_id)), + // In refine mode, use session proposals (which may include finalized versions) + const sourceProposals = (buyingMode === 'refine' && session.lastGetProductsContext?.proposals) + ? session.lastGetProductsContext.proposals + : getProposals(); + + const proposals = sourceProposals.filter(proposal => + proposal.allocations.every(a => productIds.has(a.product_id)) && + !proposalOmitIds.has(proposal.proposal_id), ); // Store context for refine @@ -723,6 +814,7 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { return { products, ...(proposals.length > 0 && { proposals }), + ...(refinementApplied.length > 0 && { refinement_applied: refinementApplied }), sandbox: true, }; } @@ -759,12 +851,44 @@ function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) { // Proposal-based creation: expand proposal allocations into packages if (req.proposal_id && !req.packages?.length) { - const proposal = getProposals().find(p => p.proposal_id === req.proposal_id); + // Check session proposals first (may have finalized versions), then global catalog + const proposal = session.lastGetProductsContext?.proposals?.find(p => p.proposal_id === req.proposal_id) + || getProposals().find(p => p.proposal_id === req.proposal_id); if (!proposal) { return { errors: [{ code: 'INVALID_REQUEST', message: `Proposal not found: ${req.proposal_id}` }] as TaskError[], }; } + + // Enforce proposal lifecycle: draft proposals cannot be purchased directly + const proposalStatus = proposalLifecycle(proposal).proposal_status; + if (proposalStatus === 'draft') { + return { + errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has draft status — finalize it first using get_products with buying_mode "refine" and action "finalize".` }] as TaskError[], + }; + } + + // Enforce proposal expiry + if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { + return { + errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Re-discover with get_products to get a fresh proposal.` }] as TaskError[], + }; + } + + // Enforce IO acceptance when required + const insertionOrder = proposalLifecycle(proposal).insertion_order; + const ioAcceptance = (req as unknown as Record).io_acceptance as { io_id: string; accepted_at: string; signatory: string } | undefined; + if (insertionOrder?.requires_signature && !ioAcceptance) { + return { + errors: [{ code: 'IO_REQUIRED', message: `Proposal "${req.proposal_id}" requires a signed insertion order. Include io_acceptance with io_id "${insertionOrder.io_id}" on create_media_buy.` }] as TaskError[], + }; + } + if (ioAcceptance && insertionOrder && ioAcceptance.io_id !== insertionOrder.io_id) { + return { + errors: [{ code: 'INVALID_REQUEST', message: `io_acceptance.io_id "${ioAcceptance.io_id}" does not match proposal insertion order io_id "${insertionOrder.io_id}".` }] as TaskError[], + }; + } + const totalBudget = req.total_budget?.amount; if (!totalBudget) { return { @@ -840,6 +964,12 @@ function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) { continue; } + // Enforce product expiry + if (product.expires_at && new Date(product.expires_at) < new Date()) { + errors.push({ code: 'PRODUCT_EXPIRED', message: `${pkgLabel}: Product "${pkg.product_id}" expired at ${product.expires_at}. Re-discover with get_products.` }); + continue; + } + const pricingOptions = product.pricing_options; const pricing = pricingOptions?.find(po => po.pricing_option_id === pkg.pricing_option_id); if (!pricing) { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 0482ed1c5d..f6651c8e1f 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3630,3 +3630,393 @@ describe('MCP Tasks protocol', () => { expect(Array.isArray(result.products)).toBe(true); }); }); + +// ── Proposal lifecycle: draft/committed workflow ──────────────────── + +describe('proposal lifecycle', () => { + beforeEach(() => { + invalidateCache(); + clearSessions(); + }); + + afterEach(() => { + clearSessions(); + }); + + const account = { brand: { domain: 'proposal-test.example' }, operator: 'proposal-test.example' }; + + async function getProductsWithProposals() { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result } = await simulateCallTool(server, 'get_products', { + buying_mode: 'brief', + brief: 'video and display', + account, + }); + return result; + } + + it('returns draft status on proposals containing guaranteed products', async () => { + const result = await getProductsWithProposals(); + const proposals = result.proposals as Array>; + expect(proposals).toBeDefined(); + expect(proposals.length).toBeGreaterThan(0); + + // Find a proposal with guaranteed products + const products = result.products as Array>; + const guaranteedProductIds = new Set( + products.filter(p => p.delivery_type === 'guaranteed').map(p => p.product_id), + ); + + for (const proposal of proposals) { + const allocations = proposal.allocations as Array<{ product_id: string }>; + const hasGuaranteed = allocations.some(a => guaranteedProductIds.has(a.product_id)); + if (hasGuaranteed) { + expect(proposal.proposal_status).toBe('draft'); + expect(proposal.expires_at).toBeDefined(); + } + } + }); + + it('omits proposal_status on proposals with only non-guaranteed products', async () => { + const result = await getProductsWithProposals(); + const proposals = result.proposals as Array>; + const products = result.products as Array>; + const guaranteedProductIds = new Set( + products.filter(p => p.delivery_type === 'guaranteed').map(p => p.product_id), + ); + + for (const proposal of proposals) { + const allocations = proposal.allocations as Array<{ product_id: string }>; + const hasGuaranteed = allocations.some(a => guaranteedProductIds.has(a.product_id)); + if (!hasGuaranteed) { + expect(proposal.proposal_status).toBeUndefined(); + } + } + }); + + it('finalizes a draft proposal to committed via refine', async () => { + // Get proposals first + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const proposals = initial.proposals as Array>; + const draftProposal = proposals?.find(p => p.proposal_status === 'draft'); + expect(draftProposal).toBeDefined(); + + // Finalize it + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + const refinedProposals = refined.proposals as Array>; + const committed = refinedProposals?.find(p => p.proposal_id === draftProposal!.proposal_id); + expect(committed).toBeDefined(); + expect(committed!.proposal_status).toBe('committed'); + expect(committed!.expires_at).toBeDefined(); + // Committed hold window should be ~24 hours from now + const expiresAt = new Date(committed!.expires_at as string); + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); + + // refinement_applied should confirm success + const applied = refined.refinement_applied as Array>; + expect(applied).toBeDefined(); + expect(applied[0].status).toBe('applied'); + }); + + it('attaches insertion_order to committed proposals with guaranteed products', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + expect(draftProposal).toBeDefined(); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + const committed = (refined.proposals as Array>)?.find( + p => p.proposal_id === draftProposal!.proposal_id, + ); + const io = committed!.insertion_order as Record; + expect(io).toBeDefined(); + expect(io.io_id).toBeDefined(); + expect(io.requires_signature).toBe(true); + expect(io.terms).toBeDefined(); + }); + + it('rejects create_media_buy for draft proposal with PROPOSAL_NOT_COMMITTED', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + expect(draftProposal).toBeDefined(); + + // Try to buy the draft directly + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server2, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: draftProposal!.proposal_id, + total_budget: { amount: 75000, currency: 'USD' }, + }); + + expect(isError).toBe(true); + expect(result.code).toBe('PROPOSAL_NOT_COMMITTED'); + }); + + it('rejects create_media_buy for expired proposal with PROPOSAL_EXPIRED', async () => { + // Get and finalize a proposal + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + // Manually expire the proposal in session state + const sessionKey = `open:proposal-test.example`; + const session = getSession(sessionKey); + const committedProposal = session.lastGetProductsContext?.proposals?.find( + p => p.proposal_id === draftProposal!.proposal_id, + ); + if (committedProposal) { + (committedProposal as Record).expires_at = '2020-01-01T00:00:00Z'; + } + + // Try to buy the expired proposal + const server3 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server3, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: draftProposal!.proposal_id, + total_budget: { amount: 75000, currency: 'USD' }, + }); + + expect(isError).toBe(true); + expect(result.code).toBe('PROPOSAL_EXPIRED'); + }); + + it('rejects create_media_buy without io_acceptance when IO required', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + const committed = (refined.proposals as Array>)?.find( + p => p.proposal_id === draftProposal!.proposal_id, + ); + const io = committed!.insertion_order as Record; + expect(io.requires_signature).toBe(true); + + // Try to buy without io_acceptance + const server3 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server3, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed!.proposal_id, + total_budget: { amount: 75000, currency: 'USD' }, + }); + + expect(isError).toBe(true); + expect(result.code).toBe('IO_REQUIRED'); + }); + + it('succeeds create_media_buy with valid io_acceptance', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + const committed = (refined.proposals as Array>)?.find( + p => p.proposal_id === draftProposal!.proposal_id, + ); + const io = committed!.insertion_order as Record; + + // Buy with valid io_acceptance + const server3 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server3, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed!.proposal_id, + total_budget: { amount: 75000, currency: 'USD' }, + io_acceptance: { + io_id: io.io_id, + accepted_at: new Date().toISOString(), + signatory: 'test-agent', + }, + }); + + expect(isError).toBeFalsy(); + expect(result.media_buy_id).toBeDefined(); + }); + + it('rejects create_media_buy with mismatched io_id', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'premium video news', + account, + }); + const draftProposal = (initial.proposals as Array>)?.find( + p => p.proposal_status === 'draft', + ); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], + }); + + const server3 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server3, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: draftProposal!.proposal_id, + total_budget: { amount: 75000, currency: 'USD' }, + io_acceptance: { + io_id: 'wrong_io_id', + accepted_at: new Date().toISOString(), + signatory: 'test-agent', + }, + }); + + expect(isError).toBe(true); + expect(result.code).toBe('INVALID_REQUEST'); + }); + + it('allows create_media_buy without proposal_status (backward compat)', async () => { + // Non-guaranteed proposals have no proposal_status and should work as before + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'social engagement display', + account, + }); + const proposals = initial.proposals as Array> | undefined; + + // sparq_social_amplification has only non-guaranteed products → no proposal_status + const readyProposal = proposals?.find(p => !p.proposal_status); + expect(readyProposal).toBeDefined(); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result, isError } = await simulateCallTool(server2, 'create_media_buy', { + account, + brand: { domain: 'proposal-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: readyProposal.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + }); + + expect(isError).toBeFalsy(); + expect(result.media_buy_id).toBeDefined(); + }); + + it('returns unable when finalizing a nonexistent proposal', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'video news', + account, + }); + expect(initial.proposals).toBeDefined(); + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', id: 'nonexistent_proposal_id' }], + }); + + const applied = refined.refinement_applied as Array>; + expect(applied).toBeDefined(); + expect(applied[0].status).toBe('unable'); + }); + + it('omits proposals via refine action', async () => { + const server1 = createTrainingAgentServer(DEFAULT_CTX); + const { result: initial } = await simulateCallTool(server1, 'get_products', { + buying_mode: 'brief', + brief: 'video and display news', + account, + }); + const proposals = initial.proposals as Array>; + expect(proposals.length).toBeGreaterThan(0); + const firstId = proposals[0].proposal_id as string; + + const server2 = createTrainingAgentServer(DEFAULT_CTX); + const { result: refined } = await simulateCallTool(server2, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'omit', id: firstId }], + }); + + const refinedProposals = refined.proposals as Array> | undefined; + const refinedIds = refinedProposals?.map(p => p.proposal_id) || []; + expect(refinedIds).not.toContain(firstId); + }); +}); diff --git a/static/schemas/source/core/insertion-order.json b/static/schemas/source/core/insertion-order.json new file mode 100644 index 0000000000..b4c394fe52 --- /dev/null +++ b/static/schemas/source/core/insertion-order.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/insertion-order.json", + "title": "Insertion Order", + "description": "A formal insertion order attached to a committed proposal. Contains structured terms for agent validation and optional signing URLs for human approval workflows.", + "type": "object", + "properties": { + "io_id": { + "type": "string", + "description": "Unique identifier for this insertion order. Referenced by io_acceptance on create_media_buy.", + "maxLength": 255 + }, + "terms": { + "type": "object", + "description": "Structured terms for agent validation. Agents can programmatically verify these match the proposal and campaign requirements.", + "properties": { + "advertiser": { + "type": "string", + "description": "Advertiser name or identifier", + "maxLength": 500 + }, + "publisher": { + "type": "string", + "description": "Publisher name or identifier", + "maxLength": 500 + }, + "total_budget": { + "type": "object", + "description": "Total committed budget", + "properties": { + "amount": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string", + "description": "ISO 4217 currency code", + "minLength": 3, + "maxLength": 3 + } + }, + "required": ["amount", "currency"] + }, + "flight_start": { + "type": "string", + "format": "date-time", + "description": "Campaign start date" + }, + "flight_end": { + "type": "string", + "format": "date-time", + "description": "Campaign end date" + }, + "payment_terms": { + "type": "string", + "description": "Payment terms", + "enum": ["net_30", "net_60", "net_90", "prepaid", "due_on_receipt"] + } + }, + "additionalProperties": true + }, + "terms_url": { + "type": "string", + "format": "uri", + "description": "URL to a human-readable document containing the full insertion order terms" + }, + "signing_url": { + "type": "string", + "format": "uri", + "description": "URL to an electronic signing service (e.g., DocuSign) for human signature workflows. When present, a human must sign before the buyer agent can proceed with create_media_buy." + }, + "requires_signature": { + "type": "boolean", + "description": "Whether the buyer must accept this IO before creating a media buy. When true, create_media_buy requires an io_acceptance referencing this io_id." + } + }, + "required": ["io_id", "requires_signature"], + "additionalProperties": true +} diff --git a/static/schemas/source/core/package.json b/static/schemas/source/core/package.json index cc95ac7c3d..fa859f7411 100644 --- a/static/schemas/source/core/package.json +++ b/static/schemas/source/core/package.json @@ -117,6 +117,11 @@ "type": "string", "description": "Reason the package was canceled.", "maxLength": 500 + }, + "acknowledged_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the seller acknowledged the cancellation. Confirms inventory has been released and billing stopped. Absent until the seller processes the cancellation." } }, "additionalProperties": false diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 5c1d01c93a..031c5ecb6e 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -244,7 +244,7 @@ "expires_at": { "type": "string", "format": "date-time", - "description": "Expiration timestamp for custom products" + "description": "Expiration timestamp. After this time, the product may no longer be available for purchase and create_media_buy may reject packages referencing it." }, "product_card": { "type": "object", diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index b07e5f69bc..3f24303089 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -7,15 +7,18 @@ "properties": { "proposal_id": { "type": "string", - "description": "Unique identifier for this proposal. Used to execute it via create_media_buy." + "description": "Unique identifier for this proposal. Used to execute it via create_media_buy.", + "maxLength": 255 }, "name": { "type": "string", - "description": "Human-readable name for this media plan proposal" + "description": "Human-readable name for this media plan proposal", + "maxLength": 500 }, "description": { "type": "string", - "description": "Explanation of the proposal strategy and what it achieves" + "description": "Explanation of the proposal strategy and what it achieves", + "maxLength": 2000 }, "allocations": { "type": "array", @@ -25,10 +28,18 @@ }, "minItems": 1 }, + "proposal_status": { + "$ref": "/schemas/enums/proposal-status.json", + "description": "Lifecycle status of this proposal. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at." + }, "expires_at": { "type": "string", "format": "date-time", - "description": "When this proposal expires and can no longer be executed. After expiration, referenced products or pricing may no longer be available." + "description": "When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time." + }, + "insertion_order": { + "$ref": "/schemas/core/insertion-order.json", + "description": "Formal insertion order attached to a committed proposal. Present when the seller requires a signed agreement before the media buy can proceed. The buyer references the io_id in io_acceptance on create_media_buy." }, "total_budget_guidance": { "type": "object", @@ -58,7 +69,8 @@ }, "brief_alignment": { "type": "string", - "description": "Explanation of how this proposal aligns with the campaign brief" + "description": "Explanation of how this proposal aligns with the campaign brief", + "maxLength": 2000 }, "forecast": { "$ref": "/schemas/core/delivery-forecast.json", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 6679920703..604fbfcacc 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -33,7 +33,10 @@ "PACKAGE_NOT_FOUND", "SESSION_NOT_FOUND", "SESSION_TERMINATED", - "VALIDATION_ERROR" + "VALIDATION_ERROR", + "PRODUCT_EXPIRED", + "PROPOSAL_NOT_COMMITTED", + "IO_REQUIRED" ], "enumDescriptions": { "INVALID_REQUEST": "Request is malformed, missing required fields, or violates schema constraints. Recovery: correctable (check request parameters and fix).", @@ -64,6 +67,9 @@ "PACKAGE_NOT_FOUND": "Referenced package does not exist within the specified media buy. Recovery: correctable (verify package_id or buyer_ref via get_media_buys).", "SESSION_NOT_FOUND": "SI session ID is invalid, expired, or does not exist. Recovery: correctable (initiate a new session via si_initiate_session).", "SESSION_TERMINATED": "SI session has already been terminated and cannot accept further messages. Recovery: correctable (initiate a new session via si_initiate_session).", - "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values)." + "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", + "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", + "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize the proposal first using get_products with buying_mode 'refine' and action 'finalize').", + "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy)." } } diff --git a/static/schemas/source/enums/proposal-status.json b/static/schemas/source/enums/proposal-status.json new file mode 100644 index 0000000000..eec7d71ee7 --- /dev/null +++ b/static/schemas/source/enums/proposal-status.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/proposal-status.json", + "title": "Proposal Status", + "description": "Lifecycle status of a proposal. Absent means the proposal is ready to buy (backward compatible with pre-v3.1 proposals). 'draft' indicates indicative pricing that must be finalized before purchase. 'committed' indicates firm pricing with inventory reserved until expires_at.", + "type": "string", + "enum": [ + "draft", + "committed" + ], + "enumDescriptions": { + "draft": "Indicative pricing and availability. The buyer can compare and plan but must finalize before purchasing. Use the 'finalize' refine action to request firm pricing.", + "committed": "Firm pricing with inventory reserved. The buyer can execute this proposal via create_media_buy before expires_at. After expires_at, the hold lapses and the buyer must re-finalize or re-discover." + } +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 656b6e0e3d..82db94cc6e 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -158,6 +158,10 @@ "$ref": "/schemas/core/proposal.json", "description": "A proposed media plan with budget allocations across products - actionable via create_media_buy" }, + "insertion-order": { + "$ref": "/schemas/core/insertion-order.json", + "description": "A formal insertion order attached to a committed proposal for agreement signing" + }, "product-allocation": { "$ref": "/schemas/core/product-allocation.json", "description": "A budget allocation for a specific product within a proposal" @@ -436,6 +440,10 @@ "$ref": "/schemas/enums/delivery-type.json", "description": "Type of inventory delivery" }, + "proposal-status": { + "$ref": "/schemas/enums/proposal-status.json", + "description": "Lifecycle status of a proposal (draft or committed)" + }, "media-buy-status": { "$ref": "/schemas/enums/media-buy-status.json", "description": "Status of a media buy" diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 5c3af9fb6b..f599bd4bee 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -59,6 +59,33 @@ "$ref": "/schemas/core/business-entity.json", "description": "Override the account's default billing entity for this specific buy. When provided, the seller invoices this entity instead. The seller MUST validate the invoice recipient is authorized for this account. When governance_agents are configured, the seller MUST include invoice_recipient in the check_governance request." }, + "io_acceptance": { + "type": "object", + "description": "Acceptance of an insertion order from a committed proposal. Required when the proposal's insertion_order has requires_signature: true. References the io_id from the proposal's insertion_order.", + "properties": { + "io_id": { + "type": "string", + "description": "The io_id from the proposal's insertion_order being accepted" + }, + "accepted_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the IO was accepted" + }, + "signatory": { + "type": "string", + "description": "Who accepted the IO — agent identifier or human name", + "minLength": 1, + "maxLength": 250 + }, + "signature_id": { + "type": "string", + "description": "Reference to the electronic signature from the signing service, when signing_url was used" + } + }, + "required": ["io_id", "accepted_at", "signatory"], + "additionalProperties": true + }, "po_number": { "type": "string", "description": "Purchase order number for tracking" diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index 6e53a089a9..f21f4bf2bc 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -94,9 +94,10 @@ "type": "string", "enum": [ "include", - "omit" + "omit", + "finalize" ], - "description": "'include': return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response." + "description": "'include': return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result." }, "ask": { "type": "string",