diff --git a/.changeset/collection-lists.md b/.changeset/collection-lists.md new file mode 100644 index 0000000000..eefb4f02be --- /dev/null +++ b/.changeset/collection-lists.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add collection lists — program-level brand safety for shows, series, and other content programs across platforms. Collection lists are a parallel construct to property lists: they use distribution identifiers (IMDb, Gracenote, EIDR) for cross-publisher matching and support content rating and genre filters. New targeting overlay fields (collection_list, collection_list_exclude) enable both inclusion and exclusion. New genre taxonomy enum normalizes genre classification across buyers and sellers. diff --git a/docs.json b/docs.json index 0ec3a14a5b..612e3a17af 100644 --- a/docs.json +++ b/docs.json @@ -309,6 +309,13 @@ } ] }, + { + "group": "Collection Governance", + "pages": [ + "docs/governance/collection/index", + "docs/governance/collection/tasks/collection_lists" + ] + }, { "group": "Content Standards", "pages": [ diff --git a/docs/governance/collection/index.mdx b/docs/governance/collection/index.mdx new file mode 100644 index 0000000000..f2c35263e0 --- /dev/null +++ b/docs/governance/collection/index.mdx @@ -0,0 +1,166 @@ +--- +title: Collection Governance +description: "AdCP Collection Governance enables program-level brand safety through collection lists — managed exclusion and inclusion lists for shows, series, and other content programs independent of which properties carry them." +"og:title": "AdCP — Collection Governance" +"og:image": /images/walkthrough/collection-gov-01-spreadsheet.png +sidebarTitle: Overview +--- + +Jordan sits at her desk studying a dense brand safety spreadsheet on her monitor — rows of partner names and excluded programs that need to become machine-readable + +Jordan is staring at a spreadsheet. A holding company just sent Nova Motors' CTV "do not air" list — 200+ programs organized by network, a mix of specific shows, entire genres, and content ratings. Some entries are app-level exclusions. Some are individual programs that air on five different platforms. One section says "ALWAYS EXCLUDE kids programming" and three rows later says "animation rated G or PG is permitted." + +This spreadsheet makes sense to a human trafficking linear TV. It does not make sense to an AI agent managing programmatic CTV across a dozen sellers. + +Jordan needs to turn this into something machines can enforce. Property lists handle the app-level exclusions — she's done that before. But the program-level exclusions? A show doesn't belong to a single app. It airs everywhere. She needs a construct that identifies *the program itself*, independent of where it runs. + +That's what collection lists are for. + +## The gap in brand safety + +Three-layer brand safety diagram — properties on top, collections in the middle, content standards at the bottom — Jordan points at the collection layer she is building + +Before collection lists, AdCP had two brand safety layers: + +- **Property lists** control *where* ads run — which apps, sites, and platforms. Jordan can say "not on this news app" and every seller enforces it. +- **Content standards** control *what content* is adjacent to an ad — per-impression evaluation against a natural language policy. They handle nuance like "exclude kids content except G/PG animation." + +The missing layer is *what program* the ad runs in. A specific crime drama airs on three streaming platforms and cable syndication. Excluding it from one property doesn't exclude it from the others. Jordan needs to say "not in this program, anywhere" — and have every seller understand what she means. + +Collection lists fill this gap. Together with property lists and content standards, they form three composable layers: + +| Layer | Construct | What it controls | When | +|---|---|---|---| +| Property | Property list | Where ads run (apps, sites) | Setup | +| Collection | **Collection list** | What content ads run in (shows, series) | Setup | +| Content | Content standards | Specific content adjacent to the ad | Per-impression | + +Most buyers use one or two layers. A buyer who only needs to exclude specific programs uses a collection list alone. The three-layer model is a composition framework, not a requirement. + +## Resolving program identifiers + +Jordan maps program names to distribution identifiers at a display — green lines connect resolved programs, amber lines show unresolved ones she marks for follow-up + +The spreadsheet lists programs by name. Machines need identifiers. Jordan's buyer agent resolves each program name to a platform-independent [distribution identifier](/docs/media-buy/product-discovery/collections-and-installments) — an IMDb ID, Gracenote ID, or EIDR ID that uniquely identifies the program regardless of which CTV platform carries it. + +Most programs resolve immediately. A few don't — the agent flags these for Jordan to confirm manually. This is the translation step: human-readable names become machine-readable identifiers that every seller in the ecosystem understands. + +**Gracenote ID guidance:** Use root-level IDs — SH-prefixed for series, MV-prefixed for movies, SP-prefixed for sports programs. Episode-level IDs (EP-prefixed) don't belong in collection lists; episode-level evaluation is a content standards concern. + +## Building the collection list + +A funnel filters collections through rating, genre, and explicit exclusion layers — Jordan reviews the clean resolved list emerging at the bottom + +Jordan's buyer agent creates a collection list on the governance agent, combining explicit program exclusions with structural filters: + +```json +{ + "tool": "create_collection_list", + "arguments": { + "name": "Nova Motors CTV Do Not Air — 2026", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "imdb_id", "value": "tt9999902" }, + { "type": "gracenote_id", "value": "SH000003" } + ] + } + ], + "filters": { + "content_ratings_exclude": [ + { "system": "tv_parental", "rating": "TV-MA" }, + { "system": "bbfc", "rating": "18" } + ], + "genres_exclude": ["news"], + "genre_taxonomy": "iab_content_3.0" + }, + "brand": { "domain": "novamotors.com" } + } +} +``` + +The explicit entries handle the named programs. The filters handle the structural exclusions — no TV-MA content, no news genre. The filters are the safety net: any new TV-MA series on any platform is automatically excluded without Jordan updating the list. + +The "kids vs. G/PG animation" contradiction? That's not a collection list problem — it requires evaluating actual episode content, not metadata. Jordan puts that in [content standards](/docs/governance/content-standards/index) where it belongs. + +### How filters compose + +Include filters are allowlists, exclude filters are blocklists. When both are present for the same dimension, include applies first, then exclude narrows further. + +**Example:** `genres_include: ["drama", "comedy"]` + `genres_exclude: ["crime"]` first includes only drama and comedy collections, then removes any also tagged as crime. A collection tagged `["drama", "crime"]` is excluded — the exclude filter wins. + +## Sellers match against their inventory + +Split scene — Jordan's governance agent sends the collection list to Priya at StreamHaus, whose inventory lights up showing matched exclusions + +When Jordan's media buy references the collection list, Priya's sales agent at StreamHaus fetches it, matches entries against StreamHaus's collection inventory, and excludes matched programs from delivery. The matching uses distribution identifiers — StreamHaus declared Gracenote IDs on their collections in `adagents.json`, so the match is automatic. + +Priya's agent reports back: 47 of 200 excluded programs are in StreamHaus's library. 12 additional collections caught by the TV-MA filter. The rest aren't programs StreamHaus carries — acknowledged and ignored. + +The list is cached for a week (collection metadata changes less frequently than property metadata). When the governance agent re-resolves the list — a new season changes a show's content rating, or Jordan adds programs — sellers receive a webhook and refresh their cache. + +## Targeting integration + +Collection lists are referenced in targeting overlays alongside property lists: + +```json +{ + "targeting": { + "property_list": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "pl_novamotors_approved_ctv" + }, + "collection_list_exclude": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "cl_novamotors_dna_2026" + } + } +} +``` + +| Field | Semantics | Use case | +|---|---|---| +| `collection_list` | Inclusion — only run in these collections | "Only buy pre-roll on these three shows" | +| `collection_list_exclude` | Exclusion — never run in these collections | Brand safety do-not-air lists | + +A media buy can reference both simultaneously — "run in these approved shows, but never in these specific programs even if they appear on the approved list." The exclude list always wins on overlap. + + +**Why two fields for collections but one for properties?** Property lists predate the paired include/exclude pattern now used across other targeting dimensions (geo, audience, device). Collection lists follow the current pattern. A future evolution may add `property_list_exclude` for symmetry. + + +## Jordan's three-layer configuration + +By the time Jordan is done, Nova Motors' CTV brand safety is expressed in three machine-readable artifacts: + +1. **Property list** — excluded apps and approved CTV platforms +2. **Collection list** — excluded programs by distribution identifier + TV-MA and news genre filters +3. **Content standards** — the nuanced kids/animation policy that requires per-episode judgment + +Each layer is independently managed, independently cacheable, and independently enforceable. When the holding company sends an updated do-not-air list next quarter, Jordan's agent diffs it against the existing collection list and updates only what changed. + +No more spreadsheets. No more per-network manual trafficking. One list, enforced everywhere. + +## Relationship to property lists + +Property lists and collection lists are sibling constructs — both are inventory lists managed by governance agents with the same lifecycle pattern (create, get, update, list, delete, webhook). They differ in what they address: + +| Dimension | Property list | Collection list | +|---|---|---| +| What it identifies | Technical surfaces (domains, apps) | Content programs (shows, series) | +| Primary identifier | Property identifiers (domain, bundle ID) | Distribution identifiers (IMDb, Gracenote, EIDR) | +| Filters | Country, channel, property type, features | Content rating, genre, kind, production quality | +| Cache default | 24 hours | 168 hours (one week) | +| Cross-publisher | Via property registry (property_rid) | Via collection registry (collection_rid) | + +## Tasks + +### Collection list management + +- **[create_collection_list](/docs/governance/collection/tasks/collection_lists#create_collection_list)**: Create a new collection list on a governance agent +- **[get_collection_list](/docs/governance/collection/tasks/collection_lists#get_collection_list)**: Retrieve resolved collections (with caching guidance) +- **[update_collection_list](/docs/governance/collection/tasks/collection_lists#update_collection_list)**: Modify filters or base collections +- **[list_collection_lists](/docs/governance/collection/tasks/collection_lists#list_collection_lists)**: List collection lists for a principal +- **[delete_collection_list](/docs/governance/collection/tasks/collection_lists#delete_collection_list)**: Remove a collection list diff --git a/docs/governance/collection/tasks/collection_lists.mdx b/docs/governance/collection/tasks/collection_lists.mdx new file mode 100644 index 0000000000..f0eaa7ea33 --- /dev/null +++ b/docs/governance/collection/tasks/collection_lists.mdx @@ -0,0 +1,327 @@ +--- +title: Collection List Management +description: "Collection list tasks in AdCP create, update, get, list, and delete inclusion and exclusion lists for content programs, combining explicit program references with dynamic genre and content rating filters." +"og:title": "AdCP — Collection List Management" +--- + + +**AdCP 3.0 Proposal** - This protocol is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). + + +# Collection list management + +Collection lists are managed, cacheable artifacts that express "these collections, filtered by these criteria." They parallel [property lists](/docs/governance/property/tasks/property_lists) but operate on content programs (shows, series, podcasts) rather than technical surfaces (domains, apps). + +## Architecture + +``` +SETUP TIME BID TIME REFRESH +───────────── ──────── ─────── +Buyer creates list ──► Governance Seller uses cached ◄── Webhook notifies + base_collections agent collection list seller to + + filters resolves re-fetch + & caches +``` + +Collection lists are **setup-time resources**. They are resolved once by the governance agent, cached by sellers, and used in delivery decisions without runtime calls back to the governance agent. + +## Tasks overview + +| Task | Purpose | Response time | +|------|---------|---------------| +| `create_collection_list` | Create a new collection list | Seconds | +| `get_collection_list` | Fetch list with resolved collections | Seconds (cached) | +| `update_collection_list` | Modify base collections or filters | Seconds | +| `list_collection_lists` | List collection lists for a principal | Seconds | +| `delete_collection_list` | Remove a collection list | Seconds | + +## Base collection sources + +Collection lists start with a base set of collections selected through three patterns: + +### distribution_ids + +Select collections by platform-independent identifiers. The primary mechanism for cross-publisher exclusion — an IMDb ID identifies a program regardless of which CTV platform carries it. + +```json +{ + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "gracenote_id", "value": "SH000001" }, + { "type": "eidr_id", "value": "10.5240/XXXX-XXXX-XXXX-XXXX-XXXX-C" } + ] +} +``` + +### publisher_collections + +Select specific collections within a publisher's `adagents.json` by collection ID. Use when the publisher's internal identifiers are known. + +```json +{ + "selection_type": "publisher_collections", + "publisher_domain": "titanstreaming.com", + "collection_ids": ["danger_zone", "wild_nights"] +} +``` + +### publisher_genres + +Select all collections from a publisher matching genre criteria. Use when excluding entire content categories from a specific publisher. + +```json +{ + "selection_type": "publisher_genres", + "publisher_domain": "streamhaus.com", + "genres": ["news"], + "genre_taxonomy": "iab_content_3.0" +} +``` + +When `base_collections` is omitted, the list applies filters against the governance agent's entire collection database. + +## Filters + +Filters narrow the resolved list after base collection selection: + +| Filter | Type | Logic | Description | +|--------|------|-------|-------------| +| `content_ratings_exclude` | ContentRating[] | OR | Exclude collections with any of these ratings | +| `content_ratings_include` | ContentRating[] | OR | Include only collections with these ratings | +| `genres_exclude` | string[] | OR | Exclude collections tagged with any genre | +| `genres_include` | string[] | OR | Include only collections with any genre | +| `genre_taxonomy` | string | — | Taxonomy for genre filter values | +| `kinds` | string[] | OR | Filter to collection kinds (series, publication, event_series, rotation) | +| `exclude_distribution_ids` | DistributionId[] | OR | Always exclude these specific collections | +| `production_quality` | string[] | OR | Filter by production quality tier | + +**Include vs. exclude**: include filters are allowlists, exclude filters are blocklists. When both are present for the same dimension, include is applied first, then exclude narrows further. + +**Example:** A list with `genres_include: ["drama", "comedy"]` and `genres_exclude: ["crime"]` first includes only drama and comedy collections, then removes any tagged as crime. A collection tagged `["drama", "crime"]` is excluded — the exclude filter wins. A collection tagged `["sports"]` is excluded by the include filter (not in the allowed set). + +**Content ratings are metadata filters, not content evaluation.** `content_ratings_exclude: [{ system: "tv_parental", rating: "TV-MA" }]` excludes all collections *declared* as TV-MA. It doesn't evaluate individual episodes — that's [content standards](/docs/governance/content-standards/index). + +**Genre taxonomy** normalizes genre matching between buyers and sellers. Supported taxonomies: `iab_content_3.0`, `iab_content_2.2`, `gracenote`, `eidr`, `apple_genres`, `google_genres`, `roku`, `amazon_genres`, `custom`. The `custom` value is an escape hatch for publisher-defined taxonomies — buyer and seller negotiate the vocabulary out of band. + +## create_collection_list + +Create a new collection list on a governance agent. + +**Request:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/create-collection-list-request.json", + "name": "Nova Motors CTV Do Not Air — 2026", + "description": "Programs excluded from Nova Motors CTV advertising", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "imdb_id", "value": "tt9999902" } + ] + }, + { + "selection_type": "publisher_genres", + "publisher_domain": "streamhaus.com", + "genres": ["news", "crime"], + "genre_taxonomy": "iab_content_3.0" + } + ], + "filters": { + "content_ratings_exclude": [ + { "system": "tv_parental", "rating": "TV-MA" }, + { "system": "bbfc", "rating": "18" } + ], + "genres_exclude": ["news"], + "genre_taxonomy": "iab_content_3.0", + "kinds": ["series"] + }, + "brand": { "domain": "novamotors.com" } +} +``` + +**Response:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/create-collection-list-response.json", + "list": { + "list_id": "cl_novamotors_dna_2026", + "name": "Nova Motors CTV Do Not Air — 2026", + "collection_count": 247, + "created_at": "2026-04-07T12:00:00Z", + "updated_at": "2026-04-07T12:00:00Z" + }, + "auth_token": "tok_example_store_this_securely" +} +``` + +The `auth_token` is only returned at creation time. Store it — it authorizes sellers to fetch this list. + +## get_collection_list + +Retrieve a collection list with resolved collections. Sellers call this to fetch and cache the list. + +**Request:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/get-collection-list-request.json", + "list_id": "cl_novamotors_dna_2026", + "resolve": true, + "pagination": { + "max_results": 1000 + } +} +``` + +**Response:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/get-collection-list-response.json", + "list": { + "list_id": "cl_novamotors_dna_2026", + "name": "Nova Motors CTV Do Not Air — 2026", + "collection_count": 247 + }, + "collections": [ + { + "collection_rid": "019abc12-3d4e-7f5a-ab6c-7d8e9f0a1b2c", + "name": "Danger Zone", + "distribution_ids": [ + { "type": "imdb_id", "value": "tt9999901" } + ], + "content_rating": { "system": "tv_parental", "rating": "TV-MA" }, + "genre": ["comedy", "animation"], + "genre_taxonomy": "iab_content_3.0", + "kind": "series" + } + ], + "pagination": { "has_more": false }, + "resolved_at": "2026-04-07T14:00:00Z", + "cache_valid_until": "2026-04-14T14:00:00Z", + "coverage_gaps": { + "genre": [ + { "type": "imdb_id", "value": "tt9999905" } + ] + } +} +``` + +**Coverage gaps** report collections included in the list despite missing metadata for a filtered dimension. In this example, `tt9999905` was included but has no genre metadata — the governance agent couldn't confirm it matches the genre filter. + +**Caching**: sellers should cache the resolved collections and re-fetch after `cache_valid_until`. The default cache duration is 168 hours (one week) because collection metadata changes less frequently than property metadata. + +## update_collection_list + +Modify an existing collection list. `base_collections` and `filters` are complete replacements, not patches. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/update-collection-list-request.json", + "list_id": "cl_novamotors_dna_2026", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "imdb_id", "value": "tt9999902" }, + { "type": "imdb_id", "value": "tt9999903" } + ] + } + ], + "filters": { + "content_ratings_exclude": [ + { "system": "tv_parental", "rating": "TV-MA" } + ] + }, + "webhook_url": "https://governance.pinnacleagency.com/webhooks/collection-lists" +} +``` + +## list_collection_lists + +List collection lists for a principal. Returns metadata only, not resolved collections. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/list-collection-lists-request.json", + "principal": "ops@pinnacleagency.com", + "pagination": { "max_results": 50 } +} +``` + +## delete_collection_list + +Remove a collection list. Sellers with cached copies will stop receiving webhook updates. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/delete-collection-list-request.json", + "list_id": "cl_novamotors_dna_2026" +} +``` + +## Webhooks + +When a collection list's resolved collections change (new programs matched, ratings updated, programs removed), the governance agent sends a webhook notification: + +```json +{ + "event": "collection_list_changed", + "list_id": "cl_novamotors_dna_2026", + "list_name": "Nova Motors CTV Do Not Air — 2026", + "change_summary": { + "collections_added": 3, + "collections_removed": 1, + "total_collections": 249 + }, + "resolved_at": "2026-04-08T10:00:00Z", + "cache_valid_until": "2026-04-15T10:00:00Z", + "signature": "..." +} +``` + +Webhooks contain a summary only — recipients must call `get_collection_list` for the updated entries. Recipients MUST verify the `signature` before processing. + +## Live sports + +Live sports is one of the largest CTV brand safety concerns. Collection lists handle it through the `event_series` kind: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/collection/create-collection-list-request.json", + "name": "Acme Outdoor — Excluded Sports Events", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "gracenote_id", "value": "SP000001" }, + { "type": "gracenote_id", "value": "SP000002" } + ] + } + ], + "filters": { + "kinds": ["event_series"], + "genres_exclude": ["combat_sports"], + "genre_taxonomy": "gracenote" + }, + "brand": { "domain": "acmeoutdoor.com" } +} +``` + +This excludes specific sports programs by Gracenote ID (SP-prefixed for sports) and structurally excludes all combat sports event series. The `event_series` kind filter ensures the list targets live event programming, not documentary series about sports. + +## Sharing collection lists with sellers + +The pattern matches [property list sharing](/docs/governance/property/index#sharing-property-lists-with-sellers): + +1. Create the collection list on a governance agent +2. Store the `auth_token` from the creation response +3. Pass `collection_list` or `collection_list_exclude` in targeting overlays +4. Sellers fetch and cache the resolved list using the auth token +5. Webhooks notify sellers when the list changes diff --git a/docs/governance/overview.mdx b/docs/governance/overview.mdx index dcadfd9678..d25851c2d5 100644 --- a/docs/governance/overview.mdx +++ b/docs/governance/overview.mdx @@ -262,6 +262,9 @@ The Governance Protocol covers five domains: Control where ads can run with property lists, compliance filtering, and publisher authorization via adagents.json. + + Control what content ads run in with collection lists — program-level brand safety for shows, series, and podcasts across platforms. + Privacy-preserving brand suitability through calibration-based content evaluation and validation. @@ -290,5 +293,6 @@ Today, SI platforms enforce governance at the application layer using [content s - **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 - **Property governance**: [Where ads can run](/docs/governance/property/index) — property lists, adagents.json, and publisher authorization +- **Collection governance**: [What content ads run in](/docs/governance/collection/index) — collection lists for program-level brand safety across platforms - **Policy registry**: [Community policies](/docs/governance/policy-registry) — standardized regulations and brand safety policies - **Get certified**: [Specialist governance modules](/docs/learning/specialist/governance) teach the full governance system through interactive scenarios diff --git a/docs/governance/property/index.mdx b/docs/governance/property/index.mdx index 8315f7565c..968c046084 100644 --- a/docs/governance/property/index.mdx +++ b/docs/governance/property/index.mdx @@ -11,6 +11,8 @@ sidebarTitle: Overview Property Governance standardizes how advertising properties (websites, apps, CTV, podcasts, billboards) are identified, authorized, enriched with data, and selected for campaigns. +Property lists and [collection lists](/docs/governance/collection/index) together form the **inventory list** system — property lists control *where* ads run (technical surfaces), while collection lists control *what content* ads run in (programs, shows, series). Both are setup-time artifacts managed by governance agents with the same lifecycle pattern. + `adagents.json` is intentionally broader than `ads.txt`: it can describe not just which sales agents are present, but which properties, placements, and delegated sales paths they are actually authorized to make available. For a side-by-side comparison, see [Why adagents.json is more expressive than ads.txt](https://agenticadvertising.org/perspectives/adagents-json-vs-ads-txt). ## Overview diff --git a/docs/learning/specialist/governance.mdx b/docs/learning/specialist/governance.mdx index 7c9bda7bb9..b93ddb30ee 100644 --- a/docs/learning/specialist/governance.mdx +++ b/docs/learning/specialist/governance.mdx @@ -11,7 +11,7 @@ description: "AdCP specialist module S4: Governance protocol mastery. Content st **Members only** — Requires Practitioner credential. ~60 minutes with Addie. Combines hands-on lab and adaptive exam. -This specialist module tests your mastery of the full governance protocol. You'll work with sandbox agents to manage content standards and property lists, execute the campaign governance lifecycle (plans, validation, outcomes, audit), resolve compliance policies from the registry, and reason about how governance domains compose. Addie evaluates both your hands-on work and your conceptual understanding. +This specialist module tests your mastery of the full governance protocol. You'll work with sandbox agents to manage content standards, property lists, and collection lists, execute the campaign governance lifecycle (plans, validation, outcomes, audit), resolve compliance policies from the registry, and reason about how governance domains compose. Addie evaluates both your hands-on work and your conceptual understanding. Passing earns the **AdCP specialist — Governance** credential. @@ -19,13 +19,15 @@ Passing earns the **AdCP specialist — Governance** credential. - Create, update, list, and delete content standards - Create, update, list, and delete property lists +- Create, update, list, and delete collection lists — program-level brand safety across platforms - Calibrate content against standards and interpret results - Create and validate campaign plans with budget authority and policy configuration - Sync governance agents to accounts via `sync_governance` - Execute the full governance loop: `sync_plans`, `check_governance` (intent + execution), `report_plan_outcome` - Use the policy registry to resolve and apply compliance policies - Interpret audit logs and reason about drift metrics -- Explain how governance domains compose — campaign, property, content standards, creative +- Explain how the three layers of brand safety compose — property lists (where), collection lists (what content), content standards (per-impression adjacency) +- Explain how governance domains compose — campaign, property, collection, content standards, creative - Configure audience constraints on a campaign plan using policy categories and restricted attributes - Validate that `check_governance` correctly denies targeting that violates audience constraints @@ -37,6 +39,9 @@ Passing earns the **AdCP specialist — Governance** credential. Create and manage property lists for inclusion/exclusion filtering. + + Create and manage collection lists for program-level brand safety. + Define brand-specific content standards for automated compliance. @@ -84,6 +89,9 @@ Passing earns the **AdCP specialist — Governance** credential. Identity, authorization, and data enrichment for publishers. + + Program-level brand safety with collection lists across CTV, podcast, and other media. + How content standards work: calibration, local execution, and validation. @@ -125,12 +133,13 @@ See the [Quickstart](/docs/quickstart) for a walkthrough of your first call. 1. **Content standards lifecycle** — Create, update, and manage content standards for a fictional brand 2. **Property list management** — Create inclusion and exclusion lists for supply path control -3. **Content calibration** — Calibrate sample content against your standards and interpret results -4. **Compliance verification** — Verify that sandbox campaign deliveries meet governance requirements -5. **Campaign governance lifecycle** — Sync governance agents via `sync_governance`, create a campaign plan, validate actions (intent + execution), handle denials and conditions, report outcomes, and review audit logs -6. **Policy resolution and compliance** — Resolve policies from the registry, configure jurisdiction-scoped enforcement, verify that violations are caught -7. **Audience governance** — Configure a plan with `policy_categories` (e.g., `fair_housing`) that carry `restricted_attributes`. Submit `check_governance` requests with audience selectors that use restricted signals and verify the governance agent denies them. Then submit compliant targeting and confirm approval. -8. **Audience drift detection** — Run a series of delivery-phase governance checks with `audience_distribution` indices that gradually shift from baseline parity. Observe how cumulative indices reveal sustained bias patterns that single-period noise obscures, and how governance findings escalate when drift exceeds thresholds. +3. **Collection list management** — Create a do-not-air collection list with distribution identifiers, content rating filters, and genre exclusions. Understand how collection lists compose with property lists and content standards for three-layer brand safety. +4. **Content calibration** — Calibrate sample content against your standards and interpret results +5. **Compliance verification** — Verify that sandbox campaign deliveries meet governance requirements +6. **Campaign governance lifecycle** — Sync governance agents via `sync_governance`, create a campaign plan, validate actions (intent + execution), handle denials and conditions, report outcomes, and review audit logs +7. **Policy resolution and compliance** — Resolve policies from the registry, configure jurisdiction-scoped enforcement, verify that violations are caught +8. **Audience governance** — Configure a plan with `policy_categories` (e.g., `fair_housing`) that carry `restricted_attributes`. Submit `check_governance` requests with audience selectors that use restricted signals and verify the governance agent denies them. Then submit compliant targeting and confirm approval. +9. **Audience drift detection** — Run a series of delivery-phase governance checks with `audience_distribution` indices that gradually shift from baseline parity. Observe how cumulative indices reveal sustained bias patterns that single-period noise obscures, and how governance findings escalate when drift exceeds thresholds. ## Assessment diff --git a/images/walkthrough/collection-gov-01-spreadsheet.png b/images/walkthrough/collection-gov-01-spreadsheet.png new file mode 100644 index 0000000000..d8bd147e8b Binary files /dev/null and b/images/walkthrough/collection-gov-01-spreadsheet.png differ diff --git a/images/walkthrough/collection-gov-02-three-layers.png b/images/walkthrough/collection-gov-02-three-layers.png new file mode 100644 index 0000000000..7eafa63a49 Binary files /dev/null and b/images/walkthrough/collection-gov-02-three-layers.png differ diff --git a/images/walkthrough/collection-gov-03-resolving.png b/images/walkthrough/collection-gov-03-resolving.png new file mode 100644 index 0000000000..7c2813c013 Binary files /dev/null and b/images/walkthrough/collection-gov-03-resolving.png differ diff --git a/images/walkthrough/collection-gov-04-filters.png b/images/walkthrough/collection-gov-04-filters.png new file mode 100644 index 0000000000..536c0a349d Binary files /dev/null and b/images/walkthrough/collection-gov-04-filters.png differ diff --git a/images/walkthrough/collection-gov-05-seller-matching.png b/images/walkthrough/collection-gov-05-seller-matching.png new file mode 100644 index 0000000000..d276a3bdc7 Binary files /dev/null and b/images/walkthrough/collection-gov-05-seller-matching.png differ diff --git a/scripts/prompts-collection-governance.json b/scripts/prompts-collection-governance.json new file mode 100644 index 0000000000..3cc96620fc --- /dev/null +++ b/scripts/prompts-collection-governance.json @@ -0,0 +1,27 @@ +[ + { + "filename": "images/walkthrough/collection-gov-01-spreadsheet.png", + "prompt": "Jordan (Latina woman, mid-30s, warm brown skin, dark wavy hair past her shoulders, small silver hoop earrings, sage-green cardigan over white collared shirt) sits at her desk staring at a large monitor showing an abstract representation of a dense spreadsheet. The spreadsheet is shown as a grid of colored blocks and horizontal bars in varying shades of teal and gray — NO READABLE TEXT ANYWHERE, just abstract colored rectangles representing data cells. Jordan's expression is thoughtful, slightly overwhelmed — this dense grid needs to become machine-readable. Her desk has a coffee mug and a second monitor showing abstract dashboard shapes. Morning light from a nearby window. Flat illustration, teal/emerald color palette (#047857 primary, #0d9488 secondary, #134e4a dark accents). Graphic novel style with clean panel borders. Clean, minimal linework with subtle gradients. Tech-forward but warm. Wide aspect ratio 16:9. CRITICAL: Do not render any text, words, letters, numbers, or labels anywhere in the image. Use only abstract colored shapes to represent data.", + "alt_text": "Jordan sits at her desk studying a dense brand safety spreadsheet on her monitor — rows of partner names and excluded programs that need to become machine-readable" + }, + { + "filename": "images/walkthrough/collection-gov-02-three-layers.png", + "prompt": "A conceptual diagram showing three horizontal layers stacked like transparent shelves. Top shelf: abstract app icons and platform shapes representing properties (where ads run). Middle shelf: film reels, TV screens showing different programs, and podcast microphones representing collections (what content). Bottom shelf: a magnifying glass examining a single article/episode with checkmarks and X marks representing content standards (per-impression evaluation). Jordan stands to the right, pointing at the middle layer — the new collection layer she's building. The layers are color-coded: top in lighter teal, middle in primary emerald, bottom in darker teal. Lines connect from each layer to a central media buy shape. Flat illustration, teal/emerald color palette (#047857 primary, #0d9488 secondary, #134e4a dark accents). Graphic novel style with clean panel borders. Clean, minimal linework with subtle gradients. Wide aspect ratio 16:9. Do not include any text, words, or labels in the image.", + "alt_text": "Three-layer brand safety diagram — properties on top, collections in the middle, content standards at the bottom — Jordan points at the collection layer she is building" + }, + { + "filename": "images/walkthrough/collection-gov-03-resolving.png", + "prompt": "Jordan (Latina woman, mid-30s, warm brown skin, dark wavy hair past her shoulders, small silver hoop earrings, sage-green cardigan over white collared shirt) stands at a whiteboard-style display mapping program names to identifiers. On the left side: abstract program titles represented as cards. Lines flow from each card through a resolver (shown as a prism or gateway shape) to the right side: standardized identifier badges (representing IMDb, Gracenote, EIDR IDs). Some lines connect successfully (glowing green). A few dangle without a match (glowing amber) — programs where identifiers couldn't be resolved. Jordan marks one of the amber ones with a question mark on her tablet. The scene shows the translation from human-readable names to machine-readable identifiers. Flat illustration, teal/emerald color palette (#047857 primary, #0d9488 secondary, #134e4a dark accents). Graphic novel style. Wide aspect ratio 16:9. Do not include any text, words, or labels in the image.", + "alt_text": "Jordan maps program names to distribution identifiers at a display — green lines connect resolved programs, amber lines show unresolved ones she marks for follow-up" + }, + { + "filename": "images/walkthrough/collection-gov-04-filters.png", + "prompt": "A funnel visualization showing how collection list filters work. At the top, a wide stream of abstract program cards (representing all collections) flows into a funnel. The funnel has three visible filter layers: a rating filter (showing a shield icon rejecting some cards), a genre filter (showing category tags letting some pass), and an explicit exclusion layer (showing specific cards being pulled out by ID). At the bottom, a narrower clean stream emerges — the resolved collection list. Jordan (Latina woman, mid-30s, warm brown skin, dark wavy hair past her shoulders, small silver hoop earrings, sage-green cardigan) observes from the side, tablet in hand, reviewing the output. The rejected cards float away to the sides, slightly faded. Flat illustration, teal/emerald color palette (#047857 primary, #0d9488 secondary, #134e4a dark accents). Graphic novel style. Wide aspect ratio 16:9. Do not include any text, words, or labels in the image.", + "alt_text": "A funnel filters collections through rating, genre, and explicit exclusion layers — Jordan reviews the clean resolved list emerging at the bottom" + }, + { + "filename": "images/walkthrough/collection-gov-05-seller-matching.png", + "prompt": "Split scene. Left side: Jordan's governance agent holds a glowing collection list (represented as a compact card deck with an emerald glow). Right side: Priya (South Indian woman, late 30s, dark brown skin, black hair in a short asymmetric bob, rectangular dark-framed glasses, solid-color crew-neck top under a light jacket) sits at her desk at StreamHaus, receiving the collection list. Her screen shows her own collection inventory with matching indicators — some programs light up green (matched to the exclusion list), others remain neutral (not on the list). A subtle data stream connects the two sides. Both women are focused and professional. The matching happens automatically. Flat illustration, teal/emerald color palette (#047857 primary, #0d9488 secondary, #134e4a dark accents). Graphic novel style with clean panel borders. Wide aspect ratio 16:9. Do not include any text, words, or labels in the image.", + "alt_text": "Split scene — Jordan's governance agent sends the collection list to Priya at StreamHaus, whose inventory lights up showing matched exclusions" + } +] diff --git a/server/src/addie/mcp/adcp-tools.ts b/server/src/addie/mcp/adcp-tools.ts index 03423994ae..b420985664 100644 --- a/server/src/addie/mcp/adcp-tools.ts +++ b/server/src/addie/mcp/adcp-tools.ts @@ -1678,6 +1678,206 @@ export const ADCP_GOVERNANCE_PROPERTY_TOOLS: AddieTool[] = [ }, ]; +// ============================================ +// GOVERNANCE TOOLS - Collection Lists +// ============================================ + +export const ADCP_GOVERNANCE_COLLECTION_TOOLS: AddieTool[] = [ + { + name: 'create_collection_list', + description: + 'Create a collection list for program-level brand safety. Identifies shows, series, and other content programs to include or exclude, using distribution identifiers (IMDb, Gracenote, EIDR) for cross-publisher matching. Combines explicit program references with content rating and genre filters.', + usage_hints: + 'use when the user wants to create a do-not-air list, exclude specific programs from CTV campaigns, or set up collection-level brand safety rules', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The governance agent URL (must be HTTPS)', + }, + name: { + type: 'string', + description: 'Human-readable name for the list', + }, + description: { + type: 'string', + description: 'Description of the list purpose', + }, + base_collections: { + type: 'array', + description: 'Collection sources to evaluate (distribution_ids, publisher_collections, or publisher_genres)', + items: { + type: 'object', + properties: { + selection_type: { type: 'string', enum: ['distribution_ids', 'publisher_collections', 'publisher_genres'] }, + publisher_domain: { type: 'string', description: 'For publisher_collections/publisher_genres' }, + identifiers: { + type: 'array', + description: 'For distribution_ids — platform-independent identifiers', + items: { + type: 'object', + properties: { + type: { type: 'string', description: 'Distribution identifier type (imdb_id, gracenote_id, eidr_id, etc.)' }, + value: { type: 'string' }, + }, + }, + }, + collection_ids: { type: 'array', items: { type: 'string' }, description: 'For publisher_collections' }, + genres: { type: 'array', items: { type: 'string' }, description: 'For publisher_genres' }, + genre_taxonomy: { type: 'string', description: 'For publisher_genres — required' }, + }, + required: ['selection_type'], + }, + }, + filters: { + type: 'object', + description: 'Filters applied when resolving the list', + properties: { + content_ratings_exclude: { + type: 'array', + description: 'Exclude collections with any of these ratings', + items: { type: 'object', properties: { system: { type: 'string' }, rating: { type: 'string' } }, required: ['system', 'rating'] }, + }, + content_ratings_include: { + type: 'array', + description: 'Include only collections with these ratings', + items: { type: 'object', properties: { system: { type: 'string' }, rating: { type: 'string' } }, required: ['system', 'rating'] }, + }, + genres_exclude: { type: 'array', items: { type: 'string' }, description: 'Exclude collections with any of these genres' }, + genres_include: { type: 'array', items: { type: 'string' }, description: 'Include only collections with these genres' }, + genre_taxonomy: { type: 'string', description: 'Taxonomy for genre filter values' }, + kinds: { type: 'array', items: { type: 'string', enum: ['series', 'publication', 'event_series', 'rotation'] } }, + exclude_distribution_ids: { + type: 'array', + items: { type: 'object', properties: { type: { type: 'string' }, value: { type: 'string' } } }, + }, + production_quality: { type: 'array', items: { type: 'string', enum: ['professional', 'prosumer', 'ugc'] } }, + }, + }, + brand: { + type: 'object', + description: 'Brand reference — agent applies appropriate rules', + properties: { + domain: { type: 'string', description: "Brand domain" }, + brand_id: { type: 'string' }, + }, + required: ['domain'], + }, + debug: { + type: 'boolean', + description: 'Enable debug logging', + }, + }, + required: ['agent_url', 'name'], + }, + }, + { + name: 'update_collection_list', + description: + 'Modify an existing collection list. Can update filters, base collections, or webhook configuration.', + usage_hints: + 'use when the user wants to modify a collection list, change filters, or update program exclusions', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The governance agent URL (must be HTTPS)', + }, + list_id: { + type: 'string', + description: 'Collection list identifier to update', + }, + name: { type: 'string' }, + description: { type: 'string' }, + base_collections: { type: 'array', description: 'Replace base collection sources' }, + filters: { type: 'object', description: 'Replace filter configuration' }, + debug: { type: 'boolean' }, + }, + required: ['agent_url', 'list_id'], + }, + }, + { + name: 'get_collection_list', + description: + 'Retrieve a collection list with optional resolution of filters. Returns metadata or resolved collection entries with distribution identifiers.', + usage_hints: + 'use when the user wants to view a collection list, see what programs are included/excluded, or get the resolved list for campaign targeting', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The governance agent URL (must be HTTPS)', + }, + list_id: { + type: 'string', + description: 'Collection list identifier', + }, + resolve: { + type: 'boolean', + description: 'Whether to resolve filters and return collection entries (default: false)', + }, + max_results: { + type: 'number', + description: 'Maximum collections to return when resolved', + }, + debug: { type: 'boolean' }, + }, + required: ['agent_url', 'list_id'], + }, + }, + { + name: 'list_collection_lists', + description: + 'List all collection lists accessible to the authenticated principal.', + usage_hints: + 'use when the user wants to see all their collection lists, browse available lists, or search for specific lists', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The governance agent URL (must be HTTPS)', + }, + name_contains: { + type: 'string', + description: 'Filter by name substring', + }, + max_results: { + type: 'number', + description: 'Maximum results to return', + }, + debug: { type: 'boolean' }, + }, + required: ['agent_url'], + }, + }, + { + name: 'delete_collection_list', + description: + 'Delete a collection list.', + usage_hints: + 'use when the user wants to remove a collection list they no longer need', + input_schema: { + type: 'object', + properties: { + agent_url: { + type: 'string', + description: 'The governance agent URL (must be HTTPS)', + }, + list_id: { + type: 'string', + description: 'Collection list identifier to delete', + }, + debug: { type: 'boolean' }, + }, + required: ['agent_url', 'list_id'], + }, + }, +]; + // ============================================ // GOVERNANCE TOOLS - Content Standards // ============================================ @@ -2230,6 +2430,7 @@ export const ADCP_TOOLS: AddieTool[] = [ ...ADCP_CREATIVE_TOOLS, ...ADCP_SIGNALS_TOOLS, ...ADCP_GOVERNANCE_PROPERTY_TOOLS, + ...ADCP_GOVERNANCE_COLLECTION_TOOLS, ...ADCP_GOVERNANCE_CONTENT_TOOLS, ...ADCP_SI_TOOLS, ...ADCP_BRAND_PROTOCOL_TOOLS, diff --git a/server/src/addie/mcp/certification-tools.ts b/server/src/addie/mcp/certification-tools.ts index 5b67e9694d..7fa5e267c6 100644 --- a/server/src/addie/mcp/certification-tools.ts +++ b/server/src/addie/mcp/certification-tools.ts @@ -1148,6 +1148,8 @@ export const MODULE_RESOURCES: Record { label: 'Content standards', url: `${DOCS_BASE}/docs/governance/content-standards` }, { label: 'Shows and episodes — brand safety', url: `${DOCS_BASE}/docs/media-buy/product-discovery/collections-and-installments` }, { label: 'Property governance', url: `${DOCS_BASE}/docs/governance/property/index` }, + { label: 'Collection governance', url: `${DOCS_BASE}/docs/governance/collection/index` }, + { label: 'Collection list tasks', url: `${DOCS_BASE}/docs/governance/collection/tasks/collection_lists` }, { label: 'Campaign governance', url: `${DOCS_BASE}/docs/governance/campaign` }, { label: 'Campaign governance safety model', url: `${DOCS_BASE}/docs/governance/campaign/safety-model` }, { label: 'Campaign governance specification', url: `${DOCS_BASE}/docs/governance/campaign/specification` }, diff --git a/server/src/addie/tool-sets.ts b/server/src/addie/tool-sets.ts index 26a517ab52..1175a1628e 100644 --- a/server/src/addie/tool-sets.ts +++ b/server/src/addie/tool-sets.ts @@ -158,7 +158,7 @@ export const TOOL_SETS: Record = { adcp_operations: { name: 'adcp_operations', - description: 'Execute AdCP protocol operations - discover products, create/update media buys, manage creatives, work with signals, governance (property lists, content standards), sponsored intelligence (SI), and interact with sales/creative/signals/governance/si agents', + description: 'Execute AdCP protocol operations - discover products, create/update media buys, manage creatives, work with signals, governance (property lists, collection lists, content standards), sponsored intelligence (SI), and interact with sales/creative/signals/governance/si agents', tools: [ // Media Buy tools 'get_products', @@ -182,6 +182,12 @@ export const TOOL_SETS: Record = { 'get_property_list', 'list_property_lists', 'delete_property_list', + // Governance - Collection Lists + 'create_collection_list', + 'update_collection_list', + 'get_collection_list', + 'list_collection_lists', + 'delete_collection_list', // Governance - Content Standards 'create_content_standards', 'get_content_standards', diff --git a/server/src/training-agent/inventory-governance-handlers.ts b/server/src/training-agent/inventory-governance-handlers.ts new file mode 100644 index 0000000000..b027ec816f --- /dev/null +++ b/server/src/training-agent/inventory-governance-handlers.ts @@ -0,0 +1,238 @@ +/** + * Collection list handlers for the training agent. + * + * Implements collection list CRUD with in-memory session storage. + * Property list and content standards handlers are in property-handlers.ts + * and content-standards-handlers.ts respectively. + */ + +import { randomUUID } from 'node:crypto'; +import type { TrainingContext, ToolArgs, CollectionListState } from './types.js'; +import { getSession, sessionKeyFromArgs } from './state.js'; + +const MAX_ARRAY_INPUT = 100; + +// ── Tool definitions ───────────────────────────────────────────── + +export const COLLECTION_LIST_TOOLS = [ + { + name: 'create_collection_list', + description: 'Create a collection list for program-level brand safety. Uses distribution identifiers for cross-publisher matching.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + execution: { taskSupport: 'optional' as const }, + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + base_collections: { type: 'array' }, + filters: { type: 'object' }, + brand: { type: 'object', properties: { domain: { type: 'string' } } }, + }, + required: ['name'], + }, + }, + { + name: 'get_collection_list', + description: 'Retrieve a collection list with optional resolution.', + annotations: { readOnlyHint: true, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: { + type: 'object' as const, + properties: { + list_id: { type: 'string' }, + resolve: { type: 'boolean' }, + }, + required: ['list_id'], + }, + }, + { + name: 'update_collection_list', + description: 'Modify an existing collection list.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: { + type: 'object' as const, + properties: { + list_id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + base_collections: { type: 'array' }, + filters: { type: 'object' }, + webhook_url: { type: 'string' }, + }, + required: ['list_id'], + }, + }, + { + name: 'list_collection_lists', + description: 'List collection lists for the authenticated principal.', + annotations: { readOnlyHint: true, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: { + type: 'object' as const, + properties: { + name_contains: { type: 'string' }, + }, + }, + }, + { + name: 'delete_collection_list', + description: 'Delete a collection list. Cannot be undone.', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: { + type: 'object' as const, + properties: { + list_id: { type: 'string' }, + }, + required: ['list_id'], + }, + }, +]; + +// ── Input types ────────────────────────────────────────────────── + +interface CreateCollectionListInput extends ToolArgs { + name: string; + description?: string; + base_collections?: unknown[]; + filters?: Record; + brand?: { domain: string }; +} + +interface GetCollectionListInput extends ToolArgs { + list_id: string; + resolve?: boolean; +} + +interface UpdateCollectionListInput extends ToolArgs { + list_id: string; + name?: string; + description?: string; + base_collections?: unknown[]; + filters?: Record; + webhook_url?: string; +} + +interface ListInput extends ToolArgs { + name_contains?: string; +} + +interface DeleteInput extends ToolArgs { + list_id: string; +} + +// ── Handlers ───────────────────────────────────────────────────── + +export function handleCreateCollectionList(args: ToolArgs, ctx: TrainingContext) { + const session = getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId)); + const input = args as CreateCollectionListInput; + + if (!input.name) { + return { errors: [{ code: 'VALIDATION_ERROR', message: 'name is required' }] }; + } + + const now = new Date().toISOString(); + const listId = `cl_${randomUUID().slice(0, 8)}`; + const baseColls = (input.base_collections ?? []).slice(0, MAX_ARRAY_INPUT); + const state: CollectionListState = { + list_id: listId, + name: input.name, + description: input.description, + base_collections: baseColls, + filters: input.filters, + brand: input.brand, + collection_count: baseColls.length ? countSources(baseColls) : 0, + created_at: now, + updated_at: now, + }; + + session.collectionLists.set(listId, state); + + return { + list: state, + auth_token: `tok_${randomUUID().slice(0, 16)}`, + }; +} + +export function handleGetCollectionList(args: ToolArgs, ctx: TrainingContext) { + const session = getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId)); + const input = args as GetCollectionListInput; + + const list = session.collectionLists.get(input.list_id); + if (!list) return { errors: [{ code: 'NOT_FOUND', message: `Collection list ${input.list_id} not found` }] }; + + const now = new Date().toISOString(); + return { + list, + collections: input.resolve ? generateSampleCollections(list.collection_count) : undefined, + resolved_at: input.resolve ? now : undefined, + cache_valid_until: input.resolve ? new Date(Date.now() + 168 * 60 * 60 * 1000).toISOString() : undefined, + }; +} + +export function handleUpdateCollectionList(args: ToolArgs, ctx: TrainingContext) { + const session = getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId)); + const input = args as UpdateCollectionListInput; + + const list = session.collectionLists.get(input.list_id); + if (!list) return { errors: [{ code: 'NOT_FOUND', message: `Collection list ${input.list_id} not found` }] }; + + if (input.name !== undefined) list.name = input.name; + if (input.description !== undefined) list.description = input.description; + if (input.base_collections !== undefined) { + const clamped = input.base_collections.slice(0, MAX_ARRAY_INPUT); + list.base_collections = clamped; + list.collection_count = countSources(clamped); + } + if (input.filters !== undefined) list.filters = input.filters; + if (input.webhook_url !== undefined) list.webhook_url = input.webhook_url === '' ? undefined : input.webhook_url; + list.updated_at = new Date().toISOString(); + + return { list }; +} + +export function handleListCollectionLists(args: ToolArgs, ctx: TrainingContext) { + const session = getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId)); + const input = args as ListInput; + const lists = Array.from(session.collectionLists.values()); + const filtered = input.name_contains + ? lists.filter(l => l.name.toLowerCase().includes(input.name_contains!.toLowerCase())) + : lists; + return { lists: filtered, pagination: { has_more: false } }; +} + +export function handleDeleteCollectionList(args: ToolArgs, ctx: TrainingContext) { + const session = getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId)); + const input = args as DeleteInput; + const deleted = session.collectionLists.delete(input.list_id); + return { deleted, list_id: input.list_id }; +} + +// ── Helpers ────────────────────────────────────────────────────── + +function countSources(sources: unknown[]): number { + let count = 0; + for (const source of sources) { + const s = source as Record; + if (Array.isArray(s.identifiers)) count += s.identifiers.length; + else if (Array.isArray(s.collection_ids)) count += s.collection_ids.length; + else if (Array.isArray(s.genres)) count += 5; + else count += 1; + } + return count; +} + +function generateSampleCollections(count: number) { + const n = Math.min(count || 5, 20); + const genres = ['drama', 'comedy', 'news', 'sports', 'documentary', 'reality', 'animation']; + const ratings = ['TV-G', 'TV-PG', 'TV-14', 'TV-MA']; + return Array.from({ length: n }, (_, i) => ({ + name: `Program ${i + 1}`, + distribution_ids: [{ type: 'imdb_id', value: `tt${String(9999900 + i).padStart(7, '0')}` }], + content_rating: { system: 'tv_parental', rating: ratings[i % ratings.length] }, + genre: [genres[i % genres.length]], + kind: 'series', + })); +} diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index dad7fe3e54..35498e31a0 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -33,6 +33,7 @@ function createSession(): SessionState { governanceChecks: new Map(), governanceOutcomes: new Map(), propertyLists: new Map(), + collectionLists: new Map(), contentStandards: new Map(), creatives: new Map(), signalActivations: new Map(), diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8f03525de1..28f0dc2c9d 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -147,6 +147,14 @@ import { handleSyncAccounts, handleSyncGovernance, } from './account-handlers.js'; +import { + COLLECTION_LIST_TOOLS, + handleCreateCollectionList, + handleGetCollectionList, + handleUpdateCollectionList, + handleListCollectionLists, + handleDeleteCollectionList, +} from './inventory-governance-handlers.js'; import { CATALOG_EVENT_TOOLS, handleSyncCatalogs, @@ -703,9 +711,10 @@ const TOOLS = [ ...ACCOUNT_TOOLS, ...CATALOG_EVENT_TOOLS, ...GOVERNANCE_TOOLS, - ...BRAND_TOOLS, ...PROPERTY_TOOLS, + ...COLLECTION_LIST_TOOLS, ...CONTENT_STANDARDS_TOOLS, + ...BRAND_TOOLS, COMPLY_TEST_CONTROLLER_TOOL, { name: 'report_usage', @@ -2730,6 +2739,11 @@ const HANDLER_MAP: Record = { update_property_list: handleUpdatePropertyList, delete_property_list: handleDeletePropertyList, validate_property_delivery: handleValidatePropertyDelivery, + create_collection_list: handleCreateCollectionList, + get_collection_list: handleGetCollectionList, + update_collection_list: handleUpdateCollectionList, + list_collection_lists: handleListCollectionLists, + delete_collection_list: handleDeleteCollectionList, create_content_standards: handleCreateContentStandards, list_content_standards: handleListContentStandards, get_content_standards: handleGetContentStandards, diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 70afa9b745..f517c29e6a 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -154,6 +154,7 @@ export interface SessionState { governanceChecks: Map; governanceOutcomes: Map; propertyLists: Map; + collectionLists: Map; contentStandards: Map; usageRecords: UsageRecord[]; lastGetProductsContext?: { @@ -164,6 +165,19 @@ export interface SessionState { lastAccessedAt: Date; } +export interface CollectionListState { + list_id: string; + name: string; + description?: string; + base_collections?: unknown[]; + filters?: Record; + brand?: { domain: string }; + webhook_url?: string; + collection_count: number; + created_at: string; + updated_at: string; +} + export interface SignalActivationState { signalAgentSegmentId: string; destinationType: 'platform' | 'agent'; diff --git a/specs/collection-lists.md b/specs/collection-lists.md new file mode 100644 index 0000000000..a3e9039853 --- /dev/null +++ b/specs/collection-lists.md @@ -0,0 +1,574 @@ +# Collection Lists + +**Status**: Draft + +## Problem + +A CTV brand safety "do not air" list contains three kinds of exclusions: + +1. **Properties** — apps and platforms to avoid entirely. Property lists handle this today. +2. **Programs** — specific shows to avoid regardless of platform. No mechanism exists for this. +3. **Content categories** — genres and ratings to avoid (TV-MA, News, Kids). These are show-level attributes, but the only current home is content standards, which operates at the per-artifact adjacency level. + +The gap is #2 and the structural part of #3. A buyer needs to say "exclude these programs across all sellers" and "exclude all TV-MA collections" as reusable, setup-time artifacts — the same way property lists work for properties. + +Collections already exist in AdCP (`collection.json`): they have genre, content_rating, distribution identifiers (imdb_id, gracenote_id), and are declared in `adagents.json`. But there's no list construct for them, no way to reference them in targeting, and no registry identity for cross-publisher matching. + +## Core Insight + +Programs are independent of properties. A hit comedy series airs on multiple streaming platforms, free ad-supported services, and cable syndication simultaneously. Excluding it means excluding a single logical entity distributed across many sellers and properties. This is fundamentally different from property lists, which operate on technical surfaces. + +Collection lists are to programs what property lists are to properties: a managed, cacheable, reusable artifact that expresses "these collections, filtered by these criteria." + +## Design Principles + +1. **Collections are independent of properties.** A collection list doesn't care where a program airs — it identifies the program itself. The seller resolves which of their inventory matches. +2. **Rating and genre are collection attributes, not content adjacency.** "Exclude TV-MA" is a show-level filter applied at setup time. "Exclude articles about violence adjacent to this ad" is content standards. Different abstraction levels, different evaluation times. +3. **Distribution identifiers are the cross-publisher key.** IMDb IDs, Gracenote IDs, and EIDR IDs already solve the "same show, different platforms" problem. Collection lists use them. +4. **Setup-time, not bid-time.** Like property lists, collection lists are resolved once, cached by sellers, and used in delivery decisions without runtime calls. +5. **Governance agents manage collection lists.** The same agent that manages a brand's property list manages their collection list. Both are brand safety artifacts. + +## Architecture + +### Three Layers of Brand Safety + +| Layer | Construct | Abstraction | Evaluation Time | Example | +|-------|-----------|-------------|-----------------|---------| +| Property | Property list | Where ads run (apps, sites) | Setup | "Not on this news app" | +| Collection | Collection list | What content ads run in/around (shows, series) | Setup | "Not in this crime drama" | +| Content | Content standards | What specific content is adjacent | Per-impression | "Not next to kids content, but G/PG animation OK" | + +These three layers compose. A media buy can reference all three: +- Property list: approved CTV apps +- Collection list: excluded programs +- Content standards: contextual adjacency rules + +### Relationship to Content Standards + +Content standards evaluate individual artifacts (articles, episodes, pages) against a natural language policy with calibration exemplars. They handle nuance: "ALWAYS EXCLUDE Kids programming" + "Animation/cartoons rated G or PG are permitted" is a contradiction that requires contextual judgment. Content standards can resolve it because they evaluate each piece of content individually. + +Collection lists don't evaluate content. They filter by declared metadata — genre tags, content ratings, distribution identifiers. "Exclude all collections rated TV-MA" is a structural filter. "Exclude kids content except G/PG animation" is a content standards policy. + +The boundary: if it can be decided from the collection's declared metadata alone, it's a collection list filter. If it requires evaluating actual content, it's content standards. + +## The Collection List Object + +```json +{ + "list_id": "cl_novamotors_ctv_dna_2026", + "name": "Nova Motors CTV Do Not Air — 2026", + "description": "Programs excluded from Nova Motors CTV advertising per Pinnacle brand safety guidelines", + "principal": "ops@pinnacleagency.com", + + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "gracenote_id", "value": "SH000001" } + ] + }, + { + "selection_type": "publisher_collections", + "publisher_domain": "titanstreaming.com", + "collection_ids": ["danger_zone", "wild_nights"] + } + ], + + "filters": { + "content_ratings_exclude": [ + { "system": "tv_parental", "rating": "TV-MA" } + ], + "genres_exclude": ["news", "kids"], + "genre_taxonomy": "iab_content_3.0", + "kinds": ["series"] + }, + + "brand": { "domain": "novamotors.com" }, + "cache_duration_hours": 168, + "created_at": "2026-04-07T12:00:00Z", + "updated_at": "2026-04-07T12:00:00Z", + "collection_count": 247 +} +``` + +### Base Collection Sources + +Like property lists, collection lists start with a base set and apply filters. Three selection patterns, using the same discriminated union approach: + +**distribution_ids** — Select collections by platform-independent identifiers. The primary mechanism for cross-publisher exclusion — an IMDb ID identifies a program regardless of which CTV platform carries it. + +```json +{ + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "gracenote_id", "value": "SH000001" }, + { "type": "eidr_id", "value": "10.5240/XXXX-XXXX-XXXX-XXXX-XXXX-C" } + ] +} +``` + +**Gracenote ID guidance:** Use root-level IDs for collection lists — SH-prefixed for series, MV-prefixed for movies, SP-prefixed for sports programs. Do not use EP-prefixed episode IDs in collection lists; episode-level exclusion is a content standards concern. + +**publisher_collections** — Select specific collections within a publisher's `adagents.json`. Equivalent to `publisher_ids` on property lists. Use when the publisher's internal collection IDs are known. + +```json +{ + "selection_type": "publisher_collections", + "publisher_domain": "titanstreaming.com", + "collection_ids": ["danger_zone", "wild_nights"] +} +``` + +**publisher_genres** — Select all collections from a publisher matching genre/rating criteria. Use when excluding entire content categories from a specific publisher. + +```json +{ + "selection_type": "publisher_genres", + "publisher_domain": "streamhaus.com", + "genres": ["news"], + "genre_taxonomy": "iab_content_3.0" +} +``` + +When `base_collections` is omitted, the list applies filters against the seller's entire collection inventory. + +### Collection List Filters + +Filters narrow the resolved list. Applied after base collection selection. + +| Filter | Type | Logic | Description | +|--------|------|-------|-------------| +| `content_ratings_exclude` | ContentRating[] | OR (exclude any match) | Exclude collections with any of these ratings | +| `content_ratings_include` | ContentRating[] | OR (include any match) | Include only collections with these ratings | +| `genres_exclude` | string[] | OR (exclude any match) | Exclude collections tagged with any of these genres | +| `genres_include` | string[] | OR (include any match) | Include only collections with any of these genres | +| `genre_taxonomy` | string | — | Taxonomy for genre filter values (e.g., `iab_content_3.0`) | +| `kinds` | string[] | OR | Filter to collection kinds (series, publication, event_series, rotation) | +| `exclude_distribution_ids` | DistributionIdentifier[] | OR | Always exclude collections with these distribution IDs | +| `production_quality` | string[] | OR | Filter by production quality (professional, prosumer, ugc) | + +**Include vs. exclude semantics:** Include filters are allowlists — only matching collections pass. Exclude filters are blocklists — matching collections are removed. When both are present for the same dimension (e.g., `genres_include` and `genres_exclude`), include is applied first, then exclude narrows further. + +**Filter interaction example:** A list with `genres_include: ["drama", "comedy"]` and `genres_exclude: ["crime"]` first includes only drama and comedy collections, then removes any tagged as crime. A collection tagged `["drama", "crime"]` would be excluded — the exclude filter wins over the include. A collection tagged `["comedy"]` passes. A collection tagged `["sports"]` is excluded by the include filter (not in the allowed set). + +**Content rating filters vs. content standards:** `content_ratings_exclude: [{ system: "tv_parental", rating: "TV-MA" }]` excludes all collections declared as TV-MA. This is a metadata filter on the collection's `content_rating` field. It doesn't evaluate episode content — an individual episode rated differently than the series baseline is the content standards layer's job. + +### Resolved List Format + +When a collection list is fetched, the response includes resolved collection entries: + +```json +{ + "list_id": "cl_novamotors_ctv_dna_2026", + "name": "Nova Motors CTV Do Not Air — 2026", + "resolved_at": "2026-04-07T14:00:00Z", + "cache_valid_until": "2026-04-14T14:00:00Z", + "collection_count": 247, + "collections": [ + { + "collection_rid": "019abc12-...", + "name": "Danger Zone", + "distribution_ids": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "gracenote_id", "value": "SH000001" } + ], + "content_rating": { "system": "tv_parental", "rating": "TV-MA" }, + "genre": ["comedy", "animation"], + "kind": "series" + } + ], + "cursor": null +} +``` + +Resolved entries use `collection_rid` (from the registry) as the stable identifier. Sellers match incoming collection lists against their own collections using distribution identifiers and/or collection_rids. + +## Collection Registry + +### The Problem + +Collections today are declared per-publisher in `adagents.json`. The same program on two different streaming platforms is two independent `collection_id` strings with no guaranteed relationship. Distribution identifiers (imdb_id, gracenote_id) solve identity for known programs, but there's no shared namespace. + +The property registry assigns `property_rid`s to properties for cross-publisher matching. Collections need the same: a `collection_rid` that means "this program" regardless of where it airs. + +**Terminology note:** The collection registry is part of the broader AdCP registry infrastructure alongside the property registry. It is distinct from `sync_catalogs`, which is a seller-side task for syncing product catalogs (store feeds, SKU data) into a media buy. The collection registry is a shared namespace for content programs; `sync_catalogs` is a per-seller data pipeline for commerce inventory. + +### Extension to the Property Registry + +The property registry's fact-graph model extends naturally to collections: + +- **A collection is a content entity** with distribution identifiers (imdb_id, gracenote_id, eidr_id) as its primary keys +- **Multiple publishers distribute the same collection** — each publisher's `adagents.json` declares their local `collection_id`, linked to the canonical `collection_rid` via distribution identifiers +- **The registry resolves distribution identifiers to collection_rids** the same way it resolves domain/bundle identifiers to property_rids + +### New Tables + +```sql +CREATE TABLE registry_collections ( + collection_rid UUID PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT CHECK (kind IN ('series', 'publication', 'event_series', 'rotation')), + genre TEXT[], + genre_taxonomy TEXT, + content_rating_system TEXT, + content_rating TEXT, + language TEXT, + status TEXT DEFAULT 'active' CHECK (status IN ('active', 'hiatus', 'ended', 'upcoming')), + source TEXT NOT NULL CHECK (source IN ('authoritative', 'enriched', 'contributed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +```sql +CREATE TABLE registry_collection_identifiers ( + id UUID PRIMARY KEY, + collection_rid UUID NOT NULL REFERENCES registry_collections(collection_rid), + identifier_type TEXT NOT NULL, + identifier_value TEXT NOT NULL, + evidence TEXT NOT NULL, + confidence TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(identifier_type, identifier_value) +); + +CREATE INDEX idx_coll_identifiers_rid ON registry_collection_identifiers(collection_rid); +``` + +```sql +CREATE TABLE registry_collection_distributions ( + id UUID PRIMARY KEY, + collection_rid UUID NOT NULL REFERENCES registry_collections(collection_rid), + property_rid UUID NOT NULL REFERENCES catalog_properties(property_rid), + publisher_collection_id TEXT, + evidence TEXT NOT NULL, + confidence TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(collection_rid, property_rid) +); + +CREATE INDEX idx_coll_dist_property ON registry_collection_distributions(property_rid); +``` + +The `registry_collection_distributions` table links collections to the properties they're distributed on — "Danger Zone (collection_rid X) is available on Titan Streaming (property_rid Y)." This is the join table that lets sellers match a buyer's collection list against their own inventory. + +### Resolve Endpoint + +``` +POST /api/registry/collections/resolve +``` + +```json +{ + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "gracenote_id", "value": "SH000001" } + ], + "mode": "resolve" +} +``` + +Response: + +```json +{ + "resolved": [ + { + "identifier": { "type": "imdb_id", "value": "tt9999901" }, + "collection_rid": "019abc12-...", + "name": "Danger Zone", + "status": "existing" + } + ] +} +``` + +### Fact Sources for Collections + +| Source | What It Discovers | Confidence | +|--------|-------------------|------------| +| `adagents.json` crawler | Publisher-declared collections with distribution IDs | Authoritative | +| App store metadata | Show/series metadata within CTV app listings | Strong | +| IMDb/Gracenote/EIDR | Canonical program metadata | Authoritative (for identity) | +| Member resolve calls | "This IMDb ID is a program we need to target" | Medium | +| Addie enrichment | Genre, rating, status analysis | Medium | + +The `adagents.json` crawler is the richest source — publishers who declare collections with `distribution` entries automatically populate the registry with cross-publisher identity. + +## Targeting Integration + +### Collection List Reference + +Parallel to `property-list-ref.json`: + +```json +{ + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "cl_novamotors_ctv_dna_2026", + "auth_token": "eyJ..." +} +``` + +### In Targeting Overlays + +Two fields in `targeting.json` support both inclusion and exclusion: + +```json +{ + "property_list": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "pl_novamotors_approved_ctv" + }, + "collection_list_exclude": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "cl_novamotors_dna_2026" + } +} +``` + +| Field | Semantics | Use case | +|---|---|---| +| `collection_list` | Inclusion — only run in these collections | "Only buy pre-roll on these three shows" | +| `collection_list_exclude` | Exclusion — never run in these collections | Brand safety do-not-air lists | + +A media buy can reference both simultaneously: "run in these approved shows, but never in these specific programs even if they appear on the approved list." The exclude list always wins when there's overlap. + +**Why collection lists have both include and exclude in targeting but property lists only have include:** Property lists were designed before the include/exclude pattern was established across other targeting dimensions (geo, audience, device). Collection lists follow the current pattern. A future protocol evolution may add `property_list_exclude` for symmetry. + +For product-level collection targeting (seller declares which collections are available), use `collection_targeting_allowed: true` on the product with collection selectors — this is the existing product-level mechanism. + +### Seller Resolution Flow + +When a seller receives a media buy with a `collection_list` reference: + +1. Fetch and cache the collection list from the governance agent +2. Match list entries against their own collection inventory: + - By `collection_rid` (if both sides use the registry) + - By distribution identifiers (imdb_id, gracenote_id) as fallback + - By `publisher_collections` entries matching their domain +3. Exclude matched collections from delivery +4. Apply content rating and genre filters from the list against their collection metadata +5. Report unmatched entries (programs the seller doesn't carry) in the response + +### Validation + +Sellers SHOULD validate collection lists at buy creation time and report: +- **matched_count**: How many list entries match their inventory +- **unmatched_entries**: List entries they can't resolve (unknown programs) +- **filtered_count**: How many additional collections excluded by rating/genre filters + +## Operations + +Collection list CRUD follows the same pattern as property lists: + +| Task | Description | +|------|-------------| +| `create_collection_list` | Create a new collection list | +| `get_collection_list` | Fetch a collection list with resolved entries | +| `update_collection_list` | Modify base collections or filters | +| `list_collection_lists` | List collection lists for a principal | +| `delete_collection_list` | Remove a collection list | + +## Buyer Agent Walkthrough + +When an agency sends a CTV "do not air" list, the buyer agent should: + +1. **Parse the list** into three buckets: properties, programs, content categories +2. **Resolve program identifiers** — look up IMDb/Gracenote IDs for named programs via the collection registry resolve endpoint +3. **Create a collection list** with resolved distribution identifiers + rating/genre filters +4. **Create or update the property list** with excluded apps +5. **Create or update content standards** for nuanced rules (e.g., the kids/animation boundary) +6. **Report back** with the mapping: which programs resolved, which need manual confirmation (programs where the distribution identifier wasn't found in the registry) + +The collection registry makes step 2 possible at scale. Without it, the buyer agent has to search each seller's `adagents.json` individually to find collection IDs for a given program. + +### Three-Layer Example + +A beverage brand's CTV brand safety configuration: + +**Property list** — excluded apps: + +```json +{ + "tool": "create_property_list", + "arguments": { + "name": "Nova Motors CTV Excluded Apps", + "base_properties": [ + { + "selection_type": "identifiers", + "identifiers": [ + { "type": "domain", "value": "crimenow.example.com" }, + { "type": "domain", "value": "latenighttv.example.com" } + ] + } + ], + "brand": { "domain": "novamotors.com" } + } +} +``` + +**Collection list** — excluded programs + structural filters: + +```json +{ + "tool": "create_collection_list", + "arguments": { + "name": "Nova Motors CTV Do Not Air — Programs", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "imdb_id", "value": "tt9999901" }, + { "type": "imdb_id", "value": "tt9999902" }, + { "type": "imdb_id", "value": "tt9999903" } + ] + } + ], + "filters": { + "content_ratings_exclude": [ + { "system": "tv_parental", "rating": "TV-MA" } + ], + "genres_exclude": ["news"] + }, + "brand": { "domain": "novamotors.com" } + } +} +``` + +**Content standards** — nuanced contextual rules: + +```json +{ + "tool": "create_content_standards", + "arguments": { + "name": "Nova Motors Content Standards — CTV", + "channels_any": ["video"], + "policy": "ALWAYS EXCLUDE: Kids and children's programming of any kind. EXCEPTION: Animation and cartoons rated G or PG by MPAA or TV-G/TV-PG by TV Parental Guidelines are PERMITTED when the content is not primarily targeted at children under 6. Evaluate the specific episode content, not just the series classification.", + "calibration_exemplars": [ + { + "artifact_url": "https://example.com/toddler-adventure-s3e5", + "expected_result": "fail", + "reason": "Children's programming targeted at preschoolers" + }, + { + "artifact_url": "https://example.com/animated-satire-s15e3", + "expected_result": "pass", + "reason": "Animation rated TV-PG, targets adult audience" + } + ] + } +} +``` + +**Applied together in a media buy:** + +```json +{ + "targeting": { + "property_list": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "pl_novamotors_approved_ctv" + }, + "collection_list_exclude": { + "agent_url": "https://governance.pinnacleagency.com", + "list_id": "cl_novamotors_dna_2026" + } + } +} +``` + +Content standards are applied separately at the content evaluation layer — they don't need to be in the targeting overlay because they operate per-impression, not per-package. + +**Composition is optional.** Most buyers will use one or two of the three layers, not all three. A buyer who only needs to exclude specific programs can use a collection list alone. A buyer who only cares about contextual adjacency can use content standards alone. The three-layer model is a composition framework, not a requirement. + +## CTV Partner Mapping Patterns + +CTV partners support different levels of exclusion granularity. Collection lists accommodate all of them: + +- **Partners with program-level control** — Explicit collection entries via `distribution_ids` or `publisher_collections` map directly. The seller matches individual programs. +- **Partners with genre/category-level control** — Collection list genre and content rating filters map directly. The seller applies filters against their collection metadata. +- **Partners with app-level control only** — Property list entries handle these. Collection list entries for programs on these platforms are informational — the seller acknowledges them but may only be able to enforce at the app level. + +For partners that support both levels, the collection list provides the complete picture: explicit program exclusions plus structural genre/rating filters that catch programs not individually listed. + +## Live Sports Example + +Live sports is the largest CTV brand safety concern — a car brand not wanting to appear during a controversial halftime show, or an alcohol brand needing to avoid youth-targeted sports programming. Collection lists handle live sports through the `event_series` kind: + +```json +{ + "tool": "create_collection_list", + "arguments": { + "name": "Acme Outdoor — Excluded Sports Events", + "base_collections": [ + { + "selection_type": "distribution_ids", + "identifiers": [ + { "type": "gracenote_id", "value": "SP000001" }, + { "type": "gracenote_id", "value": "SP000002" } + ] + } + ], + "filters": { + "kinds": ["event_series"], + "genres_exclude": ["combat_sports"], + "genre_taxonomy": "gracenote" + }, + "brand": { "domain": "acmeoutdoor.com" } + } +} +``` + +This excludes specific sports programs by Gracenote ID (SP-prefixed for sports programs) and structurally excludes all combat sports event series. The `event_series` kind filter ensures the list only targets live event programming, not documentary series about sports. + +Live sports collections have the same distribution identifier structure as scripted content — a championship league is identifiable by Gracenote or EIDR ID regardless of which streaming platform carries it. + +## Schema Changes Summary + +### New Schemas + +| Schema | Location | Description | +|--------|----------|-------------| +| `collection-list.json` | `static/schemas/source/collection/` | Collection list object | +| `base-collection-source.json` | `static/schemas/source/collection/` | Discriminated union for collection sources | +| `collection-list-filters.json` | `static/schemas/source/collection/` | Filters for collection lists | +| `collection-list-ref.json` | `static/schemas/source/core/` | Reference to external collection list | +| `create-collection-list-request.json` | `static/schemas/source/collection/` | Create request | +| `create-collection-list-response.json` | `static/schemas/source/collection/` | Create response | +| `get-collection-list-request.json` | `static/schemas/source/collection/` | Get request | +| `get-collection-list-response.json` | `static/schemas/source/collection/` | Get response | +| `update-collection-list-request.json` | `static/schemas/source/collection/` | Update request | +| `update-collection-list-response.json` | `static/schemas/source/collection/` | Update response | +| `list-collection-lists-request.json` | `static/schemas/source/collection/` | List request | +| `list-collection-lists-response.json` | `static/schemas/source/collection/` | List response | +| `delete-collection-list-request.json` | `static/schemas/source/collection/` | Delete request | +| `delete-collection-list-response.json` | `static/schemas/source/collection/` | Delete response | + +### Modified Schemas + +| Schema | Change | +|--------|--------| +| `targeting.json` | Add `collection_list` (inclusion) and `collection_list_exclude` (exclusion) fields | +| `content-rating-system.json` | Add `chvrs` (Canada), `csa` (France), `pegi` (pan-European gaming) | + +## Resolved Questions + +1. **Include vs. exclude semantics in targeting.** Both supported. `collection_list` for inclusion, `collection_list_exclude` for exclusion. Follows the paired include/exclude pattern used by geo, audience, and device targeting fields. + +2. **Genre taxonomy standardization.** Normalized via the `genre-taxonomy` enum: `iab_content_3.0`, `iab_content_2.2`, `gracenote`, `eidr`, `apple_genres`, `google_genres`, `roku`, `amazon_genres`, `custom`. The `publisher_genres` base collection source requires `genre_taxonomy` so sellers can interpret genre strings unambiguously. + +3. **Rating system coverage.** Multiple systems supported via content-rating.json (system + rating). Added `chvrs` (Canada), `csa` (France), `pegi` (pan-European gaming) for international coverage alongside existing US, UK, Germany, Australia systems. + +4. **Partial matches.** Coverage gaps approach from property lists replicated. The `get_collection_list` response includes a `coverage_gaps` field mapping dimension names to distribution identifiers for collections included despite missing metadata. + +5. **Collection list webhooks.** Supported via `webhook_url` on the collection list and the `collection_list_changed` webhook schema. + +## Open Questions + +1. **Multiple lists per targeting field.** The current schema allows one `collection_list` and one `collection_list_exclude` per targeting overlay. In practice, governance is layered (brand list + agency list + trading desk list). A future evolution may accept arrays of refs. + +2. **Collection registry bootstrap.** The collection registry (collection_rid for cross-publisher identity) is designed but not implemented. Entertainment metadata services (Gracenote, EIDR) are the natural seed sources. See `specs/collection-registry.md` (planned) for the full design. + +3. **OpenRTB content signal alignment.** IAB Tech Lab's CTV Addressability Working Group is developing program-level signal specifications. When standardized, collection lists should adopt their identifier scheme. The `genre_taxonomy` enum descriptions include OpenRTB `cattax` integer mappings for the IAB taxonomies. diff --git a/static/schemas/source/collection/base-collection-source.json b/static/schemas/source/collection/base-collection-source.json new file mode 100644 index 0000000000..65014ba7fb --- /dev/null +++ b/static/schemas/source/collection/base-collection-source.json @@ -0,0 +1,103 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/base-collection-source.json", + "title": "Base Collection Source", + "description": "A source of collections for a collection list. Supports three selection patterns: distribution identifiers (cross-publisher), publisher-specific collection IDs, or publisher-specific genres.", + "discriminator": { + "propertyName": "selection_type" + }, + "oneOf": [ + { + "type": "object", + "title": "Distribution IDs Source", + "description": "Select collections by platform-independent distribution identifiers. The primary mechanism for cross-publisher collection matching.", + "properties": { + "selection_type": { + "type": "string", + "const": "distribution_ids", + "description": "Discriminator indicating selection by platform-independent distribution identifiers" + }, + "identifiers": { + "type": "array", + "description": "Platform-independent identifiers (imdb_id, gracenote_id, eidr_id, etc.). Each identifier uniquely identifies a collection across all publishers.", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/distribution-identifier-type.json", + "description": "Type of distribution identifier" + }, + "value": { + "type": "string", + "description": "The identifier value" + } + }, + "required": ["type", "value"], + "additionalProperties": false + }, + "minItems": 1 + } + }, + "required": ["selection_type", "identifiers"], + "additionalProperties": false + }, + { + "type": "object", + "title": "Publisher Collections Source", + "description": "Select specific collections within a publisher's adagents.json by collection ID", + "properties": { + "selection_type": { + "type": "string", + "const": "publisher_collections", + "description": "Discriminator indicating selection by specific collection IDs within a publisher" + }, + "publisher_domain": { + "type": "string", + "description": "Domain where publisher's adagents.json is hosted", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "collection_ids": { + "type": "array", + "description": "Specific collection IDs from the publisher's adagents.json", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": ["selection_type", "publisher_domain", "collection_ids"], + "additionalProperties": false + }, + { + "type": "object", + "title": "Publisher Genres Source", + "description": "Select all collections from a publisher matching genre criteria. Use when excluding entire content categories from a specific publisher.", + "properties": { + "selection_type": { + "type": "string", + "const": "publisher_genres", + "description": "Discriminator indicating selection by genre within a publisher" + }, + "publisher_domain": { + "type": "string", + "description": "Domain where publisher's adagents.json is hosted", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "genres": { + "type": "array", + "description": "Genre values to match against the publisher's collections", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "genre_taxonomy": { + "$ref": "/schemas/enums/genre-taxonomy.json", + "description": "Taxonomy for the genre values. Required so sellers can interpret genre strings unambiguously. Use 'custom' for free-form values negotiated out of band." + } + }, + "required": ["selection_type", "publisher_domain", "genres", "genre_taxonomy"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/collection/collection-list-changed-webhook.json b/static/schemas/source/collection/collection-list-changed-webhook.json new file mode 100644 index 0000000000..4c0c8e52ae --- /dev/null +++ b/static/schemas/source/collection/collection-list-changed-webhook.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/collection-list-changed-webhook.json", + "title": "Collection List Changed Webhook", + "description": "Webhook notification sent when a collection list's resolved collections change. Contains a summary only — recipients must call get_collection_list to retrieve the updated collections.", + "type": "object", + "properties": { + "event": { + "type": "string", + "const": "collection_list_changed", + "description": "The event type" + }, + "list_id": { + "type": "string", + "description": "ID of the collection list that changed" + }, + "list_name": { + "type": "string", + "description": "Name of the collection list" + }, + "change_summary": { + "type": "object", + "description": "Summary of changes to the resolved list", + "properties": { + "collections_added": { + "type": "integer", + "description": "Number of collections added since last resolution" + }, + "collections_removed": { + "type": "integer", + "description": "Number of collections removed since last resolution" + }, + "total_collections": { + "type": "integer", + "description": "Total collections in the resolved list" + } + }, + "additionalProperties": false + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "description": "When the list was re-resolved" + }, + "cache_valid_until": { + "type": "string", + "format": "date-time", + "description": "When the consumer should refresh from the governance agent" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature of the webhook payload, signed with the agent's private key. Recipients MUST verify this signature." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["event", "list_id", "resolved_at", "signature"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/collection-list-filters.json b/static/schemas/source/collection/collection-list-filters.json new file mode 100644 index 0000000000..6a202447ac --- /dev/null +++ b/static/schemas/source/collection/collection-list-filters.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/collection-list-filters.json", + "title": "Collection List Filters", + "description": "Filters that dynamically modify a collection list when resolved. Include filters are allowlists (only matching collections pass). Exclude filters are blocklists (matching collections are removed). When both are present for the same dimension, include is applied first, then exclude narrows further.", + "type": "object", + "properties": { + "content_ratings_exclude": { + "type": "array", + "description": "Exclude collections with any of these content ratings (OR logic). This is a metadata filter on the collection's declared content_rating field — it does not evaluate episode content.", + "items": { + "$ref": "/schemas/core/content-rating.json" + }, + "minItems": 1 + }, + "content_ratings_include": { + "type": "array", + "description": "Include only collections with any of these content ratings (OR logic). Collections without a declared content_rating are excluded.", + "items": { + "$ref": "/schemas/core/content-rating.json" + }, + "minItems": 1 + }, + "genres_exclude": { + "type": "array", + "description": "Exclude collections tagged with any of these genres (OR logic). Values are interpreted against genre_taxonomy when present.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "genres_include": { + "type": "array", + "description": "Include only collections with any of these genres (OR logic). Collections without genre metadata are excluded. Values are interpreted against genre_taxonomy when present.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "genre_taxonomy": { + "$ref": "/schemas/enums/genre-taxonomy.json", + "description": "Taxonomy for genre filter values. When present, genres_include and genres_exclude values are interpreted as taxonomy IDs." + }, + "kinds": { + "type": "array", + "description": "Filter to these collection kinds", + "items": { + "type": "string", + "enum": ["series", "publication", "event_series", "rotation"] + }, + "minItems": 1 + }, + "exclude_distribution_ids": { + "type": "array", + "description": "Always exclude collections with these distribution identifiers", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/distribution-identifier-type.json", + "description": "Type of distribution identifier" + }, + "value": { + "type": "string", + "description": "The identifier value" + } + }, + "required": ["type", "value"], + "additionalProperties": false + }, + "minItems": 1 + }, + "production_quality": { + "type": "array", + "description": "Filter by production quality tier", + "items": { + "$ref": "/schemas/enums/production-quality.json" + }, + "minItems": 1 + } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/collection/collection-list.json b/static/schemas/source/collection/collection-list.json new file mode 100644 index 0000000000..dfce1e38de --- /dev/null +++ b/static/schemas/source/collection/collection-list.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/collection-list.json", + "title": "Collection List", + "description": "A managed collection list with optional filters for dynamic evaluation. Lists are resolved at setup time and cached by orchestrators/sellers for real-time use. Collections represent programs, shows, and other content entities independent of which properties carry them.", + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "Unique identifier for this collection list" + }, + "name": { + "type": "string", + "description": "Human-readable name for the list" + }, + "description": { + "type": "string", + "description": "Description of the list's purpose" + }, + "principal": { + "type": "string", + "description": "Principal identity that owns this list" + }, + "base_collections": { + "type": "array", + "description": "Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database.", + "items": { + "$ref": "/schemas/collection/base-collection-source.json" + } + }, + "filters": { + "$ref": "/schemas/collection/collection-list-filters.json", + "description": "Dynamic filters applied when resolving the list" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json", + "description": "Brand reference used to automatically apply appropriate rules. Resolved to full brand identity at execution time." + }, + "webhook_url": { + "type": "string", + "format": "uri", + "description": "URL to receive notifications when the resolved list changes" + }, + "cache_duration_hours": { + "type": "integer", + "description": "Recommended cache duration for resolved list. Consumers should re-fetch after this period. Defaults to 168 (one week) because collection metadata changes less frequently than property metadata.", + "minimum": 1, + "default": 168 + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the list was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the list was last modified" + }, + "collection_count": { + "type": "integer", + "description": "Number of collections in the resolved list (at time of last resolution)" + } + }, + "required": ["list_id", "name"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/create-collection-list-request.json b/static/schemas/source/collection/create-collection-list-request.json new file mode 100644 index 0000000000..6826cd46ab --- /dev/null +++ b/static/schemas/source/collection/create-collection-list-request.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/create-collection-list-request.json", + "title": "Create Collection List Request", + "description": "Request parameters for creating a new collection list", + "type": "object", + "properties": { + "adcp_major_version": { + "type": "integer", + "description": "The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version.", + "minimum": 1, + "maximum": 99 + }, + "name": { + "type": "string", + "description": "Human-readable name for the list" + }, + "description": { + "type": "string", + "description": "Description of the list's purpose" + }, + "base_collections": { + "type": "array", + "description": "Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database.", + "items": { + "$ref": "/schemas/collection/base-collection-source.json" + }, + "minItems": 1 + }, + "filters": { + "$ref": "/schemas/collection/collection-list-filters.json", + "description": "Dynamic filters to apply when resolving the list" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json", + "description": "Brand reference. When provided, the agent automatically applies appropriate rules based on brand characteristics (industry, target_audience, etc.). Resolved at execution time." + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated unique key for this request. Prevents duplicate collection list creation on retries. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request.", + "minLength": 16, + "maxLength": 255 + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "name" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/create-collection-list-response.json b/static/schemas/source/collection/create-collection-list-response.json new file mode 100644 index 0000000000..0aed77926d --- /dev/null +++ b/static/schemas/source/collection/create-collection-list-response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/create-collection-list-response.json", + "title": "Create Collection List Response", + "description": "Response payload for create_collection_list task", + "type": "object", + "properties": { + "list": { + "$ref": "/schemas/collection/collection-list.json", + "description": "The created collection list" + }, + "auth_token": { + "type": "string", + "description": "Token that can be shared with sellers to authorize fetching this list. Store this - it is only returned at creation time." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["list", "auth_token"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/delete-collection-list-request.json b/static/schemas/source/collection/delete-collection-list-request.json new file mode 100644 index 0000000000..8dfdd251f1 --- /dev/null +++ b/static/schemas/source/collection/delete-collection-list-request.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/delete-collection-list-request.json", + "title": "Delete Collection List Request", + "description": "Request parameters for deleting a collection list", + "type": "object", + "properties": { + "adcp_major_version": { + "type": "integer", + "description": "The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version.", + "minimum": 1, + "maximum": 99 + }, + "list_id": { + "type": "string", + "description": "ID of the collection list to delete" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated unique key for at-most-once execution. If a request with the same key has already been processed, the server returns the original response without re-processing. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request.", + "minLength": 16, + "maxLength": 255 + } + }, + "required": [ + "list_id" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/delete-collection-list-response.json b/static/schemas/source/collection/delete-collection-list-response.json new file mode 100644 index 0000000000..dc7e470e2c --- /dev/null +++ b/static/schemas/source/collection/delete-collection-list-response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/delete-collection-list-response.json", + "title": "Delete Collection List Response", + "description": "Response payload for delete_collection_list task", + "type": "object", + "properties": { + "deleted": { + "type": "boolean", + "description": "Whether the list was successfully deleted" + }, + "list_id": { + "type": "string", + "description": "ID of the deleted list" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["deleted", "list_id"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/get-collection-list-request.json b/static/schemas/source/collection/get-collection-list-request.json new file mode 100644 index 0000000000..4459631827 --- /dev/null +++ b/static/schemas/source/collection/get-collection-list-request.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/get-collection-list-request.json", + "title": "Get Collection List Request", + "description": "Request parameters for retrieving a collection list with resolved collections", + "type": "object", + "properties": { + "adcp_major_version": { + "type": "integer", + "description": "The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version.", + "minimum": 1, + "maximum": 99 + }, + "list_id": { + "type": "string", + "description": "ID of the collection list to retrieve" + }, + "resolve": { + "type": "boolean", + "description": "Whether to apply filters and return resolved collections (default: true)", + "default": true + }, + "pagination": { + "type": "object", + "description": "Pagination parameters. Uses higher limits than standard pagination because collection lists can contain thousands of entries.", + "properties": { + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 1000, + "description": "Maximum number of collections to return per page" + }, + "cursor": { + "type": "string", + "description": "Opaque cursor from a previous response to fetch the next page" + } + }, + "additionalProperties": false + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "list_id" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/get-collection-list-response.json b/static/schemas/source/collection/get-collection-list-response.json new file mode 100644 index 0000000000..a8804c9cd0 --- /dev/null +++ b/static/schemas/source/collection/get-collection-list-response.json @@ -0,0 +1,111 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/get-collection-list-response.json", + "title": "Get Collection List Response", + "description": "Response payload for get_collection_list task. Returns resolved collection entries with identification and key metadata for matching. Consumers should cache the resolved collections and refresh based on cache_valid_until.", + "type": "object", + "properties": { + "list": { + "$ref": "/schemas/collection/collection-list.json", + "description": "The collection list metadata (always returned)" + }, + "collections": { + "type": "array", + "description": "Resolved collections that passed filters (if resolve=true). Each entry contains identification and key metadata for seller matching.", + "items": { + "type": "object", + "properties": { + "collection_rid": { + "type": "string", + "description": "Registry-assigned stable identifier for this collection. Present when the collection has been registered in the collection registry." + }, + "name": { + "type": "string", + "description": "Human-readable collection name" + }, + "distribution_ids": { + "type": "array", + "description": "Platform-independent identifiers for cross-publisher matching", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/distribution-identifier-type.json", + "description": "Type of distribution identifier" + }, + "value": { + "type": "string", + "description": "The identifier value" + } + }, + "required": ["type", "value"], + "additionalProperties": false + } + }, + "content_rating": { + "$ref": "/schemas/core/content-rating.json", + "description": "Baseline content rating for this collection" + }, + "genre": { + "type": "array", + "description": "Genre tags for this collection", + "items": { + "type": "string" + } + }, + "genre_taxonomy": { + "$ref": "/schemas/enums/genre-taxonomy.json", + "description": "Taxonomy system for genre values" + }, + "kind": { + "type": "string", + "enum": ["series", "publication", "event_series", "rotation"], + "description": "What kind of content program this is" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "pagination": { + "$ref": "/schemas/core/pagination-response.json" + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "description": "When the list was resolved" + }, + "cache_valid_until": { + "type": "string", + "format": "date-time", + "description": "Cache expiration timestamp. Re-fetch the list after this time to get updated collections." + }, + "coverage_gaps": { + "type": "object", + "description": "Collections included in the list despite missing metadata for a filtered dimension. Maps dimension name (e.g., 'genre', 'content_rating') to arrays of distribution identifiers for collections not covered. Only present when filters are applied and some collections lack the filtered metadata.", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "$ref": "/schemas/enums/distribution-identifier-type.json", + "description": "Type of distribution identifier" + }, + "value": { + "type": "string", + "description": "The identifier value" + } + }, + "required": ["type", "value"], + "additionalProperties": false + } + } + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["list"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/list-collection-lists-request.json b/static/schemas/source/collection/list-collection-lists-request.json new file mode 100644 index 0000000000..7b4331e4e6 --- /dev/null +++ b/static/schemas/source/collection/list-collection-lists-request.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/list-collection-lists-request.json", + "title": "List Collection Lists Request", + "description": "Request parameters for listing collection lists", + "type": "object", + "properties": { + "adcp_major_version": { + "type": "integer", + "description": "The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version.", + "minimum": 1, + "maximum": 99 + }, + "principal": { + "type": "string", + "description": "Filter to lists owned by this principal" + }, + "name_contains": { + "type": "string", + "description": "Filter to lists whose name contains this string" + }, + "pagination": { + "$ref": "/schemas/core/pagination-request.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/collection/list-collection-lists-response.json b/static/schemas/source/collection/list-collection-lists-response.json new file mode 100644 index 0000000000..a3d00edcfb --- /dev/null +++ b/static/schemas/source/collection/list-collection-lists-response.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/list-collection-lists-response.json", + "title": "List Collection Lists Response", + "description": "Response payload for list_collection_lists task", + "type": "object", + "properties": { + "lists": { + "type": "array", + "description": "Array of collection lists (metadata only, not resolved collections)", + "items": { + "$ref": "/schemas/collection/collection-list.json" + } + }, + "pagination": { + "$ref": "/schemas/core/pagination-response.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["lists"], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/update-collection-list-request.json b/static/schemas/source/collection/update-collection-list-request.json new file mode 100644 index 0000000000..5fc3fd2619 --- /dev/null +++ b/static/schemas/source/collection/update-collection-list-request.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/update-collection-list-request.json", + "title": "Update Collection List Request", + "description": "Request parameters for updating an existing collection list", + "type": "object", + "properties": { + "adcp_major_version": { + "type": "integer", + "description": "The AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version.", + "minimum": 1, + "maximum": 99 + }, + "list_id": { + "type": "string", + "description": "ID of the collection list to update" + }, + "name": { + "type": "string", + "description": "New name for the list" + }, + "description": { + "type": "string", + "description": "New description" + }, + "base_collections": { + "type": "array", + "description": "Complete replacement for the base collections list (not a patch). Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres).", + "items": { + "$ref": "/schemas/collection/base-collection-source.json" + } + }, + "filters": { + "$ref": "/schemas/collection/collection-list-filters.json", + "description": "Complete replacement for the filters (not a patch)" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json", + "description": "Update brand reference. Resolved to full brand identity at execution time." + }, + "webhook_url": { + "type": "string", + "format": "uri", + "description": "Update the webhook URL for list change notifications (set to empty string to remove)" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated unique key for at-most-once execution. If a request with the same key has already been processed, the server returns the original response without re-processing. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request.", + "minLength": 16, + "maxLength": 255 + } + }, + "required": [ + "list_id" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/collection/update-collection-list-response.json b/static/schemas/source/collection/update-collection-list-response.json new file mode 100644 index 0000000000..b504483de1 --- /dev/null +++ b/static/schemas/source/collection/update-collection-list-response.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/collection/update-collection-list-response.json", + "title": "Update Collection List Response", + "description": "Response payload for update_collection_list task", + "type": "object", + "properties": { + "list": { + "$ref": "/schemas/collection/collection-list.json", + "description": "The updated collection list" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["list"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/collection-list-ref.json b/static/schemas/source/core/collection-list-ref.json new file mode 100644 index 0000000000..7908f163e8 --- /dev/null +++ b/static/schemas/source/core/collection-list-ref.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/collection-list-ref.json", + "title": "Collection List Reference", + "description": "Reference to an externally managed collection list. Enables passing large collection exclusion/inclusion sets without embedding them in requests. The receiving agent fetches and caches the list independently.", + "type": "object", + "properties": { + "agent_url": { + "type": "string", + "format": "uri", + "description": "URL of the agent managing the collection list" + }, + "list_id": { + "type": "string", + "description": "Identifier for the collection list within the agent", + "minLength": 1 + }, + "auth_token": { + "type": "string", + "description": "JWT or other authorization token for accessing the list. Optional if the list is public or caller has implicit access." + } + }, + "required": ["agent_url", "list_id"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/targeting.json b/static/schemas/source/core/targeting.json index e3092fafa6..faa734b66a 100644 --- a/static/schemas/source/core/targeting.json +++ b/static/schemas/source/core/targeting.json @@ -178,6 +178,14 @@ "$ref": "/schemas/core/property-list-ref.json", "description": "Reference to a property list for targeting specific properties within this product. The package runs on the intersection of the product's publisher_properties and this list. Sellers SHOULD return a validation error if the product has property_targeting_allowed: false." }, + "collection_list": { + "$ref": "/schemas/core/collection-list-ref.json", + "description": "Reference to a collection list for including specific collections (programs, shows) within this product. The package runs on the intersection of matched collections and this list. Use for inclusion-based collection targeting. Seller must declare support in get_adcp_capabilities." + }, + "collection_list_exclude": { + "$ref": "/schemas/core/collection-list-ref.json", + "description": "Reference to a collection list for excluding specific collections (programs, shows) from this product. Matched collections must not carry the buyer's ads. Use for brand safety do-not-air lists. Seller must declare support in get_adcp_capabilities." + }, "age_restriction": { "type": "object", "description": "Age restriction for compliance. Use for legal requirements (alcohol, gambling), not audience targeting.", diff --git a/static/schemas/source/enums/content-rating-system.json b/static/schemas/source/enums/content-rating-system.json index a6870c3d28..18492a1611 100644 --- a/static/schemas/source/enums/content-rating-system.json +++ b/static/schemas/source/enums/content-rating-system.json @@ -12,16 +12,22 @@ "bbfc", "fsk", "acb", + "chvrs", + "csa", + "pegi", "custom" ], "enumDescriptions": { "tv_parental": "US TV Parental Guidelines (TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA)", "mpaa": "Motion Picture Association film ratings (G, PG, PG-13, R, NC-17)", "podcast": "Podcast content advisories (clean, explicit)", - "esrb": "Entertainment Software Rating Board (E, E10+, T, M, AO)", + "esrb": "Entertainment Software Rating Board (E, E10+, T, M, AO) — US/Canada", "bbfc": "British Board of Film Classification (U, PG, 12A, 12, 15, 18, R18)", - "fsk": "Freiwillige Selbstkontrolle der Filmwirtschaft (0, 6, 12, 16, 18)", + "fsk": "Freiwillige Selbstkontrolle der Filmwirtschaft (0, 6, 12, 16, 18) — Germany", "acb": "Australian Classification Board (G, PG, M, MA15+, R18+)", + "chvrs": "Canadian Home Video Rating System (G, PG, 14A, 18A, R, A)", + "csa": "Conseil superieur de l'audiovisuel / Arcom (-10, -12, -16, -18) — France", + "pegi": "Pan European Game Information (3, 7, 12, 16, 18) — pan-European gaming", "custom": "Publisher-defined rating system with free-form values" } } diff --git a/static/schemas/source/enums/genre-taxonomy.json b/static/schemas/source/enums/genre-taxonomy.json new file mode 100644 index 0000000000..cd4a2dde50 --- /dev/null +++ b/static/schemas/source/enums/genre-taxonomy.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/genre-taxonomy.json", + "title": "Genre Taxonomy", + "description": "Taxonomy systems for genre classification. When declared, genre values should be valid identifiers within the specified taxonomy.", + "type": "string", + "enum": [ + "iab_content_3.0", + "iab_content_2.2", + "gracenote", + "eidr", + "apple_genres", + "google_genres", + "roku", + "amazon_genres", + "custom" + ], + "enumDescriptions": { + "iab_content_3.0": "IAB Tech Lab Content Taxonomy 3.0 — industry standard for digital content classification. Maps to OpenRTB cattax=6.", + "iab_content_2.2": "IAB Tech Lab Content Taxonomy 2.2 — previous version, still widely used. Maps to OpenRTB cattax=2.", + "gracenote": "Gracenote genre classification — entertainment metadata standard for TV, film, and music. Supports multi-level genre hierarchies; hierarchy-aware matching is the seller's responsibility.", + "eidr": "EIDR genre classification — ISO 10528 for audiovisual content identification", + "apple_genres": "Apple App Store / Apple TV genre taxonomy", + "google_genres": "Google Play / Google TV genre taxonomy", + "roku": "Roku Channel Store / Roku TV genre taxonomy", + "amazon_genres": "Amazon Fire TV / Freevee genre taxonomy", + "custom": "Publisher-defined genre taxonomy with free-form values. Buyer and seller must negotiate the vocabulary out of band." + } +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index e79420050d..8eb1ac5349 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -310,6 +310,10 @@ "$ref": "/schemas/core/property-list-ref.json", "description": "Reference to an externally managed property list for passing large property sets" }, + "collection-list-ref": { + "$ref": "/schemas/core/collection-list-ref.json", + "description": "Reference to an externally managed collection list for passing large collection exclusion/inclusion sets" + }, "identifier": { "$ref": "/schemas/core/identifier.json", "description": "A property identifier with type and value" @@ -784,6 +788,10 @@ "$ref": "/schemas/enums/governance-domain.json", "description": "Governance sub-domains a registry policy applies to (campaign, property, creative, content_standards)" }, + "genre-taxonomy": { + "$ref": "/schemas/enums/genre-taxonomy.json", + "description": "Taxonomy systems for genre classification (iab_content_3.0, gracenote, eidr, etc.)" + }, "governance-mode": { "$ref": "/schemas/enums/governance-mode.json", "description": "Operating mode for a governance agent (audit, advisory, enforce)" @@ -1148,6 +1156,22 @@ "$ref": "/schemas/property/base-property-source.json", "description": "A source of properties for a property list - supports publisher+tags, publisher+property_ids, or direct identifiers" }, + "collection-list": { + "$ref": "/schemas/collection/collection-list.json", + "description": "A managed collection list with optional filters for dynamic evaluation — collections represent programs/shows independent of properties" + }, + "collection-list-filters": { + "$ref": "/schemas/collection/collection-list-filters.json", + "description": "Filters that dynamically modify a collection list when resolved — content ratings, genres, kinds, production quality" + }, + "collection-list-changed-webhook": { + "$ref": "/schemas/collection/collection-list-changed-webhook.json", + "description": "Webhook payload when a collection list changes" + }, + "base-collection-source": { + "$ref": "/schemas/collection/base-collection-source.json", + "description": "A source of collections for a collection list - supports distribution_ids, publisher_collections, or publisher_genres" + }, "content-standards": { "$ref": "/schemas/content-standards/content-standards.json", "description": "Reusable content standards configuration - defines brand safety/suitability policies with scope, policy, calibration exemplars, and lifecycle dates" @@ -1220,6 +1244,56 @@ "description": "Response payload for delete_property_list task" } }, + "create-collection-list": { + "request": { + "$ref": "/schemas/collection/create-collection-list-request.json", + "description": "Request parameters for creating a new collection list" + }, + "response": { + "$ref": "/schemas/collection/create-collection-list-response.json", + "description": "Response payload for create_collection_list task" + } + }, + "update-collection-list": { + "request": { + "$ref": "/schemas/collection/update-collection-list-request.json", + "description": "Request parameters for updating an existing collection list" + }, + "response": { + "$ref": "/schemas/collection/update-collection-list-response.json", + "description": "Response payload for update_collection_list task" + } + }, + "get-collection-list": { + "request": { + "$ref": "/schemas/collection/get-collection-list-request.json", + "description": "Request parameters for retrieving a collection list with resolved collections" + }, + "response": { + "$ref": "/schemas/collection/get-collection-list-response.json", + "description": "Response payload for get_collection_list task" + } + }, + "list-collection-lists": { + "request": { + "$ref": "/schemas/collection/list-collection-lists-request.json", + "description": "Request parameters for listing collection lists" + }, + "response": { + "$ref": "/schemas/collection/list-collection-lists-response.json", + "description": "Response payload for list_collection_lists task" + } + }, + "delete-collection-list": { + "request": { + "$ref": "/schemas/collection/delete-collection-list-request.json", + "description": "Request parameters for deleting a collection list" + }, + "response": { + "$ref": "/schemas/collection/delete-collection-list-response.json", + "description": "Response payload for delete_collection_list task" + } + }, "list-content-standards": { "request": { "$ref": "/schemas/content-standards/list-content-standards-request.json",