From 1ed761b7c3347d0d9cf6297e82bc498629e89a28 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 24 Mar 2026 08:37:33 +0900 Subject: [PATCH 1/4] feat: proposal lifecycle with draft/committed status, finalization, and IO signing Add proposal_status (draft/committed) to enable indicative vs firm pricing workflows. Proposals with guaranteed products start as draft and must be finalized via the refine action before purchase. Committed proposals carry insertion orders for formal agreement signing. Expiry enforcement on both proposals and products at create_media_buy time. Closes #1308 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/proposal-lifecycle.md | 4 + server/src/addie/mcp/adcp-tools.ts | 11 + server/src/training-agent/product-factory.ts | 12 +- server/src/training-agent/task-handlers.ts | 142 ++++++- server/tests/unit/training-agent.test.ts | 369 ++++++++++++++++++ .../schemas/source/core/insertion-order.json | 73 ++++ static/schemas/source/core/product.json | 2 +- static/schemas/source/core/proposal.json | 10 +- static/schemas/source/enums/error-code.json | 10 +- .../schemas/source/enums/proposal-status.json | 15 + static/schemas/source/index.json | 8 + .../media-buy/create-media-buy-request.json | 26 ++ .../media-buy/get-products-request.json | 5 +- 13 files changed, 673 insertions(+), 14 deletions(-) create mode 100644 .changeset/proposal-lifecycle.md create mode 100644 static/schemas/source/core/insertion-order.json create mode 100644 static/schemas/source/enums/proposal-status.json diff --git a/.changeset/proposal-lifecycle.md b/.changeset/proposal-lifecycle.md new file mode 100644 index 0000000000..a74fbbedeb --- /dev/null +++ b/.changeset/proposal-lifecycle.md @@ -0,0 +1,4 @@ +--- +--- + +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..cf56afbb03 100644 --- a/server/src/addie/mcp/adcp-tools.ts +++ b/server/src/addie/mcp/adcp-tools.ts @@ -237,6 +237,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..7a0c30d78c 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -80,6 +80,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: unknown): ProposalLifecycle { + return proposal 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 +665,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 +690,82 @@ 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 + 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); + } + if (session.lastGetProductsContext) { + 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 +796,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 +812,7 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { return { products, ...(proposals.length > 0 && { proposals }), + ...(refinementApplied.length > 0 && { refinement_applied: refinementApplied }), sandbox: true, }; } @@ -759,12 +849,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 +962,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..ab820cc45e 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3630,3 +3630,372 @@ 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); + const { result: refined } = 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('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..af82a42169 --- /dev/null +++ b/static/schemas/source/core/insertion-order.json @@ -0,0 +1,73 @@ +{ + "$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." + }, + "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" + }, + "publisher": { + "type": "string", + "description": "Publisher name or identifier" + }, + "total_budget": { + "type": "object", + "description": "Total committed budget", + "properties": { + "amount": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string", + "description": "ISO 4217 currency code" + } + }, + "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 (e.g., 'net_30', 'net_60', 'prepaid')" + } + }, + "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/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..2f141d6d84 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -25,10 +25,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", 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..80f65e4fef 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,32 @@ "$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", + "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", From e9e22b24922d5317f65dfba6119e5f8c635f4fcc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 24 Mar 2026 09:01:37 +0900 Subject: [PATCH 2/4] fix: remove unused variable in expired proposal test Co-Authored-By: Claude Opus 4.6 (1M context) --- server/tests/unit/training-agent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index ab820cc45e..8ea3cc2914 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3798,7 +3798,7 @@ describe('proposal lifecycle', () => { ); const server2 = createTrainingAgentServer(DEFAULT_CTX); - const { result: refined } = await simulateCallTool(server2, 'get_products', { + await simulateCallTool(server2, 'get_products', { buying_mode: 'refine', account, refine: [{ scope: 'proposal', action: 'finalize', id: draftProposal!.proposal_id }], From f57a8d957bc3dc8edcd34d3bfb7b6d7f67587529 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 24 Mar 2026 20:38:57 +0900 Subject: [PATCH 3/4] feat: add acknowledged_at to package cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For guaranteed/reserved buys, sellers need to confirm they've released inventory. acknowledged_at is set by the seller when cancellation is processed. Driven by DBCFM gap analysis — DBCFM has cancellation_confirmation as a separate seller acknowledgment. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/schemas/source/core/package.json | 5 +++++ 1 file changed, 5 insertions(+) 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 From 92d2ad8d919c6fdaa85735ba71f73088c58d0907 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 25 Mar 2026 21:01:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20expert=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20schema=20constraints,=20type=20safety,=20test=20?= =?UTF-8?q?coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add maxLength to insertion-order.json strings (io_id, advertiser, publisher) - Add minLength/maxLength to currency fields (ISO 4217 = exactly 3 chars) - Add payment_terms enum (net_30, net_60, net_90, prepaid, due_on_receipt) - Add maxLength to proposal.json strings (proposal_id, name, description, brief_alignment) - Add minLength: 1 to io_acceptance.signatory to prevent empty audit trail - Narrow ProposalLifecycle cast input from unknown to Proposal - Initialize session.lastGetProductsContext when null during finalize - Add property schema to MCP refine items (scope, action, id, ask) - Add test for finalize on nonexistent proposal - Fix changeset from empty to minor (schema change) Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/proposal-lifecycle.md | 1 + server/src/addie/mcp/adcp-tools.ts | 11 +++++++++- server/src/training-agent/task-handlers.ts | 14 +++++++------ server/tests/unit/training-agent.test.ts | 21 +++++++++++++++++++ .../schemas/source/core/insertion-order.json | 16 +++++++++----- static/schemas/source/core/proposal.json | 12 +++++++---- .../media-buy/create-media-buy-request.json | 1 + 7 files changed, 60 insertions(+), 16 deletions(-) diff --git a/.changeset/proposal-lifecycle.md b/.changeset/proposal-lifecycle.md index a74fbbedeb..ddcc9b9198 100644 --- a/.changeset/proposal-lifecycle.md +++ b/.changeset/proposal-lifecycle.md @@ -1,4 +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 cf56afbb03..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', diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 7a0c30d78c..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, @@ -85,8 +86,8 @@ interface ProposalLifecycle { proposal_status?: 'draft' | 'committed'; insertion_order?: { io_id: string; requires_signature: boolean; terms?: Record }; } -function proposalLifecycle(proposal: unknown): ProposalLifecycle { - return proposal as ProposalLifecycle; +function proposalLifecycle(proposal: Proposal): ProposalLifecycle { + return proposal as unknown as ProposalLifecycle; } import { buildCatalog, buildShowsForProducts, buildProposals } from './product-factory.js'; @@ -742,7 +743,10 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { } // Update proposal in session context - const sessionProposals = session.lastGetProductsContext?.proposals || []; + 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) { @@ -750,9 +754,7 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) { } else { sessionProposals.push(updatedProposal); } - if (session.lastGetProductsContext) { - session.lastGetProductsContext.proposals = sessionProposals; - } + session.lastGetProductsContext.proposals = sessionProposals; refinementApplied.push({ status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); } else { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 8ea3cc2914..f6651c8e1f 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3976,6 +3976,27 @@ describe('proposal lifecycle', () => { 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', { diff --git a/static/schemas/source/core/insertion-order.json b/static/schemas/source/core/insertion-order.json index af82a42169..b4c394fe52 100644 --- a/static/schemas/source/core/insertion-order.json +++ b/static/schemas/source/core/insertion-order.json @@ -7,7 +7,8 @@ "properties": { "io_id": { "type": "string", - "description": "Unique identifier for this insertion order. Referenced by io_acceptance on create_media_buy." + "description": "Unique identifier for this insertion order. Referenced by io_acceptance on create_media_buy.", + "maxLength": 255 }, "terms": { "type": "object", @@ -15,11 +16,13 @@ "properties": { "advertiser": { "type": "string", - "description": "Advertiser name or identifier" + "description": "Advertiser name or identifier", + "maxLength": 500 }, "publisher": { "type": "string", - "description": "Publisher name or identifier" + "description": "Publisher name or identifier", + "maxLength": 500 }, "total_budget": { "type": "object", @@ -31,7 +34,9 @@ }, "currency": { "type": "string", - "description": "ISO 4217 currency code" + "description": "ISO 4217 currency code", + "minLength": 3, + "maxLength": 3 } }, "required": ["amount", "currency"] @@ -48,7 +53,8 @@ }, "payment_terms": { "type": "string", - "description": "Payment terms (e.g., 'net_30', 'net_60', 'prepaid')" + "description": "Payment terms", + "enum": ["net_30", "net_60", "net_90", "prepaid", "due_on_receipt"] } }, "additionalProperties": true diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index 2f141d6d84..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", @@ -66,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/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 80f65e4fef..f599bd4bee 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -75,6 +75,7 @@ "signatory": { "type": "string", "description": "Who accepted the IO — agent identifier or human name", + "minLength": 1, "maxLength": 250 }, "signature_id": {