feat(media-buy): dependency-impact cluster + webhook foundation (3.1)#4588
Conversation
Lands the impairment side of the dependency-impact cluster (#2853) and the foundational webhook contracts (#4582 tracks 1–2) that tie every push channel to its read API. Media-buy health (#2855, #2856): - New enums/media-buy-health.json (ok | impaired) — orthogonal to status - New core/impairment.json — package-scoped, materiality enforced - New enums/impairment-reason-code.json — flat shared enum, per-type subset - media-buy.json gains health + impairments[] - notification-type.json gains impairment Resource-level offline states (#2838, #2857, #2858): - audience-status: add suspended - creative-status: clarify approved → rejected post-approval - catalog-item-status: add withdrawn - event-source-health: clarify insufficient covers source-offline - Property depublication verified via brand.json / adagents.json (no per-property status field) Foundation docs (#4582 tracks 1–2): - docs/protocol/snapshot-and-log.mdx — the five-rule contract - docs/building/by-layer/L3/webhooks.mdx — persistent channel contract - docs/media-buy/media-buys/lifecycle.mdx — health surface documented Additive throughout; safe in a minor. Defect (#4586) and advisory (#4587) signals spun out to their own epics during the design walk. Refs #2838, #2853, #2855, #2856, #2857, #2858, #4582. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-push hook surfaced a drift between package.json version (3.0.3) and the schema registry adcp_version legacy alias (3.0.12). Running update-schema-versions reconciles them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review pass from ad-tech protocol, ad-tech product, docs, and code reviewers on PR #4588. Addresses the high-signal items; defers snapshot-and-log positioning question to a follow-up. Schema: - Register x-entity: impairment in x-entity-types.json (blocker — lint) - impairment.json additionalProperties: true (per repo policy on durable schemas) - impairment.transition.from now optional (covers seller-discovered-already- offline cases like brand.json crawl) - impairment_id removal semantics: closing fire reuses same id; new impairment for same resource gets new id (resolves protocol-expert concern about receiver dedupe collision) - notification-type.json: remove invented enumDescriptions for pre-existing scheduled/final/delayed/adjusted values; keep only the new impairment description Spec rules: - media-buy.impairments[] gains explicit 5-minute max-staleness budget on snapshot reflection — addresses the synchrony gap (protocol expert "the actual gap") - impairment.json materiality clarified: MAY report conservatively when uncertain; MUST NOT report when serving is provably unaffected. MUST- strength for cheap-join types (audience/event_source/property), SHOULD for creative/catalog_item where pool join is expensive (product expert flag on mid-size SSP enforceability) Docs: - Lifecycle: explicit non-goal — impairments are operational signals, not commercial events; commercial remedies route through accountability_terms (product-expert flag to prevent contracted-buy implementers from building dispute pipelines off impairment) - Lifecycle: materiality coverage section explains the MUST/SHOULD split - snapshot-and-log.mdx: "When you'd be right to push back" wrapped in non-normative Note and moved below the gaps section so agents retrieving push-back framing don't misread it as a sanctioned exception (docs- expert flag) - Feature-named link text with issue numbers as parenthetical detail (docs-expert flag — RFCs go stale) - Coherence fix on webhook_activity[] framing: both pages now state "buyers MUST NOT rely on it for state reconciliation; that's what the snapshot is for" (docs-expert seam flag) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expert review pass — addressed in 92fd09cRan the PR through four reviewers (ad-tech protocol, ad-tech product, docs, code). Consolidated the high-signal items into one fix commit. Headline issues and what landed: Blockers fixed
High-signal protocol gaps closed
Docs cleanup
Deferred / acknowledged
What reviewers explicitly endorsed
Diff is small (7 files, +27 -17). Ready for another look. |
Three additional review items from Brian after the first expert-review pass:
1. Rule 1 conflated stable id with idempotency key.
- notification_id (impairment_id for impairment events) is the per-state
event id — stable across re-emission.
- idempotency_key is the per-fire transport-layer key — stable across
retry of the same logical fire.
- Seeing the same notification_id under different idempotency_keys is a
re-emission signal (missed events warning), not a retry. Doc now states
this explicitly. impairment.json description tightened to match.
2. Rule 4 ("replay = re-read") overpromised for data-bearing events.
- State events (impairment, lifecycle, status) reconstruct fully from the
snapshot — rule holds.
- Data events (delivery report fires) reconstruct only the cumulative
aggregate; per-fire detail can be lost. Doc now narrows the rule and
names webhook_activity[] (#4278) as the path to making it hold
universally.
3. Coalescence ≤ 5min was a flat ceiling — wrong for latency-sensitive
impairment classes (fraud, brand safety).
- Coalescence is now per event type with a table of defaults.
- Latency-sensitive impairments SHOULD NOT coalesce.
- Sellers MAY declare shorter windows via get_agent_capabilities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sharpening pass on the foundation contractThree additional items from a follow-up review — all hit the snapshot/log doc. 1. Two ids, not one (Rule 1 conflation)Doc previously read as if Now explicit:
The transport-layer split already existed in the protocol ( 2. Replay rule narrowed to state events (Rule 4 honesty)Previously stated as universal: "Replay = re-read the snapshot." Holds for state-shaped events (impairment, lifecycle, status). Doesn't hold for data-bearing events (delivery report fires) — Doc now explicitly differentiates:
Rule 4 is now positioned as the destination, not a uniform present-day claim. Pretending it held universally today would invite buyers to build recovery paths that silently lose data. 3. Coalescence is per-type, not flat (5min ceiling was wrong)Previously: "Window SHOULD NOT exceed 5 minutes." Wrong for latency-sensitive impairment subclasses — a fraud-impairment receiver cannot wait 5min for coalescence to flush. Now a per-event-type table:
Sellers MAY declare shorter via Diff summaryThree files: |
Reviewer point: prior framing made webhooks sound authoritative for reporting (pub/sub-ish), with the snapshot as a degraded fallback. That's wrong direction for the protocol — pulls the architecture toward "broker is truth" model when AdCP is REST-with-events (Stripe/GitHub style). Reframe Rule 4 to "either path is complete": - Buyer using webhooks reliably → gets all the data - Buyer using only GET → gets the same data - Two paths at parity in content and granularity For state events this holds today. For reporting it doesn't yet — get_media_buy_delivery returns aggregates and daily breakdowns but cannot reconstruct the hourly windows reporting_webhook delivers. Filed #4590 to add windowed pulls. Also clarify webhook_activity[] (#4278) role: - Debugging surface (which fires hit my endpoint, what HTTP status) - Not a data recovery channel - Buyers MUST NOT use it for state reconciliation The two-path principle is load-bearing for the protocol. Without it, AdCP becomes pub/sub for some channels and REST for others, and buyers have to know which model applies where. With it, both paths are equivalent — buyer chooses on ergonomics, gets same data either way. Refs #4582, #4590, #4278. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-paths principle — replacing the "snapshot is authoritative" framingReviewer point: prior framing made webhooks read as authoritative-for-reporting (pub/sub-ish) with the snapshot as a degraded fallback. Wrong direction — pulls the architecture toward "broker is truth" when AdCP is REST-with-events (Stripe/GitHub model). The principleEither path is complete. A buyer using webhooks reliably gets all the data. A buyer using only GET gets the same data. The two paths are at parity in content and granularity; the buyer chooses based on latency, ergonomics, and receiver infrastructure. This is now Rule 4 of the snapshot/log doc, replacing the old "replay = re-read" framing. Today's gaps closed in 3.1State events — holds today. Missed Reporting (data-bearing events) — partial today. Two specific gaps:
Both land in 3.1. Once they do, Rule 4 is uniform — every channel has full pull parity. Why this matters more than the prior framingWithout two-paths-equal, AdCP is REST for some channels and pub/sub for others, and buyers building against the contract have to know which model applies where. That's the architecture the reviewer was worried about — and they're right to be. The fix isn't to make webhooks more authoritative. It's to make the GET path complete for the channels where it isn't yet. That's a smaller, cleaner spec lift (one new field on one tool plus capability), and it preserves the snapshot-authoritative direction that matches how every modern API does it. Doc edits
Filed
|
…tion.to Brian's diff comments on PR #4588: 1. resource_type was coining "resource" as a quiet new concept. Tighten the description to make explicit that values are drawn from the existing x-entity vocabulary — not a new typology, just the impairment-relevant subset of x-entity types with their own lifecycle. 2. transition.to was an open string, letting sellers write any value. Introduce enums/impairment-offline-state.json — single shared enum of the canonical offline values (suspended, rejected, withdrawn, insufficient, depublished). transition.to now $ref's it. The resource_type ↔ offline_state pairing is enforced by impairment.coherence (#2859), not at the field-validation layer — same place all other cross-resource pairing already lives. 3. transition.from stays as an open string (each resource_type has its own serviceable-state vocabulary; impairment.coherence validates). Plus: enrich impairment-reason-code.json enumDescriptions with "Typical remediation:" lines per code. Walks back from a structured suggested_action field — the seller knows what happened, the buyer knows the campaign implication, and tool-name guidance is derivable from reason_code + resource_type. Putting the typical-path guidance in the enum docs once (at the protocol layer) beats making sellers fill in a per-impairment action field they'd often get wrong or over-claim with. remediation field stays as optional free-text for seller-specific context not covered by the typical path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addressed both diff comments + walk-back on structured remediationDiff comment #1 —
|
Items from protocol/product/docs/code reviewers' second-pass review of #4588. Docs (snapshot-and-log.mdx): - Rule 1: lead with imperative ("Dedupe transport retries by idempotency_key. Correlate fires to state by notification_id.") so coding agents land on the actionable instruction first (docs) - Rule 1: note notification_id typing landing via #4594 (protocol — was documented in prose but not typed in envelope schema) - Rule 4: MUST → SHOULD-with-promotion (protocol — was telling implementers they were already non-compliant on a rule whose schema support is six weeks out via #4590) - Rule 4: trim "load-bearing for the protocol" advocacy line (docs) Schema (impairment-reason-code.json): - Strip "Typical remediation:" lines out of enumDescriptions (docs — SDK generators emit these as docstring/godoc and the verbose prose conflates value-definition with normative guidance) - Clarify seller_removed vs catalog_item.withdrawn vs policy_violation boundary (code) - Tighten pii_audit_failed prose moved to lifecycle table (protocol — was understating seller-side re-audit requirement; buyer-agent loop hazard) Docs (lifecycle.mdx): - Add "Remediation by reason_code" table — the typical-remediation prose belongs in a normative doc, not enum value docstrings. SDK generators pick up clean categorical reasons; humans/agents reading lifecycle docs pick up the remediation paths. Schema (notification-type.json): - Re-add minimal factual enumDescriptions for scheduled/final/delayed/adjusted (code — silently producing missing docstrings for 4 of 5 values) Schema (media-buy.json): - Add default: "ok" on health field (code — description claimed defaulting but schema didn't enforce) - Wrap $ref in allOf to allow sibling default keyword Schema (impairment.json): - Add pattern: "^[a-z][a-z0-9_]*$" on transition.from (protocol — blocks free-form garbage while keeping it open for per-resource vocab) Issues: - #4590 updated: capability-scoped MUST (product — strict MUST would force mid-size SSPs with stream-tap webhook architectures to downgrade webhook frequency to dodge requirement); rename window_granularity → time_granularity (product — industry naming matches DV360/TTD/Xandr) - #4594 filed: type notification_id as first-class envelope field (protocol) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second expert-review pass — appliedRan the PR through the four reviewers again (protocol / product / docs / code). Consolidated the actionable items into one commit. Headlines: Docs (snapshot-and-log.mdx)
Schema
Lifecycle doc
Issues
Reviewer endorsements this pass
Acknowledged-but-deferred
Files in this pass
Schema + example tests clean. |
…cycles The original changeset captured the initial design walk. Final body absorbs: - default: "ok" on health field - impairment-offline-state.json shared enum (new) - transition.from pattern; transition.to $ref - materiality MUST/SHOULD split by resource-type join cost - snapshot synchrony 5-minute budget - two-id model (idempotency_key + notification_id) - two-paths Rule 4 framing - per-event-type coalescence windows - remediation-by-reason_code table - enumDescriptions for notification-type pre-existing values - follow-ups #4590, #4594, #4595 named Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Lands the impairment side of the dependency-impact cluster (#2853) and the foundational webhook contracts (#4582 tracks 1–2) that tie every push channel to its read API. Result of a design walk that narrowed #2853 from a single
at_risk+impacts[]field into a four-axis taxonomy (impairment / defect / advisory / performance) — this PR ships impairment + the foundation; defects (#4586) and advisories (#4587) are spun out to their own epics.Two review cycles applied — protocol, product, docs, and code reviewer feedback consolidated into the current state.
Media-buy health surface (#2855, #2856)
enums/media-buy-health.json—ok|impaired, orthogonal tomedia-buy-status. A paused/pending/active buy can each be impaired without affectingstatus.default: "ok"on the field.core/impairment.json— package-scoped dependency state change. Materiality:package_idsminItems: 1; MUST-strength for resource types where the resource→buy join is cheap (audience, event_source, property), SHOULD for creative and catalog_item where the pool join is expensive.enums/impairment-reason-code.json— flat shared enum; per-resource-type valid subset documented in enumDescriptions. Categorical reasons only — the typical remediation paths live in the lifecycle doc (see below).enums/impairment-offline-state.json— canonical offline values referenced byimpairment.transition.to(was open string in the first draft). Theresource_type↔offline_statepairing is enforced byimpairment.coherence(compliance: impact.coherence assertion across media-buy and resource status #2859), not the JSON Schema.core/media-buy.jsongainshealth+impairments[]. Synchrony: snapshot MUST reflect the impairment within 5 minutes ofobserved_atregardless of buyer poll cadence (closed the gap where "next sync" had no max-staleness bound).enums/notification-type.jsongainsimpairment. Minimal factual enumDescriptions added for the four pre-existing values (scheduled,final,delayed,adjusted) so generators don't produce partial docs.Resource-level offline states (#2838, #2857, #2858)
audience-status: addssuspendedfor seller-initiated offline transitions (closes audience-sync: seller-initiated archival / suspension not modeled #2838).creative-status: enumDescriptions clarifyapproved → rejectedis a valid post-approval transition (creative-status: clarify approved → rejected transition post-approval #2857).catalog-item-status: addswithdrawnfor seller-initiated removal — distinct fromrejected(no buyer-side resubmit path).event-source-health: documents thatinsufficientcovers source-offline; disambiguate viaevents_received_24h: 0.Foundation docs (#4582 tracks 1–2)
docs/protocol/snapshot-and-log.mdx— five-rule contract for push/pull duality:idempotency_key; correlate fires to state bynotification_id. Two distinct ids, distinct purposes — samenotification_idunder differentidempotency_keys = re-emission signal (missed-events warning), not retry.get_media_buy_deliveryin 3.1.docs/building/by-layer/L3/webhooks.mdxadds Persistent channel contract section — at-least-once delivery, no-ordering, per-event-type coalescence windows (5min default for impairment; sub-minute for latency-sensitive subclasses like fraud/brand-safety; defer to seller capabilities for shorter), replay-via-snapshot, mutability viaupdate_*, auth renewal, termination.docs/media-buy/media-buys/lifecycle.mdxdocuments thehealthsurface, materiality coverage (MUST vs SHOULD by resource type), reverse-direction rule,impairment.coherenceinvariant, the operational-vs-commercial non-goal, and a Remediation byreason_codetable (moved here from enumDescriptions on reviewer feedback — SDK generators emit enum descriptions as type-doc comments; normative remediation guidance belongs in narrative docs).Why this lands as one PR
The design walk on #2853 surfaced that the original RFC was implicitly defining its own webhook contract. Rather than ship that contract twice (#2261 creative lifecycle has the same need), the webhook foundation (#4582) was extracted as a separate epic. This PR coordinates both sides because (a) the impairment schemas reference the snapshot/log doc, (b) reviewers benefit from seeing the full design landing intact, and (c) splitting would have forced six small PRs through six review cycles.
Additive across the board — no breaking changes. Buyers that exhaustively switch on
media-buy-statussee no change (no new status value); buyers that readmedia-buy.healthget the new dependency-health signal alongside their existingstatushandling.Test plan
npm run build:schemas— cleannpm run test:schemas— 7/7 passnpm run test:examples— 36/36 passnpm run test:json-schema— 256/256 passnpm run test:composed— 40/40 passnpm run test:unit— 887/887 passnpm run test:error-codes— cleannpm run test:storyboard-check-enum,test:storyboard-doc-parity— cleannpm run check:registry— 14 entries OKFollow-up tickets (deliberately not in this PR)
get_media_buy_delivery: windowed-breakdown parity withreporting_webhookfrequency (closes Rule 4 for data events; capability-scoped MUST so mid-size SSPs declare honestly rather than degrade webhook frequency).notification_idas a first-class envelope field (closes the prose-not-typed gap; SDK generators currently don't surfacenotification_id).impairment.coherenceconformance tooling (spec language is in this PR; assertion implementation is a separate code change)./docs/protocol/placement or should be renamed to "The contract for push channels" and demoted. Filed as a follow-up to debate post-merge rather than block.Refs #2838, #2853, #2855, #2856, #2857, #2858, #4582. Spin-outs: #4586, #4587.
🤖 Generated with Claude Code