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,
+ });
+ });
+});