Skip to content

feat(media-buy): dependency-impact cluster + webhook foundation (3.1)#4588

Merged
bokelley merged 8 commits into
mainfrom
bokelley/media-buy-at-risk-impacts
May 16, 2026
Merged

feat(media-buy): dependency-impact cluster + webhook foundation (3.1)#4588
bokelley merged 8 commits into
mainfrom
bokelley/media-buy-at-risk-impacts

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 16, 2026

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.jsonok | impaired, orthogonal to media-buy-status. A paused/pending/active buy can each be impaired without affecting status. default: "ok" on the field.
  • core/impairment.json — package-scoped dependency state change. Materiality: package_ids minItems: 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 by impairment.transition.to (was open string in the first draft). The resource_typeoffline_state pairing is enforced by impairment.coherence (compliance: impact.coherence assertion across media-buy and resource status #2859), not the JSON Schema.
  • core/media-buy.json gains health + impairments[]. Synchrony: snapshot MUST reflect the impairment within 5 minutes of observed_at regardless of buyer poll cadence (closed the gap where "next sync" had no max-staleness bound).
  • enums/notification-type.json gains impairment. 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)

Foundation docs (#4582 tracks 1–2)

  • New docs/protocol/snapshot-and-log.mdx — five-rule contract for push/pull duality:
    • Rule 1: dedupe transport retries by idempotency_key; correlate fires to state by notification_id. Two distinct ids, distinct purposes — same notification_id under different idempotency_keys = re-emission signal (missed-events warning), not retry.
    • Rule 2: every push event has a snapshot delta. No webhook-only state.
    • Rule 3: at-least-once delivery; snapshot is authoritative.
    • Rule 4: either path is complete. Buyer using webhooks reliably gets all data; buyer using only GET gets the same data. Holds today for state events; partial for data-bearing events (delivery report fires) — get_media_buy_delivery: windowed-breakdown parity with reporting_webhook frequency #4590 closes the gap for get_media_buy_delivery in 3.1.
    • Rule 5: push events and log entries share an id space.
  • docs/building/by-layer/L3/webhooks.mdx adds 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 via update_*, auth renewal, termination.
  • docs/media-buy/media-buys/lifecycle.mdx documents the health surface, materiality coverage (MUST vs SHOULD by resource type), reverse-direction rule, impairment.coherence invariant, the operational-vs-commercial non-goal, and a Remediation by reason_code table (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-status see no change (no new status value); buyers that read media-buy.health get the new dependency-health signal alongside their existing status handling.

Test plan

  • npm run build:schemas — clean
  • npm run test:schemas — 7/7 pass
  • npm run test:examples — 36/36 pass
  • npm run test:json-schema — 256/256 pass
  • npm run test:composed — 40/40 pass
  • npm run test:unit — 887/887 pass
  • npm run test:error-codes — clean
  • npm run test:storyboard-check-enum, test:storyboard-doc-parity — clean
  • npm run check:registry — 14 entries OK
  • Pre-push: docs validation + version sync — pass
  • Two expert review passes (protocol / product / docs / code) — all actionable items applied

Follow-up tickets (deliberately not in this PR)

Refs #2838, #2853, #2855, #2856, #2857, #2858, #4582. Spin-outs: #4586, #4587.

🤖 Generated with Claude Code

bokelley and others added 3 commits May 16, 2026 06:09
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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Expert review pass — addressed in 92fd09c

Ran 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

  • x-entity: impairment unregistered (code reviewer) — added to x-entity-types.json enum and x-entity-definitions.
  • adcp_version apparent downgrade (protocol reviewer) — false alarm; main is already at 3.0.3, the prior commit reconciled in-tree drift. No regression.

High-signal protocol gaps closed

  • Max-staleness budget on synchrony (protocol — "the actual gap"). media-buy.impairments[] description now states the snapshot MUST reflect the impairment within 5 minutes of observed_at regardless of buyer poll cadence. Closes the loophole where "next sync/poll response" without a bound let snapshots lag arbitrarily.
  • impairment_id removal semantics (protocol — deferral was unsafe). Closing webhook fire MUST reuse the same impairment_id as its notification_id; a new impairment for the same resource gets a new id. Receivers reconcile open-vs-closed via the snapshot, not via a separate id.
  • Materiality MUST/SHOULD split (product — mid-size SSP enforceability). MUST for resource types where the resource→buy join is cheap and 1:N (audience, event_source, property); SHOULD for creative and catalog_item where the pool join is expensive. Plus explicit "MAY report conservatively when uncertain; MUST NOT report when serving is provably unaffected."
  • transition.from now optional. Handles cases where the seller never observed the prior state (e.g., property depublished discovered via brand.json crawl).
  • additionalProperties: true on impairment.json (code reviewer). Aligns with the rest of core/ — published schemas are durable contracts.

Docs cleanup

  • Operational vs commercial non-goal (product — first contracted-buy implementer would otherwise build a dispute pipeline against impairments[]). New section in lifecycle.mdx: impairments are operational signals; commercial remedies route through accountability_terms.
  • Materiality coverage section in lifecycle.mdx explains the MUST/SHOULD split for implementers.
  • "When you'd be right to push back" wrapped in non-normative <Note> and moved below the gaps section (docs — agent retrieval landing on push-back framing as a sanctioned exception).
  • Coherence seam fixed (docs). Both snapshot-and-log.mdx and webhooks.mdx now state webhook_activity[] is for debugging and "buyers MUST NOT rely on it for state reconciliation; that's what the snapshot is for."
  • Feature-named link text for #4278 / #2261 references so anchors don't go stale when those land.
  • Removed invented enumDescriptions for pre-existing scheduled/final/delayed/adjusted values (code reviewer). Only the new impairment value carries a description in this PR.

Deferred / acknowledged

  • snapshot-and-log.mdx positioning (docs reviewer — "earns the foundation positioning vs. reads as RFC-justification?"). Real question, but the answer is a design discussion that shouldn't block merge. Two options: rename to "The contract for push channels" and demote from top-level /docs/protocol/, or invest a generalization pass against non-webhook surfaces. Filing as a follow-up after merge.
  • Coverage self-declaration (product reviewer — for sellers that don't compute the full materiality join). Useful but a separate capability and bigger surface; deferring to a future RFC rather than expanding scope here.
  • Remediation hooks (product reviewer — suggested_substitutes[], signals-agent handoff). Out of scope for this PR; the protocol delivers structured paging, not structured remediation. Worth a separate ticket if buyer agents accumulate concrete asks.

What reviewers explicitly endorsed

  • Orthogonal health vs. extending media-buy-status — protocol: "right call, file this debate as closed." Product: "every DSP has some 'running but something's wrong' indicator; buyers will map this cleanly."
  • Snapshot/log + persistent webhook contract — product: "production-grade, will be cited for years."
  • Shipping impairment-only (deferring defect/advisory to RFC: Resource defect signals (epic) #4586/RFC: Seller advisory / recommendation signals (epic) #4587) — product: "right sequencing — only axis with unambiguous owner + trigger."

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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Sharpening pass on the foundation contract

Three 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 notification_id was the only id. Receivers couldn't distinguish "got the same fire twice via transport retry" (uninteresting) from "seller re-emitted the open impairment because my receiver was down for 4 hours" (signal — missed events warning).

Now explicit:

  • idempotency_key — per-fire, transport-layer. Receivers dedupe to suppress retries.
  • notification_id (= impairment_id for impairment events) — per-state-event. Stable across re-emissions of the same logical event.
  • Same notification_id with different idempotency_keys = re-emission, not retry. Buyer should treat as missed-events warning.

The transport-layer split already existed in the protocol (idempotency_key is documented in webhooks.mdx). My doc collapsed them; now it doesn't. impairment.json schema description matches.

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) — get_media_buy_delivery returns the cumulative integral, not the per-fire differential. A buyer who misses a delivery fire can see the aggregate moved but loses the per-window detail.

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:

Event type Default Notes
impairment (general) 5min Resource-state impairments
impairment (latency-sensitive) sub-minute / no coalescence Fraud, brand safety — MUST NOT apply general default
Future advisory hours to daily High noise tolerance
Future defect minutes to hours Between impairment and advisory

Sellers MAY declare shorter via get_agent_capabilities. Sellers MUST NOT exceed per-type default without buyer opt-in.

Diff summary

Three files: docs/protocol/snapshot-and-log.mdx, docs/building/by-layer/L3/webhooks.mdx, static/schemas/source/core/impairment.json. All test-clean (schemas + examples + unit).

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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Two-paths principle — replacing the "snapshot is authoritative" framing

Reviewer 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 principle

Either 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.1

State events — holds today. Missed impairment → re-read get_media_buys, snapshot fully reconstructs.

Reporting (data-bearing events) — partial today. Two specific gaps:

  1. get_media_buy_delivery: windowed-breakdown parity with reporting_webhook frequency #4590 (filed)get_media_buy_delivery needs windowed pulls. If reporting_webhook fires hourly, the GET must accept window_granularity: hourly and return per-window slices matching the webhook payload. Today it returns daily breakdowns + cumulative aggregates only. The fix is additive: new request params + new windows[] response array + capability declaration.
  2. RFC: Buyer-side webhook delivery visibility on get_media_buys #4278 (existing)webhook_activity[] for per-fire transport observability. Closes "did the webhook hit my endpoint?" — debugging surface, not data recovery.

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 framing

Without 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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Addressed both diff comments + walk-back on structured remediation

Diff comment #1resource_type coining "resource" as a new concept

Tightened the field description on core/impairment.json. The values are the existing x-entity vocabulary (audience, creative, catalog_item, event_source, property) — not a new typology, just the subset of x-entity types whose offline state can impair serving. Description now says this explicitly so we don't drift into formalizing "Resource" as a separate AdCP abstraction.

Diff comment #2transition.to open string vs. canonical enum

New schema enums/impairment-offline-state.json — single shared enum of the canonical offline values: suspended | rejected | withdrawn | insufficient | depublished. transition.to now $refs it. Per-value enumDescriptions document which resource_type each is valid for.

The resource_typeoffline_state pairing is enforced by impairment.coherence (#2859) — same layer where all cross-resource pairing validation already lives. JSON Schema constraint at the field stops typos and case-sensitivity drift; coherence assertion stops resource-type/offline-state mismatch.

transition.from stays as open string (each resource_type has its own serviceable-state vocabulary; same coherence assertion validates).

Walk-back: dropped structured suggested_action idea

Brian flagged that for many reason codes (creative rejected, audience suspended) the buyer's remediation path is the same one they used at campaign setup — they already know which tool to call. A structured suggested_action.action_tool would either state the obvious or overreach into campaign-level judgment the seller can't make (is this creative load-bearing? does removal break the rotation math?).

The right boundary: seller knows what happened; buyer knows what it means for the campaign.

Approach: enrich impairment-reason-code.json enumDescriptions with "Typical remediation:" lines per code — protocol-level guidance, documented once, no per-impairment seller burden. E.g.:

content_rejected: "Previously-approved creative content was re-reviewed and rejected. Applies to: creative. Typical remediation: same path as initial creative approval — fix the issue and resubmit via sync_creatives. Buyer's campaign-level decision (replace with an alternate creative vs. wait for resubmit approval) depends on creative pool composition and is left to the buyer."

impairment.remediation stays as optional free-text on the impairment for seller-specific context that doesn't belong in protocol-level enum docs (e.g., "we restored this audience yesterday; sync now to pick up the refresh").

Files

  • static/schemas/source/enums/impairment-offline-state.json (new)
  • static/schemas/source/core/impairment.json (resource_type description + transition.to $ref + transition.from clarification)
  • static/schemas/source/enums/impairment-reason-code.json (Typical remediation: lines per code)

Schema + example tests clean.

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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Second expert-review pass — applied

Ran 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

  • impairment-reason-code.json — stripped "Typical remediation:" lines from enumDescriptions (docs flag — verbose normative prose in enum value docstrings conflates value-definition with guidance; SDK generators emit these as docstring/godoc on the enum value). Clarified seller_removed vs withdrawn vs policy_violation boundary (code flag).
  • notification-type.json — re-added minimal factual enumDescriptions for scheduled / final / delayed / adjusted (code flag — was silently producing missing docstrings for 4 of 5 values). These are short factual descriptions, not invented semantics.
  • media-buy.json health — added default: "ok" and wrapped $ref in allOf to allow the sibling default keyword (code flag — description claimed defaulting but schema didn't enforce).
  • impairment.json transition.from — added pattern: "^[a-z][a-z0-9_]*$" (protocol flag — blocks free-form garbage while keeping vocabulary open per resource_type).

Lifecycle doc

  • New "Remediation by reason_code" table in lifecycle.mdx. The typical-remediation prose moved here from enumDescriptions. SDK generators get clean categorical reasons in the enum; humans/agents reading the lifecycle docs get the remediation paths. The pii_audit_failed row in the table now correctly reflects that buyers MUST wait for the seller to clear the audit before re-syncing — protocol expert flagged the prior phrasing as a buyer-agent loop hazard.

Issues

  • get_media_buy_delivery: windowed-breakdown parity with reporting_webhook frequency #4590 updated — capability-scoped MUST (product flag — strict MUST would force mid-size SSPs with stream-tap webhook architectures to downgrade webhook frequency to dodge the requirement; honest declaration via windowed_pull_granularities capability is the right answer). Renamed window_granularitytime_granularity (product flag — industry naming matches DV360 groupBy: HOUR, TTD TimeBreakdown, Xandr equivalents).
  • webhook envelope: type notification_id as a first-class field #4594 filed — type notification_id as a first-class envelope field. Closes the gap protocol expert flagged: receivers building strictly from JSON Schema wouldn't see notification_id in their type definitions today.

Reviewer endorsements this pass

  • Two-paths principle reads as honest contract, not marketing — the ✅ Holds today / ⚠️ Partial today pairing and the "until get_media_buy_delivery: windowed-breakdown parity with reporting_webhook frequency #4590 lands" callout earn foundation positioning.
  • Two-id model is buildable from the docs alone — receiver logic ("dedupe on idempotency_key; upsert state on notification_id; flag re-emission") is unambiguous.
  • transition.to enum + from open + impairment.coherence pairing validation is the right split between field-level constraint and conformance-assertion enforcement.
  • Walk-back from structured suggested_action is correct — buyer agents will hardcode reason_code → tool mappings regardless; enumDescriptions make those mappings consistent across agents (that's the actual interop win).
  • get_media_buy_delivery: windowed-breakdown parity with reporting_webhook frequency #4590's windows[] shape matching webhook payload is correct — makes the "either path is complete" claim verifiable instead of aspirational; costs sellers nothing since the serializer is already written.

Acknowledged-but-deferred

  • Move "Typical remediation:" out of enumDescriptions entirely — done; now lives in lifecycle.mdx table.
  • enumDescriptions are now doing normative work (product flag) — true. Bar for changing them post-3.1 should be enum-value-change level (back-compat review), not "doc tweak." Worth a separate note in spec-guidelines but doesn't block this PR.

Files in this pass

docs/protocol/snapshot-and-log.mdx, docs/media-buy/media-buys/lifecycle.mdx, static/schemas/source/enums/impairment-reason-code.json, static/schemas/source/enums/notification-type.json, static/schemas/source/core/media-buy.json, static/schemas/source/core/impairment.json.

Schema + example tests clean.

@bokelley bokelley added rfc Protocol change — auto-adds to roadmap board media-buy Issue concerns the media-buy protocol domain labels May 16, 2026
…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>
@bokelley bokelley merged commit c382ec0 into main May 16, 2026
18 checks passed
@bokelley bokelley deleted the bokelley/media-buy-at-risk-impacts branch May 16, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

media-buy Issue concerns the media-buy protocol domain rfc Protocol change — auto-adds to roadmap board

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

audience-sync: seller-initiated archival / suspension not modeled

1 participant