diff --git a/.agents/sdk-shim-ledger.json b/.agents/sdk-shim-ledger.json
index d2103cc31b..4f2561de08 100644
--- a/.agents/sdk-shim-ledger.json
+++ b/.agents/sdk-shim-ledger.json
@@ -39,5 +39,24 @@
"node_modules/@adcp/sdk/dist/lib/types/schemas.generated.mjs",
"schemas.generated.js"
]
+ },
+ {
+ "id": "current-change-terms-negotiation-verifier-overlay",
+ "title": "Current change-terms field in SDK proposal verifier",
+ "kind": "node_modules_sdk_cache_mutation",
+ "status": "temporary",
+ "owner": "media-buy",
+ "upstream": "adcontextprotocol/adcp-client#2664",
+ "problem": "The pinned SDK proposal verifier has an independent commercial_terms allowlist that predates the 3.2 change_terms field, so it rejects otherwise schema-valid same-PR refinement responses.",
+ "localBehavior": "During current-source storyboard runs, add change_terms to the installed SDK verifier allowlist and restore the original verifier after the matrix.",
+ "removalCondition": "Remove when the pinned @adcp/sdk release natively validates proposal-bound change_terms through its public negotiation verifier.",
+ "paths": [
+ "scripts/overlay-compliance-cache.sh",
+ "scripts/run-storyboards-matrix.sh"
+ ],
+ "terms": [
+ "node_modules/@adcp/sdk/dist/lib/negotiation/verification.js",
+ "node_modules/@adcp/sdk/dist/lib/negotiation/verification.mjs"
+ ]
}
]
diff --git a/.changeset/seller-governance-policy-surfaces.md b/.changeset/seller-governance-policy-surfaces.md
new file mode 100644
index 0000000000..e2de931f6a
--- /dev/null
+++ b/.changeset/seller-governance-policy-surfaces.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": minor
+---
+
+Add registry-backed seller acceptance-policy discovery with reusable version-pinned platform profiles, proposal-bound media-buy change terms, structured seller disposition evidence on failed governance outcomes, and seller acceptance criteria for buyer-selected governance agents. Add typed change constraints and status scope, project accepted terms into current `available_actions` through `change_term_id`, and deprecate the released 3.1 `terms_ref` action-link overload for removal in 4.0 while retaining explicit 3.1 compatibility. Add compliance storyboards for product promises, proposal materialization, accepted-term persistence, service-mode routing, state transitions, latent rights, and cross-version action parsing. Harden all four surfaces against stale or ambiguous policy, authorization confusion, untrusted audit evidence, unsafe remote resolution, and rejected-binding persistence.
diff --git a/.github/workflows/training-agent-storyboards.yml b/.github/workflows/training-agent-storyboards.yml
index 7c8223637b..a84d5c012c 100644
--- a/.github/workflows/training-agent-storyboards.yml
+++ b/.github/workflows/training-agent-storyboards.yml
@@ -82,7 +82,7 @@ jobs:
- surface: current
tenant: governance
min_clean_storyboards: 47
- min_passing_steps: 157
+ min_passing_steps: 161
- surface: current
tenant: creative
min_clean_storyboards: 49
@@ -375,6 +375,23 @@ jobs:
fi
done
+ - name: Enforce governance seller-disposition evidence
+ if: matrix.surface == 'current' && matrix.tenant == 'governance'
+ run: |
+ set -euo pipefail
+ requirement="governance/failed_outcome_audit_persistence:4:0"
+ storyboard_id="${requirement%%:*}"
+ counts="${requirement#*:}"
+ expected_passed="${counts%%:*}"
+ expected_skipped="${counts##*:}"
+ if grep -E "^[[:space:]]+${storyboard_id}[[:space:]]+✓[[:space:]]+${expected_passed}P / ${expected_skipped}S" /tmp/storyboards.log >/dev/null; then
+ echo "Required-exact storyboard passed: ${storyboard_id} (${expected_passed}P / ${expected_skipped}S)"
+ else
+ echo "::error::Required-exact storyboard counts changed on /governance current surface: ${storyboard_id}"
+ grep -F "${storyboard_id}" /tmp/storyboards.log || true
+ exit 1
+ fi
+
- name: Enforce creative required-clean storyboards
if: matrix.surface == 'current' && matrix.tenant == 'creative'
run: |
@@ -678,6 +695,9 @@ jobs:
"media_buy_seller/declined_proposal_refinement:6:0"
"media_buy_seller/declined_proposal_execution:8:0"
"media_buy_seller/expired_proposal_execution:8:0"
+ "media_buy_seller/change_rights_state_projection:8:0"
+ "media_buy_seller/acceptance_policy_discovery:3:0"
+ "media_buy_seller/governance_agent_binding_acceptance:5:0"
)
for requirement in "${required_exact[@]}"; do
storyboard_id="${requirement%%:*}"
diff --git a/docs/accounts/tasks/sync_governance.mdx b/docs/accounts/tasks/sync_governance.mdx
index 7b851b537b..c5beb21456 100644
--- a/docs/accounts/tasks/sync_governance.mdx
+++ b/docs/accounts/tasks/sync_governance.mdx
@@ -11,6 +11,21 @@ An account binds to one governance agent that owns the full lifecycle. Authoriza
This uses **replace semantics** — each call replaces any previously registered agent on the specified accounts. Accounts not included in the request keep their existing configuration.
+## Seller acceptance of governance agents
+
+Sellers may publish seller-wide advisory criteria at `adcp.governance_enforcement.accepted_governance_agents` in [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities). `any_of[]` is a typed union: a candidate is acceptable when it satisfies at least one matcher.
+
+- `agent_url` matches one explicit HTTPS endpoint.
+- `verification` requires a fresh result from the named HTTPS registry for the declared agent role, AdCP version, allowed verification modes, and maximum result age.
+
+URL matchers use the shared [AdCP URL canonicalization rules](/docs/reference/url-canonicalization), not raw string equality. They match the exact canonical endpoint; redirects, DNS aliases, and candidate assertions do not expand an allowlist. A verification matcher is satisfied only by a deterministic result from the seller-configured trusted registry. The candidate cannot nominate the registry, and its self-described role or verification state is never evidence. Registry resolution uses HTTPS, no redirects or forwarded credentials, public-network address checks, DNS pinning through connection, bounded responses, and authenticated registry records. If the registry or verification service cannot be resolved, the seller returns [`GOVERNANCE_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-governance-unavailable); it does not guess or silently turn an availability problem into rejection.
+
+The capabilities declaration is seller-wide preflight guidance. The response to `sync_governance` is authoritative for each account because account contracts, jurisdictions, and product access may narrow the criteria. Verification evidence is evaluated for freshness at binding time and pinned to the accepted binding. Later capability or registry drift does not silently revoke or redirect an existing binding; an explicit account resynchronization creates a new decision. If the submitted agent does not satisfy the applicable criteria, the account result is `failed` with [`GOVERNANCE_AGENT_NOT_ACCEPTED`](/docs/building/verification/compliance-catalog#error-code-governance-agent-not-accepted). A seller may return either opaque details (`{"disclosure":"opaque"}` plus an optional local `rejection_ref`) or disclosed details containing the parsed HTTPS origin and authoritative criteria. The disclosed arm uses `attempted_agent_origin`, never the submitted URL: userinfo, path, query, and fragment are stripped after parsing. Buyers reconciling a disclosed rejection against their `any_of[]` matcher list should compare full matcher entries, not origins — an `agent_url` matcher may include a path and therefore be narrower than the disclosed origin. The seller MUST NOT persist or contact a rejected endpoint and MUST NOT echo credentials or a raw candidate URL in responses, logs, or error details.
+
+Acceptance matchers MUST express objective operational, security, interoperability, or compliance requirements. A seller MUST NOT use a URL allowlist or verification criterion to exclude a functionally equivalent governance provider merely as commercial leverage. Sellers SHOULD prefer verification criteria over enumerating providers when the registry can express the requirement deterministically.
+
+Absence of `accepted_governance_agents` means the seller does not restrict governance-agent identity through this mechanism and accepts any otherwise valid binding. Adding or changing a declaration does not retroactively invalidate existing bindings; the seller applies it when a buyer next binds or replaces an agent. Account-specific criteria returned on a failed sync override the advisory capability for that attempt.
+
**Response Time**: ~1s.
**Request Schema**: [`/schemas/v3/account/sync-governance-request.json`](https://adcontextprotocol.org/schemas/v3/account/sync-governance-request.json)
@@ -101,20 +116,20 @@ asyncio.run(main())
## Request Parameters
| Parameter | Type | Required | Description |
-|-----------|------|----------|-------------|
+|-----------|------|----------|--------------|
| `accounts` | array | Yes | Per-account governance agent entries. Each pairs an account reference with governance agents for that account. |
**Each account entry:**
| Field | Type | Required | Description |
-|-------|------|----------|-------------|
+|-------|------|----------|--------------|
| `account` | object | Yes | [Account reference](/docs/building/by-layer/L2/accounts-and-agents#account-references): `{account_id}` for account-id namespaces or `{brand, operator}` for buyer-declared accounts. |
| `governance_agents` | array | Yes | Governance agent endpoint for this account. Array with exactly one entry (`minItems: 1`, `maxItems: 1`). |
**The governance agent:**
| Field | Type | Required | Description |
-|-------|------|----------|-------------|
+|-------|------|----------|--------------|
| `url` | string | Yes | HTTPS endpoint URL for the governance agent. |
| `authentication` | object | Yes | Credentials the seller presents when calling this agent. Contains `schemes` (array with one auth scheme) and `credentials` (token, min 32 characters). |
@@ -131,6 +146,8 @@ Returns an `accounts` array with per-account results. Individual entries may fai
| `governance_agents` | Governance agents now active on this account. Reflects persisted state. Only present when `status: "synced"`. |
| `errors` | Per-account errors. Only present when `status: "failed"`. |
+For [`GOVERNANCE_AGENT_NOT_ACCEPTED`](/docs/building/verification/compliance-catalog#error-code-governance-agent-not-accepted), `errors[].details` conforms to `error-details/governance-agent-not-accepted.json`. It never contains authentication credentials.
+
**Error response:**
`errors` array with operation-level errors (auth failure, service unavailable). No `accounts` array is present.
@@ -291,6 +308,7 @@ Earlier drafts of 3.0 allowed up to 10 governance agents per account with per-ag
## Next Steps
+- [Sell-side governance boundaries](/docs/governance/sell-side-governance) — Understand governance-agent acceptance as a seller binding constraint, not shared campaign authority
- [list_accounts](/docs/accounts/tasks/list_accounts) — Discover accounts and their current governance agents
- [sync_accounts](/docs/accounts/tasks/sync_accounts) — Provision or link advertiser accounts
- [check_governance](/docs/governance/campaign/tasks/check_governance) — How sellers call governance agents during media buy events
diff --git a/docs/governance/campaign/specification.mdx b/docs/governance/campaign/specification.mdx
index 679a174ce5..2b17923985 100644
--- a/docs/governance/campaign/specification.mdx
+++ b/docs/governance/campaign/specification.mdx
@@ -19,6 +19,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S
This document defines the data models, validation logic, and integration patterns for Campaign Governance.
+Campaign governance authorizes spending on behalf of the buyer; it does not make the buyer's governance agent a seller-side approver. See [sell-side governance boundaries](/docs/governance/sell-side-governance) for seller acceptance policies, proposal-bound change rights, typed seller dispositions, and seller-internal review.
+
## Campaign plan
The campaign plan is the source of truth for all validation. Plans are pushed to the governance agent via [`sync_plans`](/docs/governance/campaign/tasks/sync_plans) and define the plan parameters for a campaign -- budget limits, channels, flight dates, and plan markets. The governance agent resolves applicable policies from the brand's compliance configuration. Plans can also reference registry policies directly via `policy_ids` and include campaign-specific rules via `custom_policies`.
@@ -185,7 +187,9 @@ Identity is role-specific. On the buyer's intent check, `caller` is the authenti
Sellers MUST persist the accepted intent token and any lifecycle tokens they subsequently hold, keyed at minimum by `jti` with `iss`, `aud`, the opaque action binding in `sub`, `phase`, decision outcome, and the timestamp of acceptance. They MUST NOT treat `sub` as a plan identifier. Retention follows the seller's regulatory retention period. Without seller-side retention, the audit log is single-sourced from the governance agent; independent reconciliation between seller records and `get_plan_audit_logs` is the cross-check that catches a compromised or misbehaving governance agent.
-Seller-side governance (if the seller itself has configured a governance agent on the account) is an **independent layer**. A buyer's successful `check_governance` does not obligate the seller to accept the request; the seller's own compliance policies MAY still reject the action via `PERMISSION_DENIED`.
+A buyer's successful `check_governance` does not obligate the seller to accept the request. Seller acceptance policy is an independent commercial and compliance layer; the seller's internal automation, review, and escalation process is not protocol authority and need not be exposed. Use `POLICY_VIOLATION` when seller content or advertising policy rejects an otherwise authorized action, `ACTION_NOT_ALLOWED` when a media-buy change is outside the accepted or currently available rights, and `PERMISSION_DENIED` only when the authenticated caller, account scope, credential, or signed governance context lacks authority.
+
+When an authorized action fails after approval, the buyer reports the seller error through `report_plan_outcome` with `outcome: "failed"`. The governance agent retains that buyer-attributed error on the outcome audit entry. This closes the unexplained-audit gap without granting the seller a second approval role or claiming that the copied error is seller-authenticated evidence.
An approved token carries an `exp` that is authoritative at verification time; a policy change inside the token's validity window is accepted residual risk of any signed-decision system. Tight `exp` values (intent tokens SHOULD expire within 15 minutes per the JWS profile) bound the window rather than closing it. Operators who cannot tolerate the window MUST set `reallocation_threshold` to `0` or `human_review_required: true` so every action goes through an internal human review regardless of caching.
diff --git a/docs/governance/campaign/tasks/report_plan_outcome.mdx b/docs/governance/campaign/tasks/report_plan_outcome.mdx
index f657bfd421..84711c3455 100644
--- a/docs/governance/campaign/tasks/report_plan_outcome.mdx
+++ b/docs/governance/campaign/tasks/report_plan_outcome.mdx
@@ -235,13 +235,25 @@ If the seller rejected the request, report it so the governance agent can update
"governance_context": "gc_mb_seller_456",
"outcome": "failed",
"error": {
- "code": "PRODUCT_UNAVAILABLE",
- "message": "Product premium_video_300k is no longer available."
+ "code": "POLICY_VIOLATION",
+ "message": "The requested campaign is not accepted for this inventory.",
+ "classification_source": "seller_response_copy",
+ "details": {
+ "origin": "seller",
+ "category": "political_advertising",
+ "seller_policy_ref": "restricted-category-rule-17"
+ }
}
}
}
```
+The reporter preserves a structured seller decline on the existing `outcome: "failed"` path; there is no seller authority or new outcome state. `classification_source: "seller_response_copy"` attributes the record correctly: it is the buyer's copy of what the seller returned, not an independently authenticated seller statement. A buyer that inferred the classification uses `buyer_classification` instead. The governance agent stores the complete error on the outcome audit entry so an approved plan action that did not execute has an attributable reason.
+
+Every field in this error remains reporter-supplied evidence. Governance agents MUST NOT use it to authorize an action, debit or release budget, alter seller reputation, or establish that the seller made a statement. Unknown fields cannot create provenance or attestation semantics. Treat messages, suggestions, details, and extensions as untrusted display data: isolate them from privileged prompts, escape them in operator interfaces and exports, and enforce transport and storage size limits. A future seller-attested receipt can add stronger provenance without upgrading historical buyer copies retroactively.
+
+Sellers may disclose progressively. A [`POLICY_VIOLATION`](/docs/building/verification/compliance-catalog#error-code-policy-violation) can include a registry `policy_id`, category, rule IDs, and remediation when those are publishable, or an opaque `seller_policy_ref` when disclosure would reveal enforcement controls. Use [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) when the proposed change was not a negotiated/current right. Reserve [`PERMISSION_DENIED`](/docs/building/verification/compliance-catalog#error-code-permission-denied) for caller identity, credential, account scope, or signed-governance authorization failures.
+
```json
{
"outcome_id": "out_003",
@@ -282,9 +294,11 @@ If the seller rejected the request, report it so the governance agent can update
| `delivery.seller_statement_id` | string | For seller copy | ID received with the seller statement. |
| `delivery.seller_statement_digest` | string | For seller copy | Digest received with the seller statement. |
| `delivery.impressions` | integer | No | Optional buyer-attributed impressions. |
-| `error` | object | No | Error details. Required when outcome is `failed`. |
-| `error.code` | string | No | Error code from the seller. |
+| `error` | object | No | Buyer-attributed structured error evidence. Required when outcome is `failed`; retained on the audit entry. |
+| `error.code` | string | No | Error code returned by the seller or assigned by the buyer. Optional for compatibility with 3.1 message-only evidence; reporters SHOULD include it when available. |
| `error.message` | string | No | Human-readable error description. |
+| `error.details` | object | No | Structured error details preserved without forcing policy disclosure. [`POLICY_VIOLATION`](/docs/building/verification/compliance-catalog#error-code-policy-violation) details SHOULD use the standard policy-violation shape. |
+| `error.classification_source` | enum | No | `seller_response_copy` or `buyer_classification`. Omission is permitted for compatibility. Additional seller fields remain forward-compatible but are size-, width-, and depth-bounded; prefer the bounded `error.ext` envelope. |
| `governance_context` | string | Conditional | Exact approved context paired with `check_id` and `plan_id`. Required for every outcome. |
### Response
diff --git a/docs/governance/overview.mdx b/docs/governance/overview.mdx
index 7344adcbd9..7a48a0fc96 100644
--- a/docs/governance/overview.mdx
+++ b/docs/governance/overview.mdx
@@ -299,6 +299,7 @@ Today, SI platforms enforce governance at the application layer using [content s
## Go deeper
+- **Sell-side boundaries**: [Seller acceptance, negotiated rights, and internal review](/docs/governance/sell-side-governance) — where campaign governance authority ends
- **Safety model**: [Three-party trust in depth](/docs/governance/campaign/safety-model) — separation of duties, delegation, and escalation patterns
- **Campaign specification**: [Full data model](/docs/governance/campaign/specification) — plans, checks, outcomes, and policy resolution
- **Content standards**: [Brand suitability](/docs/governance/content-standards/index) — privacy-preserving calibration for content evaluation
diff --git a/docs/governance/sell-side-governance.mdx b/docs/governance/sell-side-governance.mdx
new file mode 100644
index 0000000000..c99cf5ca61
--- /dev/null
+++ b/docs/governance/sell-side-governance.mdx
@@ -0,0 +1,72 @@
+---
+title: Sell-side governance boundaries
+sidebarTitle: Sell-side boundaries
+description: "How seller acceptance, buyer spend authority, negotiated change rights, and seller-internal review remain distinct in AdCP."
+"og:title": "AdCP — Sell-side governance boundaries"
+---
+
+AdCP governance is intentionally asymmetric. A buyer agent spends money on
+behalf of a principal, so it needs authority from that principal. A seller does
+not need authority from the buyer to decide whether it will accept a campaign,
+creative, or change. The seller instead acts under its own commercial,
+operational, and legal policies.
+
+This is a split-authority model, not a missing seller-side approval layer.
+
+## Keep the authority layers separate
+
+| Layer | Question it answers | Protocol surface |
+|---|---|---|
+| Buyer governance | May this caller commit or change spend for the buyer? | [`check_governance`](/docs/governance/campaign/tasks/check_governance) and signed `governance_context` |
+| Seller acceptance | Will this seller accept this business, campaign, creative, targeting, or format? | [Acceptance-policy discovery](/docs/media-buy/media-buys/policy-compliance) and structured task errors |
+| Negotiated change rights | Which post-acceptance changes did these parties agree the buyer may request? | `commercial_terms.change_terms[]` and runtime `available_actions[]` |
+| Seller operations | How does the seller evaluate or fulfill an accepted request? | Seller implementation detail |
+
+A successful governance check establishes buyer authority; it does not oblige a
+seller to transact. Conversely, a seller's willingness to transact does not
+grant a caller permission to spend buyer funds.
+
+## Seller review is internal
+
+A seller may use deterministic rules, an AI system, human review, or any
+combination of them to decide whether to accept a request. That workflow does
+not need protocol orchestration and does not make the seller a counterpart
+governance authority over the buyer's plan.
+
+Buyer-visible timing may still be useful. Proposal-bound change terms can state
+that an action is `seller_managed` and provide elapsed-time response and
+completion maxima. Those terms describe the service the buyer can rely on; they
+do not reveal whether a person reviewed the request or require the buyer to
+coordinate the seller's internal queue.
+
+## Rejection is a disposition, not a governance veto
+
+An authorized action can still fail under seller policy. Use the error category
+that identifies the actual boundary:
+
+- `POLICY_VIOLATION` when seller content, advertising, legal, or commercial
+ policy rejects an otherwise authorized action.
+- `ACTION_NOT_ALLOWED` when a media-buy change is outside the accepted terms or
+ is not currently available.
+- `PERMISSION_DENIED` when the authenticated caller, credential, account scope,
+ or signed governance context lacks authority.
+
+When a seller rejects an approved action, the buyer reports the failure through
+[`report_plan_outcome`](/docs/governance/campaign/tasks/report_plan_outcome)
+with `outcome: "failed"`. The governance audit retains the bounded,
+buyer-attributed error evidence so an auditor can explain why execution did not
+occur. The copied error is an audit record; it neither grants the seller plan
+authority nor becomes seller-attested evidence.
+
+## Governance-agent acceptance is dependency management
+
+A seller may declare which governance agents it can depend on and reject an
+unacceptable binding during
+[`sync_governance`](/docs/accounts/tasks/sync_governance). This decision is about
+the endpoint the seller must call for authorization and recovery. It does not
+allow the seller to modify the buyer's governance rules or approve buyer spend.
+
+Together, these boundaries preserve the central invariant: buyer authority,
+seller acceptance, negotiated commercial rights, and seller-internal operations
+remain attributable without being collapsed into one jointly controlled
+governance agent.
diff --git a/docs/media-buy/media-buys/policy-compliance.mdx b/docs/media-buy/media-buys/policy-compliance.mdx
index f344aee290..752abdfb0d 100644
--- a/docs/media-buy/media-buys/policy-compliance.mdx
+++ b/docs/media-buy/media-buys/policy-compliance.mdx
@@ -7,6 +7,32 @@ description: "AdCP policy compliance — how publishers enforce brand safety and
AdCP includes comprehensive policy compliance features to ensure brand safety and regulatory compliance across all advertising operations. This document explains how publishers should implement and enforce policy checks throughout the media buying lifecycle.
+## Acceptance-policy discovery
+
+Seller acceptance policy answers a narrower question than buyer governance: **what business, campaign, creative, targeting, destination, and format will this seller accept?** It does not give the seller authority over the buyer's plan and does not expose the seller's internal review workflow.
+
+A seller that supports structured discovery publishes `media_buy.acceptance_policy_discovery` from `get_adcp_capabilities`:
+
+```json
+{
+ "catalog_url": "https://seller.example/.well-known/adcp-acceptance-policies.json",
+ "catalog_digest": "sha256:54f04c3e2e8dfdf682b0da0cd0ee4d5265e54a6e0f72b9260785dfc1526fd08a",
+ "default_profile_ids": ["seller-default", "us-political"]
+}
+```
+
+The HTTPS document at `catalog_url` conforms to `acceptance-policy-catalog.json`. Each local `profiles[]` entry declares `coverage: "partial"` or `"complete"` and contains rules with a `disposition` of `allowed`, `conditional`, or `prohibited`. Conditional rules carry typed requirements such as advertiser verification, prior authorization, disclosure, targeting restrictions, or sales-assisted setup. Every `policy_ids[]` value resolves through that profile's digest- and version-pinned `policy_refs[]`. The profile's own `content_digest` covers its RFC 8785 canonical form with that field omitted, making a profile ID/version immutable. Registry-published platform policies may include a reusable `acceptance_profile`; a seller adopts it through `registry_profiles[]`, which pins both policy and profile versions and digests. For each adoption, resolve the exact policy version, RFC 8785-canonicalize the resolver's `canonical_content`, and require its SHA-256 to equal both the resolver `content_digest` and the pinned `policy_digest`; null canonical content or a mismatch fails closed. Verify `profile_digest` separately against the embedded profile. The seller MUST NOT copy and alter a registry profile under the same identity; seller-specific restrictions use a separate local profile and compose restrictively. A profile ID may occur only once across a catalog's local and registry lists. Registry publication alone never binds a seller.
+
+Products may add `acceptance_policy_profile_ids[]`. Resolve the seller defaults and every product profile referenced by the contemplated purchase. All selected profiles apply; for matching rules, `prohibited` wins over `conditional`, which wins over `allowed`. A missing capability, an omitted rule in a partial profile, or missing buyer facts means **unknown**, never allowed. `complete` is exhaustive only for that profile's declared scope and version; the scope explicitly names countries, declared region aliases, or `all_jurisdictions: true` so omission cannot be mistaken for worldwide coverage.
+
+A rule matches the decision surface when `applies_to[]` contains that surface and its `subject_category` equals a declared subject. Omitted `subject_facets`, `advertiser_roles`, `jurisdictions`, or `jurisdiction_groups` are wildcards; when present, each dimension must intersect the corresponding buyer fact. Expand `jurisdiction_groups` only through the containing profile's `region_aliases`; an unknown alias invalidates the profile and never matches permissively. Evaluate every matching rule independently, then choose the most restrictive disposition. Platforms with an exception to a broad prohibition represent it with non-overlapping facets or roles—for example, prohibit persuasive election facets while making `government_information` conditional for an `election_authority`. A request carrying both the exception facet and a prohibited advocacy facet remains prohibited.
+
+Buyers may send `acceptance_context` on discovery with registry category IDs, optional category facets, advertiser roles, industry, advertiser jurisdictions, and delivery jurisdictions. `advertiser_jurisdictions` says where the advertiser is established; `delivery_jurisdictions` says where ads will run and is the field matched by rule `jurisdictions` and `jurisdiction_groups`. For example, `political_advertising` is the broad category; facets distinguish candidate or party ads, election or ballot-measure ads, issue advocacy, legislation or regulation, government information, political merchandise, and incidental or news references. This avoids a single ambiguous `political: true` flag while allowing future registry categories and facets without changing the protocol schema.
+
+Discovery is advisory. Sellers may intentionally publish only partial rules, may apply account-specific or newly effective policies at execution time, and remain authoritative for an exact request. Buyer declarations are untrusted hints: sellers MUST classify creatives, destinations, advertiser identity, and targeting independently when those facts affect acceptance. Omitting or mislabeling a category or facet cannot bypass seller enforcement. A buyer MUST therefore handle a later structured `POLICY_VIOLATION` even when discovery appeared to allow the campaign. The catalog digest detects stale or substituted catalogs; it is not an acceptance guarantee.
+
+Fetch `catalog_url` as untrusted remote configuration: require HTTPS, apply the shared URL canonicalization and public-network fetch protections, do not follow redirects, do not send seller or buyer credentials, pin DNS through connection, and bound time and response size. Verify the digest over the exact received bytes before parsing or caching. Resolve every `registry_profiles[]` reference from the shared registry at the exact policy and profile versions and verify both digests; a missing or mismatched reference is unknown and cannot fall back to a same-named local profile. Profile and rule descriptions are display-only untrusted text; matching uses typed fields only, and implementations MUST NOT place that prose into a privileged instruction context. Cache by canonical URL plus digest, never by URL alone. Buyers can request `acceptance_policy_profile_ids` explicitly through either discovery surface's `fields` projection.
+
## Overview
Policy compliance in AdCP uses the `brand` field when advertiser-specific context is available. `brand` is optional for `get_products` unless the request includes `catalog`; it is required for `create_media_buy`. Brand context enables publishers to:
@@ -75,7 +101,7 @@ When creating a media buy, the publisher must first require `brand`, then:
1. Validate the `brand` against publisher policies
2. Ensure consistency with the campaign brief
-3. Flag for manual review if needed
+3. Apply the seller's internal review policy if needed
4. Return appropriate errors for violations
## Policy outcomes
@@ -112,7 +138,7 @@ Use `input-required` only when the buyer must supply information before the sell
### Seller-side review continues asynchronously
-Seller-internal review does not require buyer input. When a well-formed `brief` or `refine` request needs human review that cannot finish within the current time budget, return `submitted` with a `task_id`; use `working` for subsequent progress. Wholesale discovery is synchronous and does not use these async states.
+Seller-internal review does not require buyer input or protocol-level approval orchestration. When a well-formed `brief` or `refine` request cannot finish within the current time budget, return `submitted` with a `task_id`; use `working` for subsequent progress. Those states communicate task completion only—the seller may use people, automation, or both internally. Wholesale discovery is synchronous and does not use these async states.
### The seller declines a well-formed request
@@ -141,7 +167,7 @@ When the request itself violates an applicable content or advertising policy and
"field": "brand",
"recovery": "correctable",
"details": {
- "policy_id": "publisher-restricted-industries",
+ "seller_policy_ref": "restricted-industries-rule-4",
"policy_url": "https://publisher.example/policies/restricted-industries",
"violated_rules": ["restricted-industry"]
}
@@ -178,7 +204,7 @@ Publishers typically implement restrictions for:
- Prohibited content (varies by region)
- Categories requiring special licensing
-### Restricted Categories (Manual Approval)
+### Restricted Categories (Conditional Acceptance)
- Alcohol (may require age-gating)
- Gambling/Gaming
- Cryptocurrency/Financial services
@@ -199,12 +225,12 @@ Publishers typically implement restrictions for:
4. **Documentation**: Maintain clear policy documentation for advertisers
5. **Appeals Process**: Allow advertisers to request policy exceptions
-## Integration with HITL
+## Internal review and buyer interaction
Keep the two human-in-the-loop paths distinct:
- **Buyer clarification:** Return `input-required` only when the buyer must answer a question. Include `task_id` and `context_id`, then continue that task context after the buyer responds.
-- **Seller-internal review:** For `brief` or `refine`, return `submitted` when review cannot finish within the current time budget and `working` while it remains active. Do not use `input-required` merely because a seller-side reviewer is involved.
+- **Seller-internal review:** Keep the review mechanism private. For `brief` or `refine`, return `submitted` when the task cannot finish within the current time budget and `working` while it remains active. Do not use `input-required` merely because a seller-side process is involved.
After review, return the canonical completed, rejected, or failed outcome.
diff --git a/docs/media-buy/product-discovery/index.mdx b/docs/media-buy/product-discovery/index.mdx
index 76007c74fa..c56cc8c2f0 100644
--- a/docs/media-buy/product-discovery/index.mdx
+++ b/docs/media-buy/product-discovery/index.mdx
@@ -192,6 +192,33 @@ Commercial changes after acceptance use the same proposal lineage to create an
amendment or negotiated cancellation. Operational changes inside the accepted
envelope use [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy).
+When the seller offers post-acceptance change rights, it places them in
+`commercial_terms.change_terms[]`. Each entry names one canonical action, its
+service mode, lifecycle statuses, an optional elapsed-time processing SLA,
+portable constraints, stable conditions, and an optional contract reference.
+Because the array is inside `commercial_terms`, the proposal `terms_digest`
+binds those rights. When the array is present, an omitted action was not
+negotiated; omission of the entire array preserves the legacy
+unspecified-rights behavior.
+
+Treat the three action surfaces as a narrowing sequence:
+
+| Surface | Buyer question | Meaning |
+|---|---|---|
+| Product `allowed_actions[]` | Could this product support the change? | Advisory selection template |
+| Proposal `commercial_terms.change_terms[]` | What change rights does this deal promise? | Binding contractual ceiling |
+| MediaBuy `available_actions[]` | What can I invoke now? | Authoritative current-state projection |
+
+The proposal must restate any product constraint that becomes binding. A live
+action links back through `change_term_id`; product and change-term `terms_ref`
+remain document references, not identities or authority grants.
+
+The accepted rights bind to the resulting media buy and account, not to the
+individual agent that accepted the proposal. A delegated operator
+may exercise a right only when both gates admit the call: seller-side account
+authorization (`allowed_tasks` and any `field_scopes`) and the buyer's signed
+governance delegation. Neither gate expands the other.
+
See [Proposal negotiation](/docs/media-buy/product-discovery/proposal-negotiation)
for request shapes, failure planes, finalization, and recovery.
diff --git a/docs/media-buy/product-discovery/proposal-negotiation.mdx b/docs/media-buy/product-discovery/proposal-negotiation.mdx
index 9fa71c5417..47c2ac35b7 100644
--- a/docs/media-buy/product-discovery/proposal-negotiation.mdx
+++ b/docs/media-buy/product-discovery/proposal-negotiation.mdx
@@ -465,6 +465,18 @@ The protocol layer owns schema validation, capability gates, result cardinality,
Fence `ask` from pricing and authorization authority. It is attacker-controlled free text and may influence candidate generation only through bounded application policy. Only the typed `commercial_terms` object becomes contractual.
+Post-acceptance change rights belong in `commercial_terms.change_terms[]`, not in `ask`, product prose, or an unbound capability. The seller emits at most one term per canonical action. `service_mode` describes the buyer-visible route; `allowed_statuses` defines the non-terminal lifecycle states in which the contractual right applies; and `processing_sla.response_max` and `completion_max` are elapsed-time maxima that already account for weekends and non-working periods. `seller_managed` means the seller controls the internal decision process without exposing whether it used automation or people. The legacy `requires_approval` value remains readable for 3.x compatibility.
+
+The complete array is covered by `terms_digest`. If `change_terms` is present, an unlisted action is not a negotiated right; if the whole field is absent, consumers retain legacy behavior and consult current `available_actions[]`. Runtime `available_actions[]` may narrow a negotiated right because of current state or a stated condition, but MUST NOT silently broaden the accepted terms or replace the negotiated mode, SLA, constraints, conditions, or contract reference. A broader right requires an accepted amendment proposal.
+
+`constraints` makes common bounds preflightable without turning arbitrary seller policy into executable code. The initial portable kinds cover budget deltas and resulting values, flight changes and notice, package-count changes, and effective timing. Product `allowed_actions[].constraints` is advisory; the proposal must restate a bound before it becomes contractual. If only opaque `conditions[]` are present, SDKs report the condition as unevaluated and do not promise that a parameterized request will pass.
+
+Change terms attach to the media buy/account. Exercising one still requires the authenticated caller to pass seller account authorization and, when governance applies, to carry a signed `governance_context` admitted by the plan's `delegations[]`. This permits a properly delegated operator without turning the commercial term into authorization by itself.
+
+At execution, the seller evaluates the currently accepted terms revision, not a caller-supplied or cached earlier proposal. Replayed, superseded, expired, or amended-away rights do not authorize a change. `conditions[]` are opaque identifiers and `description` is display text; neither is interpreted as an instruction or authority grant. Resolving a change term's `terms_ref` uses the normal authenticated contract-document path without forwarding ambient credentials, and the resolved document may explain but never expand the typed action, mode, statuses, constraints, or SLA.
+
+The current MediaBuy action uses `change_term_id` to name the governing `term_id`. The `terms_ref` property on `available_actions[]` shipped in 3.1 with deliberately opaque semantics; it remains readable through 3.x and is removed in 4.0. A 3.2 compatibility projection may echo the change-term ID there for an older consumer, but a buyer must not reinterpret an arbitrary 3.1 value as a proposal-term identity. When both link fields are present as aliases, they must agree.
+
### Stage immutable successors
For revision batches:
diff --git a/docs/media-buy/task-reference/control_media_buy.mdx b/docs/media-buy/task-reference/control_media_buy.mdx
index 55a5212f5c..11d3aadf0c 100644
--- a/docs/media-buy/task-reference/control_media_buy.mdx
+++ b/docs/media-buy/task-reference/control_media_buy.mdx
@@ -5,7 +5,7 @@ description: "Apply operational delivery controls within accepted MediaBuy terms
testable: true
---
-`control_media_buy` applies operational changes that remain inside the immutable commercial envelope accepted for the MediaBuy. It requires the current `revision` for optimistic concurrency.
+`control_media_buy` applies operational changes authorized by the terms currently accepted for the MediaBuy. It requires the current `revision` for optimistic concurrency. The accepted envelope includes proposal-bound `commercial_terms.change_terms[]`: when the current `available_actions[]` projection links a `self_serve` or `conditional_self_serve` action through `change_term_id`, a request inside that term's typed constraints is already authorized even if it changes the original numeric value. The accepted term is the authority; product `allowed_actions[]` is only advisory.
Supported controls include:
@@ -79,7 +79,7 @@ pause atomically with purchase, or author an exact initial
Daily-cap changes apply immediately and atomically with spend already incurred in the current cap day counted. Lowering a cap below current-day spend pauses further applicable delivery without reversing prior spend. `null` removes an aggregate or package cap. A media-buy `budget_cap_timezone` change starts at the next boundary under the previously effective timezone; package controls never carry their own timezone.
-When an otherwise valid control would exceed the accepted budget, targeting, or delivery envelope, the seller returns [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required). The buyer reads `accepted_proposal_id` from [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), passes it to [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) to create a draft amendment, finalizes that draft into a committed hold, and applies it through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). This pointer survives SDK restart and moves atomically after each accepted amendment.
+When an otherwise valid control has no negotiated change right, the seller returns [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) with `reason: "not_supported_on_buy"`. A negotiated right whose opaque condition cannot yet be evaluated returns `reason: "condition_unresolved"` on the current protocol surface and remains absent from `available_actions[]`; released 3.1 callers receive the compatible `not_supported_on_buy` reason. When the buy has the right but the request exceeds its typed constraints, the seller returns [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required). The buyer reads `accepted_proposal_id` from [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), passes it to [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) to create a draft amendment, finalizes that draft into a committed hold, and applies it through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). This pointer survives SDK restart and moves atomically after each accepted amendment.
A completed in-envelope control MAY include `warnings[]` for non-blocking observations, such as a material inventory shortfall forecast observed after the applied control. A continuing condition is also readable as an indicator through `get_media_buys`. Failed and submitted controls never carry this warning surface. Flight-date changes are not controls; a `flight_change_creates_pacing_risk` warning belongs on the completed `accept_proposal` response that applies the amendment.
diff --git a/docs/media-buy/task-reference/update_media_buy.mdx b/docs/media-buy/task-reference/update_media_buy.mdx
index c7833f4643..95d675c359 100644
--- a/docs/media-buy/task-reference/update_media_buy.mdx
+++ b/docs/media-buy/task-reference/update_media_buy.mdx
@@ -58,7 +58,7 @@ must reject the field before mutation.
## Action vocabulary and field mapping
-Buyers express intent through actions; the seller declares the actions available on each buy via the structured `available_actions[]` field (authoritative) and the flat `valid_actions[]` field (legacy, deprecated in 4.0). When both fields are present, consumers MUST prefer `available_actions[]` — it carries the resolved `mode`, optional `sla`, and optional `terms_ref` that the flat string array cannot represent. When a buyer issues an `update_media_buy` request, the seller maps the request's fields to one or more actions and rejects with [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) (carrying `attempted_action`, `reason`, and `currently_available_actions` in `error.details`) if any mapped action is not in the buy's resolved `available_actions[]`.
+Buyers express intent through actions; the seller declares the actions available on each buy via the structured `available_actions[]` field (authoritative for current routing) and the flat `valid_actions[]` field (legacy, deprecated in 4.0). Accepted proposal `commercial_terms.change_terms[]` are the contractual ceiling: current `available_actions[]` may narrow a right because of state or a stated condition, but cannot broaden it or silently replace its mode, SLA, constraints, conditions, or contract reference. When both action fields are present, consumers MUST prefer `available_actions[]` — it carries the resolved `mode`, optional `sla`, and in 3.2 an optional `change_term_id` that the flat string array cannot represent. Deprecated 3.1 `terms_ref` remains an opaque compatibility pointer and MUST NOT be assumed to identify a proposal term. When a buyer issues an `update_media_buy` request, the seller maps the request's fields to one or more actions and rejects with [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) (carrying `attempted_action`, `reason`, and `currently_available_actions` in `error.details`) if any mapped action is not in the buy's resolved `available_actions[]`.
The mapping is normative — sellers and SDKs MUST use this table to translate between request fields and action identifiers so the surface is consistent across implementations.
@@ -99,7 +99,8 @@ Each entry in `available_actions[]` carries a singular `mode` (resolved against
|------|---------|
| `self_serve` | Seller honors the request synchronously |
| `conditional_self_serve` | Auto-approves within tolerances, escalates outside them (programmatic guaranteed pattern) |
-| `requires_approval` | Human-in-the-loop, async, no proposal artifact; buyer SDK polls or awaits webhook |
+| `seller_managed` | Seller-controlled asynchronous handling. The buyer sees the declared SLA and completion result, not the seller's internal automation or review process. |
+| `requires_approval` | Legacy 3.x spelling for an asynchronous seller decision. New proposal terms use `seller_managed`. |
Buyer SDKs MUST branch on `mode` to decide whether to expect a synchronous response, conditional handling, or an asynchronous approval callback. Requotes are not modeled as an action mode in 3.1; sellers return [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required) when a requested update exceeds the current quoted envelope.
@@ -371,7 +372,7 @@ For budget/pacing interaction and bidding inheritance, see [Budget & Pacing Cont
| `implementation_date` | ISO 8601 timestamp when changes take effect (null if pending approval) |
| `invoice_recipient` | Updated invoice recipient, echoed from request when provided. Confirms the seller accepted the billing override. Bank details are omitted (write-only). |
| `valid_actions` | Flat-vocabulary actions the buyer can perform after this update. Saves a round-trip to [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys). Deprecated in favor of `available_actions[]` and removed in 4.0. |
-| `available_actions` | Structured per-buy resolution of actions available after this update, each entry with action, resolved `mode`, optional `sla`, optional `terms_ref`. Authoritative — buyer SDKs SHOULD prefer this over `valid_actions` when both are present. |
+| `available_actions` | Structured per-buy resolution of actions available after this update, each entry with action, resolved `mode`, optional `sla`, and 3.2 `change_term_id`. Authoritative — buyer SDKs SHOULD prefer this over `valid_actions` when both are present. Deprecated `terms_ref` is retained as an opaque 3.1 compatibility field through 3.x. |
| `affected_packages` | Full Package objects for every modified package, including every active package rescaled by a fixed-mode `total_budget` update. This is a state snapshot, not a sparse delta: sellers MUST NOT return `{ package_id }`-only stubs. Campaign-level updates that do not modify packages may return an empty array. |
| `warnings` | Structured non-blocking observations. The update was still applied. Continuing conditions appear on the next `get_media_buys` snapshot. |
diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx
index 2ae8491492..c3e8a91ed8 100644
--- a/docs/protocol/get_adcp_capabilities.mdx
+++ b/docs/protocol/get_adcp_capabilities.mdx
@@ -186,6 +186,7 @@ Declares cross-role enforcement of buyer governance for consequential tasks. Thi
| Field | Type | Description |
| ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tasks` | object[] | Task-scoped enforcement claims. Each entry has `task` and `modes`; the corresponding request schema defines when the operation is commitment-bearing through `x-governed-commitment`. `task` is the semantic uniqueness key: emit one entry per task and combine modes in that entry. |
+| `accepted_governance_agents` | object | Optional seller-wide advisory matchers for governance agents a buyer may bind. `any_of[]` accepts explicit canonicalized HTTPS URLs or deterministic registry-verification criteria. Omission means any otherwise-valid agent is accepted; [`sync_governance`](/docs/accounts/tasks/sync_governance) remains authoritative per account. |
```json
{
@@ -199,7 +200,23 @@ Declares cross-role enforcement of buyer governance for consequential tasks. Thi
"modes": ["signed_context", "online_execution_check"]
},
{ "task": "activate_signal", "modes": ["signed_context"] }
- ]
+ ],
+ "accepted_governance_agents": {
+ "any_of": [
+ {
+ "kind": "agent_url",
+ "agent_url": "https://governance.example/mcp"
+ },
+ {
+ "kind": "verification",
+ "registry": "https://registry.example/agents",
+ "role": "governance",
+ "adcp_version": "3.2",
+ "verification_modes": ["spec", "live"],
+ "max_age_seconds": 86400
+ }
+ ]
+ }
}
}
}
@@ -461,6 +478,24 @@ See [Accounts and Agents](/docs/building/by-layer/L2/accounts-and-agents#what-se
Media-buy protocol capabilities. Only present if `media_buy` is in `supported_protocols`. Sellers declaring `media_buy` should also include `account` (with `supported_billing`) and `media_buy.portfolio` — buyers need both to establish billing and understand inventory coverage. Compliance testing validates their presence.
+#### acceptance_policy_discovery
+
+`media_buy.acceptance_policy_discovery` advertises a structured catalog for what the seller will accept. `catalog_url` identifies an HTTPS `acceptance-policy-catalog.json` document, `catalog_digest` pins its bytes, and `default_profile_ids[]` selects seller-wide profiles. The declaration does not claim the seller evaluates buyer-supplied acceptance context during discovery; buyers compose the published profiles for advisory preflight and treat exact task responses as authoritative.
+
+This capability is advisory. Missing capability or omitted rules in a partial profile are unknown, not unrestricted acceptance. Products may add profile IDs; defaults and product profiles compose restrictively. The exact task response remains authoritative and may return [`POLICY_VIOLATION`](/docs/building/verification/compliance-catalog#error-code-policy-violation) for an account-specific or undisclosed rule.
+
+```json
+{
+ "media_buy": {
+ "acceptance_policy_discovery": {
+ "catalog_url": "https://seller.example/.well-known/adcp-acceptance-policies.json",
+ "catalog_digest": "sha256:54f04c3e2e8dfdf682b0da0cd0ee4d5265e54a6e0f72b9260785dfc1526fd08a",
+ "default_profile_ids": ["seller-default"]
+ }
+ }
+}
+```
+
#### lifecycle_tools
AdCP 3.2 sellers use `lifecycle_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. When absent, buyers use the legacy `get_products`, `create_media_buy`, and `update_media_buy` facades. The advertised subset is a statement of supported surfaces, not a quality signal: a seller that omits `list_products` offers no wholesale feed and is fully conformant — wholesale-path conformance scenarios are capability-gated and grade not applicable for sellers that do not advertise the surface. Buyers SHOULD NOT infer inventory breadth or planning capability from the subset a seller advertises. The compact tools share the authorization framework with their legacy peers, but grants remain task-specific and each stateful tool has its own idempotency identity: retry with the same tool name and payload. SDKs use the release manifest's `legacy_fallback` metadata to distinguish a direct one-call translation from a stateful orchestration or an unsupported fallback; see [Same-major tool replacements](/docs/building/cross-cutting/version-adaptation#same-major-tool-replacements).
diff --git a/docs/reference/migration/3-1-to-3-2.mdx b/docs/reference/migration/3-1-to-3-2.mdx
index aef494e857..7d325ead66 100644
--- a/docs/reference/migration/3-1-to-3-2.mdx
+++ b/docs/reference/migration/3-1-to-3-2.mdx
@@ -109,6 +109,20 @@ Absence of the declaration means use the 3.x compatibility facades. Retry a
stateful request with the same tool name, idempotency key, and payload; an
idempotency key does not create replay identity across old and new task names.
+### Resolve product, proposal, and live action rights
+
+Use product `allowed_actions[]` only to select offers. Binding rights live in
+accepted `commercial_terms.change_terms[]`; the live buy's
+`available_actions[]` is the authoritative current-state subset. A 3.2 buyer
+uses `available_actions[].change_term_id` to resolve the accepted proposal
+term, including its `allowed_statuses`, constraints, mode, and processing SLA.
+
+The `available_actions[].terms_ref` field shipped in 3.1 and remains valid but
+opaque. Continue parsing it for 3.1 peers. A 3.2 seller may echo a
+`change_term_id` there for an older consumer, but new integrations emit and
+prefer `change_term_id`; if both are used as term links, they must agree.
+Remove the alias only with AdCP 4.0.
+
### Targeting-aware discovery
Move structured delivery constraints from briefs and legacy filters into
diff --git a/docs/reference/url-canonicalization.mdx b/docs/reference/url-canonicalization.mdx
index dd87cfa2a1..647c5bfcf2 100644
--- a/docs/reference/url-canonicalization.mdx
+++ b/docs/reference/url-canonicalization.mdx
@@ -43,8 +43,11 @@ After all eight steps, comparison is byte-for-byte. Implementations MUST NOT app
| `format-id` resolution | `format-id.agent_url` against the URL an agent publishes for its formats | [format-id schema](https://adcontextprotocol.org/schemas/v3/core/format-id.json) |
| `adagents.json` `authoritative_location` indirection | Following the pointer; the target URL MUST canonicalize the same way | [Managed networks](/docs/governance/property/managed-networks#security-considerations) |
| Provenance verifier allowlist | `verify_agent.agent_url` vs `creative_policy.accepted_verifiers[].agent_url` | [Provenance Verification](/docs/governance/creative/provenance-verification#the-verifier-contract-seller-publishes-buyer-represents-seller-confirms) |
+| Governance-agent acceptance | Candidate `governance_agents[].url` vs `accepted_governance_agents.any_of[].agent_url` | [`sync_governance`](/docs/accounts/tasks/sync_governance#seller-acceptance-of-governance-agents) |
| Any registry with a URL primary key | Canonical form is the key; raw input is not | - |
+Canonicalization is for matching, not safe reflection. A disclosed governance-agent rejection derives `attempted_agent_origin` from the parsed URL and strips userinfo, path, query, and fragment. It never returns the raw candidate URL; an opaque rejection returns no attempted endpoint at all.
+
## Signing profile extensions
The [request-signing profile](/docs/building/by-layer/L1/security#signed-requests-transport-layer) layers transport-specific rules on top of this algorithm:
diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx
index 9e0eaaf34a..8ee3792261 100644
--- a/docs/snippets/compliance-error-codes.mdx
+++ b/docs/snippets/compliance-error-codes.mdx
@@ -20,7 +20,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat
| `ACCOUNT_REQUIRED` | correctable | pass account, or reference an existing resource whose owning account the service can resolve |
| `ACCOUNT_SETUP_REQUIRED` | correctable | check details.setup for URL or instructions |
| `ACCOUNT_SUSPENDED` | terminal | contact seller to resolve suspension |
-| `ACTION_NOT_ALLOWED` | correctable | branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[<action>].mode (await the seller's webhook for requires_approval); for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate |
+| `ACTION_NOT_ALLOWED` | correctable | branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[<action>].mode; for condition_unresolved, wait until the seller advertises the action after resolving its opaque condition; for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate |
| `AGENT_BLOCKED` | terminal | surface to a human at the buyer — the relationship is permanently denied and is reinstated only through offline operator action with the seller, not via any seller-callable AdCP task |
| `AGENT_SUSPENDED` | terminal | surface to a human at the buyer — the agent cannot unilaterally lift a suspension; re-onboarding with the seller offline may resolve |
| `AMBIGUOUS_BIDDING_POLICY` | correctable | remove either the canonical bidding block or all legacy bidding fields from the effective package |
@@ -64,6 +64,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat
| `FORMAT_OPTION_UNRESOLVED` | correctable | advisory — publisher-side fix needed: a placement's {"format_options[].format_option_id"} references a {"format_option_id"} that does not exist in this file's top-level {"formats[]"}. Buyer SDK MUST fail closed for that placement (drop the format from the placement's accepted set) and surface this code. Publisher fix: either declare the missing format option in {"formats[]"} or remove the reference from the placement. Do not auto-retry |
| `FORMAT_PROJECTION_FAILED` | correctable | advisory — seller-side fix needed: ask the seller to add an explicit {"canonical"} field on the legacy format declaration, or contribute a registry entry (format_id_glob or structural match) to v1-canonical-mapping.json. Do not auto-retry; the product is still valid on the legacy named-format path |
| `FORMAT_SHAPE_PROMOTED` | correctable | advisory — migrate the legacy custom format_shape to error.details.promoted_to before error.details.transition_end; dual emission is allowed during the transition window. Do not auto-retry |
+| `GOVERNANCE_AGENT_NOT_ACCEPTED` | correctable | select a governance agent matching {"error.details.accepted_governance_agents"}, then retry {"sync_governance"} with a fresh idempotency key |
| `GOVERNANCE_DENIED` | correctable | restructure the buy, escalate to human spending authority, or contact the governance agent for details |
| `GOVERNANCE_UNAVAILABLE` | transient | retry with backoff; if the agent remains unreachable, the buyer MUST contact the plan's governance operator |
| `IDEMPOTENCY_CONFLICT` | correctable | use a fresh UUID v4 for the new request, or resend the exact original payload to get the cached response |
@@ -207,9 +208,9 @@ Account has been suspended. Recovery: terminal (contact seller to resolve suspen
-**Suggested action:** branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[<action>].mode (await the seller's webhook for requires_approval); for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate
+**Suggested action:** branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[<action>].mode; for condition_unresolved, wait until the seller advertises the action after resolving its opaque condition; for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate
-The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate {"error.details"} with {"attempted_action"} (the {"media_buy_valid_action"} value the request maps to), {"reason"} (an {"action-not-allowed-reason"} value: {"wrong_status"}, {"not_supported_on_product"}, {"not_supported_on_buy"}, or {"mode_mismatch"}), and {"currently_available_actions"} (echo of the buy's resolved {"available_actions[]"} so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when {"reason"} is {"wrong_status"} (wait for or transition to an allowed status) or {"mode_mismatch"} (re-issue through the appropriate flow). Terminal-for-this-buy when {"reason"} is {"not_supported_on_product"} or {"not_supported_on_buy"} — buyers select a different product or renegotiate buy terms.
+The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate {"error.details"} with {"attempted_action"} (the {"media_buy_valid_action"} value the request maps to), {"reason"} (an {"action-not-allowed-reason"} value: {"wrong_status"}, {"not_supported_on_product"}, {"not_supported_on_buy"}, {"mode_mismatch"}, or {"condition_unresolved"}), and {"currently_available_actions"} (echo of the buy's resolved {"available_actions[]"} so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when {"reason"} is {"wrong_status"} (wait for or transition to an allowed status), {"mode_mismatch"} (re-issue through the appropriate flow), or {"condition_unresolved"} (wait until the seller can resolve the condition and advertises the action). Terminal-for-this-buy when {"reason"} is {"not_supported_on_product"} or {"not_supported_on_buy"} — buyers select a different product or renegotiate buy terms.
@@ -630,6 +631,15 @@ Recovery: correctable (seller-side action — add explicit {"canonical"}
Non-fatal deprecation advisory raised when a 3.2-aware SDK encounters {"format_kind: custom"} with a {"format_shape"} that has been promoted to a first-class canonical. SDKs MUST preserve the declaration during its transition window and SHOULD augment the containing response's {"errors[]"} with {"source: sdk"}, {"sdk_id"}, this code, and {"details: { format_shape, promoted_to, promotion_release, transition_end }"}. Producers that self-detect their own legacy declaration MAY emit the same advisory with {"source: producer"}. Recovery is seller-side: dual-emit during the published transition window, migrate consumers, then replace the custom declaration with the promoted canonical.
+
+
+
+
+
+**Suggested action:** select a governance agent matching {"error.details.accepted_governance_agents"}, then retry {"sync_governance"} with a fresh idempotency key
+
+The governance agent proposed in {"sync_governance"} does not satisfy the seller's authoritative per-account acceptance criteria. The failed binding MUST NOT be persisted or contacted, and credentials supplied for it MUST NOT be echoed in responses or logs. {"error.details"} SHOULD conform to {"error-details/governance-agent-not-accepted.json"}. Distinct from {"GOVERNANCE_UNAVAILABLE"}, which means a verification criterion or registry could not be resolved and is retryable. Recovery: correctable (select an agent satisfying the returned criteria or resolve the account-specific requirement with the seller).
+
@@ -809,7 +819,7 @@ The seller does not accept the requested {"payment_terms"} value fo
**Suggested action:** call check_governance to mint a valid token, or contact the seller to resolve the underlying permission; when error.details.scope is 'agent' with reason 'sandbox_only' the rejection is terminal-pending-onboarding — surface to a human rather than auto-retrying. For suspended/blocked agent relationships, sellers emit AGENT_SUSPENDED / AGENT_BLOCKED instead (those codes carry recovery: terminal directly).
-The authenticated caller is not authorized for the requested action under the seller's own policies, or a required signed credential (e.g., a {"governance_context"} token on a spend-commit) is missing, fails verification, or was issued for a different plan, seller, or phase. Distinct from {"AUTH_MISSING"} (no credentials presented), {"AUTH_INVALID"} (credentials presented but rejected), {"GOVERNANCE_DENIED"} (governance agent denied), {"AGENT_SUSPENDED"} (agent's relationship temporarily paused), and {"AGENT_BLOCKED"} (agent's relationship permanently denied). When the gate that fired is specifically a non-status per-agent provisioning constraint — e.g., the agent is provisioned for sandbox traffic only and the request was against a non-sandbox account — {"error.details"} SHOULD conform to {"error-details/agent-permission-denied.json"} ({"scope: \"agent\""} plus {"reason: \"sandbox_only\""}) so callers can dispatch without parsing prose. Sellers MUST emit {"scope: \"agent\""} only when buyer-agent identity has been established via signed-request derivation or a credential-to-agent mapping in the seller's onboarding record; in all other cases (including bearer credentials not mapped to a specific agent record) sellers MUST return {"PERMISSION_DENIED"} and MUST omit {"error.details.scope"} — emitting the per-agent scope without established identity is a cross-tenant onboarding oracle, and the omit MUST be enforced across every observable channel (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity) per the channel-coverage rules in error-handling.mdx Per-Agent Authorization Gate, mirroring the {"*_NOT_FOUND"} uniform-response rule and {"BILLING_NOT_PERMITTED_FOR_AGENT"}. The {"suspended"} and {"blocked"} per-agent states are NOT carried on this code — sellers MUST emit {"AGENT_SUSPENDED"} / {"AGENT_BLOCKED"} instead, each of which is its own discriminator. Recovery: correctable (call {"check_governance"} to mint a valid token, or contact the seller to resolve the underlying permission); when {"details.reason"} is present the rejection is terminal-pending-onboarding — the agent MUST surface to a human at the buyer rather than auto-retrying, since the agent cannot unilaterally extend its sandbox-only provisioning.
+The authenticated caller is not authorized for the requested action, or a required signed credential (e.g., a {"governance_context"} token on a spend-commit) is missing, fails verification, or was issued for a different plan, seller, or phase. Seller content or advertising policy denials use {"POLICY_VIOLATION"}; media-buy change-right and current-availability denials use {"ACTION_NOT_ALLOWED"}. Distinct from {"AUTH_MISSING"} (no credentials presented), {"AUTH_INVALID"} (credentials presented but rejected), {"GOVERNANCE_DENIED"} (governance agent denied), {"AGENT_SUSPENDED"} (agent's relationship temporarily paused), and {"AGENT_BLOCKED"} (agent's relationship permanently denied). When the gate that fired is specifically a non-status per-agent provisioning constraint — e.g., the agent is provisioned for sandbox traffic only and the request was against a non-sandbox account — {"error.details"} SHOULD conform to {"error-details/agent-permission-denied.json"} ({"scope: \"agent\""} plus {"reason: \"sandbox_only\""}) so callers can dispatch without parsing prose. Sellers MUST emit {"scope: \"agent\""} only when buyer-agent identity has been established via signed-request derivation or a credential-to-agent mapping in the seller's onboarding record; in all other cases (including bearer credentials not mapped to a specific agent record) sellers MUST return {"PERMISSION_DENIED"} and MUST omit {"error.details.scope"} — emitting the per-agent scope without established identity is a cross-tenant onboarding oracle, and the omit MUST be enforced across every observable channel (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity) per the channel-coverage rules in error-handling.mdx Per-Agent Authorization Gate, mirroring the {"*_NOT_FOUND"} uniform-response rule and {"BILLING_NOT_PERMITTED_FOR_AGENT"}. The {"suspended"} and {"blocked"} per-agent states are NOT carried on this code — sellers MUST emit {"AGENT_SUSPENDED"} / {"AGENT_BLOCKED"} instead, each of which is its own discriminator. Recovery: correctable (call {"check_governance"} to mint a valid token, or contact the seller to resolve the underlying permission); when {"details.reason"} is present the rejection is terminal-pending-onboarding — the agent MUST surface to a human at the buyer rather than auto-retrying, since the agent cannot unilaterally extend its sandbox-only provisioning.
diff --git a/governance/decisions/DR-0015-seller-acceptance-policy-discovery.md b/governance/decisions/DR-0015-seller-acceptance-policy-discovery.md
new file mode 100644
index 0000000000..6815f8a2c4
--- /dev/null
+++ b/governance/decisions/DR-0015-seller-acceptance-policy-discovery.md
@@ -0,0 +1,39 @@
+---
+id: DR-0015
+title: Sellers may publish registry-backed acceptance-policy discovery
+class: normative
+status: ratified
+date: 2026-08-24
+decided: 2026-08-24
+decided_by: WG approval confirmed by Brian O'Kelley
+refs: ["#6749", "PR #6794"]
+dissent: No dissent was reported in the ratification confirmation
+---
+
+## Decision
+
+Sellers may publish structured acceptance-policy catalogs that compose exact,
+version-pinned policy-registry entries into reusable profiles. Catalogs are
+content-addressed, may declare partial or complete coverage, and may be
+projected by products. Political-advertising acceptance is represented through
+registry category facets and contextual rules rather than a single boolean.
+
+Discovery is advisory. A seller's authoritative response to a transaction may
+still reject it using the protocol's structured error vocabulary, including an
+opaque seller policy reference when disclosure would expose enforcement
+controls.
+
+## Rationale
+
+Buyers need machine-readable preflight guidance without requiring sellers to
+publish every internal rule. Registry-backed profiles provide shared meaning,
+version and digest pins provide reproducibility, and contextual facets can
+represent regional and conditional platform policies without hard-coding one
+special category into the protocol.
+
+## Implications
+
+Catalog and policy publications are immutable at a pinned version. Buyers must
+respect coverage and omission semantics and must not treat discovery as a
+promise that a buy will be accepted. Sellers retain progressive-disclosure
+control over authoritative rejection details.
diff --git a/governance/decisions/DR-0016-proposal-bound-buyer-change-rights.md b/governance/decisions/DR-0016-proposal-bound-buyer-change-rights.md
new file mode 100644
index 0000000000..75f4907bf8
--- /dev/null
+++ b/governance/decisions/DR-0016-proposal-bound-buyer-change-rights.md
@@ -0,0 +1,40 @@
+---
+id: DR-0016
+title: Accepted proposal terms bind buyer change rights
+class: normative
+status: ratified
+date: 2026-08-24
+decided: 2026-08-24
+decided_by: WG approval confirmed by Brian O'Kelley
+refs: ["#6750", "PR #6794"]
+dissent: No dissent was reported in the ratification confirmation
+---
+
+## Decision
+
+Post-acceptance buyer change rights are expressed in
+`commercial_terms.change_terms[]` and covered by `terms_digest`. When the field
+is present, its entries are the authorization ceiling: an omitted action is not
+a negotiated right. Runtime `available_actions[]`, product templates, current
+state, account authorization, and governance may narrow those rights but must
+not broaden or replace them.
+
+Each right carries a service mode, applicable states, elapsed-time SLA,
+conditions, and typed constraints. Constraints and conditions fail closed when
+they cannot be evaluated. A delegated caller may exercise a buy-bound right
+only when the existing account authorization and signed governance checks also
+admit that caller.
+
+## Rationale
+
+Separating accepted rights from current availability makes the commercial
+agreement durable while preserving operational state projection. Binding the
+complete array into the existing commercial digest prevents an advisory product
+declaration from silently changing the accepted deal.
+
+## Implications
+
+Changing the rights requires a newly accepted proposal. Omission of the entire
+field preserves legacy behavior. AdCP 3.2 uses `change_term_id` and
+`seller_managed`; released 3.1 consumers receive the explicit compatibility
+projection through `terms_ref` and `requires_approval`.
diff --git a/governance/decisions/DR-0017-seller-dispositions-are-audit-records.md b/governance/decisions/DR-0017-seller-dispositions-are-audit-records.md
new file mode 100644
index 0000000000..38f6221c9b
--- /dev/null
+++ b/governance/decisions/DR-0017-seller-dispositions-are-audit-records.md
@@ -0,0 +1,39 @@
+---
+id: DR-0017
+title: Seller dispositions are attributable audit records, not governance authority
+class: normative
+status: ratified
+date: 2026-08-24
+decided: 2026-08-24
+decided_by: WG approval confirmed by Brian O'Kelley
+refs: ["#6757", "PR #6794"]
+dissent: No dissent was reported in the ratification confirmation
+---
+
+## Decision
+
+A seller may decline an otherwise governed action under its own standing policy
+and attribute that disposition with `POLICY_VIOLATION` using progressive
+disclosure, including an optional seller origin and an opaque
+`seller_policy_ref`. The buyer reports the failed interaction through the
+existing `report_plan_outcome` path, and the governance audit retains the
+bounded buyer-attributed error evidence.
+
+This record grants no seller authority over the buyer's governance plan. Seller
+internal automation, human review, escalation, and timing remain implementation
+details.
+
+## Rationale
+
+An approved buyer action can still fail at the seller for independent content,
+legal, or commercial reasons. Preserving an attributable failure explains why
+execution did not occur without conflating seller policy with credentials,
+account authorization, or buyer governance authority.
+
+## Implications
+
+`PERMISSION_DENIED` remains reserved for caller, credential, account-scope, or
+signed-governance authorization failures. `ACTION_NOT_ALLOWED` identifies a
+change outside negotiated or current rights. Reported failure evidence is
+untrusted audit data: it is bounded, mutation-isolated, and excluded from
+privileged prompts and governance decisions.
diff --git a/governance/decisions/DR-0018-governance-agent-binding-acceptance.md b/governance/decisions/DR-0018-governance-agent-binding-acceptance.md
new file mode 100644
index 0000000000..56fbe0d723
--- /dev/null
+++ b/governance/decisions/DR-0018-governance-agent-binding-acceptance.md
@@ -0,0 +1,42 @@
+---
+id: DR-0018
+title: Sellers may constrain governance-agent bindings at account sync
+class: normative
+status: ratified
+date: 2026-08-24
+decided: 2026-08-24
+decided_by: WG approval confirmed by Brian O'Kelley
+refs: ["#6758", "PR #6794"]
+dissent: No dissent was reported in the ratification confirmation
+---
+
+## Decision
+
+Sellers may publish advisory governance-agent acceptance criteria under the
+cross-role `adcp.governance_enforcement` capability. Criteria use a typed
+`any_of` union of exact canonical agent-URL matchers and deterministic trusted
+registry-verification matchers. The `sync_governance` result is authoritative
+for the account and rejects an unacceptable candidate with
+`GOVERNANCE_AGENT_NOT_ACCEPTED`.
+
+Rejection details may be opaque or disclosed. Disclosed details expose only the
+parsed HTTPS origin and applicable criteria, never the submitted URL or its
+credentials. A rejected endpoint is neither contacted nor persisted. A
+verification dependency that cannot be resolved produces retryable
+`GOVERNANCE_UNAVAILABLE`, not a definitive rejection.
+
+## Rationale
+
+A bound governance agent becomes a seller dependency for authorization and
+recovery, so sellers need a deterministic binding-time trust decision. The
+cross-role capability fits sellers that enforce governance without themselves
+implementing the governance protocol, while typed matchers make permissive OR
+semantics explicit.
+
+## Implications
+
+Absent a declaration, otherwise valid bindings remain accepted. Later policy or
+registry changes do not retroactively invalidate a binding; an explicit
+resynchronization creates a new decision. Eligibility criteria must be
+objective and non-discriminatory and must not become a commercial exclusion
+mechanism.
diff --git a/package.json b/package.json
index 3aa5b063e1..6e76fbe0a7 100644
--- a/package.json
+++ b/package.json
@@ -114,7 +114,9 @@
"test:release-workflow": "node tests/release-workflow-immutability.test.cjs",
"test:immutable-release-artifacts": "node tests/immutable-release-artifacts.test.cjs",
"test:patch-3-0-compat-bundle": "node --test --test-force-exit --test-timeout=30000 tests/patch-3-0-compat-bundle.test.cjs",
- "test:error-codes": "node scripts/lint-error-codes.cjs",
+ "test:error-codes": "node scripts/lint-error-codes.cjs && npm run test:seller-governance-policy-surfaces && npm run test:test-script-registration",
+ "test:seller-governance-policy-surfaces": "node --test --test-force-exit tests/seller-governance-policy-surfaces.test.cjs",
+ "test:test-script-registration": "node --test --test-force-exit tests/test-script-registration.test.cjs",
"test:error-code-drift": "node scripts/lint-error-code-drift.cjs",
"test:language-tag-refs": "node --test --test-force-exit --test-timeout=30000 tests/lint-language-tag-refs.test.cjs && node scripts/lint-language-tag-refs.cjs",
"test:compliance-snippets": "node --test --test-force-exit --test-timeout=30000 tests/generate-compliance-snippets.node.mjs tests/link-compliance-symbols.node.mjs && npm run build:compliance -- --check",
diff --git a/scripts/check-registry-completeness.cjs b/scripts/check-registry-completeness.cjs
index 115b83f2e8..3c605fb63e 100644
--- a/scripts/check-registry-completeness.cjs
+++ b/scripts/check-registry-completeness.cjs
@@ -17,13 +17,74 @@
const fs = require('fs');
const path = require('path');
+const crypto = require('crypto');
+const { canonicalize } = require('@adcp/sdk');
+const yaml = require('js-yaml');
const REGISTRY_DIR = path.join(__dirname, '..', 'static', 'registry', 'policies');
+const POLICY_VERSION_DIR = path.join(__dirname, '..', 'static', 'registry', 'policy-versions');
+const CATEGORY_DIR = path.join(__dirname, '..', 'static', 'registry', 'policy-categories');
+const COMPLIANCE_SOURCE_DIR = path.join(__dirname, '..', 'static', 'compliance', 'source');
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_ALPHA2 = /^[A-Z]{2}$/;
const VALID_CATEGORIES = new Set(['regulation', 'standard']);
+const DIGEST = /^sha256:[a-f0-9]{64}$/;
+
+function digest(value) {
+ return `sha256:${crypto.createHash('sha256').update(canonicalize(value)).digest('hex')}`;
+}
+
+function policyContentDigest(entry) {
+ const copy = structuredClone(entry);
+ delete copy.acceptance_profile;
+ return digest(copy);
+}
+
+function profileContentDigest(profile) {
+ const copy = structuredClone(profile);
+ delete copy.content_digest;
+ return digest(copy);
+}
+
+function loadPolicyCategories() {
+ const categories = new Map();
+ if (!fs.existsSync(CATEGORY_DIR)) return categories;
+ for (const file of fs.readdirSync(CATEGORY_DIR).filter((name) => name.endsWith('.json')).sort()) {
+ const category = JSON.parse(fs.readFileSync(path.join(CATEGORY_DIR, file), 'utf8'));
+ categories.set(category.category_id, category);
+ }
+ return categories;
+}
+
+const POLICY_CATEGORIES = loadPolicyCategories();
+
+function checkPolicyCategory(category, filename) {
+ const errors = [];
+ const expectedId = filename.replace(/\.json$/, '');
+ if (category.category_id !== expectedId) {
+ errors.push(`category_id must match filename (expected "${expectedId}")`);
+ }
+ const seen = new Set();
+ for (const [index, facet] of (category.facets || []).entries()) {
+ if (seen.has(facet.facet_id)) errors.push(`duplicate facets[${index}].facet_id ${JSON.stringify(facet.facet_id)}`);
+ seen.add(facet.facet_id);
+ }
+ for (const [frameworkIndex, framework] of (category.regulatory_frameworks || []).entries()) {
+ for (const policyId of framework.policy_ids || []) {
+ if (!fs.existsSync(path.join(REGISTRY_DIR, `${policyId}.json`))) {
+ errors.push(`regulatory_frameworks[${frameworkIndex}].policy_ids references unknown registry policy ${JSON.stringify(policyId)}`);
+ }
+ }
+ }
+ for (const relatedId of category.related_categories || []) {
+ if (!fs.existsSync(path.join(CATEGORY_DIR, `${relatedId}.json`))) {
+ errors.push(`related_categories references unknown policy category ${JSON.stringify(relatedId)}`);
+ }
+ }
+ return errors;
+}
function checkExemplars(list, kind, errors) {
if (!Array.isArray(list) || list.length < 1) {
@@ -44,6 +105,123 @@ function checkExemplars(list, kind, errors) {
});
}
+function checkAcceptanceProfile(entry, errors) {
+ const profile = entry.acceptance_profile;
+ if (profile === undefined) return;
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
+ errors.push('acceptance_profile must be an object');
+ return;
+ }
+ if (profile.profile_id !== entry.policy_id) {
+ errors.push('acceptance_profile.profile_id must equal the containing registry policy_id');
+ }
+ if (typeof profile.version !== 'string' || !SEMVER.test(profile.version)) {
+ errors.push('acceptance_profile.version must be semver');
+ }
+ if (!DIGEST.test(profile.content_digest || '')) {
+ errors.push('acceptance_profile.content_digest must be a canonical sha256 digest');
+ }
+ if (!['partial', 'complete'].includes(profile.coverage)) {
+ errors.push('acceptance_profile.coverage must be partial or complete');
+ }
+ if (profile.coverage === 'complete' && (!profile.scope || typeof profile.scope !== 'object')) {
+ errors.push('a complete acceptance_profile must declare scope');
+ }
+
+ const refs = Array.isArray(profile.policy_refs) ? profile.policy_refs : [];
+ const expectedPolicyDigest = policyContentDigest(entry);
+ const containingRef = refs.find((ref) => ref && ref.policy_id === entry.policy_id && ref.version === entry.version);
+ if (!containingRef) {
+ errors.push('acceptance_profile.policy_refs must pin the containing policy_id and exact version');
+ } else if (containingRef.content_digest !== expectedPolicyDigest) {
+ errors.push(`acceptance_profile.policy_refs digest for ${entry.policy_id}@${entry.version} must be ${expectedPolicyDigest}`);
+ }
+ for (const [index, ref] of refs.entries()) {
+ if (!DIGEST.test(ref?.content_digest || '')) {
+ errors.push(`acceptance_profile.policy_refs[${index}].content_digest must be a canonical sha256 digest`);
+ continue;
+ }
+ const referencedFile = path.join(REGISTRY_DIR, `${ref.policy_id}.json`);
+ if (!fs.existsSync(referencedFile)) {
+ errors.push(`acceptance_profile.policy_refs[${index}] does not resolve to a registry policy ${JSON.stringify(ref.policy_id)}`);
+ continue;
+ }
+ const referencedEntry = JSON.parse(fs.readFileSync(referencedFile, 'utf8'));
+ if (referencedEntry.version !== ref.version) {
+ errors.push(`acceptance_profile.policy_refs[${index}] pins unavailable ${ref.policy_id}@${ref.version}`);
+ } else if (policyContentDigest(referencedEntry) !== ref.content_digest) {
+ errors.push(`acceptance_profile.policy_refs[${index}] digest does not match ${ref.policy_id}@${ref.version}`);
+ }
+ }
+
+ const expectedProfileDigest = profileContentDigest(profile);
+ if (profile.content_digest !== expectedProfileDigest) {
+ errors.push(`acceptance_profile.content_digest must be ${expectedProfileDigest}`);
+ }
+
+ const aliases = profile.region_aliases && typeof profile.region_aliases === 'object'
+ ? new Set(Object.keys(profile.region_aliases))
+ : new Set();
+ for (const categoryId of profile.scope?.subject_categories || []) {
+ if (!POLICY_CATEGORIES.has(categoryId)) {
+ errors.push(`acceptance_profile.scope references unknown subject_category ${JSON.stringify(categoryId)}`);
+ }
+ }
+ for (const group of profile.scope?.jurisdiction_groups || []) {
+ if (!aliases.has(group)) {
+ errors.push(`acceptance_profile.scope references undeclared jurisdiction group ${JSON.stringify(group)}`);
+ }
+ }
+ const rules = Array.isArray(profile.rules) ? profile.rules : [];
+ if (rules.length === 0) {
+ errors.push('acceptance_profile.rules must contain at least one rule');
+ return;
+ }
+ const ruleIds = new Set();
+ const pinnedPolicyIds = new Set(refs.map((ref) => ref?.policy_id).filter(Boolean));
+ for (const [index, rule] of rules.entries()) {
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
+ errors.push(`acceptance_profile.rules[${index}] must be an object`);
+ continue;
+ }
+ if (typeof rule.rule_id !== 'string' || rule.rule_id.length === 0) {
+ errors.push(`acceptance_profile.rules[${index}].rule_id must be non-empty`);
+ } else if (ruleIds.has(rule.rule_id)) {
+ errors.push(`acceptance_profile contains duplicate rule_id ${JSON.stringify(rule.rule_id)}`);
+ } else {
+ ruleIds.add(rule.rule_id);
+ }
+ if (typeof rule.effective_at !== 'string' || !Number.isFinite(Date.parse(rule.effective_at))) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} must declare a valid source effective_at`);
+ }
+ if (!Array.isArray(rule.policy_ids) || rule.policy_ids.length === 0) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} must cite at least one pinned policy_id`);
+ } else {
+ for (const policyId of rule.policy_ids) {
+ if (!pinnedPolicyIds.has(policyId)) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} cites unpinned policy_id ${JSON.stringify(policyId)}`);
+ }
+ }
+ }
+ const category = POLICY_CATEGORIES.get(rule.subject_category);
+ if (!category) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} references unknown subject_category ${JSON.stringify(rule.subject_category)}`);
+ } else {
+ const knownFacets = new Set((category.facets || []).map((facet) => facet.facet_id));
+ for (const facet of rule.subject_facets || []) {
+ if (!knownFacets.has(facet)) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} references unknown ${rule.subject_category} facet ${JSON.stringify(facet)}`);
+ }
+ }
+ }
+ for (const group of rule.jurisdiction_groups || []) {
+ if (!aliases.has(group)) {
+ errors.push(`acceptance_profile rule ${JSON.stringify(rule.rule_id ?? index)} references undeclared jurisdiction group ${JSON.stringify(group)}`);
+ }
+ }
+ }
+}
+
function checkEntry(entry, filename) {
const errors = [];
@@ -105,6 +283,8 @@ function checkEntry(entry, filename) {
checkExemplars(exemplars.fail, 'fail', errors);
}
+ checkAcceptanceProfile(entry, errors);
+
const expectedId = filename.replace(/\.json$/, '');
if (entry.policy_id !== expectedId) {
errors.push(`policy_id must match filename (expected "${expectedId}", got ${JSON.stringify(entry.policy_id ?? null)})`);
@@ -113,6 +293,53 @@ function checkEntry(entry, filename) {
return errors;
}
+function checkStoryboardPolicyFacets(sourceDirectory = COMPLIANCE_SOURCE_DIR) {
+ const failures = [];
+ const walkValue = (value, location, errors) => {
+ if (Array.isArray(value)) {
+ value.forEach((entry, index) => walkValue(entry, `${location}[${index}]`, errors));
+ return;
+ }
+ if (!value || typeof value !== 'object') return;
+ if (typeof value.subject_category === 'string' && Array.isArray(value.subject_facets)) {
+ const category = POLICY_CATEGORIES.get(value.subject_category);
+ if (!category) {
+ errors.push(`${location}.subject_category references unknown category ${JSON.stringify(value.subject_category)}`);
+ } else {
+ const knownFacets = new Set((category.facets || []).map((facet) => facet.facet_id));
+ for (const facet of value.subject_facets) {
+ if (!knownFacets.has(facet)) {
+ errors.push(`${location}.subject_facets references unknown ${value.subject_category} facet ${JSON.stringify(facet)}`);
+ }
+ }
+ }
+ }
+ for (const [key, entry] of Object.entries(value)) walkValue(entry, `${location}.${key}`, errors);
+ };
+ const walkFiles = (directory) => {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const fullPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ walkFiles(fullPath);
+ continue;
+ }
+ if (!/\.ya?ml$/.test(entry.name)) continue;
+ const errors = [];
+ try {
+ walkValue(yaml.load(fs.readFileSync(fullPath, 'utf8')), '$', errors);
+ } catch (error) {
+ errors.push(`invalid YAML: ${error.message}`);
+ }
+ if (errors.length > 0) failures.push({
+ file: path.relative(COMPLIANCE_SOURCE_DIR, fullPath),
+ errors,
+ });
+ }
+ };
+ walkFiles(sourceDirectory);
+ return failures;
+}
+
function main() {
if (!fs.existsSync(REGISTRY_DIR)) {
console.error(`Registry directory not found: ${REGISTRY_DIR}`);
@@ -127,6 +354,12 @@ function main() {
}
const failures = [];
+ let archivedVersionCount = 0;
+
+ for (const file of fs.readdirSync(CATEGORY_DIR).filter((name) => name.endsWith('.json')).sort()) {
+ const errors = checkPolicyCategory(POLICY_CATEGORIES.get(file.replace(/\.json$/, '')), file);
+ if (errors.length > 0) failures.push({ file: `policy-categories/${file}`, errors });
+ }
for (const file of files) {
const fullPath = path.join(REGISTRY_DIR, file);
@@ -143,8 +376,47 @@ function main() {
}
}
+ failures.push(...checkStoryboardPolicyFacets());
+
+ if (fs.existsSync(POLICY_VERSION_DIR)) {
+ for (const policyId of fs.readdirSync(POLICY_VERSION_DIR).sort()) {
+ const policyDir = path.join(POLICY_VERSION_DIR, policyId);
+ if (!fs.statSync(policyDir).isDirectory()) continue;
+ for (const file of fs.readdirSync(policyDir).filter((name) => name.endsWith('.json')).sort()) {
+ archivedVersionCount += 1;
+ const relative = `policy-versions/${policyId}/${file}`;
+ let entry;
+ try {
+ entry = JSON.parse(fs.readFileSync(path.join(policyDir, file), 'utf8'));
+ } catch (err) {
+ failures.push({ file: relative, errors: [`invalid JSON: ${err.message}`] });
+ continue;
+ }
+ const expectedVersion = file.replace(/\.json$/, '');
+ const errors = checkEntry(entry, `${policyId}.json`);
+ if (entry.version !== expectedVersion) {
+ errors.push(`version must match archived filename (expected ${JSON.stringify(expectedVersion)})`);
+ }
+ const currentFile = path.join(REGISTRY_DIR, `${policyId}.json`);
+ if (!fs.existsSync(currentFile)) {
+ errors.push(`archived policy must have a current registry entry ${JSON.stringify(policyId)}`);
+ } else {
+ try {
+ const current = JSON.parse(fs.readFileSync(currentFile, 'utf8'));
+ if (current.version === entry.version) {
+ errors.push('archive must contain a retired version, not the current registry version');
+ }
+ } catch {
+ // The current entry already reports its parse error in the main loop.
+ }
+ }
+ if (errors.length > 0) failures.push({ file: relative, errors });
+ }
+ }
+ }
+
if (failures.length === 0) {
- console.log(`Registry completeness: ${files.length} entr${files.length === 1 ? 'y' : 'ies'} OK.`);
+ console.log(`Registry completeness: ${files.length} current entr${files.length === 1 ? 'y' : 'ies'} and ${archivedVersionCount} archived version${archivedVersionCount === 1 ? '' : 's'} OK.`);
return;
}
@@ -161,4 +433,14 @@ function main() {
process.exit(1);
}
-main();
+if (require.main === module) main();
+
+module.exports = {
+ canonicalize,
+ digest,
+ policyContentDigest,
+ profileContentDigest,
+ checkPolicyCategory,
+ checkEntry,
+ checkStoryboardPolicyFacets,
+};
diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json
index 9422d75adb..8dc4a1c637 100644
--- a/scripts/error-code-drift-dispositions.json
+++ b/scripts/error-code-drift-dispositions.json
@@ -306,6 +306,11 @@
"target_version": "3.1",
"note": "From sync_accounts polymorphic-key refactor (#2261). Returned per-entry when the seller does not implement the entry's `sync_accounts` mode \u2014 settings-update sellers (explicit-account platforms) reject natural-key entries; provisioning-only sellers reject AccountRef-keyed entries. Wire change \u2014 held for 3.1."
},
+ "GOVERNANCE_AGENT_NOT_ACCEPTED": {
+ "disposition": "held-for-next-minor",
+ "target_version": "3.2",
+ "note": "Seller acceptance criteria for sync_governance bindings (#6758). New per-account rejection on the experimental governance.campaign surface; held for 3.2."
+ },
"INVALID_PRICING_OPTION": {
"disposition": "held-for-next-minor",
"target_version": "3.2",
diff --git a/scripts/generate-policy-publication-migration.cjs b/scripts/generate-policy-publication-migration.cjs
index 5668053529..bea08ceb8b 100644
--- a/scripts/generate-policy-publication-migration.cjs
+++ b/scripts/generate-policy-publication-migration.cjs
@@ -6,6 +6,7 @@
* Usage:
* node scripts/generate-policy-publication-migration.cjs \
* 548_publish_creative_rejection_policies \
+ * --replace eu_dsa_political_targeting=1.0.0 \
* creative_security_malicious_code [...more policy ids]
*/
@@ -14,21 +15,37 @@ const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const POLICY_DIR = path.join(REPO_ROOT, 'static', 'registry', 'policies');
+const POLICY_VERSION_DIR = path.join(REPO_ROOT, 'static', 'registry', 'policy-versions');
const MIGRATION_DIR = path.join(REPO_ROOT, 'server', 'src', 'db', 'migrations');
const MIGRATION_NAME = /^\d{3}_[a-z0-9_]+$/;
const POLICY_ID = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
+const { policyContentDigest } = require('./check-registry-completeness.cjs');
function fail(message) {
console.error(message);
process.exit(1);
}
-const [migrationName, ...requestedIds] = process.argv.slice(2);
+const [migrationName, ...args] = process.argv.slice(2);
if (!migrationName || !MIGRATION_NAME.test(migrationName)) {
fail('First argument must be a migration name such as 548_publish_creative_rejection_policies');
}
+const replacements = new Map();
+const requestedIds = [];
+for (let index = 0; index < args.length; index += 1) {
+ if (args[index] === '--replace') {
+ const declaration = args[index + 1];
+ const match = /^([a-z0-9]+(?:_[a-z0-9]+)*)=(.+)$/.exec(declaration || '');
+ if (!match || !SEMVER.test(match[2])) fail('--replace must be followed by policy_id=prior_semver');
+ if (replacements.has(match[1])) fail(`Duplicate replacement declaration for ${match[1]}`);
+ replacements.set(match[1], match[2]);
+ index += 1;
+ } else {
+ requestedIds.push(args[index]);
+ }
+}
if (requestedIds.length === 0) {
fail('Provide at least one policy_id to publish');
}
@@ -37,6 +54,9 @@ const ids = [...new Set(requestedIds)].sort();
if (ids.length !== requestedIds.length) {
fail('Duplicate policy_id arguments are not allowed');
}
+for (const policyId of replacements.keys()) {
+ if (!ids.includes(policyId)) fail(`Replacement declaration names unpublished policy_id: ${policyId}`);
+}
const entries = ids.map((policyId) => {
if (!POLICY_ID.test(policyId)) {
@@ -68,25 +88,101 @@ const entries = ids.map((policyId) => {
return entry;
});
+function loadArchivedVersion(policyId, version) {
+ const file = path.join(POLICY_VERSION_DIR, policyId, `${version}.json`);
+ if (!fs.existsSync(file)) {
+ fail(`Replacement requires archived source: ${path.relative(REPO_ROOT, file)}`);
+ }
+ let entry;
+ try {
+ entry = JSON.parse(fs.readFileSync(file, 'utf8'));
+ } catch (error) {
+ fail(`Invalid JSON in ${path.relative(REPO_ROOT, file)}: ${error.message}`);
+ }
+ if (entry.policy_id !== policyId || entry.version !== version || entry.source !== 'registry') {
+ fail(`${path.relative(REPO_ROOT, file)} must identify registry policy ${policyId}@${version}`);
+ }
+ return entry;
+}
+
+const archivedEntries = [...replacements.entries()].map(([policyId, version]) =>
+ loadArchivedVersion(policyId, version));
+const publicationEntries = [...archivedEntries, ...entries];
+const publicationKeys = new Set();
+for (const entry of publicationEntries) {
+ const key = `${entry.policy_id}@${entry.version}`;
+ if (publicationKeys.has(key)) fail(`Duplicate publication snapshot: ${key}`);
+ publicationKeys.add(key);
+}
+
const sourceList = ids.map((id) => `-- static/registry/policies/${id}.json`).join('\n');
-const json = JSON.stringify(entries, null, 2);
+const archiveSourceList = [...replacements.entries()]
+ .map(([id, version]) => `-- static/registry/policy-versions/${id}/${version}.json`)
+ .join('\n');
+const json = JSON.stringify(publicationEntries.map((entry) => ({
+ entry,
+ content_digest: policyContentDigest(entry),
+ is_current: entries.some((current) => current.policy_id === entry.policy_id && current.version === entry.version),
+})), null, 2);
const sql = `-- Generated by scripts/generate-policy-publication-migration.cjs.
-- The checked-in JSON files are authoritative; regenerate this migration rather
-- than editing the data below by hand.
${sourceList}
+${archiveSourceList}
--
--- The conflict guard intentionally updates only the same authoritative version.
--- A different version already present in the registry is left untouched, so a
--- delayed or repeated deployment cannot replace a later publication.
+-- The conflict guard updates the same authoritative version or an explicitly
+-- declared predecessor. Any other version is left untouched, so a delayed
+-- deployment cannot overwrite a later publication or perform an undeclared
+-- version transition.
-WITH incoming AS (
- SELECT entry
+CREATE TEMP TABLE incoming_policy_publications ON COMMIT DROP AS
+WITH bundled AS (
+ SELECT payload
FROM jsonb_array_elements(
$registry_policies$
${json}
$registry_policies$::jsonb
- ) AS bundled(entry)
+ ) AS bundled(payload)
)
+SELECT
+ payload->'entry' AS entry,
+ payload->>'content_digest' AS content_digest,
+ (payload->>'is_current')::boolean AS is_current
+FROM bundled;
+
+DO $immutable_policy_publication_conflict$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM incoming_policy_publications incoming
+ JOIN policy_publications publication
+ ON publication.policy_id = incoming.entry->>'policy_id'
+ AND publication.version = incoming.entry->>'version'
+ WHERE publication.content_digest <> incoming.content_digest
+ OR publication.canonical_content <> incoming.entry - 'acceptance_profile'
+ OR publication.acceptance_profile IS DISTINCT FROM incoming.entry->'acceptance_profile'
+ ) THEN
+ RAISE EXCEPTION 'conflicting immutable policy publication; publish a new version';
+ END IF;
+END
+$immutable_policy_publication_conflict$;
+
+INSERT INTO policy_publications (
+ policy_id,
+ version,
+ content_digest,
+ canonical_content,
+ acceptance_profile
+)
+SELECT
+ entry->>'policy_id',
+ entry->>'version',
+ content_digest,
+ entry - 'acceptance_profile',
+ entry->'acceptance_profile'
+FROM incoming_policy_publications
+ON CONFLICT (policy_id, version) DO NOTHING;
+
INSERT INTO policies (
policy_id,
version,
@@ -103,6 +199,8 @@ INSERT INTO policies (
governance_domains,
source_url,
source_name,
+ issuer,
+ acceptance_profile,
policy,
guidance,
exemplars,
@@ -126,14 +224,27 @@ SELECT
COALESCE(entry->'governance_domains', '[]'::jsonb),
entry->>'source_url',
entry->>'source_name',
+ entry->'issuer',
+ entry->'acceptance_profile',
entry->>'policy',
entry->>'guidance',
entry->'exemplars',
entry->'ext',
'registry',
'approved'
-FROM incoming
+FROM incoming_policy_publications incoming
+WHERE is_current
+ AND EXISTS (
+ SELECT 1
+ FROM policy_publications publication
+ WHERE publication.policy_id = incoming.entry->>'policy_id'
+ AND publication.version = incoming.entry->>'version'
+ AND publication.content_digest = incoming.content_digest
+ AND publication.canonical_content = incoming.entry - 'acceptance_profile'
+ AND publication.acceptance_profile IS NOT DISTINCT FROM incoming.entry->'acceptance_profile'
+ )
ON CONFLICT (policy_id) DO UPDATE SET
+ version = EXCLUDED.version,
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
@@ -147,13 +258,29 @@ ON CONFLICT (policy_id) DO UPDATE SET
governance_domains = EXCLUDED.governance_domains,
source_url = EXCLUDED.source_url,
source_name = EXCLUDED.source_name,
+ issuer = EXCLUDED.issuer,
+ acceptance_profile = EXCLUDED.acceptance_profile,
policy = EXCLUDED.policy,
guidance = EXCLUDED.guidance,
exemplars = EXCLUDED.exemplars,
ext = EXCLUDED.ext,
review_status = 'approved'
-WHERE policies.version = EXCLUDED.version
- AND policies.source_type = 'registry';
+WHERE (
+ policies.version = EXCLUDED.version${[...replacements.entries()].map(([policyId, priorVersion]) => `
+ OR (policies.policy_id = ${JSON.stringify(policyId).replace(/"/g, "'")} AND policies.version = ${JSON.stringify(priorVersion).replace(/"/g, "'")} AND EXCLUDED.version = ${JSON.stringify(entries.find((entry) => entry.policy_id === policyId).version).replace(/"/g, "'")})`).join('')}
+ )
+ AND policies.source_type = 'registry'
+ AND EXISTS (
+ SELECT 1
+ FROM policy_publications publication
+ JOIN incoming_policy_publications incoming
+ ON incoming.entry->>'policy_id' = publication.policy_id
+ AND incoming.entry->>'version' = publication.version
+ AND incoming.content_digest = publication.content_digest
+ AND publication.acceptance_profile IS NOT DISTINCT FROM incoming.entry->'acceptance_profile'
+ WHERE publication.policy_id = EXCLUDED.policy_id
+ AND publication.version = EXCLUDED.version
+ );
`;
const output = path.join(MIGRATION_DIR, `${migrationName}.sql`);
diff --git a/scripts/lint-storyboard-check-enum.cjs b/scripts/lint-storyboard-check-enum.cjs
index fa63b5fcaf..9dea24cec5 100644
--- a/scripts/lint-storyboard-check-enum.cjs
+++ b/scripts/lint-storyboard-check-enum.cjs
@@ -48,6 +48,20 @@ const SYNTHESIZED_CHECK_KINDS = new Set([
'capture_path_not_resolvable',
'unresolved_substitution',
]);
+const NEGATIVE_PATH_VALUES = new Set(['schema_invalid', 'payload_well_formed']);
+const CHECK_MATCHER_KEYS = new Map([
+ ['field_pattern', ['pattern']],
+ ['envelope_field_pattern', ['pattern']],
+ ['field_value', ['value', 'allowed_values']],
+ ['field_value_or_absent', ['value', 'allowed_values']],
+ ['field_contains', ['value']],
+ ['array_length', ['value', 'min', 'max']],
+ ['http_status', ['value']],
+ ['http_status_in', ['value', 'values', 'allowed_values']],
+ ['error_code', ['value', 'allowed_values']],
+ ['field_less_than', ['value', 'context_key']],
+ ['field_greater_than', ['value']],
+]);
function loadKnownCheckKinds() {
const doc = yaml.load(fs.readFileSync(CONTRACT_FILE, 'utf8'));
@@ -75,6 +89,11 @@ const RULE_MESSAGES = {
`check. Synthesized codes are emitted by the runner after authored checks complete — a ` +
`storyboard cannot meaningfully assert against them. Remove this entry. See ` +
`storyboard-schema.yaml's "Runner grading codes" section for which codes are synthesized.`,
+ missing_check_matcher: (check) =>
+ `validations[].check "${check}" is missing its recognized matcher key; use the ` +
+ `matcher documented for that check (normally value, values, or pattern).`,
+ invalid_negative_path: (value) =>
+ `negative_path "${value}" is not valid; use schema_invalid or payload_well_formed.`,
};
function isStoryboardYaml(rel) {
@@ -102,6 +121,7 @@ function* walkValidations(doc) {
step: step.id,
index: i,
check: v.check,
+ validation: v,
};
}
}
@@ -142,6 +162,39 @@ function lint(sourceDir = SOURCE_DIR) {
check: hit.check,
rule: 'unknown_check_kind',
});
+ continue;
+ }
+ const matcherKeys = CHECK_MATCHER_KEYS.get(hit.check);
+ const authoredMatcherKeys = Object.keys(hit.validation).filter(key => ![
+ 'check', 'path', 'description', 'id', 'severity', 'permanent_advisory',
+ ].includes(key));
+ if (
+ matcherKeys
+ && authoredMatcherKeys.length > 0
+ && !matcherKeys.some(key => Object.hasOwn(hit.validation, key))
+ ) {
+ violations.push({
+ file: rel,
+ phase: hit.phase,
+ step: hit.step,
+ index: hit.index,
+ check: hit.check,
+ rule: 'missing_check_matcher',
+ });
+ }
+ }
+ for (const phase of doc?.phases ?? []) {
+ for (const step of phase?.steps ?? []) {
+ if (step?.negative_path !== undefined && !NEGATIVE_PATH_VALUES.has(step.negative_path)) {
+ violations.push({
+ file: rel,
+ phase: phase.id,
+ step: step.id,
+ index: -1,
+ check: step.negative_path,
+ rule: 'invalid_negative_path',
+ });
+ }
}
}
}
@@ -184,6 +237,7 @@ if (require.main === module) main();
module.exports = {
RULE_MESSAGES,
SYNTHESIZED_CHECK_KINDS,
+ NEGATIVE_PATH_VALUES,
loadKnownCheckKinds,
lint,
};
diff --git a/scripts/mcp-schema-projection.cjs b/scripts/mcp-schema-projection.cjs
index 24b2c5d821..b103bc2093 100644
--- a/scripts/mcp-schema-projection.cjs
+++ b/scripts/mcp-schema-projection.cjs
@@ -793,10 +793,18 @@ function stripModelContextAnnotations(schema) {
walkSchema(stripped, node => {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
for (const keyword of MODEL_CONTEXT_OMISSIONS) delete node[keyword];
- // Exact const and enum values already communicate their JSON types.
- // Keep the validation profile authoritative and avoid repeating type
- // information across every prompt-view copy of discriminators and enums.
- if (Object.hasOwn(node, 'const') || Array.isArray(node.enum)) delete node.type;
+ // Exact const values and homogeneous enums already communicate their JSON
+ // types. Mixed enums retain type because it still narrows the listed values.
+ if (Object.hasOwn(node, 'const')) delete node.type;
+ if (typeof node.type === 'string' && Array.isArray(node.enum) && node.enum.length > 0) {
+ const matchesType = node.enum.every(value => {
+ if (node.type === 'integer') return Number.isInteger(value);
+ if (node.type === 'number') return typeof value === 'number' && Number.isFinite(value);
+ if (node.type === 'null') return value === null;
+ return typeof value === node.type;
+ });
+ if (matchesType) delete node.type;
+ }
// Closed-object enforcement belongs to the validation profile. The
// declared property list already communicates the prompt shape, while
// retaining `additionalProperties: true` and schema-valued maps preserves
@@ -805,6 +813,20 @@ function stripModelContextAnnotations(schema) {
for (const keyword of Object.keys(node)) {
if (keyword.startsWith('x-')) delete node[keyword];
}
+ // A homogeneous enum already communicates both the allowed values and
+ // their primitive type. Keep the enum in the prompt view and omit the
+ // redundant type keyword; the parent discovery profile remains the
+ // validation authority. Mixed enums retain type because it still narrows
+ // the otherwise-listed values.
+ if (typeof node.type === 'string' && Array.isArray(node.enum) && node.enum.length > 0) {
+ const matchesType = node.enum.every(value => {
+ if (node.type === 'integer') return Number.isInteger(value);
+ if (node.type === 'number') return typeof value === 'number' && Number.isFinite(value);
+ if (node.type === 'null') return value === null;
+ return typeof value === node.type;
+ });
+ if (matchesType) delete node.type;
+ }
});
return stripped;
}
diff --git a/scripts/overlay-compliance-cache.sh b/scripts/overlay-compliance-cache.sh
index b9f678d079..3b7ecf2121 100755
--- a/scripts/overlay-compliance-cache.sh
+++ b/scripts/overlay-compliance-cache.sh
@@ -125,9 +125,9 @@ function patchFile(file) {
const zod = file.endsWith('.mjs') ? 'z' : 'import_zod.z';
function addLiteralToConstUnion(constName, afterValue, value) {
const declarationStart = text.indexOf(`const ${constName} =`);
- if (declarationStart < 0) return;
+ if (declarationStart < 0) throw new Error(`${file}: ${constName} declaration anchor changed`);
const unionEnd = text.indexOf(']);', declarationStart);
- if (unionEnd < 0) return;
+ if (unionEnd < 0) throw new Error(`${file}: ${constName} union terminator anchor changed`);
const before = text.slice(0, declarationStart);
let declaration = text.slice(declarationStart, unionEnd + 3);
const literal = `${zod}.literal("${value}")`;
@@ -137,14 +137,17 @@ function patchFile(file) {
`${zod}.literal("${afterValue}"), ${literal}`,
);
}
+ if (!declaration.includes(literal)) {
+ throw new Error(`${file}: ${constName} literal anchor ${afterValue} changed; failed to add ${value}`);
+ }
text = before + declaration + text.slice(unionEnd + 3);
}
function addLiteralToFirstActionUnion(constName, afterValue, value) {
const declarationStart = text.indexOf(`const ${constName} =`);
- if (declarationStart < 0) return;
+ if (declarationStart < 0) throw new Error(`${file}: ${constName} declaration anchor changed`);
const actionStart = text.indexOf(`action: ${zod}.union([`, declarationStart);
const actionEnd = text.indexOf(']),', actionStart);
- if (actionStart < 0 || actionEnd < 0) return;
+ if (actionStart < 0 || actionEnd < 0) throw new Error(`${file}: ${constName} action union anchor changed`);
const before = text.slice(0, actionStart);
let action = text.slice(actionStart, actionEnd + 2);
const literal = `${zod}.literal("${value}")`;
@@ -154,6 +157,9 @@ function patchFile(file) {
`${zod}.literal("${afterValue}"), ${literal}`,
);
}
+ if (!action.includes(literal)) {
+ throw new Error(`${file}: ${constName} action anchor ${afterValue} changed; failed to add ${value}`);
+ }
text = before + action + text.slice(actionEnd + 2);
}
const legacyCapabilities =
@@ -203,11 +209,43 @@ function patchFile(file) {
addLiteralToConstUnion('CanonicalMediaBuyActionNameSchema', 'cancel', 'update_name');
addLiteralToConstUnion('MediaBuyValidActionSchema', 'cancel', 'update_name');
addLiteralToFirstActionUnion('CanonicalMediaBuyActionSchema', 'cancel', 'update_name');
+ // Proposal-bound change terms add seller_managed as the externally visible
+ // async route. The installed SDK snapshot predates that enum member even
+ // after its JSON Schema cache is overlaid, so its generated Zod response
+ // validators must accept the current-source value as well.
+ addLiteralToConstUnion('CanonicalMediaBuyActionModeSchema', 'conditional_self_serve', 'seller_managed');
+ addLiteralToConstUnion('MediaBuyActionModeSchema', 'conditional_self_serve', 'seller_managed');
fs.writeFileSync(file, text);
}
patchFile('node_modules/@adcp/sdk/dist/lib/types/schemas.generated.js');
patchFile('node_modules/@adcp/sdk/dist/lib/types/schemas.generated.mjs');
+
+function patchProposalVerificationFile(file) {
+ if (!fs.existsSync(file)) return;
+ const backup = `${file}.adcp-overlay-backup`;
+ if (!fs.existsSync(backup)) {
+ fs.copyFileSync(file, backup);
+ }
+ let text = fs.readFileSync(file, 'utf8');
+ // The current commercial-terms schema adds proposal-bound change_terms.
+ // The SDK snapshot's semantic verifier has its own allowlist in addition to
+ // the generated/JSON Schema validators, so current-source refinement flows
+ // need the same additive field there until the next SDK release includes it.
+ if (!text.includes('"change_terms"\n]);')) {
+ text = text.replace(
+ ' "cancellation_terms"\n]);',
+ ' "cancellation_terms",\n "change_terms"\n]);',
+ );
+ }
+ if (!text.includes('"change_terms"\n]);')) {
+ throw new Error(`${file}: SDK proposal verifier allowlist anchor changed; current-source overlay was not applied`);
+ }
+ fs.writeFileSync(file, text);
+}
+
+patchProposalVerificationFile('node_modules/@adcp/sdk/dist/lib/negotiation/verification.js');
+patchProposalVerificationFile('node_modules/@adcp/sdk/dist/lib/negotiation/verification.mjs');
NODE
echo "Building development compliance bundle for SDK cache overlay"
diff --git a/scripts/run-storyboards-matrix.sh b/scripts/run-storyboards-matrix.sh
index 4b39296498..320aee3a52 100755
--- a/scripts/run-storyboards-matrix.sh
+++ b/scripts/run-storyboards-matrix.sh
@@ -26,10 +26,16 @@ fi
export ADCP_RELEASE_GIT_REF="${RELEASE_GIT_REF}"
SDK_GENERATED_SCHEMA_FILE="${REPO_ROOT}/node_modules/@adcp/sdk/dist/lib/types/schemas.generated.js"
SDK_GENERATED_SCHEMA_MJS_FILE="${REPO_ROOT}/node_modules/@adcp/sdk/dist/lib/types/schemas.generated.mjs"
+SDK_PROPOSAL_VERIFICATION_FILE="${REPO_ROOT}/node_modules/@adcp/sdk/dist/lib/negotiation/verification.js"
+SDK_PROPOSAL_VERIFICATION_MJS_FILE="${REPO_ROOT}/node_modules/@adcp/sdk/dist/lib/negotiation/verification.mjs"
-restore_sdk_generated_schema() {
+restore_sdk_overlays() {
local file backup
- for file in "${SDK_GENERATED_SCHEMA_FILE}" "${SDK_GENERATED_SCHEMA_MJS_FILE}"; do
+ for file in \
+ "${SDK_GENERATED_SCHEMA_FILE}" \
+ "${SDK_GENERATED_SCHEMA_MJS_FILE}" \
+ "${SDK_PROPOSAL_VERIFICATION_FILE}" \
+ "${SDK_PROPOSAL_VERIFICATION_MJS_FILE}"; do
backup="${file}.adcp-overlay-backup"
if [ -f "${backup}" ]; then
cp "${backup}" "${file}"
@@ -224,8 +230,8 @@ if [ -n "${SCHEMA_ROOT}" ]; then
export ADCP_SCHEMA_ROOT="${SCHEMA_ROOT}"
fi
-restore_sdk_generated_schema
-trap restore_sdk_generated_schema EXIT
+restore_sdk_overlays
+trap restore_sdk_overlays EXIT
if [ "${OVERLAY}" -eq 1 ]; then
# Mirror CI's overlay step before running tenants: copies in-repo
# compliance source onto the SDK's bundled cache so the runner grades
@@ -260,7 +266,7 @@ else
# clean-result-row and passing-step regression floors.
"signals:45:80"
"sales:133:632"
- "governance:47:157"
+ "governance:47:161"
"creative:49:209"
"creative-builder:50:184"
"brand:45:116"
@@ -291,6 +297,12 @@ REQUIRED_EXACT_CURRENT_SALES=(
"media_buy_seller/declined_proposal_refinement:6:0"
"media_buy_seller/declined_proposal_execution:8:0"
"media_buy_seller/expired_proposal_execution:8:0"
+ "media_buy_seller/change_rights_state_projection:8:0"
+ "media_buy_seller/acceptance_policy_discovery:3:0"
+ "media_buy_seller/governance_agent_binding_acceptance:5:0"
+)
+REQUIRED_EXACT_CURRENT_GOVERNANCE=(
+ "governance/failed_outcome_audit_persistence:4:0"
)
REQUIRED_CLEAN_CURRENT_SIGNALS=(
"wholesale_feed_signals"
@@ -448,6 +460,21 @@ for entry in "${TENANTS[@]}"; do
done
fi
+ if [ "${FLOOR_SET}" = "current" ] && [ "${tenant}" = "governance" ]; then
+ for requirement in "${REQUIRED_EXACT_CURRENT_GOVERNANCE[@]}"; do
+ storyboard_id="${requirement%%:*}"
+ counts="${requirement#*:}"
+ expected_passed="${counts%%:*}"
+ expected_skipped="${counts##*:}"
+ if storyboard_executed_exactly "${storyboard_id}" "${expected_passed}" "${expected_skipped}" "${log}"; then
+ echo " ✓ required-exact ${storyboard_id} (${expected_passed}P / ${expected_skipped}S)"
+ else
+ status="✗"
+ failed_floor="${failed_floor:+${failed_floor}; }required-exact ${storyboard_id} did not pass ${expected_passed} steps with ${expected_skipped} skips"
+ fi
+ done
+ fi
+
if [ "${FLOOR_SET}" = "current" ] && [ "${tenant}" = "signals" ]; then
for storyboard_id in "${REQUIRED_CLEAN_CURRENT_SIGNALS[@]}"; do
if storyboard_passed "${storyboard_id}" "${log}"; then
diff --git a/server/src/db/migrations/561_policy_acceptance_profiles.sql b/server/src/db/migrations/561_policy_acceptance_profiles.sql
new file mode 100644
index 0000000000..3b8ec4fb18
--- /dev/null
+++ b/server/src/db/migrations/561_policy_acceptance_profiles.sql
@@ -0,0 +1,38 @@
+-- Migration 561: preserve machine-readable issuer identity and structured acceptance profiles
+-- on authoritative policy-registry records. Community policy authoring does not
+-- populate these fields; registry publication is the controlled write path.
+
+ALTER TABLE policies
+ ADD COLUMN IF NOT EXISTS issuer JSONB,
+ ADD COLUMN IF NOT EXISTS acceptance_profile JSONB;
+
+ALTER TABLE policies
+ ADD CONSTRAINT policies_issuer_object_check
+ CHECK (issuer IS NULL OR jsonb_typeof(issuer) = 'object'),
+ ADD CONSTRAINT policies_acceptance_profile_object_check
+ CHECK (acceptance_profile IS NULL OR jsonb_typeof(acceptance_profile) = 'object');
+
+-- Preserve every authoritative registry publication as an immutable,
+-- resolver-verifiable snapshot. The digest covers canonical_content after RFC
+-- 8785 (JCS) serialization; acceptance_profile is stored separately because it
+-- carries its own digest and may pin this policy snapshot.
+CREATE TABLE policy_publications (
+ policy_id TEXT NOT NULL,
+ version TEXT NOT NULL,
+ content_digest TEXT NOT NULL CHECK (content_digest ~ '^sha256:[a-f0-9]{64}$'),
+ canonical_content JSONB NOT NULL CHECK (jsonb_typeof(canonical_content) = 'object'),
+ acceptance_profile JSONB CHECK (acceptance_profile IS NULL OR jsonb_typeof(acceptance_profile) = 'object'),
+ published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (policy_id, version)
+);
+
+CREATE OR REPLACE FUNCTION reject_policy_publication_mutation()
+RETURNS TRIGGER AS $$
+BEGIN
+ RAISE EXCEPTION 'policy_publications rows are immutable; publish a new version instead';
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER policy_publications_immutable
+ BEFORE UPDATE OR DELETE ON policy_publications
+ FOR EACH ROW EXECUTE FUNCTION reject_policy_publication_mutation();
diff --git a/server/src/db/migrations/562_publish_political_acceptance_policies.sql b/server/src/db/migrations/562_publish_political_acceptance_policies.sql
new file mode 100644
index 0000000000..bedd1b3e20
--- /dev/null
+++ b/server/src/db/migrations/562_publish_political_acceptance_policies.sql
@@ -0,0 +1,1513 @@
+-- Migration 562: generated by scripts/generate-policy-publication-migration.cjs.
+-- The checked-in JSON files are authoritative; regenerate this migration rather
+-- than editing the data below by hand.
+-- static/registry/policies/amazon_political_advertising_acceptance.json
+-- static/registry/policies/eu_dsa_political_targeting.json
+-- static/registry/policies/google_political_advertising_acceptance.json
+-- static/registry/policies/linkedin_political_advertising_acceptance.json
+-- static/registry/policies/meta_political_advertising_acceptance.json
+-- static/registry/policies/pinterest_political_advertising_acceptance.json
+-- static/registry/policies/snap_political_advertising_acceptance.json
+-- static/registry/policies/tiktok_political_advertising_acceptance.json
+-- static/registry/policies/x_political_advertising_acceptance.json
+-- static/registry/policy-versions/eu_dsa_political_targeting/1.0.0.json
+--
+-- The conflict guard updates the same authoritative version or an explicitly
+-- declared predecessor. Any other version is left untouched, so a delayed
+-- deployment cannot overwrite a later publication or perform an undeclared
+-- version transition.
+
+CREATE TEMP TABLE incoming_policy_publications ON COMMIT DROP AS
+WITH bundled AS (
+ SELECT payload
+ FROM jsonb_array_elements(
+$registry_policies$
+[
+ {
+ "entry": {
+ "policy_id": "eu_dsa_political_targeting",
+ "source": "registry",
+ "version": "1.0.0",
+ "name": "EU Digital Services Act — Political Ad Targeting Restrictions",
+ "description": "Prohibits targeting political advertisements using special categories of personal data under DSA Article 26 and the EU Political Advertising Regulation.",
+ "category": "regulation",
+ "enforcement": "must",
+ "jurisdictions": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ],
+ "region_aliases": {
+ "EU": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ]
+ },
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign"
+ ],
+ "effective_date": "2025-10-14",
+ "source_url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32022R2065",
+ "source_name": "European Parliament and Council",
+ "policy": "Political advertising in the EU must not use special categories of personal data (GDPR Article 9) for targeting.\n\nSpecific prohibitions:\n1. Targeting based on racial or ethnic origin, political opinions, religious beliefs, trade union membership, health data, sex life or sexual orientation, genetic data, or biometric data is prohibited for political ads.\n2. Profiling that infers special category data (e.g., inferring political opinions from browsing behavior) is also prohibited.\n3. Political ads must include transparency labels identifying the sponsor and funding source.\n4. Targeting criteria used must be disclosed in an ad transparency repository.\n\nThe EU Political Advertising Regulation (effective October 2025) further restricts targeting techniques for political advertising to only contextual, linguistic, and broad geographic criteria.",
+ "guidance": "This policy intersects with the restricted_attributes mechanism. When a plan declares policy_categories: ['political_advertising'], governance agents should automatically restrict all GDPR Article 9 categories. The DSA and Political Advertising Regulation effectively limit political ad targeting to contextual and broad geographic methods.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A political party runs ads in Germany using only German-language targeting and broad geographic targeting (Bundesland level) with no demographic or interest-based audience segments.",
+ "explanation": "Contextual, linguistic, and broad geographic targeting are permitted for political advertising under the EU Political Advertising Regulation."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A political campaign targets voters in France using audience segments based on inferred political leaning and religious affiliation.",
+ "explanation": "Targeting political ads using inferred political opinions or religious beliefs constitutes use of GDPR Article 9 special category data, which is prohibited under DSA Article 26."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:d606d004b8a91c514a666b82e4167f6c903fef21ac97236a1dbffc4c1d9db6f4",
+ "is_current": false
+ },
+ {
+ "entry": {
+ "policy_id": "amazon_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "Amazon Ads Political Advertising Acceptance",
+ "description": "Amazon Ads prohibition on political campaigns, candidates, parties, elections, and public-issue advocacy.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2026-06-25",
+ "source_url": "https://advertising.amazon.com/help/GZS8A5XCFQJTS88N",
+ "source_name": "Amazon Ads",
+ "issuer": {
+ "domain": "amazon.com",
+ "name": "Amazon"
+ },
+ "acceptance_profile": {
+ "profile_id": "amazon_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:81b80b7edb40508c97daf2f74493f2e2cb391e4a2025bf50b9a1bc6331d215b9",
+ "policy_refs": [
+ {
+ "policy_id": "amazon_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:5c7dbfca6637dcc5c7d52c03534cfe929d6c21b5c71491be5e08693448cc4aa4"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "description": "Reusable coarse profile of Amazon Ads' public political-advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "amazon_political_prohibited",
+ "effective_at": "2026-06-25T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation",
+ "political_merchandise"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "amazon_political_advertising_acceptance"
+ ],
+ "description": "Political campaigns, candidates, parties, elections, public-issue advocacy, and political merchandise are not accepted."
+ }
+ ]
+ },
+ "policy": "Advertising for political campaigns, candidates, political parties, elections, and public issues intended to influence political outcomes is prohibited under the operator's advertising acceptance policies. Product eligibility does not override the prohibition when the advertisement or promoted merchandise is political.",
+ "guidance": "Represent political-advertising facets as prohibited unless a more specific current issuer rule establishes an exception. Evaluate both creative message and promoted product or merchandise.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A seller advertises an ordinary consumer product with no political messaging or political merchandising.",
+ "explanation": "The advertisement is outside the political prohibition."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "An advertiser promotes merchandise designed to support a political candidate.",
+ "explanation": "Political campaign advertising and political products are prohibited."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:5c7dbfca6637dcc5c7d52c03534cfe929d6c21b5c71491be5e08693448cc4aa4",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "eu_dsa_political_targeting",
+ "source": "registry",
+ "version": "2.0.0",
+ "name": "EU Political Advertising Regulation — Transparency and Targeting",
+ "description": "Regulation (EU) 2024/900 transparency, sponsor-eligibility, targeting, and ad-delivery requirements for political advertising.",
+ "category": "regulation",
+ "enforcement": "must",
+ "jurisdictions": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ],
+ "region_aliases": {
+ "EU": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ]
+ },
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign"
+ ],
+ "effective_date": "2025-10-10",
+ "source_url": "https://eur-lex.europa.eu/eli/reg/2024/900/oj/eng",
+ "source_name": "European Parliament and Council",
+ "policy": "Political advertising disseminated in the Union, brought into the public domain in a Member State, or directed to Union citizens must comply with Regulation (EU) 2024/900.\n\n1. Political advertisements must carry the required label and transparency notice, including sponsor and targeting information.\n2. Targeting or ad-delivery techniques must not involve profiling using GDPR Article 9 special categories of personal data.\n3. Other personal data may be used for online political-ad targeting or delivery only when collected from the data subject with separate explicit consent for political advertising.\n4. Targeting a person known with reasonable certainty to be at least one year below the applicable voting age is prohibited.\n5. During the final three months before an EU or Member State election or referendum, the sponsor must meet the Regulation's Union citizenship, residence, establishment, ownership, or control criteria.\n6. Official election-participation information and qualifying official public information are excluded from the political-advertising definition when they are not designed to influence an outcome, voting behavior, or a legislative or regulatory process.",
+ "guidance": "This policy intersects with restricted_attributes and political_advertising facets. Governance agents should restrict every GDPR Article 9 category, not only political opinions, and separately evaluate consent, minor, transparency, sponsor-eligibility, and official-information exception requirements.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A political advertiser supplies the required transparency notice and uses contextual placement and broad geography without processing personal data to select recipients.",
+ "explanation": "The campaign avoids personal-data targeting and provides the required political-ad transparency information."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A political campaign targets voters in France using audience segments based on inferred political leaning and religious affiliation.",
+ "explanation": "Regulation (EU) 2024/900 Article 18 prohibits profiling for political-ad targeting or delivery using GDPR Article 9 special-category data."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:6c375f47c68b3655a607a4e3a5fad211c20efab4bea4e0f2d8ee190b2080bc43",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "google_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "Google Political Content Advertising Acceptance",
+ "description": "Google market-specific political-content eligibility, verification, disclosure, targeting, blackout, and regional prohibition rules.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2025-09-01",
+ "source_url": "https://support.google.com/displayvideo/answer/6014595?hl=en",
+ "source_name": "Google",
+ "issuer": {
+ "domain": "google.com",
+ "name": "Google"
+ },
+ "acceptance_profile": {
+ "profile_id": "google_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:29bf0717dec7f6a587d1f5aa27f23b0b38e639478d5e9b8ccb49259ca33d120d",
+ "policy_refs": [
+ {
+ "policy_id": "google_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:731e77b1d89976bee3b173741066e80b8919f0af20a80a50ca89e7d8ce92afe1"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "region_aliases": {
+ "EU": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ]
+ },
+ "description": "Reusable coarse profile of Google's public political-content advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "google_political_conditional",
+ "effective_at": "2025-09-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "advertiser_verification",
+ "verification_scheme": "google_election_ads_verification"
+ },
+ {
+ "kind": "disclosure",
+ "format": "sponsor_identity",
+ "placement": "creative"
+ },
+ {
+ "kind": "targeting_restriction",
+ "description": "Use only geographic and demographic targeting permitted for the election type and market."
+ },
+ {
+ "kind": "creative_restriction",
+ "description": "Provide any required synthetic or digitally altered content disclosure."
+ },
+ {
+ "kind": "time_restriction",
+ "description": "Observe election-specific blackout periods."
+ }
+ ],
+ "policy_ids": [
+ "google_political_advertising_acceptance"
+ ],
+ "description": "In supported non-prohibited markets, political content is conditional on market-specific verification, disclosure, targeting, content, and timing requirements."
+ },
+ {
+ "rule_id": "google_eu_political_prohibited",
+ "effective_at": "2025-09-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation",
+ "political_merchandise"
+ ],
+ "jurisdiction_groups": [
+ "EU"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "google_political_advertising_acceptance"
+ ],
+ "description": "Political advertising covered by the operator's EU policy is not accepted for EU delivery."
+ },
+ {
+ "rule_id": "google_eu_official_information_conditional",
+ "effective_at": "2025-09-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "government_information"
+ ],
+ "advertiser_roles": [
+ "government_entity",
+ "election_authority"
+ ],
+ "jurisdiction_groups": [
+ "EU"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "advertiser_eligibility",
+ "criteria": [
+ "official_public_authority_or_election_participation_source"
+ ]
+ },
+ {
+ "kind": "prior_authorization",
+ "description": "Apply for and receive the operator's official-information exception before delivery."
+ },
+ {
+ "kind": "creative_restriction",
+ "description": "Content must be non-persuasive official information or qualifying election-participation information."
+ }
+ ],
+ "policy_ids": [
+ "google_political_advertising_acceptance"
+ ],
+ "description": "Qualifying official public information may be considered only through the operator's application-based exception."
+ }
+ ]
+ },
+ "policy": "Political content acceptance varies by delivery region and election type. Where accepted, advertisers may need election-ad verification, identity and sponsor disclosures, restricted geographic and demographic targeting, synthetic-content disclosure, and compliance with election-specific blackout periods. Some official government information and qualifying news content may be treated differently. Political advertising as defined by EU Regulation 2024/900 is prohibited for European Union delivery, subject to an application-based exception for qualifying official election-participation messages and non-persuasive public-authority information.",
+ "guidance": "Resolve this entry with current region-specific issuer guidance. Treat ordinary EU political delivery as prohibited, the defined EU official-information exception as conditional, and political delivery in other supported regions as conditional unless a more specific current issuer policy defines another result.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A verified advertiser in a supported market supplies the required election disclosure and uses targeting permitted for that election type.",
+ "explanation": "The request satisfies the coarse conditional-acceptance requirements for a supported market."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A buyer declares political content but requests that the campaign serve to users in EU member states.",
+ "explanation": "The operator's EU policy does not accept political advertising for EU delivery."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:731e77b1d89976bee3b173741066e80b8919f0af20a80a50ca89e7d8ce92afe1",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "linkedin_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "LinkedIn Political Advertising Acceptance",
+ "description": "LinkedIn prohibition on political advertising.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2025-11-18",
+ "source_url": "https://www.linkedin.com/legal/ads-policy",
+ "source_name": "LinkedIn",
+ "issuer": {
+ "domain": "linkedin.com",
+ "name": "LinkedIn"
+ },
+ "acceptance_profile": {
+ "profile_id": "linkedin_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:e2683041e6abb6b8d8d2b8210bfd35d7334e24781edf6cef29f6b10816561c63",
+ "policy_refs": [
+ {
+ "policy_id": "linkedin_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:04a5cf5c6262a60f378e83043b46677372c4ed2378f9930565278105113defd1"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "description": "Reusable coarse profile of LinkedIn's public political-advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "linkedin_political_prohibited",
+ "effective_at": "2025-11-18T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "linkedin_political_advertising_acceptance"
+ ],
+ "description": "Candidate, party, ballot, legislative, election-influence, fundraising, and sensitive political-issue advertising is not accepted."
+ }
+ ]
+ },
+ "policy": "Political advertising is prohibited. This includes advertising for or against candidates, parties, ballot propositions, laws or regulations; election-influence advertising; political fundraising; and advertising that exploits a sensitive political issue even without an explicit political agenda. Sellers should apply the current issuer definition and should not infer an exception from advertiser industry alone.",
+ "guidance": "Represent candidate_or_party, election_or_ballot_measure, issue_advocacy, and legislation_or_regulation as prohibited. Editorial or incidental content should be evaluated against the issuer's current definition rather than automatically classified as paid political advertising.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A commercial campaign without political advocacy promotes an ordinary business service.",
+ "explanation": "The request is outside the political-advertising category."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "An organization purchases an advertisement urging voters to oppose a ballot proposition.",
+ "explanation": "Paid ballot-measure advocacy is prohibited political advertising."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:04a5cf5c6262a60f378e83043b46677372c4ed2378f9930565278105113defd1",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "meta_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "Meta Political, Electoral, and Social-Issue Advertising Acceptance",
+ "description": "Meta acceptance, authorization, disclosure, archive, targeting, blackout, synthetic-content, and regional availability rules for political, electoral, and social-issue advertising.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2025-10-06",
+ "source_url": "https://about.fb.com/news/2025/07/ending-political-electoral-and-social-issue-advertising-in-the-eu/",
+ "source_name": "Meta",
+ "issuer": {
+ "domain": "meta.com",
+ "name": "Meta"
+ },
+ "acceptance_profile": {
+ "profile_id": "meta_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:7584e8e357a69501b43e9cff6e18ae811355d6765bd1c13b11433ccc79dc32fe",
+ "policy_refs": [
+ {
+ "policy_id": "meta_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:a8d61d10c9fc04ae7a0af83870f65b92987b9c0a7519d5724afabbbea30b25fc"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "region_aliases": {
+ "EU": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ]
+ },
+ "description": "Reusable coarse profile of Meta's public political, electoral, and social-issue advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "meta_political_conditional",
+ "effective_at": "2025-10-06T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "category_declaration",
+ "declaration": "political_electoral_or_social_issue"
+ },
+ {
+ "kind": "advertiser_verification",
+ "verification_scheme": "meta_identity_and_location_authorization"
+ },
+ {
+ "kind": "disclosure",
+ "format": "paid_for_by",
+ "placement": "creative"
+ },
+ {
+ "kind": "targeting_restriction",
+ "description": "Use only targeting permitted for the applicable political or social-issue category and market."
+ },
+ {
+ "kind": "creative_restriction",
+ "description": "Disclose qualifying digitally created or altered content."
+ },
+ {
+ "kind": "time_restriction",
+ "description": "Observe designated election blackout periods."
+ },
+ {
+ "kind": "transparency_reporting",
+ "description": "Permit retention in the operator's public advertising archive."
+ }
+ ],
+ "policy_ids": [
+ "meta_political_advertising_acceptance"
+ ],
+ "description": "In supported non-prohibited markets, political, electoral, and social-issue advertising is conditional on authorization and market-specific restrictions."
+ },
+ {
+ "rule_id": "meta_eu_political_prohibited",
+ "effective_at": "2025-10-06T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation",
+ "political_merchandise"
+ ],
+ "jurisdiction_groups": [
+ "EU"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "meta_political_advertising_acceptance"
+ ],
+ "description": "Political, electoral, and social-issue advertising is not accepted for EU delivery."
+ }
+ ]
+ },
+ "policy": "Political, electoral, and qualifying social-issue advertising is subject to market-specific acceptance. Advertisers must declare the applicable special ad category. Where accepted, advertisers may be required to complete identity and location authorization, supply a paid-for-by disclosure, comply with restricted targeting, disclose qualifying digitally created or altered content, and allow the advertisement to be retained in a public archive. New political advertisements may be restricted during designated election blackout periods. Political, electoral, and social-issue advertisements are not accepted for delivery in the European Union under the operator policy effective 6 October 2025. Exact eligibility, targeting, disclosure, and timing rules vary by delivery market and advertiser role.",
+ "guidance": "Resolve this policy by delivery jurisdiction and political-advertising facet. Treat EU delivery as prohibited and supported non-EU markets as conditional unless a more specific current issuer policy says otherwise. Discovery does not replace the operator's authorization or creative review.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "An authorized advertiser in a supported market declares the political category, supplies the required sponsor disclosure, uses permitted targeting, and submits outside a blackout window.",
+ "explanation": "The campaign satisfies the coarse acceptance prerequisites; final operator review still applies."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "An advertiser requests delivery of a political advertisement in an EU member state after 6 October 2025.",
+ "explanation": "The operator does not accept political, electoral, or social-issue advertising for EU delivery."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:a8d61d10c9fc04ae7a0af83870f65b92987b9c0a7519d5724afabbbea30b25fc",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "pinterest_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "Pinterest Political Advertising Acceptance",
+ "description": "Pinterest prohibition on political campaigning, election influence, legislative advocacy, and political merchandise advertising.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2018-01-01",
+ "source_url": "https://policy.pinterest.com/en/advertising-guidelines",
+ "source_name": "Pinterest",
+ "issuer": {
+ "domain": "pinterest.com",
+ "name": "Pinterest"
+ },
+ "acceptance_profile": {
+ "profile_id": "pinterest_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:5ed360814d592d9271157912db6c19709d0090bf287d4192234df945205f1b93",
+ "policy_refs": [
+ {
+ "policy_id": "pinterest_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:f47f6ad9d15f6576cd3db45b272de729afddc72068836077b6dd276f0127f7b6"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "description": "Reusable coarse profile of Pinterest's public political-advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "pinterest_political_prohibited",
+ "effective_at": "2018-01-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "legislation_or_regulation",
+ "political_merchandise"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "pinterest_political_advertising_acceptance"
+ ],
+ "description": "Political campaigning, election influence, legislative advocacy, referendums, and political campaign merchandise are not accepted."
+ }
+ ]
+ },
+ "policy": "Political campaigning and election advertising is prohibited, including advertising for candidates or parties, content intended to influence elections, legislation, or referendums, and political campaign merchandise. The current issuer guidelines control classification of adjacent public-issue and incidental content.",
+ "guidance": "Represent candidate_or_party, election_or_ballot_measure, legislation_or_regulation, and political_merchandise as prohibited. Evaluate issue advocacy under the current issuer definition.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A non-political retailer advertises an ordinary household product without campaign or issue messaging.",
+ "explanation": "The advertisement is outside the prohibited political categories."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A store advertises merchandise bearing a candidate campaign slogan.",
+ "explanation": "Political campaign merchandise advertising is prohibited."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:f47f6ad9d15f6576cd3db45b272de729afddc72068836077b6dd276f0127f7b6",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "snap_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "Snap Political Advertising Acceptance",
+ "description": "Snap conditional acceptance and review requirements for election, advocacy, and issue advertising.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2025-10-01",
+ "source_url": "https://www.snap.com/ad-policies/political",
+ "source_name": "Snap",
+ "issuer": {
+ "domain": "snap.com",
+ "name": "Snap"
+ },
+ "acceptance_profile": {
+ "profile_id": "snap_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:06efe90e2adaded02529acc7a48be5e2e283020b3194a00a78aa0dc3e59ec0a0",
+ "policy_refs": [
+ {
+ "policy_id": "snap_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:22160b18384dd9ad4add60b24a9d4e862c9f8d179139c27a235098f7e5f6f96d"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "region_aliases": {
+ "EU": [
+ "AT",
+ "BE",
+ "BG",
+ "HR",
+ "CY",
+ "CZ",
+ "DK",
+ "EE",
+ "FI",
+ "FR",
+ "DE",
+ "GR",
+ "HU",
+ "IE",
+ "IT",
+ "LV",
+ "LT",
+ "LU",
+ "MT",
+ "NL",
+ "PL",
+ "PT",
+ "RO",
+ "SK",
+ "SI",
+ "ES",
+ "SE"
+ ]
+ },
+ "description": "Reusable coarse profile of Snap's public political-advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "snap_political_conditional",
+ "effective_at": "2025-10-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "advertiser_eligibility",
+ "criteria": [
+ "eligible_market_residency"
+ ],
+ "description": "Satisfy advertiser-residency and market-specific eligibility rules."
+ },
+ {
+ "kind": "funding_restriction",
+ "criteria": [
+ "sponsor_identity_required",
+ "market_specific_foreign_funding_rules"
+ ]
+ },
+ {
+ "kind": "prior_authorization",
+ "description": "Submit for operator review and any required fact-checking."
+ },
+ {
+ "kind": "disclosure",
+ "format": "paid_for_by",
+ "placement": "creative"
+ },
+ {
+ "kind": "targeting_restriction",
+ "description": "Use only targeting permitted for the delivery market and political-advertising type."
+ },
+ {
+ "kind": "transparency_reporting",
+ "description": "Permit publication in the operator's political-ad transparency library."
+ }
+ ],
+ "policy_ids": [
+ "snap_political_advertising_acceptance"
+ ],
+ "description": "Election, advocacy, and issue advertising is accepted only after eligibility, disclosure, review, targeting, and transparency conditions are met."
+ },
+ {
+ "rule_id": "snap_eu_political_prohibited",
+ "effective_at": "2025-10-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "jurisdiction_groups": [
+ "EU"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "snap_political_advertising_acceptance"
+ ],
+ "description": "Political advertising covered by EU Regulation 2024/900 is not accepted for EU delivery."
+ }
+ ]
+ },
+ "policy": "Election-related, advocacy, and issue advertising may be accepted subject to case-by-case review. Advertisements must include the required paid-for-by disclosure and comply with transparency-library requirements. Advertiser residency, foreign-funding, jurisdiction, targeting, and content restrictions may apply. The operator may review and fact-check political advertisements before or during delivery.",
+ "guidance": "Represent political advertising as conditional. Discovery should disclose sponsor identification, review, transparency, and market-eligibility requirement classes without promising approval.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "An eligible domestic sponsor submits an issue advertisement with the required paid-for-by disclosure for review and transparency publication.",
+ "explanation": "The submission satisfies the coarse prerequisites but remains subject to review."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "An undisclosed foreign funder attempts to run election advocacy in a market that restricts foreign-funded political advertising.",
+ "explanation": "The advertiser and funding arrangement fail the market-specific eligibility rules."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:22160b18384dd9ad4add60b24a9d4e862c9f8d179139c27a235098f7e5f6f96d",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "tiktok_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "TikTok Politics, Government, and Elections Advertising Acceptance",
+ "description": "TikTok prohibition on paid political advertising and controlled exceptions for eligible government and election-authority information.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2026-07-01",
+ "source_url": "https://ads.tiktok.com/help/article/tiktok-ads-policy-politics-government-and-elections",
+ "source_name": "TikTok",
+ "issuer": {
+ "domain": "tiktok.com",
+ "name": "TikTok"
+ },
+ "acceptance_profile": {
+ "profile_id": "tiktok_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:7445c38199ee65a6a7f5689e5718c4f843d6a3c28bc61040511135fde30c8f19",
+ "policy_refs": [
+ {
+ "policy_id": "tiktok_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:abd3a1b39a93c2a03c43843efe9e01dd13870c9a7633d7e1e2732a9d9a511e3f"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "landing_page",
+ "targeting",
+ "delivery"
+ ],
+ "all_jurisdictions": true
+ },
+ "description": "Reusable coarse profile of TikTok's public politics, government, and elections acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "tiktok_paid_political_prohibited",
+ "effective_at": "2026-07-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation",
+ "political_merchandise"
+ ],
+ "advertiser_roles": [
+ "political_actor",
+ "commercial_advertiser"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "delivery"
+ ],
+ "disposition": "prohibited",
+ "policy_ids": [
+ "tiktok_political_advertising_acceptance"
+ ],
+ "description": "Paid political advertising and advertising by political figures or political entities is not accepted."
+ },
+ {
+ "rule_id": "tiktok_government_information_conditional",
+ "effective_at": "2026-07-01T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "government_information"
+ ],
+ "advertiser_roles": [
+ "government_entity",
+ "election_authority",
+ "intergovernmental_organization"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "landing_page",
+ "targeting",
+ "delivery"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "advertiser_verification",
+ "verification_scheme": "tiktok_government_account_verification"
+ },
+ {
+ "kind": "account_setup",
+ "description": "Use an operator-approved government or election-authority account."
+ },
+ {
+ "kind": "prior_authorization",
+ "description": "Complete the controlled onboarding and certification process before submission."
+ },
+ {
+ "kind": "creative_restriction",
+ "description": "Content must be factual participation or public-service information and must not advocate a political outcome."
+ },
+ {
+ "kind": "targeting_restriction",
+ "description": "Use only targeting permitted for the approved exception and market."
+ }
+ ],
+ "policy_ids": [
+ "tiktok_political_advertising_acceptance"
+ ],
+ "description": "Verified government and election-authority information may qualify only through the controlled exception process."
+ }
+ ]
+ },
+ "policy": "Paid political advertising and advertising by political figures or political entities is prohibited. Eligible government, intergovernmental, and election-authority advertisers may advertise only through the operator's controlled onboarding and certification process. Permitted election-authority advertising must be factual and limited to participation information rather than persuasion. Additional market, targeting, age, gender, landing-page, and account restrictions apply to an accepted exception.",
+ "guidance": "Default political_actor, candidate_or_party, election_or_ballot_measure, issue_advocacy, and legislation_or_regulation advertising to prohibited. Evaluate government_information only as a conditional exception with the required advertiser role and onboarding.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A verified election authority uses an approved account to publish factual voting-location and participation information without advocating an outcome.",
+ "explanation": "Official election participation information can qualify for the controlled government-information exception."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A candidate campaign attempts to purchase a video advertisement asking viewers to vote for the candidate.",
+ "explanation": "Paid candidate and campaign advertising is prohibited."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:abd3a1b39a93c2a03c43843efe9e01dd13870c9a7633d7e1e2732a9d9a511e3f",
+ "is_current": true
+ },
+ {
+ "entry": {
+ "policy_id": "x_political_advertising_acceptance",
+ "source": "registry",
+ "version": "1.1.0",
+ "name": "X Political Content Advertising Acceptance",
+ "description": "X conditional political-content and campaigning acceptance by country, advertiser eligibility, certification, format, targeting, and election timing.",
+ "category": "standard",
+ "enforcement": "must",
+ "jurisdictions": [],
+ "policy_categories": [
+ "political_advertising"
+ ],
+ "governance_domains": [
+ "campaign",
+ "creative"
+ ],
+ "effective_date": "2023-08-29",
+ "source_url": "https://business.x.com/en/help/ads-policies/ads-content-policies/political-content",
+ "source_name": "X",
+ "issuer": {
+ "domain": "x.com",
+ "name": "X"
+ },
+ "acceptance_profile": {
+ "profile_id": "x_political_advertising_acceptance",
+ "version": "1.0.0",
+ "content_digest": "sha256:7df1787ce5458151286e80a00e7e1058f72850315e24626fb69458703c994f72",
+ "policy_refs": [
+ {
+ "policy_id": "x_political_advertising_acceptance",
+ "version": "1.1.0",
+ "content_digest": "sha256:3d5133d0d6d5393bc843da4ccf78aaacb83a3295271754a668aa6b0695df3ad3"
+ }
+ ],
+ "coverage": "partial",
+ "scope": {
+ "subject_categories": [
+ "political_advertising"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery",
+ "format"
+ ],
+ "all_jurisdictions": true
+ },
+ "description": "Reusable coarse profile of X's public political-content advertising acceptance policy.",
+ "rules": [
+ {
+ "rule_id": "x_political_conditional_supported_markets",
+ "effective_at": "2023-08-29T00:00:00Z",
+ "subject_category": "political_advertising",
+ "subject_facets": [
+ "candidate_or_party",
+ "election_or_ballot_measure",
+ "issue_advocacy",
+ "legislation_or_regulation"
+ ],
+ "applies_to": [
+ "account",
+ "media_buy",
+ "creative",
+ "targeting",
+ "delivery",
+ "format"
+ ],
+ "disposition": "conditional",
+ "requirements": [
+ {
+ "kind": "certification",
+ "credential": "x_political_content_advertiser_certification"
+ },
+ {
+ "kind": "advertiser_eligibility",
+ "criteria": [
+ "supported_delivery_market",
+ "market_specific_domestic_entity_or_residency"
+ ]
+ },
+ {
+ "kind": "funding_restriction",
+ "criteria": [
+ "market_specific_funding_rules"
+ ]
+ },
+ {
+ "kind": "format_restriction",
+ "description": "Use only political-content advertising formats permitted for the market."
+ },
+ {
+ "kind": "targeting_restriction",
+ "description": "Use only political-content targeting permitted for the market."
+ },
+ {
+ "kind": "time_restriction",
+ "description": "Observe applicable election silence and blackout periods."
+ }
+ ],
+ "policy_ids": [
+ "x_political_advertising_acceptance"
+ ],
+ "description": "Political campaigning is conditional in supported markets; this partial profile does not imply acceptance in an unlisted or unknown market."
+ }
+ ]
+ },
+ "policy": "Political campaigning advertisements are accepted only in specified countries and subject to certification. Eligible advertisers must satisfy applicable domestic-entity, residency, funding, and local-law requirements. Only permitted formats and constrained targeting may be used, and election silence or blackout periods apply where required. Qualifying news reporting may be exempt when it is not purchased by or coordinated with a political actor.",
+ "guidance": "Model political advertising as conditional only for a supported delivery market. Unsupported markets are prohibited. Certification, advertiser location, format, targeting, and time restrictions are separate requirements.",
+ "exemplars": {
+ "pass": [
+ {
+ "scenario": "A certified domestic advertiser in a supported country uses an eligible format and targeting configuration outside the applicable silence period.",
+ "explanation": "The campaign meets the coarse market and certification conditions."
+ }
+ ],
+ "fail": [
+ {
+ "scenario": "A foreign political organization requests campaigning delivery in a market that requires a domestic advertiser.",
+ "explanation": "The advertiser does not satisfy the market-specific eligibility rule."
+ }
+ ]
+ }
+ },
+ "content_digest": "sha256:3d5133d0d6d5393bc843da4ccf78aaacb83a3295271754a668aa6b0695df3ad3",
+ "is_current": true
+ }
+]
+$registry_policies$::jsonb
+ ) AS bundled(payload)
+)
+SELECT
+ payload->'entry' AS entry,
+ payload->>'content_digest' AS content_digest,
+ (payload->>'is_current')::boolean AS is_current
+FROM bundled;
+
+DO $immutable_policy_publication_conflict$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM incoming_policy_publications incoming
+ JOIN policy_publications publication
+ ON publication.policy_id = incoming.entry->>'policy_id'
+ AND publication.version = incoming.entry->>'version'
+ WHERE publication.content_digest <> incoming.content_digest
+ OR publication.canonical_content <> incoming.entry - 'acceptance_profile'
+ OR publication.acceptance_profile IS DISTINCT FROM incoming.entry->'acceptance_profile'
+ ) THEN
+ RAISE EXCEPTION 'conflicting immutable policy publication; publish a new version';
+ END IF;
+END
+$immutable_policy_publication_conflict$;
+
+INSERT INTO policy_publications (
+ policy_id,
+ version,
+ content_digest,
+ canonical_content,
+ acceptance_profile
+)
+SELECT
+ entry->>'policy_id',
+ entry->>'version',
+ content_digest,
+ entry - 'acceptance_profile',
+ entry->'acceptance_profile'
+FROM incoming_policy_publications
+ON CONFLICT (policy_id, version) DO NOTHING;
+
+INSERT INTO policies (
+ policy_id,
+ version,
+ name,
+ description,
+ category,
+ enforcement,
+ jurisdictions,
+ region_aliases,
+ policy_categories,
+ channels,
+ effective_date,
+ sunset_date,
+ governance_domains,
+ source_url,
+ source_name,
+ issuer,
+ acceptance_profile,
+ policy,
+ guidance,
+ exemplars,
+ ext,
+ source_type,
+ review_status
+)
+SELECT
+ entry->>'policy_id',
+ entry->>'version',
+ entry->>'name',
+ entry->>'description',
+ entry->>'category',
+ entry->>'enforcement',
+ COALESCE(entry->'jurisdictions', '[]'::jsonb),
+ COALESCE(entry->'region_aliases', '{}'::jsonb),
+ COALESCE(entry->'policy_categories', '[]'::jsonb),
+ entry->'channels',
+ entry->>'effective_date',
+ entry->>'sunset_date',
+ COALESCE(entry->'governance_domains', '[]'::jsonb),
+ entry->>'source_url',
+ entry->>'source_name',
+ entry->'issuer',
+ entry->'acceptance_profile',
+ entry->>'policy',
+ entry->>'guidance',
+ entry->'exemplars',
+ entry->'ext',
+ 'registry',
+ 'approved'
+FROM incoming_policy_publications incoming
+WHERE is_current
+ AND EXISTS (
+ SELECT 1
+ FROM policy_publications publication
+ WHERE publication.policy_id = incoming.entry->>'policy_id'
+ AND publication.version = incoming.entry->>'version'
+ AND publication.content_digest = incoming.content_digest
+ AND publication.canonical_content = incoming.entry - 'acceptance_profile'
+ AND publication.acceptance_profile IS NOT DISTINCT FROM incoming.entry->'acceptance_profile'
+ )
+ON CONFLICT (policy_id) DO UPDATE SET
+ version = EXCLUDED.version,
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ category = EXCLUDED.category,
+ enforcement = EXCLUDED.enforcement,
+ jurisdictions = EXCLUDED.jurisdictions,
+ region_aliases = EXCLUDED.region_aliases,
+ policy_categories = EXCLUDED.policy_categories,
+ channels = EXCLUDED.channels,
+ effective_date = EXCLUDED.effective_date,
+ sunset_date = EXCLUDED.sunset_date,
+ governance_domains = EXCLUDED.governance_domains,
+ source_url = EXCLUDED.source_url,
+ source_name = EXCLUDED.source_name,
+ issuer = EXCLUDED.issuer,
+ acceptance_profile = EXCLUDED.acceptance_profile,
+ policy = EXCLUDED.policy,
+ guidance = EXCLUDED.guidance,
+ exemplars = EXCLUDED.exemplars,
+ ext = EXCLUDED.ext,
+ review_status = 'approved'
+WHERE (
+ policies.version = EXCLUDED.version
+ OR (policies.policy_id = 'eu_dsa_political_targeting' AND policies.version = '1.0.0' AND EXCLUDED.version = '2.0.0')
+ )
+ AND policies.source_type = 'registry'
+ AND EXISTS (
+ SELECT 1
+ FROM policy_publications publication
+ JOIN incoming_policy_publications incoming
+ ON incoming.entry->>'policy_id' = publication.policy_id
+ AND incoming.entry->>'version' = publication.version
+ AND incoming.content_digest = publication.content_digest
+ AND publication.acceptance_profile IS NOT DISTINCT FROM incoming.entry->'acceptance_profile'
+ WHERE publication.policy_id = EXCLUDED.policy_id
+ AND publication.version = EXCLUDED.version
+ );
diff --git a/server/src/db/migrations/563_governance_bindings_and_task_owner_scope.sql b/server/src/db/migrations/563_governance_bindings_and_task_owner_scope.sql
new file mode 100644
index 0000000000..81182cc245
--- /dev/null
+++ b/server/src/db/migrations/563_governance_bindings_and_task_owner_scope.sql
@@ -0,0 +1,47 @@
+-- Migration 563: authoritative seller-side governance-agent bindings. Each principal/account
+-- has one atomic row addressable by either seller account_id or the complete
+-- canonical natural identity. Credentials are encrypted separately from the
+-- non-secret agent descriptor so they never appear in binding JSON.
+CREATE TABLE IF NOT EXISTS governance_agent_bindings (
+ principal_scope TEXT NOT NULL,
+ account_id TEXT NOT NULL,
+ account_scope TEXT NOT NULL,
+ brand_domain TEXT NOT NULL,
+ account_ref JSONB NOT NULL,
+ agents JSONB NOT NULL,
+ credentials_encrypted TEXT NOT NULL,
+ credentials_iv TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (principal_scope, account_id),
+ CONSTRAINT governance_agent_bindings_principal_nonempty CHECK (length(principal_scope) > 0),
+ CONSTRAINT governance_agent_bindings_account_id_nonempty CHECK (length(account_id) > 0),
+ CONSTRAINT governance_agent_bindings_account_scope_nonempty CHECK (length(account_scope) > 0),
+ CONSTRAINT governance_agent_bindings_brand_domain_nonempty CHECK (length(brand_domain) > 0),
+ CONSTRAINT governance_agent_bindings_account_ref_object CHECK (jsonb_typeof(account_ref) = 'object'),
+ CONSTRAINT governance_agent_bindings_agents_singleton CHECK (
+ jsonb_typeof(agents) = 'array' AND jsonb_array_length(agents) = 1
+ ),
+ CONSTRAINT governance_agent_bindings_credentials_nonempty CHECK (
+ length(credentials_encrypted) > 0 AND length(credentials_iv) > 0
+ ),
+ CONSTRAINT governance_agent_bindings_principal_scope_unique
+ UNIQUE (principal_scope, account_scope)
+);
+
+CREATE INDEX IF NOT EXISTS idx_governance_agent_bindings_principal_brand
+ ON governance_agent_bindings (principal_scope, brand_domain, account_id);
+
+COMMENT ON COLUMN governance_agent_bindings.credentials_encrypted IS
+ 'AES-256-GCM encrypted governance-agent Bearer credential';
+
+-- SDK 14's durable decisioning task registry records owner_scope on every
+-- create and uses it with account_id for tenant/principal-isolated polling.
+-- Migration 463 predates that column; keep it nullable for its legacy rows,
+-- matching the SDK's current upgrade DDL and fallback semantics.
+ALTER TABLE adcp_decisioning_tasks
+ ADD COLUMN IF NOT EXISTS owner_scope TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_adcp_decisioning_tasks_owner_account
+ ON adcp_decisioning_tasks (owner_scope, account_id);
diff --git a/server/src/db/migrations/564_seller_managed_control_jobs.sql b/server/src/db/migrations/564_seller_managed_control_jobs.sql
new file mode 100644
index 0000000000..72c60bc4e8
--- /dev/null
+++ b/server/src/db/migrations/564_seller_managed_control_jobs.sql
@@ -0,0 +1,48 @@
+-- Migration 564: recoverable execution outbox for seller-managed control_media_buy jobs.
+-- No FK to adcp_decisioning_tasks: the outbox is committed before SDK task
+-- creation so a worker can recover a crash in that boundary.
+CREATE TABLE IF NOT EXISTS seller_managed_control_jobs (
+ task_id TEXT PRIMARY KEY,
+ tool TEXT NOT NULL DEFAULT 'control_media_buy',
+ account_id TEXT NOT NULL,
+ owner_scope TEXT NOT NULL,
+ idempotency_principal TEXT NOT NULL,
+ idempotency_key TEXT NOT NULL,
+ request_fingerprint TEXT NOT NULL,
+ media_buy_id TEXT NOT NULL,
+ expected_revision INTEGER NOT NULL CHECK (expected_revision >= 1),
+ authorized_actions JSONB NOT NULL CHECK (jsonb_typeof(authorized_actions) = 'array'),
+ request JSONB NOT NULL,
+ execution_context JSONB NOT NULL,
+ push_config_encrypted TEXT,
+ push_config_iv TEXT,
+ has_webhook BOOLEAN NOT NULL DEFAULT FALSE,
+ webhook_tenant_scope TEXT,
+ status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'working', 'succeeded', 'failed')),
+ lease_owner TEXT,
+ lease_version BIGINT NOT NULL DEFAULT 0,
+ lease_expires_at TIMESTAMPTZ,
+ attempt_count INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ result JSONB,
+ error JSONB,
+ terminal_at TIMESTAMPTZ,
+ task_synced_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (idempotency_principal, account_id, idempotency_key),
+ CHECK ((push_config_encrypted IS NULL) = (push_config_iv IS NULL)),
+ CHECK (NOT has_webhook OR webhook_tenant_scope IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_seller_managed_control_jobs_recovery
+ ON seller_managed_control_jobs (status, next_attempt_at, lease_expires_at)
+ WHERE task_synced_at IS NULL;
+
+COMMENT ON COLUMN seller_managed_control_jobs.push_config_encrypted IS
+ 'AES-256-GCM encrypted push_notification_config; may contain callback credentials';
+COMMENT ON COLUMN seller_managed_control_jobs.request_fingerprint IS
+ 'Keyed canonical-request digest for principal/account idempotency arbitration';
+COMMENT ON COLUMN seller_managed_control_jobs.webhook_tenant_scope IS
+ 'Exact trusted SDK webhook partition; required for recovery/framework deduplication';
diff --git a/server/src/db/policies-db.ts b/server/src/db/policies-db.ts
index e0917a4e95..3a7b0f170b 100644
--- a/server/src/db/policies-db.ts
+++ b/server/src/db/policies-db.ts
@@ -1,4 +1,178 @@
import { query, getClient } from './client.js';
+import { z } from 'zod';
+import { validateProtocolSchema } from '../services/protocol-schema-validator.js';
+
+const ACCEPTANCE_POLICY_PROFILE_SCHEMA_URI = '/schemas/media-buy/acceptance-policy-profile.json';
+const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
+const PROFILE_ID_RE = /^[A-Za-z0-9_.:-]+$/;
+const POLICY_CATEGORY_RE = /^[a-z][a-z0-9_]*$/;
+const JURISDICTION_RE = /^[A-Z]{2}$/;
+const JURISDICTION_GROUP_RE = /^[A-Z][A-Z0-9_-]*$/;
+
+const RequirementDescriptionSchema = z.string().min(1);
+const RequirementCriteriaSchema = z.array(z.string().regex(/^[a-z][a-z0-9_.:-]*$/)).min(1);
+const AcceptancePolicyRequirementSchema = z.discriminatedUnion('kind', [
+ z.object({
+ kind: z.literal('category_declaration'),
+ declaration: z.string().min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('advertiser_verification'),
+ verification_scheme: z.string().min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('advertiser_eligibility'),
+ criteria: RequirementCriteriaSchema,
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('funding_restriction'),
+ criteria: RequirementCriteriaSchema,
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('certification'),
+ credential: z.string().min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('license'),
+ credential: z.string().min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ ...(['prior_authorization', 'account_setup', 'sales_assisted', 'transparency_reporting'] as const).map(kind =>
+ z.object({ kind: z.literal(kind), description: RequirementDescriptionSchema.optional() }).strict(),
+ ),
+ z.object({
+ kind: z.literal('disclosure'),
+ format: z.string().min(1).optional(),
+ placement: z.string().min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('targeting_restriction'),
+ restricted_attributes: z.array(z.enum([
+ 'racial_ethnic_origin',
+ 'political_opinions',
+ 'religious_beliefs',
+ 'trade_union_membership',
+ 'health_data',
+ 'sex_life_sexual_orientation',
+ 'genetic_data',
+ 'biometric_data',
+ 'age',
+ 'familial_status',
+ ])).min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ ...(['creative_restriction', 'destination_restriction'] as const).map(kind =>
+ z.object({ kind: z.literal(kind), description: RequirementDescriptionSchema }).strict(),
+ ),
+ z.object({
+ kind: z.literal('format_restriction'),
+ format_ids: z.array(z.string().min(1)).min(1).optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('time_restriction'),
+ starts_at: z.string().datetime().optional(),
+ ends_at: z.string().datetime().optional(),
+ description: RequirementDescriptionSchema.optional(),
+ }).strict(),
+ z.object({
+ kind: z.literal('custom'),
+ id: z.string().regex(/^[a-z][a-z0-9_.:-]*$/),
+ description: RequirementDescriptionSchema,
+ ext: z.record(z.string(), z.unknown()).optional(),
+ }).strict(),
+]);
+
+const AcceptancePolicyRuleSchema = z.object({
+ rule_id: z.string().regex(PROFILE_ID_RE),
+ subject_category: z.string().regex(POLICY_CATEGORY_RE),
+ subject_facets: z.array(z.string().regex(POLICY_CATEGORY_RE)).min(1).optional(),
+ advertiser_roles: z.array(z.string().regex(POLICY_CATEGORY_RE)).min(1).optional(),
+ jurisdictions: z.array(z.string().regex(JURISDICTION_RE)).min(1).optional(),
+ jurisdiction_groups: z.array(z.string().regex(JURISDICTION_GROUP_RE)).min(1).optional(),
+ applies_to: z.array(z.enum(['account', 'media_buy', 'creative', 'landing_page', 'targeting', 'delivery', 'format'])).min(1),
+ disposition: z.enum(['allowed', 'conditional', 'prohibited']),
+ requirements: z.array(AcceptancePolicyRequirementSchema).min(1).optional(),
+ policy_ids: z.array(z.string().min(1)).min(1).optional(),
+ description: z.string().min(1).max(1000).optional(),
+ effective_at: z.string().datetime().optional(),
+ expires_at: z.string().datetime().optional(),
+ ext: z.record(z.string(), z.unknown()).optional(),
+}).strict().superRefine((rule, ctx) => {
+ if (rule.disposition === 'conditional' && !rule.requirements) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['requirements'], message: 'required for conditional rules' });
+ }
+});
+
+const AcceptancePolicyScopeSchema = z.object({
+ subject_categories: z.array(z.string().regex(POLICY_CATEGORY_RE)).min(1),
+ applies_to: z.array(z.enum(['account', 'media_buy', 'creative', 'landing_page', 'targeting', 'delivery', 'format'])).min(1),
+ jurisdictions: z.array(z.string().regex(JURISDICTION_RE)).min(1).optional(),
+ jurisdiction_groups: z.array(z.string().regex(JURISDICTION_GROUP_RE)).min(1).optional(),
+ all_jurisdictions: z.literal(true).optional(),
+}).strict().superRefine((scope, ctx) => {
+ if (!scope.jurisdictions && !scope.jurisdiction_groups && scope.all_jurisdictions !== true) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'jurisdiction coverage must be explicit' });
+ }
+});
+
+export const AcceptancePolicyProfileSchema = z.object({
+ profile_id: z.string().regex(PROFILE_ID_RE),
+ version: z.string().min(1),
+ content_digest: z.string().regex(SHA256_DIGEST_RE),
+ policy_refs: z.array(z.object({
+ policy_id: z.string().min(1),
+ version: z.string().min(1),
+ content_digest: z.string().regex(SHA256_DIGEST_RE),
+ }).strict()).min(1),
+ coverage: z.enum(['partial', 'complete']),
+ scope: AcceptancePolicyScopeSchema.optional(),
+ region_aliases: z.record(z.string().regex(JURISDICTION_GROUP_RE), z.array(z.string().regex(JURISDICTION_RE)).min(1)).optional(),
+ description: z.string().min(1).optional(),
+ rules: z.array(AcceptancePolicyRuleSchema).min(1),
+ ext: z.record(z.string(), z.unknown()).optional(),
+}).strict().superRefine((profile, ctx) => {
+ if (profile.coverage === 'complete' && !profile.scope) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['scope'], message: 'required for complete coverage' });
+ }
+});
+export type AcceptancePolicyProfile = z.infer;
+
+const CanonicalPolicyDocumentSchema = z.object({
+ policy_id: z.string(),
+ source: z.enum(['registry', 'inline']).optional(),
+ version: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ category: z.enum(['regulation', 'standard']),
+ enforcement: z.enum(['must', 'should', 'may']),
+ requires_human_review: z.boolean().optional(),
+ jurisdictions: z.array(z.string()).optional(),
+ region_aliases: z.record(z.string(), z.array(z.string())).optional(),
+ policy_categories: z.array(z.string()).optional(),
+ channels: z.array(z.string()).optional(),
+ governance_domains: z.array(z.string()).optional(),
+ effective_date: z.string().optional(),
+ sunset_date: z.string().optional(),
+ source_url: z.string().optional(),
+ source_name: z.string().optional(),
+ issuer: z.object({ domain: z.string(), name: z.string().optional() }).strict().optional(),
+ acceptance_profile: AcceptancePolicyProfileSchema.optional(),
+ policy: z.string(),
+ guidance: z.string().optional(),
+ exemplars: z.object({
+ pass: z.array(z.object({ scenario: z.string(), explanation: z.string() }).strict()).optional(),
+ fail: z.array(z.object({ scenario: z.string(), explanation: z.string() }).strict()).optional(),
+ }).strict().optional(),
+ ext: z.record(z.string(), z.unknown()).optional(),
+}).strict();
+type CanonicalPolicyDocument = z.infer;
export interface Policy {
policy_id: string;
@@ -16,6 +190,10 @@ export interface Policy {
sunset_date: string | null;
source_url: string | null;
source_name: string | null;
+ issuer: { domain: string; name?: string } | null;
+ acceptance_profile: AcceptancePolicyProfile | null;
+ content_digest?: string | null;
+ canonical_content?: Record | null;
policy: string;
guidance: string | null;
exemplars: { pass?: Array<{ scenario: string; explanation: string }>; fail?: Array<{ scenario: string; explanation: string }> } | null;
@@ -79,7 +257,41 @@ export interface EditorInfo {
name?: string;
}
-function deserializePolicy(row: any): Policy {
+type PolicyRow = Omit & Record;
+
+interface PolicyPublicationRow {
+ policy_id: string;
+ version: string;
+ content_digest: string;
+ canonical_content: unknown;
+ acceptance_profile: unknown;
+ published_at: string | Date;
+}
+
+function parsedJson(value: unknown): unknown {
+ return typeof value === 'string' ? JSON.parse(value) : value;
+}
+
+async function parseAcceptancePolicyProfile(value: unknown): Promise {
+ const parsed = parsedJson(value);
+ const validation = await validateProtocolSchema(ACCEPTANCE_POLICY_PROFILE_SCHEMA_URI, parsed);
+ if (!validation.valid) {
+ const details = validation.errors
+ .map(error => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`)
+ .join('; ');
+ throw new Error(`Invalid policy acceptance_profile: ${details}`);
+ }
+ return parsed as AcceptancePolicyProfile;
+}
+
+async function deserializePolicy(row: PolicyRow): Promise {
+ const acceptanceProfile = row.acceptance_profile == null
+ ? null
+ : await parseAcceptancePolicyProfile(row.acceptance_profile);
return {
...row,
jurisdictions: typeof row.jurisdictions === 'string' ? JSON.parse(row.jurisdictions) : (row.jurisdictions || []),
@@ -88,10 +300,57 @@ function deserializePolicy(row: any): Policy {
channels: row.channels == null ? null : (typeof row.channels === 'string' ? JSON.parse(row.channels) : row.channels),
governance_domains: typeof row.governance_domains === 'string' ? JSON.parse(row.governance_domains) : (row.governance_domains || []),
exemplars: row.exemplars == null ? null : (typeof row.exemplars === 'string' ? JSON.parse(row.exemplars) : row.exemplars),
+ issuer: row.issuer == null ? null : (typeof row.issuer === 'string' ? JSON.parse(row.issuer) : row.issuer),
+ acceptance_profile: acceptanceProfile,
+ ...(row.content_digest !== undefined ? { content_digest: row.content_digest } : {}),
+ ...(row.canonical_content !== undefined ? {
+ canonical_content: row.canonical_content == null
+ ? null
+ : (typeof row.canonical_content === 'string' ? JSON.parse(row.canonical_content) : row.canonical_content),
+ } : {}),
ext: row.ext == null ? null : (typeof row.ext === 'string' ? JSON.parse(row.ext) : row.ext),
- created_at: new Date(row.created_at),
- updated_at: new Date(row.updated_at),
- };
+ created_at: new Date(String(row.created_at)),
+ updated_at: new Date(String(row.updated_at)),
+ } as Policy;
+}
+
+async function deserializePublishedPolicy(row: PolicyPublicationRow): Promise {
+ const canonical = CanonicalPolicyDocumentSchema.parse(parsedJson(row.canonical_content));
+ const publishedAt = new Date(row.published_at);
+ return deserializePolicy({
+ policy_id: canonical.policy_id,
+ version: canonical.version,
+ name: canonical.name,
+ description: canonical.description ?? null,
+ category: canonical.category,
+ enforcement: canonical.enforcement,
+ jurisdictions: canonical.jurisdictions ?? [],
+ region_aliases: canonical.region_aliases ?? {},
+ policy_categories: canonical.policy_categories ?? [],
+ channels: canonical.channels ?? null,
+ governance_domains: canonical.governance_domains ?? [],
+ effective_date: canonical.effective_date ?? null,
+ sunset_date: canonical.sunset_date ?? null,
+ source_url: canonical.source_url ?? null,
+ source_name: canonical.source_name ?? null,
+ issuer: canonical.issuer ?? null,
+ // The authoritative canonical JSON Schema is applied by
+ // deserializePolicy below; keep this raw so every source follows the same
+ // validation path and error contract.
+ acceptance_profile: row.acceptance_profile == null
+ ? null
+ : parsedJson(row.acceptance_profile),
+ policy: canonical.policy,
+ guidance: canonical.guidance ?? null,
+ exemplars: canonical.exemplars ?? null,
+ ext: canonical.ext ?? null,
+ source_type: 'registry',
+ review_status: 'approved',
+ content_digest: row.content_digest,
+ canonical_content: canonical,
+ created_at: publishedAt,
+ updated_at: publishedAt,
+ });
}
function deserializeRevision(row: any): PolicyRevision {
@@ -149,7 +408,7 @@ export async function listPolicies(options: ListPoliciesOptions = {}): Promise<{
`SELECT policy_id, version, name, description, category, enforcement,
jurisdictions, region_aliases, policy_categories, channels,
governance_domains, effective_date, sunset_date,
- source_url, source_name, source_type, review_status,
+ source_url, source_name, issuer, source_type, review_status,
created_at, updated_at
FROM policies ${where} ORDER BY category, name LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, limit, offset]
@@ -165,7 +424,7 @@ export async function listPolicies(options: ListPoliciesOptions = {}): Promise<{
const stats = statsResult.rows[0];
return {
- policies: dataResult.rows.map(deserializePolicy),
+ policies: await Promise.all(dataResult.rows.map(row => deserializePolicy(row))),
total: parseInt(stats.total, 10),
regulation: parseInt(stats.regulation, 10),
standard: parseInt(stats.standard, 10),
@@ -176,13 +435,31 @@ export async function listPolicies(options: ListPoliciesOptions = {}): Promise<{
* Resolve a single policy by ID, optionally pinned to a version.
*/
export async function resolvePolicy(policyId: string, version?: string): Promise {
- const result = await query(
- 'SELECT * FROM policies WHERE policy_id = $1',
+ if (version) {
+ const publication = await query(
+ `SELECT policy_id, version, content_digest, canonical_content,
+ acceptance_profile, published_at
+ FROM policy_publications
+ WHERE policy_id = $1 AND version = $2`,
+ [policyId, version]
+ );
+ if (publication.rows.length > 0) return deserializePublishedPolicy(publication.rows[0]);
+ // An explicit version is an immutable-publication request. Never fall
+ // through to the mutable current-row projection, even when its version
+ // string happens to match the requested version.
+ return null;
+ }
+ const result = await query(
+ `SELECT policy.*, publication.content_digest, publication.canonical_content
+ FROM policies policy
+ LEFT JOIN policy_publications publication
+ ON publication.policy_id = policy.policy_id
+ AND publication.version = policy.version
+ WHERE policy.policy_id = $1`,
[policyId]
);
if (result.rows.length === 0) return null;
const policy = deserializePolicy(result.rows[0]);
- if (version && policy.version !== version) return null;
return policy;
}
@@ -191,12 +468,17 @@ export async function resolvePolicy(policyId: string, version?: string): Promise
*/
export async function bulkResolve(policyIds: string[]): Promise> {
if (policyIds.length === 0) return {};
- const result = await query(
- 'SELECT * FROM policies WHERE policy_id = ANY($1)',
+ const result = await query(
+ `SELECT policy.*, publication.content_digest, publication.canonical_content
+ FROM policies policy
+ LEFT JOIN policy_publications publication
+ ON publication.policy_id = policy.policy_id
+ AND publication.version = policy.version
+ WHERE policy.policy_id = ANY($1)`,
[policyIds]
);
const map: Record = Object.create(null);
- const rows = result.rows.map(deserializePolicy);
+ const rows = await Promise.all(result.rows.map(row => deserializePolicy(row)));
for (const id of policyIds) {
// Validate property name to prevent prototype pollution
if (typeof id === 'string' && !['__proto__', 'constructor', 'prototype'].includes(id)) {
@@ -281,7 +563,7 @@ export async function savePolicy(
);
await client.query('COMMIT');
- return { policy: deserializePolicy(updateResult.rows[0]), revision_number: revisionNumber };
+ return { policy: await deserializePolicy(updateResult.rows[0]), revision_number: revisionNumber };
}
// Insert new policy (community policies start as pending review)
@@ -311,7 +593,7 @@ export async function savePolicy(
);
await client.query('COMMIT');
- return { policy: deserializePolicy(insertResult.rows[0]), revision_number: null };
+ return { policy: await deserializePolicy(insertResult.rows[0]), revision_number: null };
} catch (err) {
await client.query('ROLLBACK');
throw err;
diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts
index 85ffdc7ca9..9c341da854 100644
--- a/server/src/routes/registry-api.ts
+++ b/server/src/routes/registry-api.ts
@@ -2482,7 +2482,7 @@ registry.registerPath({
operationId: "resolvePolicy",
summary: "Resolve policy",
description:
- "Resolve a single policy by ID. Optionally pin to a specific version — returns null if the version does not match.",
+ "Resolve a single policy by ID. Optionally pin to an immutable published version. Registry publications include canonical_content and its RFC 8785 SHA-256 content_digest; a version that was never published returns not found.",
tags: ["Policy Registry"],
request: {
query: z.object({
diff --git a/server/src/schemas/registry.ts b/server/src/schemas/registry.ts
index f38d86ceb4..ee0545aa78 100644
--- a/server/src/schemas/registry.ts
+++ b/server/src/schemas/registry.ts
@@ -13,6 +13,7 @@ import {
PUBLIC_COMPLIANCE_NOTICE_LIMITS,
PublicComplianceNoticeSchema,
} from "./public-compliance-notice.js";
+import { AcceptancePolicyProfileSchema } from "../db/policies-db.js";
import {
extendZodWithOpenApi,
OpenAPIRegistry,
@@ -1035,6 +1036,10 @@ export const PolicySchema = z
sunset_date: z.string().nullable(),
source_url: z.string().nullable().openapi({ example: "https://eur-lex.europa.eu/eli/reg/2016/679/oj" }),
source_name: z.string().nullable().openapi({ example: "EUR-Lex" }),
+ issuer: z.object({ domain: z.string(), name: z.string().optional() }).nullable().openapi({ description: "Machine-readable identity of the authoritative policy issuer" }),
+ acceptance_profile: AcceptancePolicyProfileSchema.nullable().openapi({ description: "Version-pinned structured acceptance profile published with an authoritative registry policy" }),
+ content_digest: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable().openapi({ description: "SHA-256 of the RFC 8785 (JCS) serialization of canonical_content. Present for immutable registry publications; null for legacy or community policies." }),
+ canonical_content: z.record(z.string(), z.unknown()).nullable().openapi({ description: "Exact immutable registry policy document covered by content_digest. This excludes acceptance_profile, which has its own digest. Consumers MUST canonicalize this object with RFC 8785 before verifying content_digest." }),
policy: z.string().openapi({ example: "Data subjects must provide freely given, specific, informed and unambiguous consent..." }),
guidance: z.string().nullable(),
exemplars: z
@@ -1052,7 +1057,7 @@ export const PolicySchema = z
.openapi("Policy");
export const PolicySummarySchema = PolicySchema
- .omit({ policy: true, guidance: true, exemplars: true, ext: true })
+ .omit({ policy: true, guidance: true, exemplars: true, acceptance_profile: true, content_digest: true, canonical_content: true, ext: true })
.openapi("PolicySummary");
const PolicyRevisionEntrySchema = z.object({
diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts
index 9a0aa83184..c889de23a8 100644
--- a/server/src/training-agent/account-handlers.ts
+++ b/server/src/training-agent/account-handlers.ts
@@ -6,10 +6,16 @@
*/
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
+import { canonicalTargetUri } from '@adcp/sdk/signing';
import type { WebhookAuthentication } from '@adcp/sdk/server';
import type { TrainingContext, ToolArgs, AccountRef, OperatorUnit } from './types.js';
-import { accountScopeFromRef } from './account-scope.js';
-import { sessionKeyFromArgs } from './state.js';
+import { accountScopeFromRef, canonicalizeAccountRef } from './account-scope.js';
+import { SESSION_STORE_UNAVAILABLE_MESSAGE, sessionKeyFromArgs } from './state.js';
+import {
+ clearInMemoryGovernanceBindings,
+ governanceBindingStore,
+ type GovernanceBindingRecord,
+} from './governance-binding-store.js';
import { getAgentUrl } from './config.js';
import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js';
import { getCommercialRelationship } from './commercial-relationships.js';
@@ -34,6 +40,31 @@ import { isPrivateHostname, normalizeExternalHostname } from '../utils/url-secur
export const MAX_ACCOUNT_WEBHOOK_PROOF_CANDIDATES_PER_SYNC = 16;
const ACCOUNT_WEBHOOK_PROOF_SYNC_DEADLINE_MS = 30_000;
+/**
+ * Deterministic governance-agent allowlist used by the public training seller.
+ *
+ * The first endpoint is the normative acceptance/rejection conformance fixture.
+ * The remaining endpoints are the multi-agent runner's governance service and
+ * the legacy storyboard fixture. Keeping this list explicit makes the
+ * advertised capability and binding-time decision use one source of truth.
+ */
+const TRAINING_ACCEPTED_GOVERNANCE_AGENT_INPUT_URLS = [
+ 'https://governance.example/mcp',
+ 'https://test-agent.adcontextprotocol.org',
+ 'https://governance.pinnacle-agency.example',
+] as const;
+
+export const TRAINING_ACCEPTED_GOVERNANCE_AGENT_URLS = TRAINING_ACCEPTED_GOVERNANCE_AGENT_INPUT_URLS
+ .map(agentUrl => canonicalTargetUri(agentUrl));
+const TRAINING_ACCEPTED_GOVERNANCE_AGENT_URL_SET = new Set(TRAINING_ACCEPTED_GOVERNANCE_AGENT_URLS);
+
+export const TRAINING_ACCEPTED_GOVERNANCE_AGENTS = {
+ any_of: TRAINING_ACCEPTED_GOVERNANCE_AGENT_URLS.map(agent_url => ({
+ kind: 'agent_url' as const,
+ agent_url,
+ })),
+};
+
// ── Types ────────────────────────────────────────────────────────
interface SyncAccountsInput extends ToolArgs {
@@ -47,6 +78,7 @@ interface SyncAccountInput {
operator?: string;
operator_unit?: OperatorUnit;
currency?: string;
+ timezone?: string;
billing?: 'operator' | 'agent' | 'advertiser';
billing_entity?: Record;
payment_terms?: string;
@@ -62,6 +94,7 @@ interface AccountState {
operator: string;
operatorUnit?: OperatorUnit;
currency?: string;
+ timezone?: string;
billing: string;
billingEntity?: Record;
paymentTerms: string;
@@ -296,6 +329,7 @@ function accountKey(
operatorUnit?: OperatorUnit,
currency?: string,
sandbox = false,
+ timezone?: string,
): string {
return accountScopeFromRef({
brand: {
@@ -306,6 +340,7 @@ function accountKey(
operator,
...(operatorUnit && { operator_unit: operatorUnit }),
...(currency && { currency }),
+ ...(timezone && { timezone }),
sandbox,
});
}
@@ -324,6 +359,7 @@ function findAccountByRef(accounts: Map, ref: AccountRef):
ref.operator_unit,
ref.currency,
ref.sandbox === true,
+ ref.timezone,
));
}
return undefined;
@@ -365,6 +401,7 @@ export function sandboxAccountRefForId(
operator: account.operator.toLowerCase(),
...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }),
...(account.currency && { currency: account.currency }),
+ ...(account.timezone && { timezone: account.timezone }),
sandbox: true,
};
}
@@ -401,6 +438,7 @@ function accountStateFromWire(wire: AccountWireShape, now: string): AccountState
operator: wire.operator,
operatorUnit: wire.operator_unit,
currency: wire.currency,
+ timezone: wire.timezone,
billing: wire.billing,
paymentTerms: wire.payment_terms ?? 'net_30',
status: wire.status,
@@ -420,8 +458,8 @@ function findComplianceAccountById(accountId: string, now: string): AccountState
return fixture ? accountStateFromWire(fixture, now) : undefined;
}
-/** Exported for testing — clear all account state */
-export function clearAccountStore(): void {
+/** Simulate a process restart without erasing the authoritative binding store. */
+export function clearProcessLocalAccountStore(): void {
accountStore.clear();
accountChangeStore.clear();
accountChangeNextSequence.clear();
@@ -430,6 +468,12 @@ export function clearAccountStore(): void {
clearSharedAccountResources();
}
+/** Exported for isolated tests/manual storyboards — clear all account state. */
+export function clearAccountStore(): void {
+ clearProcessLocalAccountStore();
+ clearInMemoryGovernanceBindings();
+}
+
interface AccountWireShape {
account_id: string;
name: string;
@@ -440,6 +484,7 @@ interface AccountWireShape {
operator: string;
operator_unit?: OperatorUnit;
currency?: string;
+ timezone?: string;
billing: string;
account_scope: string;
status: string;
@@ -470,6 +515,7 @@ function accountStateToWire(account: AccountState): AccountWireShape {
operator: account.operator,
...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }),
...(account.currency && { currency: account.currency }),
+ ...(account.timezone && { timezone: account.timezone }),
billing: account.billing,
account_scope: account.accountScope,
status: account.status,
@@ -728,12 +774,14 @@ export function getAccountNotificationSubscribers(
accountRef!.operator_unit,
accountRef!.currency,
accountRef!.sandbox === true,
+ accountRef!.timezone,
) !== accountKey(
account.brand,
account.operator,
account.operatorUnit,
account.currency,
account.sandbox,
+ account.timezone,
)
) continue;
for (const config of account.notificationConfigs) {
@@ -869,21 +917,52 @@ export function resolveAccountCurrencyForRef(
: undefined;
}
-export function resolveGovernanceAgentsForAccount(
- sessionKey: string,
+function governanceBindingAgents(binding: GovernanceBindingRecord | null): GovernanceAgentEntry[] {
+ return binding?.agents.map(agent => structuredClone(agent)) ?? [];
+}
+
+export async function resolveGovernanceAgentsForAccount(
+ _sessionKey: string,
principal: string | undefined,
ref: AccountRef | undefined,
-): GovernanceAgentEntry[] {
+): Promise {
if (!ref) return [];
- for (const accounts of accountMapsForPrincipal(sessionKey, principal)) {
- const account = findAccountByRef(accounts, ref);
- if (account) return account.governanceAgents.map(agent => structuredClone(agent));
- }
- if (ref.account_id) {
- const account = findAccountByIdAcrossSessions(ref.account_id, principal);
- if (account) return account.governanceAgents.map(agent => structuredClone(agent));
+ const scopedPrincipal = principalScope(principal);
+ try {
+ const store = governanceBindingStore();
+ // Controller-seeded legacy buys can carry a dual identity. account_id is
+ // seller-issued and remains the strongest unambiguous alias in that shape.
+ if (typeof ref.account_id === 'string' && ref.account_id.length > 0) {
+ return governanceBindingAgents(await store.getByAccountId(scopedPrincipal, ref.account_id));
+ }
+
+ let canonical;
+ try {
+ canonical = canonicalizeAccountRef(ref);
+ } catch {
+ // A few legacy fixtures retain only brand identity. Resolve that alias
+ // only when it identifies exactly one authoritative binding. Multiple
+ // matches are ambiguous and fail closed. Zero matches means there is no
+ // authoritative governed state to recover; ordinary ungoverned legacy
+ // buys must remain operable in a fresh worker.
+ const brandDomain = ref.brand?.domain?.toLowerCase();
+ if (!brandDomain) throw new Error(SESSION_STORE_UNAVAILABLE_MESSAGE);
+ const matches = await store.findByBrandDomain(scopedPrincipal, brandDomain, 2);
+ if (matches.length === 0) return [];
+ if (matches.length !== 1) throw new Error(SESSION_STORE_UNAVAILABLE_MESSAGE);
+ return governanceBindingAgents(matches[0]);
+ }
+ if (canonical.kind === 'account_id') {
+ return governanceBindingAgents(await store.getByAccountId(scopedPrincipal, canonical.account_id));
+ }
+ return governanceBindingAgents(await store.getByAccountScope(
+ scopedPrincipal,
+ accountScopeFromRef(ref),
+ ));
+ } catch (error) {
+ if (error instanceof Error && error.message === SESSION_STORE_UNAVAILABLE_MESSAGE) throw error;
+ throw new Error(SESSION_STORE_UNAVAILABLE_MESSAGE, { cause: error });
}
- return [];
}
/** Resolve the seller's principal-scoped account record to the buyer identity
@@ -932,6 +1011,7 @@ export function seedAccountFixture(
? fixture.operator_unit as unknown as OperatorUnit
: undefined;
const currency = typeof fixture.currency === 'string' ? fixture.currency : undefined;
+ const timezone = typeof fixture.timezone === 'string' ? fixture.timezone : undefined;
if (!brand?.domain) {
return { success: false, error: 'INVALID_PARAMS', error_detail: 'params.fixture.brand.domain is required for seed_account' };
}
@@ -951,6 +1031,7 @@ export function seedAccountFixture(
operatorUnit,
currency,
sandbox,
+ timezone,
);
const existing = accounts.get(key)
?? findAccountByIdAcrossSessions(accountId, ctx.principal);
@@ -962,6 +1043,7 @@ export function seedAccountFixture(
operator,
operatorUnit,
currency,
+ timezone,
billing,
paymentTerms: typeof fixture.payment_terms === 'string' ? fixture.payment_terms : 'net_30',
status,
@@ -1081,6 +1163,7 @@ export const ACCOUNT_REF_SCHEMA = {
additionalProperties: false,
},
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
+ timezone: { type: 'string', minLength: 1 },
sandbox: { type: 'boolean' },
},
required: ['brand', 'operator'],
@@ -1162,6 +1245,7 @@ export const ACCOUNT_TOOLS = [
additionalProperties: false,
},
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
+ timezone: { type: 'string', minLength: 1 },
billing: { type: 'string', enum: ['operator', 'agent', 'advertiser'] },
billing_entity: { type: 'object' },
payment_terms: { type: 'string', enum: ['net_15', 'net_30', 'net_45', 'net_60', 'net_90', 'prepay'] },
@@ -1201,6 +1285,7 @@ export const ACCOUNT_TOOLS = [
{ not: { required: ['operator'] } },
{ not: { required: ['operator_unit'] } },
{ not: { required: ['currency'] } },
+ { not: { required: ['timezone'] } },
{ not: { required: ['billing'] } },
],
},
@@ -1312,6 +1397,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
input.operator !== undefined && 'operator',
input.operator_unit !== undefined && 'operator_unit',
input.currency !== undefined && 'currency',
+ input.timezone !== undefined && 'timezone',
input.billing !== undefined && 'billing',
input.sandbox !== undefined && 'sandbox',
].filter(Boolean);
@@ -1351,6 +1437,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
existing.operatorUnit,
existing.currency,
existing.sandbox,
+ existing.timezone,
), existing);
}
@@ -1400,6 +1487,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
operator: existing.operator,
...(existing.operatorUnit && { operator_unit: existing.operatorUnit }),
...(existing.currency && { currency: existing.currency }),
+ ...(existing.timezone && { timezone: existing.timezone }),
action: 'updated',
status: existing.status,
billing: existing.billing,
@@ -1565,6 +1653,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
input.operator_unit,
input.currency,
input.sandbox === true,
+ input.timezone,
);
const existing = accounts.get(key);
const isSandbox = input.sandbox === true;
@@ -1586,6 +1675,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
operator: input.operator,
...(input.operator_unit && { operator_unit: input.operator_unit }),
...(input.currency && { currency: input.currency }),
+ ...(input.timezone && { timezone: input.timezone }),
action: existing ? 'updated' : 'created',
status: isSandbox ? 'active' : 'pending_approval',
billing: input.billing,
@@ -1616,6 +1706,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
operator: input.operator,
operatorUnit: input.operator_unit,
currency: input.currency,
+ timezone: input.timezone,
billing: input.billing!,
billingEntity: input.billing_entity,
paymentTerms: input.payment_terms || 'net_30',
@@ -1673,6 +1764,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
operator: input.operator,
...(input.operator_unit && { operator_unit: input.operator_unit }),
...(input.currency && { currency: input.currency }),
+ ...(input.timezone && { timezone: input.timezone }),
name: input.operator_unit?.name
? `${input.brand.name || input.brand.domain} (via ${input.operator_unit.name})`
: `${input.brand.name || input.brand.domain} (via ${input.operator})`,
@@ -1746,6 +1838,7 @@ function wireAccountMatchesRef(account: AccountWireShape, ref: AccountRef): bool
if (account.operator !== ref.operator) return false;
if ((account.operator_unit?.id ?? undefined) !== (ref.operator_unit?.id ?? undefined)) return false;
if ((account.currency ?? undefined) !== (ref.currency ?? undefined)) return false;
+ if ((account.timezone ?? undefined) !== (ref.timezone ?? undefined)) return false;
if (typeof ref.sandbox === 'boolean') return (account.sandbox === true) === ref.sandbox;
return true;
}
@@ -2066,7 +2159,7 @@ export function handleListAccountChanges(args: ToolArgs, ctx: TrainingContext):
};
}
-export function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
+export async function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
const req = args as unknown as SyncGovernanceInput;
const identityError = durableAccountIdentityError(ctx);
if (identityError) return identityError;
@@ -2119,7 +2212,9 @@ export function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
continue;
}
- // Validate governance agent URL
+ // Validate and canonicalize before comparing. Userinfo is rejected rather
+ // than stripped because it may contain credentials; rejected endpoints are
+ // never echoed or persisted.
const agent = input.governance_agents[0];
if (!agent.url) {
results.push({
@@ -2129,25 +2224,21 @@ export function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
});
continue;
}
- let governanceEndpoint: URL;
+ let canonicalAgentUrl: string;
try {
- governanceEndpoint = new URL(agent.url);
+ const governanceEndpoint = new URL(agent.url);
+ const governanceHostname = normalizeExternalHostname(governanceEndpoint.hostname);
+ if (
+ governanceEndpoint.protocol !== 'https:'
+ || governanceEndpoint.username !== ''
+ || governanceEndpoint.password !== ''
+ || !governanceHostname
+ || isPrivateHostname(governanceHostname)
+ ) {
+ throw new TypeError('governance agent endpoints require a public HTTPS URL without userinfo');
+ }
+ canonicalAgentUrl = canonicalTargetUri(governanceEndpoint.toString());
} catch {
- results.push({
- account: acctRef,
- status: 'failed',
- errors: [{ code: 'INVALID_REQUEST', message: 'governance_agents[].url must be a valid HTTPS URL.' }],
- });
- continue;
- }
- const governanceHostname = normalizeExternalHostname(governanceEndpoint.hostname);
- if (
- governanceEndpoint.protocol !== 'https:'
- || governanceEndpoint.username !== ''
- || governanceEndpoint.password !== ''
- || !governanceHostname
- || isPrivateHostname(governanceHostname)
- ) {
results.push({
account: acctRef,
status: 'failed',
@@ -2173,14 +2264,56 @@ export function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
continue;
}
const validAgents: GovernanceAgentEntry[] = [{
- url: agent.url,
+ url: canonicalAgentUrl,
authentication: {
schemes: ['Bearer'],
credentials: agent.authentication.credentials,
},
}];
- // Replace semantics — overwrite previous governance agents
+ // Binding policy is a seller-side authorization decision. It must not be
+ // weakened by a caller-selected protocol version.
+ if (!TRAINING_ACCEPTED_GOVERNANCE_AGENT_URL_SET.has(canonicalAgentUrl)) {
+ results.push({
+ account: acctRef,
+ status: 'failed',
+ errors: [{
+ code: 'GOVERNANCE_AGENT_NOT_ACCEPTED',
+ message: 'The proposed governance agent does not satisfy this account\'s seller policy',
+ recovery: 'correctable',
+ // The reference seller deliberately exercises progressive disclosure:
+ // neither the private allowlist nor the rejected URL is reflected.
+ details: { disclosure: 'opaque' },
+ }],
+ });
+ continue;
+ }
+
+ const completeAccountRef: AccountRef = {
+ brand: {
+ domain: acct.brand.domain,
+ ...(acct.brand.brand_id && { brand_id: acct.brand.brand_id }),
+ ...(acct.brand.countries && { countries: [...acct.brand.countries] }),
+ },
+ operator: acct.operator,
+ ...(acct.operatorUnit && { operator_unit: { ...acct.operatorUnit } }),
+ ...(acct.currency && { currency: acct.currency }),
+ ...(acct.timezone && { timezone: acct.timezone }),
+ sandbox: acct.sandbox,
+ };
+ const durableBinding: GovernanceBindingRecord = {
+ principal: principalScope(ctx.principal),
+ accountId: acct.accountId,
+ accountScope: accountScopeFromRef(completeAccountRef),
+ brandDomain: acct.brand.domain.toLowerCase(),
+ account: completeAccountRef,
+ agents: validAgents.map(value => structuredClone(value)),
+ updatedAt: new Date().toISOString(),
+ };
+ // One atomic authoritative row serves both account_id and complete-natural
+ // aliases. Do not mutate the process-local account projection until the
+ // durable replacement succeeds.
+ await governanceBindingStore().upsert(durableBinding);
acct.governanceAgents = validAgents;
results.push({
diff --git a/server/src/training-agent/brand-handlers.ts b/server/src/training-agent/brand-handlers.ts
index 91c90cd381..dda62dc571 100644
--- a/server/src/training-agent/brand-handlers.ts
+++ b/server/src/training-agent/brand-handlers.ts
@@ -1211,7 +1211,7 @@ export async function handleAcquireRights(
: basePrice;
const accountSessionKey = sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId);
const session = await getSession(accountSessionKey);
- const registeredGovernanceAgents = resolveGovernanceAgentsForAccount(
+ const registeredGovernanceAgents = await resolveGovernanceAgentsForAccount(
accountSessionKey,
ctx.principal,
req.account,
@@ -1473,7 +1473,7 @@ export async function handleUpdateRights(
const increasesObligation = paused === false && grant.paused
|| Boolean(endDate && endDate > currentEndDate)
|| Boolean(impressionCap !== undefined && impressionCap > (grant.impressionCap ?? 0));
- const registeredGovernanceAgents = resolveGovernanceAgentsForAccount(
+ const registeredGovernanceAgents = await resolveGovernanceAgentsForAccount(
accountSessionKey,
ctx.principal,
req.account,
diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts
index 424f68693d..711f08b252 100644
--- a/server/src/training-agent/comply-test-controller.ts
+++ b/server/src/training-agent/comply-test-controller.ts
@@ -51,6 +51,7 @@ import {
getAccountNotificationSubscribers,
recordAccountChange,
resolveAccountIdForRef,
+ resolveGovernanceAgentsForAccount,
sandboxAccountRefForId,
seedAccountFixture,
} from './account-handlers.js';
@@ -541,6 +542,7 @@ function normalizeAvailableActions(actions: unknown): MediaBuyAvailableActionSta
if (
src.mode !== 'self_serve'
&& src.mode !== 'conditional_self_serve'
+ && src.mode !== 'seller_managed'
&& src.mode !== 'requires_approval'
) continue;
const mode = src.mode;
@@ -556,6 +558,7 @@ function normalizeAvailableActions(actions: unknown): MediaBuyAvailableActionSta
...(typeof sla.completion_max === 'string' && { completion_max: sla.completion_max }),
},
}),
+ ...(typeof src.change_term_id === 'string' && { change_term_id: src.change_term_id }),
...(typeof src.terms_ref === 'string' && { terms_ref: src.terms_ref }),
});
}
@@ -1366,6 +1369,9 @@ function createStore(
.filter(pkg => pkg && typeof pkg === 'object' && !Array.isArray(pkg))
.map(pkg => normalizeSeedPackage(pkg as Record, startTime, endTime))
: existing?.packages ?? [];
+ const acceptedProposal = isRecord(fx.accepted_proposal)
+ ? structuredClone(fx.accepted_proposal) as unknown as NonNullable
+ : existing?.acceptedProposal;
session.mediaBuys.set(mediaBuyId, {
mediaBuyId,
accountRef:
@@ -1376,6 +1382,7 @@ function createStore(
status: (fx.status as string | undefined) ?? existing?.status ?? 'active',
currency: (fx.currency as string | undefined) ?? existing?.currency ?? 'USD',
packages,
+ ...(acceptedProposal && { acceptedProposal }),
availableActions: normalizeAvailableActions(fx.available_actions) ?? existing?.availableActions,
startTime,
endTime,
@@ -1422,6 +1429,7 @@ const LOCAL_SCENARIOS = [
'compact_product_lifecycle_probe',
'compact_direct_buy_lifecycle_probe',
'query_provenance_audit_observations',
+ 'query_account_governance_binding',
'evaluate_distributed_brand_resolution',
'verify_governance_token',
] as const;
@@ -1575,7 +1583,7 @@ async function handleCompactLifecycleProbe(
function localScenariosFor(ctx: TrainingContext): string[] {
const scenarios = ctx.storyboardCompat?.version === '3.0'
- ? LOCAL_SCENARIOS.filter(s => s !== 'force_creative_purge' && s !== 'force_wholesale_feed_webhook' && s !== 'seed_rights_grant' && s !== 'query_provenance_audit_observations')
+ ? LOCAL_SCENARIOS.filter(s => s !== 'force_creative_purge' && s !== 'force_wholesale_feed_webhook' && s !== 'seed_rights_grant' && s !== 'query_provenance_audit_observations' && s !== 'query_account_governance_binding')
: [...LOCAL_SCENARIOS];
return supportsAccountChangeFeed(ctx.servedAdcpVersion ?? '3.2-beta.6')
? scenarios
@@ -1943,6 +1951,24 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
if (scenario === 'verify_governance_token') {
return handleInspectGovernanceTokenFixture(rawArgs);
}
+ if (scenario === 'query_account_governance_binding') {
+ const accountRef = isRecord(params.account) ? params.account as AccountRef : undefined;
+ if (!accountRef) {
+ return {
+ success: false,
+ error: 'INVALID_PARAMS',
+ error_detail: 'params.account is required for query_account_governance_binding',
+ };
+ }
+ return {
+ success: true,
+ simulated: {
+ account: structuredClone(accountRef),
+ governance_agents: (await resolveGovernanceAgentsForAccount(sessionKey, ctx.principal, accountRef))
+ .map(agent => ({ url: agent.url })),
+ },
+ };
+ }
if (scenario === 'force_upstream_unavailable') {
const params = (rawArgs.params ?? {}) as Record;
const tool = typeof params.tool === 'string' ? params.tool : undefined;
diff --git a/server/src/training-agent/governance-binding-store.ts b/server/src/training-agent/governance-binding-store.ts
new file mode 100644
index 0000000000..c620899265
--- /dev/null
+++ b/server/src/training-agent/governance-binding-store.ts
@@ -0,0 +1,259 @@
+import type { QueryResultRow } from 'pg';
+import { getPool, isDatabaseInitialized } from '../db/client.js';
+import { decrypt, encrypt } from '../db/encryption.js';
+import type { AccountRef } from './types.js';
+
+export interface GovernanceBindingRecord {
+ principal: string;
+ accountId: string;
+ accountScope: string;
+ brandDomain: string;
+ account: AccountRef;
+ agents: Array<{
+ url: string;
+ authentication: { schemes: string[]; credentials: string };
+ }>;
+ updatedAt: string;
+}
+
+export interface GovernanceBindingStore {
+ upsert(binding: GovernanceBindingRecord): Promise;
+ getByAccountId(principal: string, accountId: string): Promise;
+ getByAccountScope(principal: string, accountScope: string): Promise;
+ findByBrandDomain(principal: string, brandDomain: string, limit: number): Promise;
+}
+
+interface PgQueryable {
+ query(text: string, values?: unknown[]): Promise<{ rows: T[] }>;
+}
+
+interface GovernanceBindingRow extends QueryResultRow {
+ principal_scope: string;
+ account_id: string;
+ account_scope: string;
+ brand_domain: string;
+ account_ref: AccountRef | string;
+ agents: Array<{
+ url: string;
+ authentication: { schemes: string[] };
+ }> | string;
+ credentials_encrypted: string;
+ credentials_iv: string;
+ updated_at: Date | string;
+}
+
+function parseJson(value: T | string): T {
+ return typeof value === 'string' ? JSON.parse(value) as T : value;
+}
+
+function bindingCredentialSalt(principal: string, accountId: string): string {
+ return `governance-binding:${principal}\u001F${accountId}`;
+}
+
+function bindingFromRow(row: GovernanceBindingRow): GovernanceBindingRecord {
+ const account = parseJson(row.account_ref);
+ const agents = parseJson(row.agents);
+ if (!Array.isArray(agents) || agents.some(agent => (
+ agent === null
+ || typeof agent !== 'object'
+ || typeof agent.url !== 'string'
+ || agent.authentication === null
+ || typeof agent.authentication !== 'object'
+ || !Array.isArray(agent.authentication.schemes)
+ || agent.authentication.schemes.some(scheme => typeof scheme !== 'string')
+ ))) {
+ throw new Error('Stored governance-agent binding is malformed.');
+ }
+ if (typeof row.credentials_encrypted !== 'string' || typeof row.credentials_iv !== 'string') {
+ throw new Error('Stored governance-agent binding credentials are malformed.');
+ }
+ const credentials = decrypt(
+ row.credentials_encrypted,
+ row.credentials_iv,
+ bindingCredentialSalt(row.principal_scope, row.account_id),
+ );
+ return {
+ principal: row.principal_scope,
+ accountId: row.account_id,
+ accountScope: row.account_scope,
+ brandDomain: row.brand_domain,
+ account,
+ agents: agents.map(agent => ({
+ url: agent.url,
+ authentication: {
+ schemes: [...agent.authentication.schemes],
+ credentials,
+ },
+ })),
+ updatedAt: new Date(row.updated_at).toISOString(),
+ };
+}
+
+const BINDING_COLUMNS = `
+ principal_scope, account_id, account_scope, brand_domain,
+ account_ref, agents, credentials_encrypted, credentials_iv, updated_at
+`;
+
+export class PostgresGovernanceBindingStore implements GovernanceBindingStore {
+ constructor(private readonly db: PgQueryable) {}
+
+ async upsert(binding: GovernanceBindingRecord): Promise {
+ const agent = binding.agents[0];
+ if (!agent || binding.agents.length !== 1) {
+ throw new Error('Governance bindings require exactly one agent.');
+ }
+ const sealedCredentials = encrypt(
+ agent.authentication.credentials,
+ bindingCredentialSalt(binding.principal, binding.accountId),
+ );
+ const durableAgents = [{
+ url: agent.url,
+ authentication: { schemes: [...agent.authentication.schemes] },
+ }];
+ await this.db.query(
+ `INSERT INTO governance_agent_bindings (
+ principal_scope, account_id, account_scope, brand_domain,
+ account_ref, agents, credentials_encrypted, credentials_iv, updated_at
+ ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9::timestamptz)
+ ON CONFLICT (principal_scope, account_id) DO UPDATE SET
+ account_scope = EXCLUDED.account_scope,
+ brand_domain = EXCLUDED.brand_domain,
+ account_ref = EXCLUDED.account_ref,
+ agents = EXCLUDED.agents,
+ credentials_encrypted = EXCLUDED.credentials_encrypted,
+ credentials_iv = EXCLUDED.credentials_iv,
+ updated_at = EXCLUDED.updated_at`,
+ [
+ binding.principal,
+ binding.accountId,
+ binding.accountScope,
+ binding.brandDomain,
+ JSON.stringify(binding.account),
+ JSON.stringify(durableAgents),
+ sealedCredentials.encrypted,
+ sealedCredentials.iv,
+ binding.updatedAt,
+ ],
+ );
+ }
+
+ async getByAccountId(principal: string, accountId: string): Promise {
+ const { rows } = await this.db.query(
+ `SELECT ${BINDING_COLUMNS}
+ FROM governance_agent_bindings
+ WHERE principal_scope = $1 AND account_id = $2`,
+ [principal, accountId],
+ );
+ return rows[0] ? bindingFromRow(rows[0]) : null;
+ }
+
+ async getByAccountScope(principal: string, accountScope: string): Promise {
+ const { rows } = await this.db.query(
+ `SELECT ${BINDING_COLUMNS}
+ FROM governance_agent_bindings
+ WHERE principal_scope = $1 AND account_scope = $2`,
+ [principal, accountScope],
+ );
+ return rows[0] ? bindingFromRow(rows[0]) : null;
+ }
+
+ async findByBrandDomain(
+ principal: string,
+ brandDomain: string,
+ limit: number,
+ ): Promise {
+ const { rows } = await this.db.query(
+ `SELECT ${BINDING_COLUMNS}
+ FROM governance_agent_bindings
+ WHERE principal_scope = $1 AND brand_domain = $2
+ ORDER BY account_id
+ LIMIT $3`,
+ [principal, brandDomain, limit],
+ );
+ return rows.map(bindingFromRow);
+ }
+}
+
+export class InMemoryGovernanceBindingStore implements GovernanceBindingStore {
+ private readonly byAccountId = new Map();
+ private readonly byAccountScope = new Map();
+
+ private accountKey(principal: string, accountId: string): string {
+ return `${principal}\u001F${accountId}`;
+ }
+
+ private scopeKey(principal: string, accountScope: string): string {
+ return `${principal}\u001F${accountScope}`;
+ }
+
+ async upsert(binding: GovernanceBindingRecord): Promise {
+ const accountKey = this.accountKey(binding.principal, binding.accountId);
+ const prior = this.byAccountId.get(accountKey);
+ const conflictingAccountKey = this.byAccountScope.get(
+ this.scopeKey(binding.principal, binding.accountScope),
+ );
+ if (conflictingAccountKey !== undefined && conflictingAccountKey !== accountKey) {
+ throw new Error('Governance binding account scope already belongs to another account.');
+ }
+ if (prior) this.byAccountScope.delete(this.scopeKey(prior.principal, prior.accountScope));
+ this.byAccountId.set(accountKey, structuredClone(binding));
+ this.byAccountScope.set(this.scopeKey(binding.principal, binding.accountScope), accountKey);
+ }
+
+ async getByAccountId(principal: string, accountId: string): Promise {
+ const binding = this.byAccountId.get(this.accountKey(principal, accountId));
+ return binding ? structuredClone(binding) : null;
+ }
+
+ async getByAccountScope(principal: string, accountScope: string): Promise {
+ const accountKey = this.byAccountScope.get(this.scopeKey(principal, accountScope));
+ const binding = accountKey ? this.byAccountId.get(accountKey) : undefined;
+ return binding ? structuredClone(binding) : null;
+ }
+
+ async findByBrandDomain(
+ principal: string,
+ brandDomain: string,
+ limit: number,
+ ): Promise {
+ const matches: GovernanceBindingRecord[] = [];
+ for (const binding of this.byAccountId.values()) {
+ if (binding.principal !== principal || binding.brandDomain !== brandDomain) continue;
+ matches.push(structuredClone(binding));
+ if (matches.length >= limit) break;
+ }
+ return matches;
+ }
+
+ clear(): void {
+ this.byAccountId.clear();
+ this.byAccountScope.clear();
+ }
+}
+
+const inMemoryStore = new InMemoryGovernanceBindingStore();
+let overrideStore: GovernanceBindingStore | null = null;
+let postgresStore: GovernanceBindingStore | null = null;
+
+export function governanceBindingStore(): GovernanceBindingStore {
+ if (overrideStore) return overrideStore;
+ if (!isDatabaseInitialized()) return inMemoryStore;
+ postgresStore ??= new PostgresGovernanceBindingStore(getPool());
+ return postgresStore;
+}
+
+/** Test seam. Production cannot replace the authoritative binding store. */
+export function setGovernanceBindingStore(store: GovernanceBindingStore | null): void {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('setGovernanceBindingStore is not allowed in production');
+ }
+ overrideStore = store;
+}
+
+export function clearInMemoryGovernanceBindings(): void {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('clearInMemoryGovernanceBindings is not allowed in production');
+ }
+ inMemoryStore.clear();
+ overrideStore = null;
+}
diff --git a/server/src/training-agent/governance-handlers.ts b/server/src/training-agent/governance-handlers.ts
index f556c3f013..9a59ac2b3e 100644
--- a/server/src/training-agent/governance-handlers.ts
+++ b/server/src/training-agent/governance-handlers.ts
@@ -34,8 +34,127 @@ import {
computeGovernanceOutcomeHash,
computeGovernedPayloadHash,
} from './governance-payload-hash.js';
+import { loadSourceSchema, validateSourceSchema } from './source-schema.js';
const EXECUTION_GOVERNANCE_PHASES = new Set(['purchase', 'modification', 'delivery']);
+const MAX_REPORTED_OUTCOME_ERROR_BYTES = 16 * 1024;
+const MAX_REPORTED_OUTCOME_ERROR_CONTAINER_ENTRIES = 32;
+const MAX_REPORTED_OUTCOME_ERROR_STRING_LENGTH = 4_000;
+const MAX_REPORTED_OUTCOME_ERROR_PROPERTY_NAME_LENGTH = 128;
+const FORBIDDEN_EVIDENCE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+const REPORTED_OUTCOME_ERROR_SCHEMA_PATH = 'governance/reported-outcome-error.json';
+const REPORTED_OUTCOME_ERROR_INPUT_SCHEMA = loadSourceSchema(REPORTED_OUTCOME_ERROR_SCHEMA_PATH);
+
+type AuditEvidenceCloneResult =
+ | { ok: true; value: Record }
+ | { ok: false; reason: string };
+
+/**
+ * Clone buyer-attributed failure evidence into a deliberately bounded JSON
+ * value. This data is persisted only for audit readback; it must never be
+ * promoted into governance decisions or privileged prompt/context surfaces.
+ */
+function cloneReportedOutcomeError(value: unknown): AuditEvidenceCloneResult {
+ const seen = new Set