From e97e951d4eb956051b921e075742b280a384e6f9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 13 Aug 2026 11:42:37 +0200 Subject: [PATCH] docs(media-buy): correct brand requirement guidance --- .../media-buys/policy-compliance.mdx | 179 +++++++++--------- .../product-discovery/brief-expectations.mdx | 40 ++-- tests/lint-doc-compliance-drift.test.cjs | 86 +++++++++ 3 files changed, 202 insertions(+), 103 deletions(-) diff --git a/docs/media-buy/media-buys/policy-compliance.mdx b/docs/media-buy/media-buys/policy-compliance.mdx index f80514cca3..f344aee290 100644 --- a/docs/media-buy/media-buys/policy-compliance.mdx +++ b/docs/media-buy/media-buys/policy-compliance.mdx @@ -1,5 +1,5 @@ --- -title: Policy Compliance +title: Policy compliance description: "AdCP policy compliance — how publishers enforce brand safety and regulatory checks during product discovery and media buy creation. Includes policy types and violation handling." "og:title": "AdCP — Policy Compliance" --- @@ -9,7 +9,7 @@ AdCP includes comprehensive policy compliance features to ensure brand safety an ## Overview -Policy compliance in AdCP centers around the `brand` field - a required reference to the advertiser brand. This enables publishers to: +Policy compliance in AdCP uses the `brand` field when advertiser-specific context is available. `brand` is optional for `get_products` unless the request includes `catalog`; it is required for `create_media_buy`. Brand context enables publishers to: - Filter inappropriate advertisers before showing inventory - Enforce category-specific restrictions @@ -18,7 +18,7 @@ Policy compliance in AdCP centers around the `brand` field - a required referenc ## Brand -All product discovery and media buy creation requests must include a `brand` field that identifies the advertiser brand: +Buyers should include `brand` in `get_products` when the advertiser is known so sellers can return context-rich recommendations and apply advertiser-specific policy checks. A catalog-bearing `get_products` request must include `brand`, and every `create_media_buy` request must include `brand`. ```json { @@ -28,110 +28,130 @@ All product discovery and media buy creation requests must include a `brand` fie } ``` -The brand domain is used to look up the brand's published identity (via `brand.json`), which provides: +The brand domain resolves `/.well-known/brand.json`. On a single-brand domain, `brand_id` can be omitted. On a house or portfolio domain, use `brand_id` to select the intended entry. The selected entry provides: - **Brand name and identity** for verification -- **Industry category** for policy filtering +- **Industries** for policy filtering - **Brand assets** for creative compliance -Combined with the `brief` field (which describes what's being promoted), publishers have full context for policy decisions. +Callers can supply `brand.industries` as a per-request override when the canonical entry is missing or does not describe the campaign's current industry context. When that field is absent, sellers and governance agents resolve `industries` from the selected `brand.json` entry. + +Combined with the `brief` field (which describes what's being promoted), publishers have fuller context for policy decisions. When `brand` is absent from a non-catalog discovery request, a seller may apply general policy rules or limit the products it returns rather than perform advertiser-specific checks. For comprehensive guidance on briefs and brand information, see [Brief Expectations](/docs/media-buy/product-discovery/brief-expectations). -## Policy Check Implementation +## Policy check implementation Publishers must implement policy checks at two key points in the workflow: -### 1. During Product Discovery (`get_products`) +### 1. During product discovery (`get_products`) When a `get_products` request is received, the publisher should: -1. Validate that the `brand` field is present and meaningful -2. Extract brand and category information -3. Check against publisher policies -4. Filter out unsuitable products +1. Require `brand` when `catalog` is present +2. When `brand` is present, validate it and resolve the selected brand entry and its `industries` +3. Apply advertiser-specific policy checks when that context is available +4. Otherwise, apply general policy rules or limit discovery +5. Filter out unsuitable products -**Example Policy Check Flow:** +**Example policy context resolution:** ```python -def check_brand_policy(brand: dict) -> PolicyResult: - # Look up brand identity from domain - domain = brand.get("domain") - brand_identity = fetch_brand_json(domain) - - # Verify brand identity if needed - if not verify_brand_domain(domain, brand_identity): - return PolicyResult( - status="blocked", - message="Brand verification failed" - ) - - category = brand_identity.get("category") - - # Check blocked categories - if category in BLOCKED_CATEGORIES: - return PolicyResult( - status="blocked", - message=f"{category} advertising is not permitted on this publisher" - ) - - # Check restricted categories - if category in RESTRICTED_CATEGORIES: - return PolicyResult( - status="restricted", - message=f"{category} advertising requires manual approval", - contact="sales@publisher.com" - ) - - return PolicyResult(status="allowed", category=category) +def resolve_policy_industries(brand: dict | None) -> list[str] | None: + if brand is None: + return None + + manifest = fetch_brand_json(brand["domain"]) + brand_entry = select_brand_entry(manifest, brand.get("brand_id")) + + if "industries" in brand: + return brand["industries"] + return brand_entry.get("industries", []) ``` -### 2. During Media Buy Creation (`create_media_buy`) +If this function returns `None`, apply general discovery policy or limit the returned products. Otherwise, evaluate policy against the resolved industries and any other applicable request context. -When creating a media buy: +### 2. During media buy creation (`create_media_buy`) + +When creating a media buy, the publisher must first require `brand`, then: 1. Validate the `brand` against publisher policies 2. Ensure consistency with the campaign brief 3. Flag for manual review if needed 4. Return appropriate errors for violations -## Policy Compliance Responses +## Policy outcomes + +AdCP does not define a separate policy-compliance status or response field. Use the task status and error model that matches the outcome. -The protocol defines three compliance statuses: +### Products can be returned -### `allowed` -The brand passes initial policy checks. Products are returned normally. +When policy checks pass, return the ordinary completed response. An empty `products` array means the search completed but no products matched. ```json { - "products": [...], - "policy_compliance": { - "status": "allowed" - } + "status": "completed", + "products": [], + "cache_scope": "public", + "message": "No products matched the request." } ``` -### `restricted` -The brand category requires manual approval before products can be shown. +### More information is needed + +Use `input-required` only when the buyer must supply information before the seller can continue. Include `task_id` and `context_id` so the buyer can continue the same task, plus the status-specific reason and optional suggestions. ```json { - "products": [], - "policy_compliance": { - "status": "restricted", - "message": "Cryptocurrency advertising is restricted but may be approved on a case-by-case basis.", - "contact": "sales@publisher.com" - } + "status": "input-required", + "task_id": "task_policy_clarification_001", + "context_id": "ctx_policy_clarification_001", + "message": "Which country will this campaign target?", + "reason": "CLARIFICATION_NEEDED", + "suggestions": ["United States", "Canada"] } ``` -### `blocked` -The brand category cannot be supported by this publisher. +### Seller-side review continues asynchronously + +Seller-internal review does not require buyer input. When a well-formed `brief` or `refine` request needs human review that cannot finish within the current time budget, return `submitted` with a `task_id`; use `working` for subsequent progress. Wholesale discovery is synchronous and does not use these async states. + +### The seller declines a well-formed request + +When the request is valid and understood but the seller makes a business decision not to offer products, use the typed `rejected` outcome. This remains a successful transport response. ```json { - "products": [], - "policy_compliance": { - "status": "blocked", - "message": "Publisher policy prohibits alcohol advertising without age verification capabilities." + "status": "rejected", + "reason": "This publisher does not offer inventory for the requested campaign.", + "suggestions": ["Try a different channel or publisher."] +} +``` + +### The request violates a policy + +When the request itself violates an applicable content or advertising policy and can be corrected, return a failed response with `POLICY_VIOLATION`. Populate `errors[]` and mirror the fatal error in `adcp_error` so clients can handle both response layers. + +```json +{ + "status": "failed", + "message": "The request violates an applicable advertising policy.", + "errors": [ + { + "code": "POLICY_VIOLATION", + "message": "The requested campaign is not permitted on this inventory.", + "field": "brand", + "recovery": "correctable", + "details": { + "policy_id": "publisher-restricted-industries", + "policy_url": "https://publisher.example/policies/restricted-industries", + "violated_rules": ["restricted-industry"] + } + } + ], + "adcp_error": { + "code": "POLICY_VIOLATION", + "message": "The requested campaign is not permitted on this inventory.", + "field": "brand", + "recovery": "correctable" } } ``` @@ -179,29 +199,14 @@ Publishers typically implement restrictions for: 4. **Documentation**: Maintain clear policy documentation for advertisers 5. **Appeals Process**: Allow advertisers to request policy exceptions -## Error Handling - -For policy violations during media buy creation: - -```json -{ - "error": { - "code": "POLICY_VIOLATION", - "message": "Brand category not permitted on this publisher", - "field": "brand", - "suggestion": "Contact publisher for category approval process" - } -} -``` - ## Integration with HITL -Policy decisions can trigger Human-in-the-Loop workflows: +Keep the two human-in-the-loop paths distinct: + +- **Buyer clarification:** Return `input-required` only when the buyer must answer a question. Include `task_id` and `context_id`, then continue that task context after the buyer responds. +- **Seller-internal review:** For `brief` or `refine`, return `submitted` when review cannot finish within the current time budget and `working` while it remains active. Do not use `input-required` merely because a seller-side reviewer is involved. -1. Restricted categories create `pending_manual` tasks -2. Human reviewers assess the campaign -3. Approval or rejection is communicated back -4. Campaign proceeds or is terminated based on decision +After review, return the canonical completed, rejected, or failed outcome. ## Related Documentation diff --git a/docs/media-buy/product-discovery/brief-expectations.mdx b/docs/media-buy/product-discovery/brief-expectations.mdx index 61ed76ac09..41b249f344 100644 --- a/docs/media-buy/product-discovery/brief-expectations.mdx +++ b/docs/media-buy/product-discovery/brief-expectations.mdx @@ -1,5 +1,5 @@ --- -title: Brief Expectations +title: Brief expectations description: "AdCP brief expectations — how to write campaign briefs for get_products. Required fields, optional details, and implementation guidance for publishers and buyer agents." "og:title": "AdCP — Brief Expectations" --- @@ -11,19 +11,21 @@ A comprehensive brief is essential for effective media buying through AdCP. This A brief in AdCP is a natural language description of campaign requirements that helps publishers understand and fulfill media buying requests. While briefs can be as simple or detailed as needed, complete briefs enable better product recommendations and more efficient campaign execution. -## Required Components +## Request requirements -Every `get_products` and `create_media_buy` request MUST include: +Every `get_products` request MUST include `buying_mode`. For `buying_mode: "brief"`, `brief` is required. For `buying_mode: "wholesale"`, omit `brief`. For `buying_mode: "refine"`, omit `brief` and use the `refine` array instead. -### Brand +### Brand context -The `brand` field is **required** in all requests. It identifies the advertiser brand: +The `brand` field is optional for `get_products` unless the request includes `catalog`, in which case `brand` is required. Include `brand` whenever the advertiser is known so the publisher can provide advertiser-specific recommendations and policy checks. A context-rich discovery request looks like this: ```json { + "buying_mode": "brief", "brand": { "domain": "acmecorp.com" - } + }, + "brief": "Build awareness for Acme Corp's new commuter bicycle among urban professionals" } ``` @@ -32,22 +34,24 @@ This enables publishers to: - Verify brand identity - Enforce brand safety standards -### Brief Field +If `brand` is absent, a seller may apply general policy rules or limit discovery rather than perform advertiser-specific checks. The `create_media_buy` request always requires `brand`, even if the preceding discovery request omitted it. + +### Brief field The `brief` field describes **what is being promoted** and **campaign requirements**: ```json { "buying_mode": "brief", - "brief": "Nike Air Max 2024 - the latest innovation in cushioning technology featuring sustainable materials, targeting runners and fitness enthusiasts" + "brief": "Nova Run shoes feature plant-based materials and target recreational runners" } ``` -## When Briefs Are Optional +## Brief requirements by mode -The `brief` field is **optional only when `buying_mode` is `"wholesale"`**. For catalog discovery without publisher curation, set `buying_mode: "wholesale"` explicitly: +The `brief` field is required in brief mode and omitted in the other modes. For wholesale discovery without publisher curation, set `buying_mode: "wholesale"` explicitly: -### Wholesale Buying Mode +### Wholesale buying mode When the buyer will apply their own audience targeting and does not want publisher curation: @@ -66,6 +70,10 @@ When the buyer will apply their own audience targeting and does not want publish `buying_mode: "wholesale"` and `brief` are mutually exclusive — providing both is an error. If `buying_mode: "brief"` is set explicitly, `brief` is required. +### Refine mode + +Use `buying_mode: "refine"` with a `refine` array to iterate on products or proposals from an earlier response. Omit `brief`; each `refine` entry describes the requested change and its scope. + When a publisher receives `buying_mode: "wholesale"`: 1. Returns products that support buyer-directed targeting 2. Does not apply AI curation or personalization @@ -336,10 +344,10 @@ Publishers should implement NLP to extract: - **Geographic references**: "west coast", "major cities", "nationwide" - **Objective keywords**: "awareness", "drive sales", "generate leads" -## Best Practices +## Best practices -### DO: -- ✅ Include both advertiser and product in brand and brief +### Do +- ✅ Include the advertiser as `brand` when known and describe the promoted product in the brief - ✅ Specify measurable success criteria - ✅ Provide clear timing requirements - ✅ Describe target audience in detail @@ -347,7 +355,7 @@ Publishers should implement NLP to extract: - ✅ State budget or budget constraints - ✅ Include brand safety requirements -### DON'T: +### Don't - ❌ Provide vague objectives like "good performance" - ❌ Omit timing without expecting clarification requests - ❌ Use undefined abbreviations or jargon @@ -357,7 +365,7 @@ Publishers should implement NLP to extract: ## Examples -### Wholesale Buying +### Wholesale buying ```json { "brand": {"domain": "acmecorp.com"}, diff --git a/tests/lint-doc-compliance-drift.test.cjs b/tests/lint-doc-compliance-drift.test.cjs index 87bade1130..c9f623218a 100644 --- a/tests/lint-doc-compliance-drift.test.cjs +++ b/tests/lint-doc-compliance-drift.test.cjs @@ -23,6 +23,26 @@ function withVerifyVector(markdown) { return `${markdown}${VERIFY_VECTOR_BLOCK}`; } +function isCatalogQualifiedRequirement(clause) { + return /\bcatalog[- ]bearing\b/i.test(clause) || + /\b(?:when|if|unless)\b[^.\n;]*\bcatalog\b/i.test(clause) || + /\bcatalog\b[^.\n;]*\b(?:when|if|unless)\b/i.test(clause) || + /\b(?:get_products|product discovery)\b[^.\n;]*\b(?:with|including|that includes?|containing)\b[^.\n;]*\bcatalog\b/i.test(clause) || + /\bcatalog\b[^.\n;]*\b(?:get_products|product discovery)\b/i.test(clause); +} + +function findUnqualifiedBrandRequirement(markdown) { + return markdown + .replaceAll('`', '') + .split(/[.\n;]|,\s+(?:and|but)\s+/i) + .map(clause => clause.trim()) + .find(clause => + /\b(?:get_products|product discovery)\b/i.test(clause) && + /\bbrand\b/i.test(clause) && + /\b(?:must|shall|mandatory|require(?:d|s)?|needs?)\b/i.test(clause) && + !isCatalogQualifiedRequirement(clause)); +} + function makeFixture({ code = 'request_signature_required' } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-doc-compliance-drift-')); const contractRoot = path.join( @@ -148,6 +168,72 @@ test('headings inside fenced examples do not terminate the guarded section', () assert.match(result.errors[0], /missing_signature/); }); +test('media-buy brand guidance matches request schema requirements', () => { + const getProducts = JSON.parse(fs.readFileSync( + path.join(__dirname, '../static/schemas/source/media-buy/get-products-request.json'), + 'utf8', + )); + const createMediaBuy = JSON.parse(fs.readFileSync( + path.join(__dirname, '../static/schemas/source/media-buy/create-media-buy-request.json'), + 'utf8', + )); + + assert.ok(getProducts.required.includes('buying_mode')); + assert.ok(!getProducts.required.includes('brand')); + assert.deepEqual(getProducts.dependencies.catalog, ['brand']); + assert.ok(createMediaBuy.required.includes('brand')); + + for (const relativePath of [ + '../docs/media-buy/product-discovery/brief-expectations.mdx', + '../docs/media-buy/media-buys/policy-compliance.mdx', + ]) { + const doc = fs.readFileSync(path.join(__dirname, relativePath), 'utf8'); + assert.match(doc, /`brand` (?:field )?is optional for `get_products` unless the request includes `catalog`/); + assert.match(doc, /`create_media_buy` request (?:always )?(?:must include|requires) `brand`/); + assert.match(doc, /general policy rules or limit (?:the products it returns|discovery)/); + assert.equal(findUnqualifiedBrandRequirement(doc), undefined); + } + + const briefGuide = fs.readFileSync( + path.join(__dirname, '../docs/media-buy/product-discovery/brief-expectations.mdx'), + 'utf8', + ); + assert.match(briefGuide, /For `buying_mode: "brief"`, `brief` is required/); + assert.match(briefGuide, /For `buying_mode: "wholesale"`, omit `brief`/); + assert.match(briefGuide, /For `buying_mode: "refine"`, omit `brief` and use the `refine` array instead/); + + const policyGuide = fs.readFileSync( + path.join(__dirname, '../docs/media-buy/media-buys/policy-compliance.mdx'), + 'utf8', + ); + assert.doesNotMatch(policyGuide, /policy_compliance/); + assert.doesNotMatch(policyGuide, /three compliance statuses/i); + assert.doesNotMatch(policyGuide, /"status": "(?:allowed|restricted|blocked)"/); + assert.match(policyGuide, /"code": "POLICY_VIOLATION"/); + assert.match(policyGuide, /"status": "input-required"[\s\S]{0,160}"task_id"[\s\S]{0,160}"context_id"/); + assert.match(policyGuide, /Seller-internal review does not require buyer input/); + assert.match(policyGuide, /return `submitted` with a `task_id`; use `working` for subsequent progress/); +}); + +test('brand guidance lint rejects unqualified requirements and allows catalog conditions', () => { + for (const claim of [ + '`get_products` requires `brand`.', + '`brand` must be provided to `get_products`.', + '`brand` is required for product discovery.', + 'Every product discovery request needs a brand.', + ]) { + assert.equal(findUnqualifiedBrandRequirement(claim), claim.replaceAll('`', '').slice(0, -1)); + } + + for (const claim of [ + 'Every catalog-bearing `get_products` request must include `brand`.', + '`get_products` requires `brand` when `catalog` is present.', + '`brand` is required for product discovery requests that include a catalog.', + ]) { + assert.equal(findUnqualifiedBrandRequirement(claim), undefined); + } +}); + test('removing the guarded section fails instead of silently disabling the lint', () => { const fixture = makeFixture(); fs.writeFileSync(fixture.docPath, withVerifyVector('# Guide\n\nNo taxonomy here.\n'));