diff --git a/.changeset/reject-negative-governance-spend.md b/.changeset/reject-negative-governance-spend.md new file mode 100644 index 0000000000..5ad6a2e6c2 --- /dev/null +++ b/.changeset/reject-negative-governance-spend.md @@ -0,0 +1,8 @@ +--- +"adcontextprotocol": minor +--- + +Harden every `report_plan_outcome` budget input. The source schema now requires +`delivery.spend` to be non-negative, and the reference governance handler +rejects negative, non-finite, or cumulatively overflowing delivery and seller +budgets before they can mutate the committed-budget ledger or audit log. diff --git a/docs.json b/docs.json index 4fd1bcb3aa..d158e86f1c 100644 --- a/docs.json +++ b/docs.json @@ -1324,7 +1324,8 @@ "docs/reference/migration/signals", "docs/reference/migration/audiences", "docs/reference/migration/attribution", - "docs/reference/migration/media-buy-status" + "docs/reference/migration/media-buy-status", + "docs/reference/migration/governance-budget-invariants" ] }, "docs/protocol/architecture", diff --git a/docs/governance/campaign/tasks/report_plan_outcome.mdx b/docs/governance/campaign/tasks/report_plan_outcome.mdx index 36be885a97..5c23fc90b6 100644 --- a/docs/governance/campaign/tasks/report_plan_outcome.mdx +++ b/docs/governance/campaign/tasks/report_plan_outcome.mdx @@ -123,6 +123,14 @@ The governance agent still commits the seller's actual amount (\$120K, not the r } ``` +All reported monetary values are cumulative-ledger inputs and MUST be finite, +non-negative numbers. This applies to `delivery.spend`, +`seller_response.committed_budget`, and every +`seller_response.packages[].budget`. A governance agent rejects an invalid +value before updating budget totals or writing an audit outcome. It also rejects +an otherwise finite value when adding it would overflow either the plan total or +the purchase-type subtotal. + ### Response (on track) ```json diff --git a/docs/reference/migration/governance-budget-invariants.mdx b/docs/reference/migration/governance-budget-invariants.mdx new file mode 100644 index 0000000000..822e8efbf5 --- /dev/null +++ b/docs/reference/migration/governance-budget-invariants.mdx @@ -0,0 +1,70 @@ +--- +title: Campaign governance budget invariants +description: "Prepare report_plan_outcome callers for non-negative, finite, overflow-safe budget validation in AdCP 3.2." +"og:title": "AdCP — Campaign governance budget invariants" +--- + +# Campaign governance budget invariants + + +**Experimental-surface notice.** This breaking validation change was published +on July 30, 2026 and will not ship before September 10, 2026. It applies only to +the experimental `governance.campaign` surface in the next AdCP 3.2 release. + + +AdCP 3.2 governance agents reject invalid monetary inputs to +`report_plan_outcome` before updating a plan's committed-budget ledger or +recording an audit outcome. Callers must send finite, non-negative values for: + +- `delivery.spend` +- `seller_response.committed_budget` +- `seller_response.packages[].budget` + +The cumulative plan total and purchase-type subtotal must also remain finite +after the addition. + +## Before + +The source schema did not constrain `delivery.spend`, so this request was +schema-valid and could move the ledger backward: + +```json +{ + "plan_id": "plan_q1", + "governance_context": "gc_q1", + "outcome": "delivery", + "delivery": { + "spend": -2500 + } +} +``` + +## After + +Send the non-negative spend incurred during the reporting period: + +```json +{ + "plan_id": "plan_q1", + "governance_context": "gc_q1", + "outcome": "delivery", + "delivery": { + "spend": 2500 + } +} +``` + +When correcting a prior report, do not send a negative compensating outcome. +Reconcile the incorrect report through the governance agent's administrative or +audit workflow so historical entries remain append-only and explainable. + +## Upgrade checklist + +1. Validate every reported monetary value as finite and greater than or equal + to zero before calling `report_plan_outcome`. +2. Prevent package sums from overflowing the numeric representation used by + your client. +3. Treat `VALIDATION_ERROR` as a rejected outcome: do not assume any ledger or + audit mutation occurred. +4. Keep the rejected payload and prior accepted outcome identifiers in your + operational audit trail. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 4110fae660..4efd8976f9 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -9,6 +9,25 @@ Authoritative version-by-version release record for AdCP, with cumulative change --- +## Upcoming 3.2 — campaign-governance budget validation + +**Breaking-change notice published July 30, 2026. Earliest release: September 10, 2026.** + +The experimental `governance.campaign` surface will reject negative values for +`report_plan_outcome.delivery.spend`. Governance agents must also reject +negative, non-finite, or cumulatively overflowing values from +`seller_response.committed_budget`, package budgets, and delivery spend before +updating a plan ledger or recording an outcome. + +This closes a budget-integrity flaw where negative delivery spend could reduce +`total_committed` and restore apparent `budget_remaining`. See the +[campaign-governance budget-invariant migration note](/docs/reference/migration/governance-budget-invariants) +for before/after examples and an upgrade checklist. The change is scheduled for +the next minor release after the experimental surface's six-week notice window; +published 3.1.x artifacts remain immutable. + +--- + ## Version 3.1.3 — withdrawn **Status:** Withdrawn. Do not use 3.1.3 as an implementation target. Use 3.1.2 for the supported 3.1 line. diff --git a/server/src/training-agent/governance-handlers.ts b/server/src/training-agent/governance-handlers.ts index 5dde45f83e..5e03a0b987 100644 --- a/server/src/training-agent/governance-handlers.ts +++ b/server/src/training-agent/governance-handlers.ts @@ -425,8 +425,27 @@ export const GOVERNANCE_TOOLS = [ governance_context: { type: 'string', description: 'Opaque governance context from the check_governance response that authorized this action.' }, purchase_type: { type: 'string', enum: ['media_buy', 'rights_license', 'signal_activation', 'creative_services'], description: 'Type of financial commitment. Defaults to media_buy.' }, outcome: { type: 'string', enum: ['completed', 'failed', 'delivery'] }, - seller_response: { type: 'object' }, - delivery: { type: 'object' }, + seller_response: { + type: 'object', + properties: { + committed_budget: { type: 'number', minimum: 0 }, + packages: { + type: 'array', + items: { + type: 'object', + properties: { + budget: { type: 'number', minimum: 0 }, + }, + }, + }, + }, + }, + delivery: { + type: 'object', + properties: { + spend: { type: 'number', minimum: 0 }, + }, + }, error: { type: 'object' }, }, required: ['plan_id', 'outcome', 'governance_context'], @@ -1254,6 +1273,19 @@ export async function handleReportPlanOutcome(args: ToolArgs, ctx: TrainingConte return { errors: [{ code: 'VALIDATION_ERROR', message: `Invalid purchase_type: ${req.purchase_type}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; } + if (delivery?.spend !== undefined && ( + typeof delivery.spend !== 'number' + || !Number.isFinite(delivery.spend) + || delivery.spend < 0 + )) { + return { + errors: [{ + code: 'VALIDATION_ERROR', + message: 'delivery.spend must be a finite, non-negative number', + }], + }; + } + let plan = session.governancePlans.get(planId); if (!plan) { // Framework-dispatch request schemas omit `account`, so the session @@ -1269,25 +1301,77 @@ export async function handleReportPlanOutcome(args: ToolArgs, ctx: TrainingConte return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `Plan not found: ${planId}` }] }; } + const validationError = (message: string) => ({ + errors: [{ code: 'VALIDATION_ERROR', message }], + }); + + const applyLedgerAddition = (amount: number): boolean => { + const currentByType = plan.committedByType?.[purchaseType] ?? 0; + const nextTotal = plan.committedBudget + amount; + const nextByType = currentByType + amount; + if (!Number.isFinite(nextTotal) || !Number.isFinite(nextByType)) { + return false; + } + + plan.committedBudget = nextTotal; + plan.committedByType = plan.committedByType || {}; + plan.committedByType[purchaseType] = nextByType; + return true; + }; + let committedBudget = 0; const findings: GovernanceFinding[] = []; if (outcome === 'completed' && sellerResponse) { - // Prefer committed_budget when present (canonical); fall back to summing packages + let packageBudgetTotal = 0; + if (sellerResponse.packages !== undefined) { + if (!Array.isArray(sellerResponse.packages)) { + return validationError('seller_response.packages must be an array'); + } + + for (const [index, pkg] of sellerResponse.packages.entries()) { + if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { + return validationError(`seller_response.packages[${index}] must be an object`); + } + + const b = pkg.budget; + let packageBudget = 0; + if (typeof b === 'number') { + packageBudget = b; + } else if (b && typeof b === 'object' && !Array.isArray(b) && b.total !== undefined) { + packageBudget = b.total; + } else if (b !== undefined) { + return validationError(`seller_response.packages[${index}].budget must be a finite, non-negative number`); + } + + if (!Number.isFinite(packageBudget) || packageBudget < 0) { + return validationError(`seller_response.packages[${index}].budget must be a finite, non-negative number`); + } + + const nextPackageBudgetTotal = packageBudgetTotal + packageBudget; + if (!Number.isFinite(nextPackageBudgetTotal)) { + return validationError('seller_response package budgets exceed numeric ledger limits'); + } + packageBudgetTotal = nextPackageBudgetTotal; + } + } + + // Prefer committed_budget when present (canonical), but validate every + // reported package budget before falling back to their sum. if (sellerResponse.committed_budget !== undefined) { + if (typeof sellerResponse.committed_budget !== 'number' + || !Number.isFinite(sellerResponse.committed_budget) + || sellerResponse.committed_budget < 0) { + return validationError('seller_response.committed_budget must be a finite, non-negative number'); + } committedBudget = sellerResponse.committed_budget; - } else if (sellerResponse.packages?.length) { - committedBudget = sellerResponse.packages.reduce((sum, pkg) => { - const b = pkg.budget; - if (typeof b === 'number') return sum + b; - if (b && typeof b === 'object') return sum + (b.total || 0); - return sum; - }, 0); + } else { + committedBudget = packageBudgetTotal; } - plan.committedBudget += committedBudget; - plan.committedByType = plan.committedByType || {}; - plan.committedByType[purchaseType] = (plan.committedByType[purchaseType] || 0) + committedBudget; + if (!applyLedgerAddition(committedBudget)) { + return validationError('seller_response committed budget exceeds numeric ledger limits'); + } // Check if committed now exceeds authorized if (plan.committedBudget > plan.budget.total) { @@ -1301,11 +1385,11 @@ export async function handleReportPlanOutcome(args: ToolArgs, ctx: TrainingConte if (outcome === 'delivery' && delivery) { const spend = delivery.spend; - if (spend) { + if (spend !== undefined) { committedBudget = spend; - plan.committedBudget += spend; - plan.committedByType = plan.committedByType || {}; - plan.committedByType[purchaseType] = (plan.committedByType[purchaseType] || 0) + spend; + if (!applyLedgerAddition(spend)) { + return validationError('delivery.spend exceeds numeric ledger limits'); + } } } diff --git a/server/tests/unit/training-agent-governance-outcomes.test.ts b/server/tests/unit/training-agent-governance-outcomes.test.ts new file mode 100644 index 0000000000..f211a7a064 --- /dev/null +++ b/server/tests/unit/training-agent-governance-outcomes.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +// Initialize the aggregate tool catalog before importing leaf handlers. The +// production entrypoint uses the same order for this intentional module cycle. +import '../../src/training-agent/task-handlers.js'; +import { + handleGetPlanAuditLogs, + handleReportPlanOutcome, + handleSyncPlans, +} from '../../src/training-agent/governance-handlers.js'; +import { clearSessions, runWithSessionContext } from '../../src/training-agent/state.js'; +import type { TrainingContext } from '../../src/training-agent/types.js'; + +const CTX: TrainingContext = { mode: 'open' }; +const PLAN = { + plan_id: 'plan-outcome-spend-invariant', + brand: { domain: 'outcome-spend.example' }, + objectives: 'Verify outcome spend cannot roll back the budget ledger.', + budget: { total: 100, currency: 'USD', reallocation_threshold: 100 }, + flight: { start: '2027-01-01T00:00:00Z', end: '2027-12-31T23:59:59Z' }, +}; + +async function syncPlan() { + const result = await handleSyncPlans({ plans: [PLAN] }, CTX) as Record; + expect(result.errors, JSON.stringify(result)).toBeUndefined(); +} + +async function reportSpend(spend: number) { + return handleReportPlanOutcome({ + plan_id: PLAN.plan_id, + governance_context: 'test-governance-context', + outcome: 'delivery', + delivery: { spend }, + }, CTX) as Promise>; +} + +async function reportCompleted(sellerResponse: Record) { + return handleReportPlanOutcome({ + plan_id: PLAN.plan_id, + governance_context: 'test-governance-context', + outcome: 'completed', + seller_response: sellerResponse, + }, CTX) as Promise>; +} + +async function getAudit() { + return handleGetPlanAuditLogs({ + brand: PLAN.brand, + plan_ids: [PLAN.plan_id], + include_entries: true, + }, CTX) as Promise>; +} + +function expectLedger(audit: Record, committed: number, outcomes: number) { + expect(audit.plans[0].budget).toMatchObject({ + authorized: 100, + committed, + remaining: 100 - committed, + }); + expect(audit.plans[0].summary.outcomes_reported).toBe(outcomes); + expect(audit.plans[0].entries).toHaveLength(outcomes); +} + +describe('report_plan_outcome spend invariants', () => { + beforeEach(() => clearSessions()); + afterEach(() => clearSessions()); + + it('accepts zero and positive delivery spend', async () => { + await runWithSessionContext(async () => { + await syncPlan(); + + const zero = await reportSpend(0); + const positive = await reportSpend(25); + const audit = await getAudit(); + + expect(zero.status).toBe('accepted'); + expect(positive).toMatchObject({ status: 'accepted', committed_budget: 25 }); + expectLedger(audit, 25, 2); + }); + }); + + it.each([ + ['negative', -1], + ['negative infinity', Number.NEGATIVE_INFINITY], + ['positive infinity', Number.POSITIVE_INFINITY], + ['NaN', Number.NaN], + ])('rejects %s delivery spend without mutating or auditing it', async (_label, spend) => { + await runWithSessionContext(async () => { + await syncPlan(); + expect((await reportSpend(25)).status).toBe('accepted'); + + const rejected = await reportSpend(spend); + const audit = await getAudit(); + + expect(rejected).toEqual({ + errors: [{ + code: 'VALIDATION_ERROR', + message: 'delivery.spend must be a finite, non-negative number', + }], + }); + expectLedger(audit, 25, 1); + expect(audit.plans[0].entries[0]).toMatchObject({ + type: 'outcome', + committed_budget: 25, + }); + }); + }); + + it.each([ + ['negative committed budget', { committed_budget: -1 }], + ['infinite committed budget', { committed_budget: Number.POSITIVE_INFINITY }], + ['NaN committed budget', { committed_budget: Number.NaN }], + ['negative package budget', { packages: [{ budget: -1 }] }], + ['infinite package budget', { packages: [{ budget: Number.POSITIVE_INFINITY }] }], + ['negative legacy package total', { packages: [{ budget: { total: -1 } }] }], + ['negative package budget alongside committed budget', { committed_budget: 10, packages: [{ budget: -1 }] }], + ['infinite package budget alongside committed budget', { committed_budget: 10, packages: [{ budget: Number.POSITIVE_INFINITY }] }], + ['package budget sum overflow', { packages: [{ budget: Number.MAX_VALUE }, { budget: Number.MAX_VALUE }] }], + ['non-array package collection', { packages: 'not-an-array' }], + ['null package entry', { packages: [null] }], + ['null package budget', { packages: [{ budget: null }] }], + ])('rejects %s without mutating or auditing it', async (_label, sellerResponse) => { + await runWithSessionContext(async () => { + await syncPlan(); + expect((await reportCompleted({ committed_budget: 25 })).status).toBe('accepted'); + + const rejected = await reportCompleted(sellerResponse); + const audit = await getAudit(); + + expect(rejected.errors).toEqual([expect.objectContaining({ code: 'VALIDATION_ERROR' })]); + expectLedger(audit, 25, 1); + }); + }); + + it('rejects cumulative delivery overflow without mutating or auditing it', async () => { + await runWithSessionContext(async () => { + await syncPlan(); + expect((await reportSpend(Number.MAX_VALUE)).status).toBe('accepted'); + + const rejected = await reportSpend(Number.MAX_VALUE); + const audit = await getAudit(); + + expect(rejected).toEqual({ + errors: [{ + code: 'VALIDATION_ERROR', + message: 'delivery.spend exceeds numeric ledger limits', + }], + }); + expectLedger(audit, Number.MAX_VALUE, 1); + }); + }); + + it('rejects cumulative completed-budget overflow without mutating or auditing it', async () => { + await runWithSessionContext(async () => { + await syncPlan(); + expect((await reportCompleted({ committed_budget: Number.MAX_VALUE })).status).toBe('findings'); + + const rejected = await reportCompleted({ committed_budget: Number.MAX_VALUE }); + const audit = await getAudit(); + + expect(rejected).toEqual({ + errors: [{ + code: 'VALIDATION_ERROR', + message: 'seller_response committed budget exceeds numeric ledger limits', + }], + }); + expectLedger(audit, Number.MAX_VALUE, 1); + }); + }); +}); diff --git a/static/schemas/source/governance/report-plan-outcome-request.json b/static/schemas/source/governance/report-plan-outcome-request.json index 38c4180446..7913f8cea3 100644 --- a/static/schemas/source/governance/report-plan-outcome-request.json +++ b/static/schemas/source/governance/report-plan-outcome-request.json @@ -108,7 +108,8 @@ }, "spend": { "type": "number", - "description": "Spend in the period." + "minimum": 0, + "description": "Non-negative spend in the period." }, "cpm": { "type": "number", diff --git a/tests/governance-outcome-invariants.test.ts b/tests/governance-outcome-invariants.test.ts new file mode 100644 index 0000000000..19f554dd6c --- /dev/null +++ b/tests/governance-outcome-invariants.test.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = process.cwd(); + +describe('governance outcome schema invariants', () => { + it('keeps every reported budget field non-negative', () => { + const schema = JSON.parse(fs.readFileSync( + path.join(repoRoot, 'static/schemas/source/governance/report-plan-outcome-request.json'), + 'utf8', + )); + + expect(schema.properties.delivery.properties.spend).toMatchObject({ + type: 'number', + minimum: 0, + }); + expect(schema.properties.seller_response.properties.committed_budget).toMatchObject({ + type: 'number', + minimum: 0, + }); + expect(schema.properties.seller_response.properties.packages.items.properties.budget).toMatchObject({ + type: 'number', + minimum: 0, + }); + }); +});