git:20260503.79c2835 to git:20260503.91c1ff8

59 added, 961 removed. Audit A to B.

---
name: build-governance-agent
- description: Use when building an AdCP governance agent — a platform that evaluates media buys against spending authority, manages property lists, and enforces content standards.
+ description: Use when building an AdCP governance agent — campaign governance (spending authority, approval/denial), property/collection lists for brand safety, or content standards for creative compliance.
---
# Build a Governance Agent
- ## Overview
-
- A governance agent sits between buyers and sellers, evaluating proposed media buys against organizational policies. Three domains: campaign governance (spending authority, approval/denial), property governance (inclusion/exclusion lists for brand safety), and content standards (creative compliance rules). Determine which domains the user needs.
-
- ## When to Use
-
- - User wants to build an agent that evaluates or approves media buys
- - User mentions governance, brand safety, spending authority, property lists, or content standards
- - User references `check_governance`, `sync_plans`, `create_property_list`, or `calibrate_content`
-
- **Not this skill:**
-
- - Selling ad inventory → `skills/build-seller-agent/`
- - Serving audience segments → `skills/build-signals-agent/`
- - Managing brand identity and licensing → `skills/build-brand-rights-agent/`
-
- **Often claimed alongside:** `measurement-verification` _(preview)_ + `content-standards` — governance vendors (IAS, DV) typically claim all three together. See [Common multi-specialism bundles](../../examples/README.md#common-multi-specialism-bundles).
-
- ## Specialisms This Skill Covers
-
- Your compliance obligations come from the specialisms you claim in `get_adcp_capabilities`. Each maps to a storyboard at `compliance/cache/latest/specialisms/<id>/`:
-
- | Specialism | Status | Delta from baseline | See |
- | ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
- | `governance-spend-authority` | stable | `check_governance` evaluates `binding` against Plan's `budget.total`, `human_review_required`, and `custom_policies`; return `approved`, `conditions`, or `denied` | [§ governance-spend-authority](#specialism-governance-spend-authority) |
- | `governance-delivery-monitor` | stable | `check_governance` with `phase: 'delivery'` + `delivery_metrics`; compute drift vs Plan's `budget.reallocation_threshold`; return `BUDGET_DRIFT_EXCEEDED` findings | [§ governance-delivery-monitor](#specialism-governance-delivery-monitor) |
- | `property-lists` | stable | Tool family `property_list` — implement CRUD plus `validate_property_delivery` with full `violations[]` | [§ property-lists](#specialism-property-lists) |
- | `collection-lists` | stable | Tool family `collection_list` — program-level brand safety (shows, series, podcasts) identified by platform-independent IDs: IMDb, Gracenote, EIDR. Mirrors property-lists CRUD plus collection resolution. | [§ collection-lists](#specialism-collection-lists) |
- | `content-standards` | stable | `policies[]` is an array of `{ policy_id, enforcement, policy, policy_categories?, channels? }`; `validate_content_delivery` uses `records[].artifact` (not `creative_id`); re-read policies per call for `standards_version_change` | [§ content-standards](#specialism-content-standards) |
- | `measurement-verification` | preview | v3.1 placeholder (empty `phases`). Pass universal + governance baseline only. Advertise `measurement_verification` capability for discoverability. | Baseline only |
-
- **Not in this skill:** `audience-sync` lives under `protocol: media-buy`. Build it in `skills/build-seller-agent/` instead — it uses `sync_audiences` (overloaded for discovery, add, and delete) and `list_accounts` under the `accounts` / `eventTracking` domain groups.
-
- Specialism ID (kebab-case) = storyboard directory. Storyboard `id:` (snake_case, e.g. `campaign_governance_conditions`) is the category name — multiple specialisms can reference the same storyboard category.
-
- ## Protocol-Wide Requirements
-
- Every production governance agent — regardless of specialism — must wire these. Full treatment in `skills/build-seller-agent/SKILL.md` §Protocol-Wide Requirements and §Composing OAuth, signing, and idempotency; minimum-viable pointers:
-
- - **`idempotency_key`** on every mutating request (`sync_plans`, `create_property_list`/`update_property_list`/`delete_property_list`, `create_collection_list`/`update_collection_list`/`delete_collection_list`, `create_content_standards`/`update_content_standards`, `calibrate_content`). Pass `createIdempotencyStore` to `createAdcpServerFromPlatform(platform, { idempotency })`.
- - **Authentication** via `serve({ authenticate: verifyApiKey(...)/verifyBearer(...) })` from `@adcp/sdk/server`. Unauthenticated agents fail the universal `security_baseline` storyboard.
- - **Signature-header transparency**: don't reject requests that carry `Signature-Input`/`Signature` headers even if you don't claim `signed-requests`.
- - **Resolve-then-authorize** on id lookups (`get_property_list`, `get_content_standards`, `get_collection_list`): return byte-equivalent errors whether the id is cross-tenant or nonexistent — always `REFERENCE_NOT_FOUND`, never `PERMISSION_DENIED`. `adcp fuzz` runs a paired-probe invariant that enforces this; stand up two test tenants and pass `--auth-token` + `--auth-token-cross-tenant` for full coverage. See `skills/build-seller-agent/SKILL.md` §Resolve-then-authorize for the full rules.
- - **`comply_test_controller`** — required to pass the `governance_spend_authority` and `property_lists` storyboards. Each seeds fixtures via `comply_test_controller.seed_plan` / `seed_property_list` before running the business-logic phases. Register via `createComplyController({ seed: { plan, property_list, collection_list, content_standards } })` and call `controller.register(server)` — same pattern as seller. Full treatment in `skills/build-seller-agent/SKILL.md` §Compliance Testing. Without it, all business-logic steps skip with `missing_test_controller` and the track vacuously "passes" (no tests run, vacuous green detected by the grader as fail).
-
- ## Before Writing Code
-
- ### 1. Which Governance Domains?
-
- - **Campaign governance** — evaluates media buys against spending authority. Returns approved, denied, or approved with conditions.
- - **Property governance** — maintains inclusion/exclusion lists of publisher properties for brand safety.
- - **Content standards** — defines creative compliance rules and validates delivery against them.
-
- Most governance agents start with campaign governance. Add property and content standards as needed.
-
- ### 2. Decision Logic
-
- For campaign governance, how should the agent decide?
-
- - **Budget threshold** — deny buys over a per-transaction limit
- - **Policy conditions** — approve with conditions (e.g., "weekly reporting required for CTV")
- - **Channel restrictions** — deny certain channels or require review
- - **Delivery monitoring** — re-evaluate when spend drifts past threshold
-
- ### 3. Property List Types
-
- - **Inclusion lists** — only serve ads on these properties
- - **Exclusion lists** — never serve ads on these properties
- - **GARM category filters** — exclude by IAB/GARM category
-
- ## Tools and Required Response Shapes
-
- > **Before writing any handler's return statement, fetch [`docs/llms.txt`](../../docs/llms.txt) and grep for `#### \`<tool_name>\``(e.g.`#### \`check_governance\``) to read the exact required + optional field list.** The schema-derived contract lives there; this skill covers patterns, gotchas, and domain-specific examples. Strict response validation is on by default in dev — it will tell you the exact field path if you drift, so write the obvious thing and trust the contract.
- >
- > **Cross-cutting pitfalls matrix runs keep catching:**
- >
- > - **Declare `capabilities.specialisms: ['governance-spend-authority', 'property-lists'] as const` on the `DecisioningPlatform` you pass to `createAdcpServerFromPlatform`.** Value is `string[]` of enum ids (not `[{id, version}]`). Agents that don't declare their specialism fail the grader with "No applicable tracks found" even if every tool works — tracks are gated on the specialism claim.
- > - Every mutating-tool response (`create_property_list`, `create_collection_list`, `create_content_standards`, etc.) has `additionalProperties: false` — don't add extra fields. Return exactly what the schema declares.
-
- ### Campaign Governance
-
- **`get_adcp_capabilities`** — register first, empty `{}` schema
-
- ```
- capabilitiesResponse({
- adcp: { major_versions: [3] },
- supported_protocols: ['governance'],
- })
- ```
-
- **`sync_plans`** — `SyncPlansRequestSchema.shape`
-
- Register governance plans. Each plan in the response needs `plan_id`, `status`, and `version`.
-
- ```
- taskToolResponse({
- plans: [{
- plan_id: string, // required — echo from request
- status: 'active', // required — 'active' | 'paused'
- version: 1, // required — integer version number
- }],
- })
- ```
-
- **`check_governance`** — `CheckGovernanceRequestSchema.shape`
-
- Evaluate a media buy against the registered plan. The request carries a `binding` (what is being evaluated) and a `phase`:
-
- ```
- // Request shape:
- {
- plan_id: string, // required — registered via sync_plans
- phase?: 'create' | 'delivery', // optional — authoritative when present
- binding: { // what to evaluate (create-phase)
- type: 'media_buy',
- media_buy_id?: string, // on delivery phase, the already-created buy
- account: { brand: {...}, operator: string },
- total_budget: { amount: number, currency: string },
- packages: [{ product_id, pricing_option_id, budget }],
- },
- delivery_metrics?: { // on delivery-phase checks — NOT delivery_evidence
- reporting_period: { start: string, end: string },
- spend: number,
- cumulative_spend: number,
- channel_distribution: { [channel: string]: number }, // percent by channel
- pacing: 'ahead' | 'on_pace' | 'behind',
- },
- governance_context?: string, // prior check's context, for re-evaluation
- }
- ```
-
- `phase` is an optional top-level field (`'create' | 'delivery'`). When present, it is authoritative — use it to route. When absent, the presence of `delivery_metrics` is the corroborating signal. The storyboard sends both for delivery-phase checks.
-
- The Plan object (stored via `sync_plans`) drives decisions. Expected shape:
-
- ```
- {
- plan_id: string,
- brand: { domain: string },
- objectives: string,
- budget: {
- total: number,
- currency: string,
- // Exactly one of the next two is required:
- reallocation_threshold?: number, // absolute currency amount the orchestrator can reallocate without human escalation
- reallocation_unlimited?: boolean, // set true for full autonomy up to total (prefer this over threshold == total)
- },
- flight: { start: string, end: string },
- countries: string[],
- human_review_required?: boolean, // GDPR Art 22 / EU AI Act Annex III — when true, every action on this plan needs human review regardless of budget. Set automatically by the agent if any resolved policy has requires_human_review: true.
- custom_policies: [ // array of structured policy objects — NOT bare strings
- {
- policy_id: string,
- enforcement: 'must' | 'should',
- policy: string, // prose description of the rule
- },
- ],
- }
- ```
-
- Authority is split into two independent concerns:
-
- - **`budget.reallocation_threshold` / `reallocation_unlimited`** — budget autonomy. Dollar-denominated cap on how much the orchestrator can shift around without asking.
- - **`human_review_required`** — decisions affecting data subjects (targeting, creative, delivery). Fires regardless of budget. Driven by regulation, not finance.
-
- Both can be true simultaneously on the same plan.
-
- The response needs `check_id`, `status`, `plan_id`, and `explanation`.
-
- ```
- // Approved:
- taskToolResponse({
- check_id: string, // required — unique check identifier
- status: 'approved', // required — enum: 'approved' | 'denied' | 'conditions'
- plan_id: string, // required — echo from request
- explanation: string, // required — human-readable explanation
- governance_context: string, // pass to create_media_buy
- })
-
- // Approved with conditions — status is literally 'conditions' (not 'approved' plus a conditions array):
- taskToolResponse({
- check_id: string,
- status: 'conditions',
- plan_id: string,
- explanation: 'Approved with conditions',
- conditions: [{ // array of binding conditions
- field: string, // required — what the condition applies to
- reason: string, // required — why the condition exists
- required_value: string, // optional — specific value required
- }],
- governance_context: string,
- })
-
- // Denied — also the way human review is signalled (no separate 'escalate' status):
- taskToolResponse({
- check_id: string,
- status: 'denied',
- plan_id: string,
- explanation: 'Exceeds spending authority',
- findings: [{ // array of policy findings
- category_id: string, // required — policy category ID
- severity: 'info' | 'warning' | 'critical', // required — human-review signal uses 'critical'
- explanation: string, // required — human-readable
- }],
- })
- ```
-
- **`get_plan_audit_logs`** — `GetPlanAuditLogsRequestSchema.shape`
-
- ```
- taskToolResponse({
- plan_id: string,
- logs: [{
- timestamp: string, // ISO timestamp
- action: string,
- actor: string,
- }],
- })
- ```
-
- ### Property Governance
-
- **`create_property_list`** — `CreatePropertyListRequestSchema.shape`
-
- Response must include `list` object and `auth_token`.
-
- ```
- taskToolResponse({
- list: {
- list_id: string, // required
- name: string, // required — echo from request
- description: string,
- property_count: 0,
- },
- auth_token: string, // required — token for subsequent operations
- })
- ```
-
- **`get_property_list`** — `GetPropertyListRequestSchema.shape`
-
- ```
- taskToolResponse({
- list: {
- list_id: string,
- name: string,
- },
- })
- ```
-
- **`update_property_list`** — `UpdatePropertyListRequestSchema.shape`
-
- ```
- taskToolResponse({
- list: {
- list_id: string,
- name: string,
- },
- })
- ```
-
- **`list_property_lists`** — `ListPropertyListsRequestSchema.shape`
-
- ```
- taskToolResponse({
- lists: [{
- list_id: string,
- name: string,
- }],
- })
- ```
-
- **`delete_property_list`** — `DeletePropertyListRequestSchema.shape`
-
- ```
- taskToolResponse({
- deleted: true, // required — boolean
- list_id: string, // required — echo from request
- })
- ```
-
- **`validate_property_delivery`** — no generated schema, use `{}` for input
-
- The storyboard's enforcement phase asserts per-record `violations` with list reference and severity. A minimal `{property, compliant}` response will pass schema but fail the behavioral checks.
-
- ```
- // Request:
- {
- list_id: string,
- records: [{
- record_id: string,
- property: { type: 'domain' | 'bundle_id' | ..., value: string },
- impressions: number,
- }],
- }
-
- // Response:
- taskToolResponse({
- compliant: true, // required — overall compliance
- list_id: string, // echo from request
- results: [{
- record_id: string, // echo
- property: { type, value }, // echo
- impressions: number,
- compliant: boolean,
- violations: [{ // empty when compliant
- list_id: string,
- list_type: 'inclusion' | 'exclusion',
- severity: 'critical',
- explanation: string, // e.g. "Property {value} is not on inclusion list {name}"
- }],
- }],
- violations: [], // flattened — all violations across results
- })
- ```
-
- ### Content Standards
-
- **`list_content_standards`** — `ListContentStandardsRequestSchema.shape`
-
- ```
- taskToolResponse({
- standards: [{
- standards_id: string,
- name: string,
- }],
- })
- ```
-
- **`create_content_standards`** — `CreateContentStandardsRequestSchema.shape`
-
- ```
- taskToolResponse({
- standards_id: string, // required — generated ID
- })
- ```
-
- **`get_content_standards`** — `GetContentStandardsRequestSchema.shape`
-
- `policies` is an array of structured rules. Each entry has a `policy_id`, enforcement level (`must` or `should`), a prose `policy` description, optional `policy_categories`, and optional `channels` scope. The prose lives **inside each entry**, not at the container level.
-
- ```
- taskToolResponse({
- standards_id: string,
- name: string,
- policies: [
- {
- policy_id: 'no_violent_imagery',
- policy_categories: ['brand_safety'],
- enforcement: 'must',
- policy: 'No violent or controversial imagery',
- },
- {
- policy_id: 'min_display_dpi',
- policy_categories: ['imagery_quality'],
- enforcement: 'should',
- channels: ['display'],
- policy: 'Minimum 72 DPI for display assets',
- },
- ],
- scope: { languages_any: ['en'], description: 'Acme Outdoor creative standards' },
- })
- ```
-
- On `create_content_standards` / `update_content_standards`, the buyer sends the same `policies[]` array. Store it indexed by `standards_id` and re-read on every `calibrate_content` / `validate_content_delivery` call — the `standards_version_change` storyboard phase re-issues policies and expects the next calibration to reflect the update (a memoized calibration will fail that phase).
-
- **`update_content_standards`** — `UpdateContentStandardsRequestSchema.shape`
-
- ```
- taskToolResponse({
- success: true,
- standards_id: string,
- })
- ```
-
- **`calibrate_content`** — `CalibrateContentRequestSchema.shape`
-
- Verdict mapping: any `enforcement: 'must'` violation → `'fail'`. Only `'should'` violations → `'review'`. No violations → `'pass'`. The `standards_version_change` phase depends on this — after policies update, the same artifact must flip verdicts.
-
- ```
- taskToolResponse({
- verdict: 'pass' | 'fail' | 'review',
- confidence: 0.95,
- explanation: string,
- features: [],
- })
- ```
-
- **`validate_content_delivery`** — `ValidateContentDeliveryRequestSchema.shape`
-
- The request uses `records[].artifact`, not `creative_id`. Each record scopes a served impression with `property_rid`, `artifact_id`, and `assets`. Response returns per-record compliance plus a `summary`.
-
- ```
- // Request:
- {
- standards_id: string,
- records: [{
- record_id: string,
- property_rid: string,
- artifact: {
- artifact_id: string,
- property_rid: string,
- description?: string, // optional prose describing the ad — used by calibration matchers
- assets: [{ // ARRAY of assets — not an object
- type: 'image' | 'video' | 'html' | 'text',
- url: string,
- width?: number,
- height?: number,
- duration_ms?: number,
- }],
- },
- impressions: number,
- }],
- }
-
- // Response:
- taskToolResponse({
- summary: {
- compliant: true,
- total_impressions: number,
- non_compliant_impressions: 0,
- },
- results: [{
- record_id: string, // echo from request
- artifact: {
- artifact_id: string,
- property_rid: string,
- description?: string, // optional prose describing the ad — used by calibration matchers
- assets: [{ // ARRAY of assets — not an object
- type: 'image' | 'video' | 'html' | 'text',
- url: string,
- width?: number,
- height?: number,
- duration_ms?: number,
- }],
- },
- impressions: number,
- compliant: boolean,
- violations: [{ // empty when compliant
- rule: string, // e.g. "No tobacco advertising"
- severity: 'must' | 'should',
- evidence: string, // why it failed
- remediation: string, // how to fix
- }],
- }],
- })
- ```
-
- ### Context and Ext Passthrough
-
- The framework auto-echoes the request's `context` into every response — **do not set `context` yourself in your handler return values.** It's injected post-handler only when the field isn't already present.
-
- **Crucial:** `context` is schema-typed as an object. If your handler hand-sets a string or narrative description, validation fails with `/context: must be object` and the framework does not overwrite. Leave the field out entirely; the framework handles it.
-
- Some schemas also define an `ext` field for vendor-namespaced extensions. If your request schema includes `ext`, accept it without error. Tools with explicit `ext` support: `sync_governance`.
-
- ## SDK Quick Reference
-
- | SDK piece | Usage |
- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
- | `createAdcpServerFromPlatform(platform, opts)` | Create server from a typed `DecisioningPlatform` — compile-time specialism enforcement, auto-generated capabilities, ctx_metadata round-trip |
- | `createAdcpServer(config)` _(legacy)_ | v5 handler-bag entry. Mid-migration / escape-hatch only; reach via `@adcp/sdk/server/legacy/v5` |
- | `serve(() => createAdcpServerFromPlatform(platform, opts))` | Start HTTP server on `:3001/mcp` |
- | `ctx.store` | State persistence — `get/put/patch/delete/list` domain objects |
- | `adcpError(code, { message })` | Structured error |
-
- Handlers return raw data objects. The framework auto-wraps responses and auto-generates `get_adcp_capabilities` from registered handlers.
-
- Import: `import { createAdcpServerFromPlatform, serve, adcpError } from '@adcp/sdk/server';`
-
- ## Setup
-
- ```bash
- npm init -y
- npm install @adcp/sdk
- npm install -D typescript @types/node
- ```
-
- Minimal `tsconfig.json`:
-
- ```json
- {
- "compilerOptions": {
- "target": "ES2022",
- "module": "Node16",
- "moduleResolution": "Node16",
- "strict": true,
- "skipLibCheck": true,
- "outDir": "dist"
- }
- }
- ```
+ A governance agent enforces policy on the buy side. It evaluates spending authority, maintains property and collection lists, and defines content standards. There is no dedicated `hello_governance_adapter_*.ts` yet — this skill collapses against the seller adapter pattern and the documented tool surface.
- `skipLibCheck: true` avoids false-positive errors from transitive `.d.ts` files (e.g., `@opentelemetry/api`).
+ ## Pick your fork target
- ## Implementation
+ | Specialism | Status | Fork this | Storyboard |
+ | --- | --- | --- | --- |
+ | `governance-spend-authority` | stable | [`hello_seller_adapter_multi_tenant.ts`](../../examples/hello_seller_adapter_multi_tenant.ts) — `campaignGovernance` block | `governance_spend_authority` |
+ | `governance-delivery-monitor` | stable | Same; add `phase: 'delivery'` branch on `checkGovernance` | `governance_delivery_monitor` |
+ | `property-lists` | stable | [`hello_seller_adapter_multi_tenant.ts`](../../examples/hello_seller_adapter_multi_tenant.ts) — `propertyLists` block | `property_lists` |
+ | `collection-lists` | stable | Same shape as property-lists; add IMDb/Gracenote/EIDR resolution | placeholder |
+ | `content-standards` | stable | Add `contentStandards` domain group via `defineContentStandardsPlatform` | placeholder |
+ | `measurement-verification` | preview | v3.1 placeholder. Baseline only. | placeholder |
- 1. Single `.ts` file — `class MyGovernance implements DecisioningPlatform` with `campaignGovernance`, `propertyLists`, `collectionLists`, `contentStandards` typed sub-platforms
- 2. Do not register `get_adcp_capabilities` — the framework generates it from registered handlers
- 3. Return raw data objects from handlers — the framework wraps responses automatically
- 4. Use `ctx.store` to persist plans, property lists, and content standards
- 5. Set `sandbox: true` on all mock/demo responses
- 6. Handlers receive `(params, ctx)` — `ctx.store` for state, `ctx.account` for resolved account
+ The multi-tenant adapter is the canonical fork target — it implements `campaignGovernance` (sync_plans, check_governance, report_plan_outcome, get_plan_audit_logs), `propertyLists` (CRUD + `validate_property_delivery`), and `brandRights` against a per-tenant in-memory store with full tenant isolation via `createTenantStore`.
- ```typescript
- import { randomUUID } from 'node:crypto';
- import {
- createAdcpServerFromPlatform,
- serve,
- createIdempotencyStore,
- memoryBackend,
- type DecisioningPlatform,
- type CampaignGovernancePlatform,
- type PropertyListsPlatform,
- type AccountStore,
- } from '@adcp/sdk/server';
+ ### What to delete if you're single-specialism
- const idempotency = createIdempotencyStore({
- backend: memoryBackend(),
- ttlSeconds: 86400,
- });
+ **Forking the multi-tenant adapter for a single specialism? Delete these blocks first** — leaning on stable symbol names rather than line numbers (the adapter evolves; greppable identifiers don't):
- class MyGovernance implements DecisioningPlatform {
- capabilities = {
- specialisms: ['governance-spend-authority', 'property-lists'] as const,
- config: {},
- };
+ A single-specialism `governance-spend-authority` adopter (an in-house policy engine, IAS, DoubleVerify) deletes:
- accounts: AccountStore = {
- resolve: async ref => ({
- id: 'account_id' in ref ? ref.account_id : 'gov_acc_1',
- operator: 'me',
- ctx_metadata: {},
- }),
- upsert: async () => ({ ok: true, items: [] }),
- list: async () => ({ items: [], nextCursor: null }),
- };
+ - The `brandRights = defineBrandRightsPlatform({ ... })` block (the entire brand-rights surface)
+ - The `propertyLists = definePropertyListsPlatform({ ... })` block if you don't claim `property-lists`
+ - The `private async enforceGovernance(...)` helper and the `interface GovernanceBinding` — these belong to `brandRights` cross-specialism dispatch, not to standalone governance
+ - Per-tenant `brands` / `rights` Maps on `TenantState` (no brand-rights catalog to seed)
- campaignGovernance: CampaignGovernancePlatform = {
- syncPlans: async (req, ctx) => {
- for (const plan of req.plans) {
- await ctx.store.put('plan', plan.plan_id, plan);
- }
- return {
- plans: req.plans.map(p => ({
- plan_id: p.plan_id,
- status: 'active' as const,
- version: 1,
- })),
- };
- },
- checkGovernance: async (req, ctx) => {
- const plan = await ctx.store.get('plan', req.plan_id);
- // ... decision logic ...
- return {
- check_id: `chk_${randomUUID()}`,
- status: 'approved' as const,
- plan_id: req.plan_id,
- explanation: 'Within spending authority',
- };
- },
- // ... reportPlanOutcome, getPlanAuditLogs, etc.
- };
+ A single-specialism `property-lists` adopter mirrors this: keep the `propertyLists` block; delete `campaignGovernance`, `brandRights`, `enforceGovernance`, the brand/rights Maps, and the `governanceBindings` map.
- propertyLists: PropertyListsPlatform = {
- listPropertyLists: async (req, ctx) => ({ property_lists: [] }),
- createPropertyList: async (req, ctx) => ({
- /* ... */
- }),
- updatePropertyList: async (req, ctx) => ({
- /* ... */
- }),
- deletePropertyList: async (req, ctx) => ({
- /* ... */
- }),
- };
- }
+ **Keep**: the `accounts` / `createTenantStore` block (translates to single-tenant by passing one tenant entry — needed for tenant isolation), `agentRegistry`, the specialism block(s) you claim, `getTenant(ctx)` resolution. **Don't keep `enforceGovernance` if you also delete `brandRights`** — the helper has no caller and wires a non-existent governance binding.
- const platform = new MyGovernance();
+ For `content-standards` and `collection-lists`, no worked fork target ships yet — wire `defineContentStandardsPlatform` / `defineCollectionListsPlatform` from `@adcp/sdk/server` against the multi-tenant scaffolding.
- serve(() =>
- createAdcpServerFromPlatform(platform, {
- name: 'Governance Agent',
- version: '1.0.0',
- idempotency,
- })
- );
- ```
+ For exact response shapes, error codes, and optional fields, `docs/llms.txt` is the canonical reference.
- **Decision logic for check_governance:**
+ ## When to use this skill
- Route decisions based on the plan state and request parameters:
+ - User wants to enforce campaign governance, property lists, collection lists, or content standards
+ - User describes themselves as a brand-safety vendor (IAS, DoubleVerify), policy engine (OPA/Cerbos), or compliance platform
+ - User mentions `check_governance`, `validate_property_delivery`, `validate_content_delivery`
- - Compare request budget against plan's `budget.total`; enforce reallocation autonomy using `budget.reallocation_threshold` (denominated in `budget.currency`) or `budget.reallocation_unlimited: true` — exactly one must be set
- - If `reallocation_threshold` is set and a reallocation exceeds it → require human review / deny
- - If `plan.human_review_required: true` → action must escalate regardless of `mode` (advisory/audit cannot downgrade)
- - Auto-flip `plan.human_review_required: true` when resolved `policy_categories` include `fair_housing | fair_lending | fair_employment | pharmaceutical_advertising`, or when `policy_ids` include `eu_ai_act_annex_iii`
- - If `human_review_required: true` but the brand/brand-ref has no `data_subject_contestation` contact → emit a critical finding
- - Require a `human_override` artifact (reason ≥20 chars, approver email) on re-sync to downgrade `human_review_required: true → false`
- - If policy conditions match → approve with conditions
- - If `phase: 'delivery'` → check delivery_metrics for drift
+ **Not this skill:**
- ## Idempotency
+ - Selling inventory while consuming governance signals → `skills/build-seller-agent/` (governance-aware seller track)
+ - Brand identity / rights licensing → `skills/build-brand-rights-agent/`
+ - Audience sync (despite the name overlap) → `skills/build-seller-agent/` (audience-sync track)
- AdCP v3 requires an `idempotency_key` on every mutating request — for governance agents that's `create_property_list` / `update_property_list` / `delete_property_list`, `create_content_standards` / `update_content_standards`, `sync_plans`, and `report_plan_outcome` (`check_governance` and the various `get_*` / `list_*` tools are read-only and exempt). Pass `createIdempotencyStore` from `@adcp/sdk/server` to `createAdcpServerFromPlatform(platform, { idempotency })` and the framework handles missing-key rejection (`INVALID_REQUEST`), JCS-canonicalized payload hashing, `IDEMPOTENCY_CONFLICT` on same-key-different-payload (no payload leaked in the error), `IDEMPOTENCY_EXPIRED` past the TTL, `replayed: true` envelope injection on cache hits, and automatic declaration of `adcp.idempotency.replay_ttl_seconds` on `get_adcp_capabilities`. Only successful responses cache — errors re-execute on retry so a failed `sync_plans` or outcome report can be retried cleanly. Scoping is per-principal via `resolveSessionKey` (or override with `resolveIdempotencyPrincipal`) — typically the operator / tenant id.
+ ## Cross-cutting rules
- ```typescript
- import { createIdempotencyStore, memoryBackend } from '@adcp/sdk/server';
+ Every governance agent hits the cross-cutting rules in [`../cross-cutting.md`](../cross-cutting.md). One governance-specific rule on top:
- const idempotency = createIdempotencyStore({
- backend: memoryBackend(), // or pgBackend(pool) for production
- ttlSeconds: 86400, // 3600–604800 per spec; throws if out of range
- });
+ ### `comply_test_controller` is required
- const server = createAdcpServerFromPlatform(platform, {
- name: '...',
- version: '...',
- idempotency,
- // MUST never return undefined — or every mutating request rejects as
- // SERVICE_UNAVAILABLE. A constant works for a demo; production uses
- // `(ctx) => ctx.account?.id` against the typed `Account<MyMeta>`.
- resolveSessionKey: () => 'default-principal',
- });
- ```
+ Both `governance_spend_authority` and `property_lists` storyboards seed fixtures via `comply_test_controller.seed_plan` / `seed_property_list` before running the business-logic phases. Register via `createComplyController({ seed: { plan, property_list, collection_list, content_standards } })` and call `controller.register(server)` — same pattern as the seller adapter wires for media-buy seeding.
- ## Protecting your agent
+ Without the test controller, every business-logic step skips with `missing_test_controller` and the track "passes" vacuously — the grader treats vacuous green as fail. Wire it on day one.
- **An AdCP agent that accepts unauthenticated requests is non-compliant** (see `security_baseline` in the universal storyboard bundle). Ask the operator: "API key, OAuth, or both?" — then wire one of these into `serve()`.
+ ## Specialism deltas at a glance
- ```typescript
- import { serve } from '@adcp/sdk';
- import { verifyApiKey, verifyBearer, anyOf } from '@adcp/sdk/server';
+ **`governance-spend-authority`** — `check_governance` evaluates the request's `binding` against the Plan's `budget.total`, `human_review_required`, and `custom_policies`. Returns one of `approved` / `conditions` / `denied`. The Plan model is the source of truth: read `sync_plans` / `get_plan` to materialize the spending authority, then check the inbound binding against it.
- // API key — simplest, good for B2B integrations
- serve(createAgent, {
- authenticate: verifyApiKey({
- verify: async token => {
- const row = await db.api_keys.findUnique({ where: { token } });
- return row ? { principal: row.account_id } : null;
- },
- }),
- });
+ **`governance-delivery-monitor`** — `check_governance` with `phase: 'delivery'` + `delivery_metrics`. Compute drift vs Plan's `budget.reallocation_threshold`; return `BUDGET_DRIFT_EXCEEDED` findings when delivery exceeds the threshold.
- // OAuth — best when buyers authenticate as themselves
- const AGENT_URL = 'https://my-agent.example.com/mcp';
- serve(createAgent, {
- publicUrl: AGENT_URL, // canonical RFC 8707 audience — also served as `resource` in protected-resource metadata
- authenticate: verifyBearer({
- jwksUri: 'https://auth.example.com/.well-known/jwks.json',
- issuer: 'https://auth.example.com',
- audience: AGENT_URL, // MUST equal publicUrl
- }),
- protectedResource: { authorization_servers: ['https://auth.example.com'] },
- });
+ **`property-lists`** — tool family `property_list_*` (`create`, `read`, `update`, `delete`, `list`). `validate_property_delivery` returns full `violations[]` (publisher property not in the inclusion list, or hit the exclusion list). Property identity is `{agent_url, id}` — buyers fetch lists by reference, not by inline copy.
- // Both
- serve(createAgent, {
- publicUrl: AGENT_URL,
- authenticate: anyOf(verifyApiKey({ verify: lookupKey }), verifyBearer({ jwksUri, issuer, audience: AGENT_URL })),
- protectedResource: { authorization_servers: [issuer] },
- });
- ```
+ **`collection-lists`** — program-level brand safety (shows, series, podcasts) identified by platform-independent IDs: **IMDb** (movies/TV), **Gracenote** (TV/audio metadata), **EIDR** (entertainment industry standard). Mirrors property-lists CRUD plus collection resolution.
- The framework produces RFC 6750-compliant `WWW-Authenticate: Bearer` 401s on failure, and serves `/.well-known/oauth-protected-resource<mountPath>` with `publicUrl` as the `resource` field so buyers get tokens bound to the right audience. The default JWT allowlist is asymmetric-only (RS*/ES*/PS\*/EdDSA) to prevent algorithm-confusion attacks.
+ **`content-standards`** — `policies[]` is an array of `{ policy_id, enforcement, policy, policy_categories?, channels? }`. `validate_content_delivery` uses `records[].artifact` (not `creative_id`). Re-read policies per call so `standards_version_change` events don't serve stale policy.
- ## Validate Locally
+ **`measurement-verification`** — v3.1 placeholder (empty `phases`). Pass universal + governance baseline only. Advertise the capability for discoverability.
- **Full validation checklist:** [docs/guides/VALIDATE-YOUR-AGENT.md](../../docs/guides/VALIDATE-YOUR-AGENT.md). Governance-specific commands:
+ ## Validate locally
```bash
- # Boot
- npx tsx agent.ts &
-
- # Happy paths — run the storyboards matching your claimed specialisms
- npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \
- --storyboards governance_spend_authority,governance_spend_authority/denied,governance_delivery_monitor \
- --auth $TOKEN
- npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \
- --storyboards property_lists,collection_lists,content_standards \
- --auth $TOKEN
-
- # Cross-cutting obligations
- npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \
- --storyboards security_baseline,idempotency,schema_validation,error_compliance --auth $TOKEN
-
- # Rejection-surface fuzz — includes update_property_list / update_content_standards (Tier 3)
- npx @adcp/sdk@latest fuzz http://localhost:3001/mcp --auto-seed --auth-token $TOKEN
- ```
-
- Common failure decoder:
-
- - `authority_level` field present → 3.0 GA removed it; use `human_review_required: boolean` instead
- - `status: 'escalated'` on `check_governance` → enum is `approved` / `denied` / `conditions`
- - Missing `check_id` on `check_governance` response → required; generate a unique ID per check
- - `finding.code` / `finding.message` → schema requires `category_id`, `severity`, `explanation`
-
- **Keep iterating until all steps pass.** Can't bind ports? `npm run compliance:skill-matrix -- --filter governance` runs an isolated end-to-end test.
-
- ## Common Mistakes
-
- | Mistake | Fix |
- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
- | Manually registering `get_adcp_capabilities` | Framework auto-generates it from registered handlers — do not register it yourself |
- | Using `server.tool()` instead of domain groups | Use `governance: { syncPlans, checkGovernance, ... }` — framework wires schemas and response builders |
- | Using in-memory Maps for state | Use `ctx.store.put/get/patch/delete/list` — built-in state persistence |
- | `check_governance` missing `check_id` | Generate a unique ID per check — required field |
- | `check_governance` returns `decision` not `status` | Field is `status`, not `decision`. Values: `approved`, `denied`, `conditions` |
- | Conditions use `description` instead of `reason` | Condition schema requires `field` and `reason`, not `condition_id` and `description` |
- | Findings use `code`/`message` instead of proper fields | Finding schema requires `category_id`, `severity`, `explanation` |
- | `sync_plans` response missing `version` | Each plan needs `version: 1` (integer) — required field |
- | `delete_property_list` missing `deleted: true` | Boolean `deleted` field is required in response |
- | `create_property_list` missing `auth_token` | `auth_token` is required — generate a token string |
- | Dropping `context` from responses | Echo `args.context` back unchanged in every response — buyers use it for correlation |
-
- ## Storyboards
-
- | Storyboard | Tests |
- | -------------------------------- | ------------------------------------------------------------------ |
- | `campaign_governance_conditions` | Approved with conditions flow |
- | `campaign_governance_delivery` | Delivery monitoring with drift re-evaluation |
- | `campaign_governance_denied` | Denied — buy exceeds spending authority |
- | `property_lists` | Property list lifecycle: create, query, update, delete, validate |
- | `collection_lists` | Collection list lifecycle: create, query (resolve), update, delete |
- | `content_standards` | Content standards lifecycle: create, calibrate, validate |
-
- ## Specialism Details
-
- ### <a name="specialism-governance-spend-authority"></a>governance-spend-authority
-
- Storyboard category: `campaign_governance_*`. The agent holds a Plan and evaluates each binding against it.
-
- Minimal decision logic:
-
- ```typescript
- checkGovernance: async (params, ctx) => {
- const plan = await ctx.store.get('plan', params.plan_id);
- if (!plan) return adcpError('NOT_FOUND', { message: `Plan ${params.plan_id} not found` });
-
- const budget = params.binding.total_budget.amount;
-
- // 1. Human-review gate — GDPR Art 22 / EU AI Act.
- // Every action on a human_review_required plan must be escalated, regardless of budget.
- // Signal as `denied` + a critical-severity finding.
- // The buyer resolves review off-protocol and re-calls check_governance with a fresh governance_context.
- if (plan.human_review_required) {
- return {
- check_id: `chk_${Date.now()}`,
- status: 'denied' as const,
- plan_id: params.plan_id,
- explanation: 'Plan requires human review before this action can proceed',
- findings: [{
- category_id: 'HUMAN_REVIEW_REQUIRED',
- severity: 'critical',
- explanation: 'plan.human_review_required is true — resolve off-protocol and retry with a fresh governance_context',
- }],
- };
- }
-
- // 2. Budget ceiling — cannot exceed plan.budget.total.
- if (budget > plan.budget.total) {
- return { check_id: `chk_${Date.now()}`, status: 'denied' as const, plan_id: params.plan_id,
- explanation: `Budget ${budget} exceeds plan ceiling ${plan.budget.total}`,
- findings: [{ category_id: 'BUDGET_EXCEEDED', severity: 'critical',
- explanation: `Over plan ceiling by ${budget - plan.budget.total}` }],
- };
- }
-
- // 3. Custom policy matching — custom_policies is an array of structured objects
- const conditions = [];
- for (const policy of plan.custom_policies ?? []) {
- if (policy.policy.toLowerCase().includes('ctv') && hasCtv(params.binding)) {
- conditions.push({ field: 'reporting.frequency', reason: policy.policy, policy_id: policy.policy_id });
- }
- }
-
- return {
- check_id: `chk_${Date.now()}`,
- status: conditions.length ? 'conditions' as const : 'approved' as const, // 3-value enum, pick one
- plan_id: params.plan_id,
- explanation: conditions.length ? 'Approved with conditions' : 'Within spending authority',
- conditions,
- governance_context: `gov_ctx_${params.plan_id}_${Date.now()}`, // opaque string — buyer echoes back to create_media_buy
- };
- },
- ```
-
- `governance_context` is an opaque string your agent mints and the buyer echoes back. Use it to tie a specific approval to a specific `create_media_buy` call — sign it or tag it with the plan revision if you care about tamper-resistance.
-
- ### <a name="specialism-governance-delivery-monitor"></a>governance-delivery-monitor
-
- Storyboard: `governance_delivery_monitor`. Same `check_governance` tool, but the request carries `delivery_metrics` instead of a bare binding — that's the cue to run drift logic.
-
- ```typescript
- checkGovernance: async (params, ctx) => {
- if (params.phase === 'delivery' || params.delivery_metrics) {
- const plan = await ctx.store.get('plan', params.plan_id);
- const reallocationThreshold = plan.budget.reallocation_threshold; // absolute $, e.g. 8000
-
- // Check total-spend drift against the reallocation threshold
- const cumulative = params.delivery_metrics.cumulative_spend;
- const overage = cumulative - plan.budget.total;
- const exceeded = Math.abs(overage) > reallocationThreshold;
-
- // Also flag pacing mismatches that imply channel reallocation
- const driftedChannels = Object.entries(params.delivery_metrics.channel_distribution ?? {})
- .filter(([channel, percent]) => {
- const planAllocation = plan.channel_allocations?.[channel];
- return planAllocation != null && Math.abs(percent - planAllocation) > 10; // 10pp threshold
- });
-
- if (!exceeded && driftedChannels.length === 0) {
- return { check_id: `chk_${Date.now()}`, status: 'approved' as const, plan_id: params.plan_id,
- explanation: 'Delivery within reallocation threshold',
- governance_context: params.governance_context ?? `gov_ctx_${params.plan_id}_delivery_approved` };
- }
-
- return {
- check_id: `chk_${Date.now()}`,
- status: 'conditions' as const, // approved-with-reallocation — use the 'conditions' status, not 'approved'
- plan_id: params.plan_id,
- explanation: `Drift exceeded threshold: ${overage > 0 ? 'overage' : 'underage'} of ${Math.abs(overage)}`,
- conditions: driftedChannels.map(([channel, percent]) => ({
- field: `channel_distribution.${channel}`,
- reason: `Rebalance away from ${channel} — currently ${percent}%, plan targets ${plan.channel_allocations?.[channel] ?? 'unspecified'}%`,
- })),
- findings: [{ category_id: 'BUDGET_DRIFT_EXCEEDED', severity: 'warning',
- explanation: `Cumulative spend ${cumulative} outside reallocation threshold ±${reallocationThreshold}` }],
- };
- }
- // ... create-phase logic above
- },
-
- // Policy matching in create-phase checks — custom_policies is an array of objects, not strings:
- for (const policy of plan.custom_policies ?? []) {
- if (policy.policy.toLowerCase().includes('ctv') && hasCtv(params.binding)) {
- conditions.push({ field: 'reporting.frequency', reason: policy.policy });
- }
- }
- ```
-
- The `findings[].category_id` / `severity: 'info' | 'warning' | 'critical'` enum is the schema-canonical shape per [adcontextprotocol/adcp#2286](https://github.com/adcontextprotocol/adcp/issues/2286). Storyboard validations today only check `field_present: findings`, so either spelling passes — but use the schema shape.
-
- ### <a name="specialism-property-lists"></a>property-lists
-
- Storyboard: `property_lists`. Specialism and tool family share the same name. Your agent owns both inclusion and exclusion list semantics — track `list_type` on the stored list. Wrap identifiers with `selection_type: 'identifiers'`:
-
- ```typescript
- createPropertyList: async (params, ctx) => {
- const list_id = `plist_${Date.now()}`;
- const stored = {
- list_id,
- name: params.name,
- description: params.description,
- list_type: 'inclusion' as const, // caller-modeled — infer from context or add as ext
- base_properties: params.base_properties ?? [], // each entry: { selection_type: 'identifiers', identifiers: [{ type, value }] }
- property_count: countIdentifiers(params.base_properties),
- status: 'active' as const,
- };
- await ctx.store.put('property_list', list_id, stored);
- return {
- list: summarize(stored),
- auth_token: `tok_${list_id}`,
- };
- },
-
- // Shape of a base_properties entry (matches the storyboard sample):
- type BaseProperty = {
- selection_type: 'identifiers';
- identifiers: Array<{
- type: 'domain' | 'bundle_id' | 'app_store_url' | 'podcast_rss_feed' | 'property_rid';
- value: string;
- }>;
- };
- ```
-
- **`list_property_lists` / `list_collection_lists`** — destructure `ctx.store.list`. It returns `{ items, nextCursor? }`, never a bare array. Calling `.map` / `.filter` on the raw result throws `TypeError` and the dispatcher wraps it as `SERVICE_UNAVAILABLE`. Use the typed response helper so you can't accidentally ship a bare `[...]` at the top level (the storyboard runner flags that as shape drift):
-
- ```typescript
- import { listPropertyListsResponse } from '@adcp/sdk/server';
-
- listPropertyLists: async (params, ctx) => {
- const { items } = await ctx.store.list('property_list');
- return listPropertyListsResponse({
- lists: items.map(list => ({ list_id: list.list_id, name: list.name })),
- });
- },
- ```
-
- The same pattern applies to `list_collection_lists` (use `listCollectionListsResponse`) and `list_content_standards` (use `listContentStandardsResponse`). Both wrap the same `lists` / `standards` shape and guard against the bare-array drift at compile time.
-
- `validate_property_delivery` returns `violations[]` with `list_id`, `list_type`, `severity: 'critical'`, and an explanation per non-compliant record — see the response shape in the tool section above.
-
- The three mutating tools (`create_property_list`, `update_property_list`, `delete_property_list`) require `idempotency_key` per AdCP 3.0 GA — cache the response and return the same object on replay.
-
- ### <a name="specialism-collection-lists"></a>collection-lists
-
- Storyboard: `collection_lists`. Where `property-lists` curate surfaces (domains, app bundle IDs), `collection-lists` curate **content programs** (shows, series, podcasts, series arcs) identified by platform-independent IDs: IMDb (`tt0944947`), Gracenote, EIDR. Program-level brand safety — "keep my ads out of all episodes of [show]" cuts across every surface that carries that show.
-
- **Request shape** — `base_collections[]` wraps identifiers with a `selection_type`, and `filters` is an object (not an array):
-
- ```typescript
- createCollectionList: async (params, ctx) => {
- const list_id = `clist_${Date.now()}`;
- const stored = {
- list_id,
- name: params.name,
- description: params.description,
- base_collections: params.base_collections ?? [], // see shape below
- filters: params.filters ?? {}, // object — e.g. { kinds: ['series'] }
- collection_count: resolveCollectionCount(params.base_collections, params.filters),
- status: 'active' as const,
- };
- await ctx.store.put('collection_list', list_id, stored);
- return { list: summarize(stored), auth_token: `tok_${list_id}` };
- },
-
- // Shape of a base_collections entry:
- type BaseCollection = {
- selection_type: 'distribution_ids';
- identifiers: Array<{
- type: 'imdb_id' | 'gracenote_id' | 'eidr';
- value: string; // e.g. "tt9999901"
- }>;
- };
-
- // Shape of filters:
- type Filters = {
- kinds?: ('series' | 'movie' | 'podcast' | 'episode')[];
- // other filter families added in 3.1
- };
- ```
-
- **`get_collection_list`** with `resolve: true` returns the concrete `collections[]` (not `resolved_programs`) — the resolved list of program IDs after filters are applied. Sellers cache this at bid time.
-
- ```typescript
- {
- list: {
- list_id: string,
- name: string,
- collection_count: number,
- collections: Array<{ type, value }>, // resolved programs
- cache_valid_until: string, // ISO timestamp — sellers respect this TTL
- },
- }
- ```
-
- No `validate_collection_delivery` tool exists yet (preview in 3.1). Delivery enforcement is a receiving-seller concern; the governance agent's job ends at publishing the resolved list with a `cache_valid_until`.
-
- ### <a name="specialism-content-standards"></a>content-standards
-
- Storyboard: `content_standards`. Two load-bearing protocol shapes the baseline above does not cover:
-
- 1. **`policies` is an array of structured entries**, not a prose string. Each entry carries its own `enforcement` level — don't parse severity from inline `(must)` / `(should)` markers; read it off the `enforcement` field:
+ # Run the fork-matrix gate (tsc strict)
+ npm run compliance:fork-matrix -- --test-name-pattern="hello-seller-adapter-multi-tenant"
- ```typescript
- type Policy = {
- policy_id: string;
- enforcement: 'must' | 'should';
- policy: string; // prose description of the rule
- policy_categories?: string[]; // e.g. ['brand_safety', 'imagery_quality']
- channels?: string[]; // e.g. ['display'] — scoped enforcement
- };
+ # Run your forked agent against the matching storyboard
+ adcp storyboard run http://127.0.0.1:3003/mcp governance_spend_authority \
+ --bearer "$ADCP_AUTH_TOKEN" --include-bundles --json
- function applies(p: Policy, artifact: { channel: string }): boolean {
- return !p.channels || p.channels.includes(artifact.channel);
- }
+ # Property-lists track
+ adcp storyboard run http://127.0.0.1:3003/mcp property_lists \
+ --bearer "$ADCP_AUTH_TOKEN" --include-bundles --json
```
- 2. **`validate_content_delivery` uses `records[].artifact`**, not `results[].creative_id`. See the tool section above for the full shape.
+ The fork-matrix gate is the three-gate contract from [`docs/guides/EXAMPLE-TEST-CONTRACT.md`](../../docs/guides/EXAMPLE-TEST-CONTRACT.md). The multi-tenant adapter currently runs the strict-tsc gate only (no governance / brand-rights mock-server today); storyboard-grader gates land alongside the next mock-server family.
- **Re-read policies per call.** The `standards_version_change` phase issues an update, then re-calibrates the same artifact and expects the new verdict. A memoized calibrator that caches by artifact_id will fail that phase. Always fetch the latest policies from `ctx.store.get('content_standards', standards_id)` inside `calibrate_content` / `validate_content_delivery`.
+ For deeper validation: [`docs/guides/VALIDATE-YOUR-AGENT.md`](../../docs/guides/VALIDATE-YOUR-AGENT.md).
- `calibrate_content` should return per-rule results, not just a top-level verdict:
+ ## Common shape gotchas
- ```typescript
- calibrateContent: async (params, ctx) => ({
- verdict: 'fail' as const,
- confidence: 0.95,
- explanation: 'Content violates tobacco-free rule',
- rules: [
- { rule: 'No tobacco advertising', severity: 'must', passed: false,
- evidence: 'Detected cigarette imagery in primary asset',
- remediation: 'Remove cigarette imagery or select a compliant creative' },
- { rule: 'Weekly pacing reports required for CTV', severity: 'should', passed: true,
- evidence: 'Agent has reporting_capabilities frequencies: ["weekly"]', remediation: null },
- ],
- features: [],
- }),
- ```
+ `policies[]` is `{ policy_id, enforcement, policy, ... }` — a wrapped policy, not a bare string. `validate_content_delivery` keys on `records[].artifact`, not `creative_id`. Property identity is `{agent_url, id}` (a `PropertyId`), not a bare string. `check_governance` response uses `decision: 'approved' | 'conditions' | 'denied'` — not boolean. See [`../SHAPE-GOTCHAS.md`](../SHAPE-GOTCHAS.md).
- ## Reference
+ ## Migration notes
- - `storyboards/campaign_governance_conditions.yaml` — conditional approval flow
- - `storyboards/campaign_governance_denied.yaml` — denial flow
- - `storyboards/property_lists.yaml` — property list lifecycle
- - `storyboards/collection_lists.yaml` — collection list lifecycle
- - `storyboards/content_standards.yaml` — content standards lifecycle
- - `docs/guides/BUILD-AN-AGENT.md` — SDK patterns
- - `docs/TYPE-SUMMARY.md` — curated type signatures
- - `docs/llms.txt` — full protocol reference
+ - 6.6 → 6.7: [`docs/migration-6.6-to-6.7.md`](../../docs/migration-6.6-to-6.7.md). Note: `inventory-lists` was renamed to `property-lists` in AdCP 3.0 GA (5.x → 5.2 migration).
+ - 4.x → 5.x: [`docs/migration-4.x-to-5.x.md`](../../docs/migration-4.x-to-5.x.md)