Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/reject-negative-governance-spend.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions docs/governance/campaign/tasks/report_plan_outcome.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions docs/reference/migration/governance-budget-invariants.mdx
Original file line number Diff line number Diff line change
@@ -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

<Note>
**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.
</Note>

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.
19 changes: 19 additions & 0 deletions docs/reference/release-notes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 101 additions & 17 deletions server/src/training-agent/governance-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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');
}
}
}

Expand Down
Loading
Loading