From fc7b5c0e80227929f986cafc2efc3c81090364dc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 09:33:51 +0000 Subject: [PATCH 1/8] feat(reporting): compute owed reporting-subscription periods and their capabilities Add adcp.reporting.subscriptions, the shared half of AdCP 3.3 principal-level reporting subscriptions (media_buy.reporting_subscriptions): - expected_subscription_periods(): the coverage rule from core/reporting-subscription.json as a pure function over plain data. It owes each confirmed buy and resolved offering the complete periods that start at or after the latest of the version's accepted_at, the buy's confirmed_at and the account's authorized_at, and that overlap the flight as of each period's scope_resolved_at. It applies cutovers for versions, deactivation, resolution changes and account timezone changes, removes exceptions and gaps over [from, until), and handles revocation and re-grant with and without backfill_on_regrant. Results are ExpectedReportingPeriod values, so they feed evaluate_reporting_ledger and the consumer-status planner unchanged. - resolve_subscription_offerings(): exact / finest / all selection with ties to the lowest offering_id in byte order. - subscription_capabilities() and advertise_reporting_subscriptions(): configuration_tasks, the deprecated configuration_task only when sync_accounts is listed, the subscriptions block, the feature id and the principal section. Civil-time schedules carry the normative local-midnight origin explicitly, so they do not depend on the ledger's legacy anchor default. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/__init__.py | 1 + src/adcp/reporting/subscriptions/__init__.py | 77 + src/adcp/reporting/subscriptions/_clock.py | 164 +++ .../reporting/subscriptions/capabilities.py | 157 ++ src/adcp/reporting/subscriptions/coverage.py | 804 +++++++++++ .../reporting/subscriptions/resolution.py | 421 ++++++ ...est_reporting_subscription_capabilities.py | 152 ++ tests/test_reporting_subscription_coverage.py | 1260 +++++++++++++++++ 8 files changed, 3036 insertions(+) create mode 100644 src/adcp/reporting/subscriptions/__init__.py create mode 100644 src/adcp/reporting/subscriptions/_clock.py create mode 100644 src/adcp/reporting/subscriptions/capabilities.py create mode 100644 src/adcp/reporting/subscriptions/coverage.py create mode 100644 src/adcp/reporting/subscriptions/resolution.py create mode 100644 tests/test_reporting_subscription_capabilities.py create mode 100644 tests/test_reporting_subscription_coverage.py diff --git a/src/adcp/reporting/__init__.py b/src/adcp/reporting/__init__.py index 47fea6c52..ae2c83f1f 100644 --- a/src/adcp/reporting/__init__.py +++ b/src/adcp/reporting/__init__.py @@ -161,6 +161,7 @@ "service", "service_production", "source", + "subscriptions", "testing", } ) diff --git a/src/adcp/reporting/subscriptions/__init__.py b/src/adcp/reporting/subscriptions/__init__.py new file mode 100644 index 000000000..9f72f54e6 --- /dev/null +++ b/src/adcp/reporting/subscriptions/__init__.py @@ -0,0 +1,77 @@ +"""AdCP 3.3 principal-level reporting subscriptions (experimental). + +``media_buy.reporting_subscriptions`` lets a caller register reporting once on +its principal through ``sync_principal`` instead of once per account through +``sync_accounts``. This package holds the parts both sides share: + +* :func:`expected_subscription_periods` -- the pure coverage rule. A buyer runs + it from its own records to learn exactly which periods it is owed; the + seller runtime runs it over the same facts to decide which obligations it + owes. +* :func:`resolve_subscription_offerings` -- deterministic selector resolution + (``exact`` / ``finest`` / ``all``, ties to the lowest ``offering_id``). +* :func:`subscription_capabilities` -- the capability fields a seller that + serves subscriptions must advertise. +""" + +from __future__ import annotations + +from adcp.reporting.subscriptions.capabilities import ( + REPORTING_SUBSCRIPTIONS_FEATURE, + REPORTING_SUBSCRIPTIONS_SECTION, + ReportingSubscriptionSupport, + advertise_reporting_subscriptions, + reporting_configuration_tasks, + subscription_capabilities, +) +from adcp.reporting.subscriptions.coverage import ( + AccountTimezoneChange, + ExpectedSubscriptionPeriod, + SubscriptionAuthorization, + SubscriptionBuy, + SubscriptionBuyFlight, + SubscriptionBuyPackage, + SubscriptionException, + SubscriptionVersionWindow, + expected_subscription_periods, + subscription_covers_account, + subscription_exceptions, + subscription_version_windows, +) +from adcp.reporting.subscriptions.resolution import ( + STANDARD_REPORTING_DIMENSIONS, + SubscriptionDestination, + SubscriptionMatch, + SubscriptionOffering, + SubscriptionResolution, + SubscriptionSelector, + resolve_subscription_offerings, +) + +__all__ = [ + "REPORTING_SUBSCRIPTIONS_FEATURE", + "REPORTING_SUBSCRIPTIONS_SECTION", + "STANDARD_REPORTING_DIMENSIONS", + "AccountTimezoneChange", + "ExpectedSubscriptionPeriod", + "ReportingSubscriptionSupport", + "SubscriptionAuthorization", + "SubscriptionBuy", + "SubscriptionBuyFlight", + "SubscriptionBuyPackage", + "SubscriptionDestination", + "SubscriptionException", + "SubscriptionMatch", + "SubscriptionOffering", + "SubscriptionResolution", + "SubscriptionSelector", + "SubscriptionVersionWindow", + "advertise_reporting_subscriptions", + "expected_subscription_periods", + "reporting_configuration_tasks", + "resolve_subscription_offerings", + "subscription_capabilities", + "subscription_covers_account", + "subscription_exceptions", + "subscription_version_windows", +] diff --git a/src/adcp/reporting/subscriptions/_clock.py b/src/adcp/reporting/subscriptions/_clock.py new file mode 100644 index 000000000..dfc5ef7fb --- /dev/null +++ b/src/adcp/reporting/subscriptions/_clock.py @@ -0,0 +1,164 @@ +"""Period clock shared by subscription coverage on both sides of the wire. + +A reporting subscription owes the complete periods of the resolved offering's +published schedule. The boundaries come from ``core/reporting-schedule.json`` +``period_generation``: ``utc`` counts from 1970-01-01T00:00:00Z, and +``account_timezone`` and ``source_timezone`` count from *local* midnight on +1970-01-01 in their zone. ``billing_cycle`` counts from its explicit anchor in +its zone. + +The ledger's :class:`~adcp.reporting.ledger.models.ReportingScheduleSpec` +defaults a civil-time schedule without an anchor to the UTC epoch expressed in +local time, which for ``America/New_York`` puts every daily boundary at 19:00 +local. Production admission already refuses that phase. Subscriptions never +rely on that default: every civil-time schedule built here carries the +normative local-midnight anchor explicitly, so the seller's obligations and the +buyer's expectations use one derivation. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from adcp.reporting.ledger.models import ( + ReportingScheduleSpec, + _period_instants, + first_ordinal_after, + iso_duration_to_timedelta, +) + +UTC = timezone.utc + + +def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime: + """Return ``value`` in UTC, refusing naive datetimes.""" + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{field} must be timezone-aware") + return value.astimezone(UTC) + + +def require_zone(name: str, *, field: str = "timezone") -> ZoneInfo: + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError) as error: + raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") from error + + +def local_midnight_origin(zone_name: str) -> datetime: + """The normative origin of a civil-time schedule: 1970-01-01T00:00 local.""" + zone = require_zone(zone_name) + return datetime(1970, 1, 1, tzinfo=zone).astimezone(UTC) + + +def civil_schedule( + *, + period_duration: str, + delivery_sla: str, + zone_name: str, + anchor: datetime | None = None, +) -> ReportingScheduleSpec: + """Build a ledger schedule with an explicit, normative origin. + + ``zone_name == "UTC"`` without an anchor is the ``utc`` alignment. + """ + duration = iso_duration_to_timedelta(period_duration) + if duration <= timedelta(0): + raise ValueError("period_duration must be positive") + iso_duration_to_timedelta(delivery_sla) + if anchor is None and zone_name == "UTC": + return ReportingScheduleSpec( + period_duration=period_duration, delivery_sla=delivery_sla, alignment="utc" + ) + require_zone(zone_name) + return ReportingScheduleSpec( + period_duration=period_duration, + delivery_sla=delivery_sla, + alignment="custom_timezone", + period_timezone=zone_name, + period_anchor=( + utc_instant(anchor, field="period_anchor") + if anchor is not None + else local_midnight_origin(zone_name) + ), + ) + + +def _zone_name(schedule: ReportingScheduleSpec) -> str: + return schedule.timezone_name("UTC") + + +def period_bounds(schedule: ReportingScheduleSpec, ordinal: int) -> tuple[datetime, datetime]: + return _period_instants(schedule, _zone_name(schedule), ordinal) + + +def first_ordinal_at_or_after(schedule: ReportingScheduleSpec, instant: datetime) -> int: + return first_ordinal_after( + schedule, account_timezone=_zone_name(schedule), activated_at=utc_instant(instant) + ) + + +def first_boundary_at_or_after(schedule: ReportingScheduleSpec, instant: datetime) -> datetime: + """The cutover instant E for an outgoing schedule at change instant T. + + E is T when T falls on a boundary, and otherwise the end of the period + containing T: the end of the last period that starts before T. + """ + return period_bounds(schedule, first_ordinal_at_or_after(schedule, instant))[0] + + +def last_boundary_at_or_before(schedule: ReportingScheduleSpec, instant: datetime) -> datetime: + """The start of the period containing ``instant``, or ``instant`` on a boundary.""" + at = utc_instant(instant) + ordinal = first_ordinal_at_or_after(schedule, at) + start = period_bounds(schedule, ordinal)[0] + if start == at: + return start + return period_bounds(schedule, ordinal - 1)[0] + + +def iter_periods( + schedule: ReportingScheduleSpec, *, starts_from: datetime, starts_before: datetime +) -> Iterator[tuple[datetime, datetime]]: + """Yield every nonempty period whose start lies in ``[starts_from, starts_before)``. + + A spring-forward gap can collapse a civil slot to zero elapsed time; that + slot is not a period. A nonexistent local boundary otherwise advances by + the gap, as ``period_generation`` requires, so the period is kept. + """ + upper = utc_instant(starts_before) + ordinal = first_ordinal_at_or_after(schedule, starts_from) + while True: + start, end = period_bounds(schedule, ordinal) + if start >= upper: + return + if end > start: + yield start, end + ordinal += 1 + + +def delivery_sla(schedule: ReportingScheduleSpec) -> timedelta: + return iso_duration_to_timedelta(schedule.delivery_sla) + + +def iso_z(value: datetime) -> str: + """RFC 3339 UTC with a ``Z`` suffix and no fractional seconds when whole.""" + instant = utc_instant(value) + if instant.microsecond: + return instant.isoformat().replace("+00:00", "Z") + return instant.strftime("%Y-%m-%dT%H:%M:%SZ") + + +__all__ = [ + "civil_schedule", + "delivery_sla", + "first_boundary_at_or_after", + "iso_z", + "iter_periods", + "last_boundary_at_or_before", + "local_midnight_origin", + "period_bounds", + "require_zone", + "utc_instant", +] diff --git a/src/adcp/reporting/subscriptions/capabilities.py b/src/adcp/reporting/subscriptions/capabilities.py new file mode 100644 index 000000000..59adb2f65 --- /dev/null +++ b/src/adcp/reporting/subscriptions/capabilities.py @@ -0,0 +1,157 @@ +"""Capability fields a seller serving reporting subscriptions advertises. + +``core/reporting-delivery-capabilities.json`` (3.3): + +* ``configuration_tasks`` lists every task that accepts reporting + registrations: ``sync_principal`` for subscriptions, ``sync_accounts`` for + account-level ``reporting_delivery_configs``. +* The deprecated ``configuration_task: "sync_accounts"`` is present when and + only when ``configuration_tasks`` lists ``sync_accounts`` or is absent. +* ``subscriptions`` (``supported``, optional ``max_accounts``, + ``backfill_on_regrant``, ``dimensions``) is present when and only when + ``configuration_tasks`` lists ``sync_principal``. +* Advertising subscriptions also requires the ``media_buy.reporting_subscriptions`` + experimental feature and ``reporting_subscriptions`` in + ``adcp.principal.supported_sections``. + +A seller that does not serve subscriptions keeps its existing block: these +helpers leave it byte-for-byte unchanged when ``support`` is ``None``. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +from adcp.reporting.subscriptions.resolution import STANDARD_REPORTING_DIMENSIONS + +#: Experimental feature id for principal-level reporting subscriptions. +REPORTING_SUBSCRIPTIONS_FEATURE = "media_buy.reporting_subscriptions" +#: ``adcp.principal.supported_sections`` value for the section. +REPORTING_SUBSCRIPTIONS_SECTION = "reporting_subscriptions" +_DIMENSION = re.compile(r"[a-z][a-z0-9_]*") + + +@dataclass(frozen=True) +class ReportingSubscriptionSupport: + """What a seller declares about its reporting-subscription runtime. + + ``account_configuration`` keeps ``sync_accounts`` in ``configuration_tasks`` + (and the deprecated ``configuration_task``) for sellers that still serve + account-level registration; set it ``False`` only when the seller accepts + reporting registration through ``sync_principal`` alone, which serves 3.3 + callers only. + """ + + max_accounts: int | None = None + backfill_on_regrant: bool = False + dimensions: tuple[str, ...] = () + account_configuration: bool = True + + def __post_init__(self) -> None: + if self.max_accounts is not None and self.max_accounts < 1: + raise ValueError("max_accounts must be at least 1") + dimensions = tuple(dict.fromkeys(self.dimensions)) + for value in dimensions: + if not _DIMENSION.fullmatch(value): + raise ValueError(f"dimension {value!r} is not a vocabulary token") + object.__setattr__(self, "dimensions", dimensions) + + @property + def standard_dimensions(self) -> tuple[str, ...]: + return tuple(item for item in self.dimensions if item in STANDARD_REPORTING_DIMENSIONS) + + def block(self) -> dict[str, Any]: + """The ``reporting_delivery.subscriptions`` capability object.""" + payload: dict[str, Any] = {"supported": True} + if self.max_accounts is not None: + payload["max_accounts"] = self.max_accounts + if self.backfill_on_regrant: + payload["backfill_on_regrant"] = True + if self.dimensions: + payload["dimensions"] = list(self.dimensions) + return payload + + +def reporting_configuration_tasks( + *, subscriptions: bool, account_configuration: bool = True +) -> list[str]: + """``configuration_tasks`` in the schema's order of preference.""" + tasks: list[str] = [] + if subscriptions: + tasks.append("sync_principal") + if account_configuration: + tasks.append("sync_accounts") + if not tasks: + raise ValueError("a reporting seller accepts registration through at least one task") + return tasks + + +def subscription_capabilities( + reporting_delivery: Mapping[str, Any], support: ReportingSubscriptionSupport | None +) -> dict[str, Any]: + """Return ``reporting_delivery`` with the registration fields for ``support``. + + ``None`` returns an unchanged copy, so a seller without subscriptions + advertises exactly what it did before. + """ + payload = dict(reporting_delivery) + if support is None: + return payload + tasks = reporting_configuration_tasks( + subscriptions=True, account_configuration=support.account_configuration + ) + payload["configuration_tasks"] = tasks + if "sync_accounts" in tasks: + payload["configuration_task"] = "sync_accounts" + else: + payload.pop("configuration_task", None) + payload["subscriptions"] = support.block() + return payload + + +def _append_unique(values: Iterable[Any], value: str) -> list[str]: + result = [str(getattr(item, "value", item)) for item in values] + if value not in result: + result.append(value) + return result + + +def advertise_reporting_subscriptions( + capabilities: Mapping[str, Any], support: ReportingSubscriptionSupport | None +) -> dict[str, Any]: + """Add the feature id and principal section to a capabilities response. + + Expects ``media_buy.reporting_delivery`` already projected through + :func:`subscription_capabilities`. The ``adcp.principal`` block is touched + only when present: a seller advertising subscriptions must already + advertise the principal layer, which owns its own block. + """ + payload = dict(capabilities) + if support is None: + return payload + payload["experimental_features"] = _append_unique( + payload.get("experimental_features") or (), REPORTING_SUBSCRIPTIONS_FEATURE + ) + adcp = payload.get("adcp") + if isinstance(adcp, Mapping) and isinstance(adcp.get("principal"), Mapping): + principal = dict(adcp["principal"]) + principal["supported_sections"] = _append_unique( + principal.get("supported_sections") or (), REPORTING_SUBSCRIPTIONS_SECTION + ) + adcp = dict(adcp) + adcp["principal"] = principal + payload["adcp"] = adcp + return payload + + +__all__ = [ + "REPORTING_SUBSCRIPTIONS_FEATURE", + "REPORTING_SUBSCRIPTIONS_SECTION", + "ReportingSubscriptionSupport", + "advertise_reporting_subscriptions", + "reporting_configuration_tasks", + "subscription_capabilities", +] diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py new file mode 100644 index 000000000..c7b62346a --- /dev/null +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -0,0 +1,804 @@ +"""Which reporting periods a subscription owes one account: the buyer's rule. + +This is the pure, plain-data form of the coverage rule in +``core/reporting-subscription.json`` (``x-adcp-validation.coverage`` and +``cutover``). A buyer runs it from its own records; the seller runtime runs the +same function over the same facts to decide which obligations it owes, so the +two sides agree period by period by construction. + +For each covered buy and resolved offering, the owed periods are the complete +periods of the offering's published schedule that + +1. start on or after the first boundary at or after the latest of the + subscription version's ``accepted_at``, the buy's ``confirmed_at`` and the + account's ``authorized_at``; and +2. overlap the buy's flight from ``start_time`` to its stop time, the earliest + of ``end_time``, ``canceled_at`` and ``completed_at``, with the flight taken + as of the period's ``scope_resolved_at`` (its end). + +Periods are never clipped: the period containing the stop time is owed in +full. Every change that hands periods from an outgoing schedule to an incoming +one at an instant T is a cutover: the outgoing schedule owes its periods that +start before T in full, E is the end of the last of them, and the incoming +schedule owes its periods that start at or after E. Version changes, +deactivation, resolution changes and account-timezone changes all use it. + +Exceptions only remove periods: each ``excluded[]`` entry and each ``gaps[]`` +entry removes the periods whose start falls in ``[from, until)``. Lost +authority removes periods the same way unless ``backfill_on_regrant`` keeps +them. + +Pairing rule for cutovers (where the spec is silent) + Cutovers are paired per product. An offering present on both sides of T + continues from E of its own outgoing schedule. When offerings leave and + others arrive at the same T (a resolution or selector change), each + arriving offering starts at the latest E among the leaving ones, so no + period is owed twice. An offering that arrives with nothing leaving starts + at T, under the ordinary start rule. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any + +from adcp.reporting._reconcile import ExpectedReportingPeriod +from adcp.reporting.ledger.models import ReportingScheduleSpec +from adcp.reporting.subscriptions._clock import ( + delivery_sla, + first_boundary_at_or_after, + iso_z, + iter_periods, + last_boundary_at_or_before, + utc_instant, +) +from adcp.reporting.subscriptions.resolution import ( + SubscriptionOffering, + SubscriptionSelector, + _text, + _value, + resolve_subscription_offerings, +) + +_FAR_FUTURE = datetime(9999, 1, 1, tzinfo=timezone.utc) +#: Exclusion reasons that remove periods. ``package_ids`` entries never do. +_REASONS = frozenset( + {"offering_unsupported", "capacity", "overridden", "excluded_by_buyer", "action_required"} +) + + +def _instant(value: Any, *, field: str) -> datetime | None: + if value is None: + return None + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + if not isinstance(value, datetime): + raise TypeError(f"{field} must be a datetime or an RFC 3339 string") + return utc_instant(value, field=field) + + +def _require_instant(value: Any, *, field: str) -> datetime: + instant = _instant(value, field=field) + if instant is None: + raise ValueError(f"{field} is required") + return instant + + +# -- plain-data inputs ------------------------------------------------------ + + +@dataclass(frozen=True) +class SubscriptionBuyFlight: + """A flight a buy had before a later change superseded it at ``superseded_at``. + + Periods whose ``scope_resolved_at`` (end) is before ``superseded_at`` were + resolved under this flight, so an extension or reinstatement never makes + an elapsed period owed after the fact. + """ + + superseded_at: datetime + start_time: datetime + end_time: datetime + canceled_at: datetime | None = None + completed_at: datetime | None = None + + def __post_init__(self) -> None: + for name in ("superseded_at", "start_time", "end_time", "canceled_at", "completed_at"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, utc_instant(value, field=name)) + + @property + def stop_time(self) -> datetime: + return _stop(self.end_time, self.canceled_at, self.completed_at) + + +def _stop( + end_time: datetime, canceled_at: datetime | None, completed_at: datetime | None +) -> datetime: + return min(item for item in (end_time, canceled_at, completed_at) if item is not None) + + +@dataclass(frozen=True) +class SubscriptionBuyPackage: + """One package of a buy and its product's ``reporting_delivery_offering_ids``.""" + + package_id: str + reporting_delivery_offering_ids: tuple[str, ...] + + def __post_init__(self) -> None: + object.__setattr__( + self, + "reporting_delivery_offering_ids", + tuple(self.reporting_delivery_offering_ids), + ) + + +@dataclass(frozen=True) +class SubscriptionBuy: + """One media buy the caller can see through ``get_media_buys``, as plain data. + + The top-level flight is the buy's current one. ``superseded_flights`` + records earlier flights and when a change replaced each. Without + ``packages``, ``reporting_delivery_offering_ids`` is the buy's products' + offering ids, resolved as one product. + """ + + media_buy_id: str + confirmed_at: datetime | None + start_time: datetime + end_time: datetime + canceled_at: datetime | None = None + completed_at: datetime | None = None + packages: tuple[SubscriptionBuyPackage, ...] = () + reporting_delivery_offering_ids: tuple[str, ...] = () + superseded_flights: tuple[SubscriptionBuyFlight, ...] = () + + def __post_init__(self) -> None: + if not self.media_buy_id: + raise ValueError("media_buy_id is required") + for name in ("confirmed_at", "start_time", "end_time", "canceled_at", "completed_at"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, utc_instant(value, field=name)) + object.__setattr__(self, "packages", tuple(self.packages)) + object.__setattr__( + self, "reporting_delivery_offering_ids", tuple(self.reporting_delivery_offering_ids) + ) + flights = tuple(sorted(self.superseded_flights, key=lambda item: item.superseded_at)) + object.__setattr__(self, "superseded_flights", flights) + + @property + def stop_time(self) -> datetime: + return _stop(self.end_time, self.canceled_at, self.completed_at) + + def flight_at(self, resolved_at: datetime) -> tuple[datetime, datetime]: + """``(start_time, stop_time)`` as of a period's ``scope_resolved_at``. + + A change effective exactly at ``resolved_at`` is visible. + """ + for flight in self.superseded_flights: + if resolved_at < flight.superseded_at: + return flight.start_time, flight.stop_time + return self.start_time, self.stop_time + + def product_offering_ids(self) -> tuple[tuple[str, tuple[str, ...]], ...]: + if self.packages: + return tuple( + (package.package_id, package.reporting_delivery_offering_ids) + for package in self.packages + ) + return (("", self.reporting_delivery_offering_ids),) + + def _earliest_start(self) -> datetime: + return min((self.start_time, *(flight.start_time for flight in self.superseded_flights))) + + def _latest_stop(self) -> datetime: + return max((self.stop_time, *(flight.stop_time for flight in self.superseded_flights))) + + +@dataclass(frozen=True) +class SubscriptionAuthorization: + """One grant of account authority to the caller, ``[authorized_at, unauthorized_at)``. + + Pass the account's grant history, oldest first. A loss followed by a + re-grant within ``status_retention_days`` continues coverage, with a gap + unless the seller backfills; a later re-grant is a fresh coverage start. + """ + + authorized_at: datetime + unauthorized_at: datetime | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, "authorized_at", utc_instant(self.authorized_at, field="authorized_at") + ) + if self.unauthorized_at is not None: + object.__setattr__( + self, + "unauthorized_at", + utc_instant(self.unauthorized_at, field="unauthorized_at"), + ) + if self.unauthorized_at <= self.authorized_at: + raise ValueError("unauthorized_at must be after authorized_at") + + +@dataclass(frozen=True) +class AccountTimezoneChange: + """An account timezone change at ``changed_at`` (the instant T of the cutover).""" + + changed_at: datetime + timezone: str + + def __post_init__(self) -> None: + object.__setattr__(self, "changed_at", utc_instant(self.changed_at, field="changed_at")) + + +@dataclass(frozen=True) +class SubscriptionException: + """One period-removing exception: periods starting in ``[from_, until)``. + + ``media_buy_id`` scopes it to one buy; otherwise it applies to the account. + """ + + reason: str + from_: datetime + until: datetime | None = None + media_buy_id: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "from_", utc_instant(self.from_, field="from")) + if self.until is not None: + object.__setattr__(self, "until", utc_instant(self.until, field="until")) + + def removes(self, start: datetime, media_buy_id: str) -> bool: + if self.media_buy_id is not None and self.media_buy_id != media_buy_id: + return False + return self.from_ <= start and (self.until is None or start < self.until) + + +def subscription_exceptions( + excluded: Iterable[Any] = (), gaps: Iterable[Any] = () +) -> tuple[SubscriptionException, ...]: + """Normalize ``subscription_coverage`` ``excluded[]`` and ``gaps[]`` entries. + + An ``offering_unsupported`` entry naming ``package_ids`` removes those + packages from the buy's coverage, not periods, so it is dropped here. + """ + result: list[SubscriptionException] = [] + for entry in excluded: + if isinstance(entry, SubscriptionException): + result.append(entry) + continue + if hasattr(entry, "model_dump"): + entry = entry.model_dump(mode="json", exclude_none=True, by_alias=True) + if _value(entry, "package_ids"): + continue + reason = _text(_value(entry, "reason")) + if reason not in _REASONS: + raise ValueError(f"unknown subscription exclusion reason {reason!r}") + media_buy_id = _value(entry, "media_buy_id") + result.append( + SubscriptionException( + reason=reason, + from_=_require_instant(_value(entry, "from", _value(entry, "from_")), field="from"), + until=_instant(_value(entry, "until"), field="until"), + media_buy_id=None if media_buy_id is None else _text(media_buy_id), + ) + ) + for gap in gaps: + if hasattr(gap, "model_dump"): + gap = gap.model_dump(mode="json", exclude_none=True, by_alias=True) + result.append( + SubscriptionException( + reason="unauthorized", + from_=_require_instant(_value(gap, "from", _value(gap, "from_")), field="from"), + until=_require_instant(_value(gap, "until"), field="until"), + ) + ) + return tuple(result) + + +@dataclass(frozen=True) +class SubscriptionVersionWindow: + """One subscription version and the instants it governs: ``[accepted_at, ends_at)``. + + ``ends_at`` is the next version's ``accepted_at`` (``superseded_at``) or the + ``deactivated_at`` of the last version; ``None`` while it is current. + """ + + subscription_version: int + accepted_at: datetime + ends_at: datetime | None + configuration: Any + + def __post_init__(self) -> None: + object.__setattr__(self, "accepted_at", utc_instant(self.accepted_at, field="accepted_at")) + if self.ends_at is not None: + object.__setattr__(self, "ends_at", utc_instant(self.ends_at, field="ends_at")) + if self.ends_at < self.accepted_at: + raise ValueError("a version cannot end before it was accepted") + + def contains(self, instant: datetime) -> bool: + return self.accepted_at <= instant and (self.ends_at is None or instant < self.ends_at) + + +def subscription_version_windows( + subscription: Any, *, version_configurations: Mapping[int, Any] | None = None +) -> tuple[SubscriptionVersionWindow, ...]: + """Derive version windows from a ``core/reporting-subscription-state.json`` readback. + + The readback keeps ``prior_versions`` (version, ``accepted_at``, + ``superseded_at``) but not their configurations. Supply each prior + version's configuration in ``version_configurations`` when it differs + from the current one; otherwise the current configuration is assumed. + """ + if hasattr(subscription, "model_dump"): + subscription = subscription.model_dump(mode="json", exclude_none=True) + configurations = dict(version_configurations or {}) + current_version = int(_value(subscription, "subscription_version")) + current_configuration = _value(subscription, "configuration") + if current_configuration is None: + raise ValueError("subscription state has no configuration") + state = _text(_value(subscription, "state", "active")) + deactivated_at = _instant(_value(subscription, "deactivated_at"), field="deactivated_at") + if state == "inactive" and deactivated_at is None: + raise ValueError("an inactive subscription must carry deactivated_at") + windows = [ + SubscriptionVersionWindow( + subscription_version=current_version, + accepted_at=_require_instant(_value(subscription, "accepted_at"), field="accepted_at"), + ends_at=deactivated_at if state == "inactive" else None, + configuration=configurations.get(current_version, current_configuration), + ) + ] + for prior in _value(subscription, "prior_versions", ()) or (): + version = int(_value(prior, "subscription_version")) + windows.append( + SubscriptionVersionWindow( + subscription_version=version, + accepted_at=_require_instant(_value(prior, "accepted_at"), field="accepted_at"), + ends_at=_require_instant(_value(prior, "superseded_at"), field="superseded_at"), + configuration=configurations.get(version, current_configuration), + ) + ) + windows.sort(key=lambda item: (item.accepted_at, item.subscription_version)) + for earlier, later in zip(windows, windows[1:]): + if earlier.subscription_version >= later.subscription_version: + raise ValueError("subscription versions must increase with accepted_at") + if earlier.ends_at is None or earlier.ends_at > later.accepted_at: + raise ValueError("a prior version must be superseded no later than its successor") + return tuple(windows) + + +def subscription_covers_account( + configuration: Any, *, account_id: str, account_operator: str | None = None +) -> bool: + """Whether a subscription version's scope selects the account. + + ``exclude_account_ids`` and ``operator_domains`` narrow the scope; the + seller also reports an excluded account as ``excluded_by_buyer``. + """ + if hasattr(configuration, "model_dump"): + configuration = configuration.model_dump(mode="json", exclude_none=True) + scope = _value(configuration, "scope") or {} + account_ids = _value(scope, "account_ids") + if account_ids is not None: + if account_id not in {_text(item) for item in account_ids}: + return False + elif _value(scope, "all_authorized_accounts") is not True: + return False + excluded = _value(configuration, "exclude_account_ids") or () + if account_id in {_text(item) for item in excluded}: + return False + domains = _value(configuration, "operator_domains") + if domains: + if account_operator is None: + raise ValueError( + "the subscription filters by operator_domains; pass the account's operator" + ) + return account_operator in {_text(item) for item in domains} + return True + + +# -- output ----------------------------------------------------------------- + + +@dataclass(frozen=True, kw_only=True) +class ExpectedSubscriptionPeriod(ExpectedReportingPeriod): + """One owed period of one (subscription version, resolved offering). + + An :class:`~adcp.reporting.ExpectedReportingPeriod`, so it feeds + :func:`~adcp.reporting.evaluate_reporting_ledger` and the consumer-status + planner unchanged. ``delivery_config_id`` is the ``subscription_id`` and + ``delivery_config_version`` the ``subscription_version``. ``media_buy_ids`` + are the buys owed this period under this offering, the denominator the + seller freezes at ``scope_resolved_at``. + + ``withheld`` marks a period owed before a loss of authority that is not + due while authority is absent; its ``expected_at`` is ``None``. After a + re-grant it is due at the re-grant time plus the offering's + ``delivery_sla``, never earlier than its original ``expected_at``. + """ + + account_id: str + subscription_id: str + offering_id: str + withheld: bool = False + + +# -- the rule ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Slot: + """One (version, offering, zone) feed a product resolves to in one segment.""" + + version: int + offering: SubscriptionOffering + zone: str + schedule: ReportingScheduleSpec + + def same_feed(self, other: _Slot) -> bool: + return ( + self.version == other.version + and self.offering.offering_id == other.offering.offering_id + and self.zone == other.zone + ) + + +@dataclass +class _SlotState: + slot: _Slot + lower: datetime + + +@dataclass(frozen=True) +class _Loss: + unauthorized_at: datetime + regranted_at: datetime | None + expired: bool + + +@dataclass +class _Owed: + version: int + offering: SubscriptionOffering + zone: str + start: datetime + end: datetime + expected_at: datetime | None + withheld: bool + media_buy_ids: set[str] = field(default_factory=set) + + +def _losses( + authorizations: Sequence[SubscriptionAuthorization], retention: timedelta | None +) -> tuple[datetime | None, tuple[_Loss, ...]]: + ordered = sorted(authorizations, key=lambda item: item.authorized_at) + for earlier, later in zip(ordered, ordered[1:]): + if earlier.unauthorized_at is None or earlier.unauthorized_at > later.authorized_at: + raise ValueError("authorization grants must not overlap") + losses: list[_Loss] = [] + for index, grant in enumerate(ordered): + if grant.unauthorized_at is None: + continue + regranted = ordered[index + 1].authorized_at if index + 1 < len(ordered) else None + expired = ( + regranted is not None + and retention is not None + and regranted - grant.unauthorized_at > retention + ) + losses.append(_Loss(grant.unauthorized_at, regranted, expired)) + return (ordered[0].authorized_at if ordered else None), tuple(losses) + + +def _zone_at( + instant: datetime, account_timezone: str, changes: Sequence[AccountTimezoneChange] +) -> str: + zone = account_timezone + for change in changes: + if change.changed_at <= instant: + zone = change.timezone + return zone + + +def expected_subscription_periods( + *, + subscription: Any, + account_id: str, + buys: Sequence[SubscriptionBuy], + offerings: Sequence[SubscriptionOffering], + account_timezone: str, + now: datetime, + authorizations: Sequence[SubscriptionAuthorization] = (), + exceptions: Sequence[Any] = (), + gaps: Sequence[Any] = (), + backfill_on_regrant: bool = False, + status_retention_days: int | None = None, + destinations: Mapping[str, Any] | None = None, + account_operator: str | None = None, + version_configurations: Mapping[int, Any] | None = None, + timezone_changes: Sequence[AccountTimezoneChange] = (), + include_unresolved: bool = False, +) -> list[ExpectedSubscriptionPeriod]: + """Every period one subscription owes one account, from plain data. + + :param subscription: the ``get_principal`` readback + (``core/reporting-subscription-state.json``) as a model or mapping, or + a sequence of :class:`SubscriptionVersionWindow`. + :param buys: the confirmed buys in the account the caller can see. + A buy without ``confirmed_at`` is not covered. + :param offerings: offerings with the seller's dimension mapping. + :param account_timezone: the account's timezone before the first entry of + ``timezone_changes`` (its current timezone when there are none). + :param now: periods are owed once resolved, at their end; a period ending + after ``now`` is returned only with ``include_unresolved``. + :param authorizations: the account's grant history. Empty means the seller + does not advertise ``authorized_at`` and authority never lapsed. + :param exceptions: ``subscription_coverage[].excluded`` entries, or + :class:`SubscriptionException` values. + :param gaps: ``subscription_coverage[].gaps`` entries. + :param backfill_on_regrant: the seller's ``subscriptions.backfill_on_regrant``. + :param status_retention_days: the seller's status retention; a re-grant + later than this after a loss is a fresh coverage start. + :param destinations: the caller's ``reporting_destinations`` by + ``destination_id``, needed when a version names a destination. + :param account_operator: the account's operator, needed with + ``operator_domains``. + """ + now = utc_instant(now, field="now") + windows = ( + tuple(subscription) + if isinstance(subscription, Sequence) + and not isinstance(subscription, (str, bytes, Mapping)) + else subscription_version_windows( + subscription, version_configurations=version_configurations + ) + ) + if not windows: + return [] + subscription_id = _subscription_id(subscription, windows) + retention = None if status_retention_days is None else timedelta(days=status_retention_days) + chain_start, losses = _losses(authorizations, retention) + removals = subscription_exceptions(exceptions, gaps) + tz_changes = tuple(sorted(timezone_changes, key=lambda item: item.changed_at)) + selectors = { + window.subscription_version: SubscriptionSelector.from_subscription(window.configuration) + for window in windows + } + in_scope = { + window.subscription_version: subscription_covers_account( + window.configuration, account_id=account_id, account_operator=account_operator + ) + for window in windows + } + instants = {window.accepted_at for window in windows} + instants.update(window.ends_at for window in windows if window.ends_at is not None) + instants.update(item.available_from for item in offerings if item.available_from is not None) + instants.update(item.withdrawn_at for item in offerings if item.withdrawn_at is not None) + instants.update(change.changed_at for change in tz_changes) + first = windows[0].accepted_at + boundaries = sorted(instant for instant in instants if instant >= first) + + owed: dict[tuple[int, str, str, datetime], _Owed] = {} + for buy in buys: + if buy.confirmed_at is None: + continue + floor = max(item for item in (buy.confirmed_at, chain_start) if item is not None) + ceiling = buy._latest_stop() + if not include_unresolved: + ceiling = min(ceiling, now) + for _package_id, offering_ids in buy.product_offering_ids(): + previous: dict[str, _SlotState] = {} + for index, segment_start in enumerate(boundaries): + segment_end = boundaries[index + 1] if index + 1 < len(boundaries) else None + window = next((item for item in windows if item.contains(segment_start)), None) + slots: dict[str, _Slot] = {} + if window is not None and in_scope[window.subscription_version]: + zone_name = _zone_at(segment_start, account_timezone, tz_changes) + selector = selectors[window.subscription_version] + destination = None + if selector.destination_id is not None: + if destinations is None or selector.destination_id not in destinations: + raise ValueError( + f"destination {selector.destination_id!r} is not in destinations" + ) + destination = destinations[selector.destination_id] + resolution = resolve_subscription_offerings( + selector, + offerings, + product_offering_ids=offering_ids, + account_timezone=zone_name, + destination=destination, + at=segment_start, + ) + for offering in resolution.offerings: + zone = offering.zone(zone_name) + slots[offering.offering_id] = _Slot( + window.subscription_version, + offering, + zone, + offering.schedule(zone_name), + ) + current = _pair(previous, slots, segment_start) + for state in current.values(): + _collect( + owed, + state, + buy=buy, + floor=floor, + starts_before=min(segment_end or _FAR_FUTURE, ceiling), + now=now, + include_unresolved=include_unresolved, + removals=removals, + losses=losses, + backfill=backfill_on_regrant, + ) + previous = current + + periods = [ + ExpectedSubscriptionPeriod( + delivery_config_id=subscription_id, + delivery_config_version=item.version, + report_definition_id=item.offering.report_definition_id, + feed_purpose=item.offering.feed_purpose, + reporting_profile=item.offering.reporting_profile, + media_buy_ids=tuple(sorted(item.media_buy_ids)), + period_start=iso_z(item.start), + period_end=iso_z(item.end), + source_timezone=item.zone, + expected_at=None if item.expected_at is None else iso_z(item.expected_at), + account_id=account_id, + subscription_id=subscription_id, + offering_id=item.offering.offering_id, + withheld=item.withheld, + ) + for item in owed.values() + ] + periods.sort( + key=lambda item: ( + item.period_start, + item.period_end, + item.offering_id.encode("utf-8"), + item.delivery_config_version, + ) + ) + return periods + + +def _subscription_id(subscription: Any, windows: Sequence[SubscriptionVersionWindow]) -> str: + value = None + if not isinstance(subscription, Sequence) or isinstance(subscription, Mapping): + value = _value(subscription, "subscription_id") + if value is None: + configuration = windows[-1].configuration + if hasattr(configuration, "model_dump"): + configuration = configuration.model_dump(mode="json", exclude_none=True) + value = _value(configuration, "subscription_id") + if value is None: + raise ValueError("subscription_id is required") + return _text(value) + + +def _pair( + previous: Mapping[str, _SlotState], slots: Mapping[str, _Slot], at: datetime +) -> dict[str, _SlotState]: + """Apply the cutover rule between consecutive segments of one product.""" + leaving = [state for key, state in previous.items() if key not in slots] + current: dict[str, _SlotState] = {} + for key, slot in slots.items(): + before = previous.get(key) + if before is not None: + if before.slot.same_feed(slot): + lower = max(at, before.lower) + else: + lower = max(first_boundary_at_or_after(before.slot.schedule, at), before.lower) + elif leaving: + lower = max( + max(first_boundary_at_or_after(state.slot.schedule, at), state.lower) + for state in leaving + ) + else: + lower = at + current[key] = _SlotState(slot, lower) + return current + + +def _collect( + owed: dict[tuple[int, str, str, datetime], _Owed], + state: _SlotState, + *, + buy: SubscriptionBuy, + floor: datetime, + starts_before: datetime, + now: datetime, + include_unresolved: bool, + removals: Sequence[SubscriptionException], + losses: Sequence[_Loss], + backfill: bool, +) -> None: + slot = state.slot + schedule = slot.schedule + lower = max(state.lower, floor) + # Skip straight to the period containing the earliest flight start; a + # period that ends at or before every flight start can never overlap. + earliest = buy._earliest_start() + if earliest > lower: + lower = max(lower, last_boundary_at_or_before(schedule, earliest)) + if lower >= starts_before: + return + sla = delivery_sla(schedule) + gates = [ + ( + loss, + last_boundary_at_or_before(schedule, loss.unauthorized_at), + ( + None + if loss.regranted_at is None + else first_boundary_at_or_after(schedule, loss.regranted_at) + ), + ) + for loss in losses + ] + for start, end in iter_periods(schedule, starts_from=lower, starts_before=starts_before): + if not include_unresolved and end > now: + break + flight_start, flight_stop = buy.flight_at(end) + if not (start < flight_stop and end > flight_start): + continue + if any(item.removes(start, buy.media_buy_id) for item in removals): + continue + expected_at: datetime | None = end + sla + withheld = False + removed = False + for loss, gap_from, gap_until in gates: + if start >= gap_from: + if gap_until is None: + removed = True + break + if start < gap_until: + if backfill and not loss.expired: + assert loss.regranted_at is not None + expected_at = _later(expected_at, loss.regranted_at + sla) + else: + removed = True + break + elif expected_at is not None and expected_at > loss.unauthorized_at: + # Owed before the loss but not yet due when authority ended: + # withheld, and due again only on a retained re-grant. + if loss.regranted_at is None or loss.expired: + expected_at, withheld = None, True + else: + expected_at = _later(expected_at, loss.regranted_at + sla) + if removed: + continue + key = (slot.version, slot.offering.offering_id, slot.zone, start) + entry = owed.get(key) + if entry is None: + entry = owed[key] = _Owed( + slot.version, slot.offering, slot.zone, start, end, expected_at, withheld + ) + entry.media_buy_ids.add(buy.media_buy_id) + + +def _later(current: datetime | None, candidate: datetime) -> datetime | None: + return None if current is None else max(current, candidate) + + +__all__ = [ + "AccountTimezoneChange", + "ExpectedSubscriptionPeriod", + "SubscriptionAuthorization", + "SubscriptionBuy", + "SubscriptionBuyFlight", + "SubscriptionBuyPackage", + "SubscriptionException", + "SubscriptionVersionWindow", + "expected_subscription_periods", + "subscription_covers_account", + "subscription_exceptions", + "subscription_version_windows", +] diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py new file mode 100644 index 000000000..d7ecfc7bc --- /dev/null +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -0,0 +1,421 @@ +"""Resolve a reporting-subscription selector to offerings, as both sides must. + +``core/reporting-subscription.json`` (``x-adcp-validation.selection``): an +offering is a candidate when ``feed_purpose``, ``period_duration``, +``reconciliation_mode``, ``required_finality`` (in ``supported_finality``) and +a present ``alignment`` match, a present destination matches its ``method`` +(no destination selects only API-delivered offerings), and the product lists +it in ``reporting_delivery_offering_ids``. ``match`` then applies: + +* ``exact``: the offering's dimension set equals the selector's. Native + dimensions that do not map to the standard vocabulary count as distinct. +* ``finest``: the single offering with the most mapped dimensions among those + including every selector dimension. +* ``all``: every offering including every selector dimension. + +Ties under ``exact`` and ``finest`` go to the lowest ``offering_id`` in byte +order. ``match`` defaults to ``exact`` when ``dimensions`` is present and to +``finest`` when it is absent. + +Offerings do not publish their mapped dimensions on the wire; the seller maps +each report definition's native dimensions. :class:`SubscriptionOffering` +therefore carries the mapping explicitly, so a buyer that knows the mapping +(for example from the seller's documentation or from the obligations it has +already received) resolves exactly what the seller resolves. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal + +from adcp.reporting.ledger.models import ReportingScheduleSpec, iso_duration_to_timedelta +from adcp.reporting.subscriptions._clock import civil_schedule, require_zone, utc_instant + +#: The 3.3 standard breakdown vocabulary (``enums/reporting-dimension.json``). +#: It is extensible: a selector naming a value no offering maps matches nothing. +STANDARD_REPORTING_DIMENSIONS: frozenset[str] = frozenset( + {"package", "creative", "country", "region", "metro"} +) + +SubscriptionMatch = Literal["exact", "finest", "all"] +_ALIGNMENTS = frozenset({"utc", "account_timezone", "source_timezone", "billing_cycle"}) + + +def _value(item: Any, name: str, default: Any = None) -> Any: + if isinstance(item, Mapping): + value = item.get(name, default) + else: + value = getattr(item, name, default) + return default if value is None else value + + +def _text(value: Any) -> str: + """Enum-safe string projection of generated-model values.""" + inner = getattr(value, "value", value) + return str(inner) + + +def _byte_key(offering_id: str) -> bytes: + return offering_id.encode("utf-8") + + +@dataclass(frozen=True) +class SubscriptionOffering: + """One reporting-delivery offering, with the seller's dimension mapping. + + ``dimensions`` are the standard-vocabulary values the offering's report + definition maps onto. ``unmapped_dimensions`` are native dimensions with no + vocabulary mapping; they make an ``exact`` match fail and are ignored when + ``finest`` counts mapped dimensions. + + ``period_timezone`` is the schedule's IANA zone for ``source_timezone`` + (fixed, or the seller's account-resolved upstream zone) and fixed + ``billing_cycle`` offerings. ``available_from`` and ``withdrawn_at`` bound + when the offering is a candidate; a change in resolution is a cutover. + """ + + offering_id: str + report_definition_id: str + reporting_profile: str + feed_purpose: str + period_duration: str + alignment: str + delivery_sla: str + supported_finality: frozenset[str] + reconciliation_mode: str + dimensions: frozenset[str] = frozenset() + unmapped_dimensions: frozenset[str] = frozenset() + period_timezone: str | None = None + period_anchor: datetime | None = None + period_anchor_policy: str | None = None + method_pattern: str | None = None + method_transport: str | None = None + available_from: datetime | None = None + withdrawn_at: datetime | None = None + + def __post_init__(self) -> None: + if not self.offering_id: + raise ValueError("offering_id is required") + if self.alignment not in _ALIGNMENTS: + raise ValueError(f"unknown schedule alignment {self.alignment!r}") + object.__setattr__(self, "supported_finality", frozenset(self.supported_finality)) + object.__setattr__(self, "dimensions", frozenset(self.dimensions)) + object.__setattr__(self, "unmapped_dimensions", frozenset(self.unmapped_dimensions)) + overlap = self.dimensions & self.unmapped_dimensions + if overlap: + raise ValueError(f"dimensions are both mapped and unmapped: {sorted(overlap)}") + if self.period_timezone is not None: + require_zone(self.period_timezone, field="period_timezone") + if self.period_anchor is not None: + object.__setattr__( + self, "period_anchor", utc_instant(self.period_anchor, field="period_anchor") + ) + for name in ("available_from", "withdrawn_at"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, utc_instant(value, field=name)) + if ( + self.available_from is not None + and self.withdrawn_at is not None + and self.withdrawn_at <= self.available_from + ): + raise ValueError("withdrawn_at must be after available_from") + + @classmethod + def from_offering( + cls, + offering: Any, + *, + dimensions: Iterable[str] = (), + unmapped_dimensions: Iterable[str] = (), + period_timezone: str | None = None, + available_from: datetime | None = None, + withdrawn_at: datetime | None = None, + ) -> SubscriptionOffering: + """Project a ``ReportingDeliveryOffering`` (model or wire mapping). + + ``period_timezone`` supplies the account-resolved zone of an + ``account_resolved`` ``source_timezone`` offering; a fixed zone on the + offering wins when both are present and they must agree. + """ + if hasattr(offering, "model_dump"): + offering = offering.model_dump(mode="json", exclude_none=True) + if not isinstance(offering, Mapping): + raise TypeError("offering must be a ReportingDeliveryOffering or a mapping") + schedule = offering["schedule"] + fixed_zone = schedule.get("period_timezone") + if fixed_zone is not None and period_timezone is not None and fixed_zone != period_timezone: + raise ValueError("period_timezone disagrees with the offering's fixed timezone") + anchor = schedule.get("period_anchor") + method = offering.get("method") or {} + profile = offering["reporting_profile"] + return cls( + offering_id=str(offering["offering_id"]), + report_definition_id=str(offering["report_definition_id"]), + reporting_profile=str(profile["id"] if isinstance(profile, Mapping) else profile), + feed_purpose=str(offering["feed_purpose"]), + period_duration=str(schedule["period_duration"]), + alignment=str(schedule["alignment"]), + delivery_sla=str(schedule["delivery_sla"]), + supported_finality=frozenset(str(item) for item in offering["supported_finality"]), + reconciliation_mode=str(offering["reconciliation_mode"]), + dimensions=frozenset(dimensions), + unmapped_dimensions=frozenset(unmapped_dimensions), + period_timezone=fixed_zone or period_timezone, + period_anchor=( + datetime.fromisoformat(str(anchor).replace("Z", "+00:00")) if anchor else None + ), + period_anchor_policy=schedule.get("period_anchor_policy"), + method_pattern=method.get("pattern"), + method_transport=method.get("transport"), + available_from=available_from, + withdrawn_at=withdrawn_at, + ) + + def available_at(self, instant: datetime) -> bool: + return (self.available_from is None or self.available_from <= instant) and ( + self.withdrawn_at is None or instant < self.withdrawn_at + ) + + @property + def schedulable(self) -> bool: + """Whether a subscription can derive this offering's boundaries. + + A subscription carries no billing anchor, so a ``configurable`` + ``billing_cycle`` offering (which needs one per configuration) and a + ``source_timezone`` offering whose zone is unresolved cannot be + scheduled, and calendar months and years are not derivable by the + shared clock. + """ + if self.alignment == "billing_cycle" and ( + self.period_anchor is None or self.period_timezone is None + ): + return False + if self.alignment == "source_timezone" and self.period_timezone is None: + return False + try: + iso_duration_to_timedelta(self.period_duration) + iso_duration_to_timedelta(self.delivery_sla) + except ValueError: + return False + return True + + def zone(self, account_timezone: str) -> str: + """The IANA zone this offering's periods use for an account.""" + if self.alignment == "utc": + return "UTC" + if self.alignment == "account_timezone": + return account_timezone + if self.period_timezone is None: + raise ValueError(f"offering {self.offering_id!r} has no resolved period_timezone") + return self.period_timezone + + def schedule(self, account_timezone: str) -> ReportingScheduleSpec: + """The ledger schedule with the normative origin for this offering.""" + zone = self.zone(account_timezone) + return civil_schedule( + period_duration=self.period_duration, + delivery_sla=self.delivery_sla, + zone_name=zone, + anchor=self.period_anchor if self.alignment == "billing_cycle" else None, + ) + + +@dataclass(frozen=True) +class SubscriptionDestination: + """The delivery pattern and transport of a principal reporting destination.""" + + pattern: str + transport: str | None = None + + @classmethod + def coerce(cls, value: Any) -> SubscriptionDestination: + if isinstance(value, SubscriptionDestination): + return value + configuration = _value(value, "configuration") + source = configuration if configuration is not None else value + if hasattr(source, "model_dump"): + source = source.model_dump(mode="json", exclude_none=True) + root = getattr(source, "root", None) + if root is not None: + source = root.model_dump(mode="json", exclude_none=True) + pattern = _value(source, "pattern") + if pattern is None: + raise ValueError("a reporting destination needs a pattern") + transport = _value(source, "transport") + return cls( + pattern=_text(pattern), transport=None if transport is None else _text(transport) + ) + + +@dataclass(frozen=True) +class SubscriptionSelector: + """Normalized ``selector`` plus the subscription's ``reconciliation_mode``.""" + + feed_purpose: str + period_duration: str + required_finality: str + reconciliation_mode: str + match: SubscriptionMatch + dimensions: frozenset[str] | None = None + alignment: str | None = None + period_timezone: str | None = None + destination_id: str | None = None + + @classmethod + def from_subscription(cls, subscription: Any) -> SubscriptionSelector: + """Read ``selector``, ``reconciliation_mode`` and destination from a subscription.""" + if hasattr(subscription, "model_dump"): + subscription = subscription.model_dump(mode="json", exclude_none=True) + selector = _value(subscription, "selector") + if selector is None: + raise ValueError("subscription has no selector") + raw_dimensions = _value(selector, "dimensions") + dimensions = ( + None if raw_dimensions is None else frozenset(_text(item) for item in raw_dimensions) + ) + raw_match = _value(selector, "match") + match: str = ( + _text(raw_match) + if raw_match is not None + else ("exact" if dimensions is not None else "finest") + ) + if match not in {"exact", "finest", "all"}: + raise ValueError(f"unknown selector match {match!r}") + destination = _value(subscription, "destination") + alignment = _value(selector, "alignment") + period_timezone = _value(selector, "period_timezone") + return cls( + feed_purpose=_text(_value(selector, "feed_purpose")), + period_duration=_text(_value(selector, "period_duration")), + required_finality=_text(_value(selector, "required_finality")), + reconciliation_mode=_text(_value(subscription, "reconciliation_mode")), + match=match, # type: ignore[arg-type] + dimensions=dimensions, + alignment=None if alignment is None else _text(alignment), + period_timezone=None if period_timezone is None else _text(period_timezone), + destination_id=( + None if destination is None else _text(_value(destination, "destination_id")) + ), + ) + + +@dataclass(frozen=True) +class SubscriptionResolution: + """Offerings a selector resolved to for one product, in byte order.""" + + offerings: tuple[SubscriptionOffering, ...] + candidates: tuple[str, ...] = field(default=()) + + @property + def offering_ids(self) -> tuple[str, ...]: + return tuple(item.offering_id for item in self.offerings) + + +def _is_candidate( + offering: SubscriptionOffering, + selector: SubscriptionSelector, + *, + destination: SubscriptionDestination | None, + account_timezone: str, +) -> bool: + if ( + offering.feed_purpose != selector.feed_purpose + or offering.period_duration != selector.period_duration + or offering.reconciliation_mode != selector.reconciliation_mode + or selector.required_finality not in offering.supported_finality + ): + return False + if selector.alignment is not None and offering.alignment != selector.alignment: + return False + if destination is None: + if offering.method_pattern is not None: + return False + elif offering.method_pattern != destination.pattern or ( + destination.transport is not None and offering.method_transport != destination.transport + ): + return False + if not offering.schedulable: + return False + if selector.period_timezone is not None: + # The override selects only offerings whose published schedule already + # produces periods in that zone; the SDK never re-buckets a schedule. + return offering.zone(account_timezone) == selector.period_timezone + return True + + +def resolve_subscription_offerings( + subscription: Any, + offerings: Sequence[SubscriptionOffering], + *, + product_offering_ids: Iterable[str], + account_timezone: str, + destination: Any | None = None, + at: datetime | None = None, +) -> SubscriptionResolution: + """Resolve one subscription for one product, deterministically. + + ``subscription`` is a ``ReportingSubscription`` (model or mapping) or a + :class:`SubscriptionSelector`. ``destination`` is the named principal + reporting destination (model, mapping, or :class:`SubscriptionDestination`) + and is required when the subscription names one. ``at`` restricts + candidates to offerings available at that instant. + """ + selector = ( + subscription + if isinstance(subscription, SubscriptionSelector) + else SubscriptionSelector.from_subscription(subscription) + ) + if selector.destination_id is not None and destination is None: + raise ValueError( + f"subscription names destination {selector.destination_id!r}; pass its pattern" + ) + target = None if destination is None else SubscriptionDestination.coerce(destination) + listed = set(product_offering_ids) + instant = None if at is None else utc_instant(at, field="at") + candidates = sorted( + ( + offering + for offering in offerings + if offering.offering_id in listed + and (instant is None or offering.available_at(instant)) + and _is_candidate( + offering, selector, destination=target, account_timezone=account_timezone + ) + ), + key=lambda item: _byte_key(item.offering_id), + ) + wanted = selector.dimensions + if selector.match == "exact": + matches = [ + item + for item in candidates + if wanted is None or (item.dimensions == wanted and not item.unmapped_dimensions) + ] + chosen: tuple[SubscriptionOffering, ...] = tuple(matches[:1]) + else: + including = [item for item in candidates if wanted is None or wanted <= item.dimensions] + if selector.match == "all": + chosen = tuple(including) + elif including: + most = max(len(item.dimensions) for item in including) + chosen = (next(item for item in including if len(item.dimensions) == most),) + else: + chosen = () + return SubscriptionResolution( + offerings=chosen, candidates=tuple(item.offering_id for item in candidates) + ) + + +__all__ = [ + "STANDARD_REPORTING_DIMENSIONS", + "SubscriptionDestination", + "SubscriptionMatch", + "SubscriptionOffering", + "SubscriptionResolution", + "SubscriptionSelector", + "resolve_subscription_offerings", +] diff --git a/tests/test_reporting_subscription_capabilities.py b/tests/test_reporting_subscription_capabilities.py new file mode 100644 index 000000000..72f4b3eef --- /dev/null +++ b/tests/test_reporting_subscription_capabilities.py @@ -0,0 +1,152 @@ +"""Capability fields for AdCP 3.3 reporting subscriptions. + +Each projected block is validated against the pinned 3.3.0-beta.1 +``core/reporting-delivery-capabilities.json``, whose ``allOf`` couples +``configuration_task``, ``configuration_tasks`` and ``subscriptions``. +""" + +from __future__ import annotations + +import pytest + +from adcp.reporting.subscriptions import ( + REPORTING_SUBSCRIPTIONS_FEATURE, + REPORTING_SUBSCRIPTIONS_SECTION, + ReportingSubscriptionSupport, + advertise_reporting_subscriptions, + reporting_configuration_tasks, + subscription_capabilities, +) +from adcp.validation import schema_loader + +VERSION = "3.3.0-beta.1" + +BASE = { + "supported": True, + "reliable_reporting_version": "1.0", + "configuration_task": "sync_accounts", + "status_task": "get_reporting_status", + "revision_content_task": "get_media_buy_delivery", + "offerings": [ + { + "offering_id": "daily-official", + "feed_purpose": "analytics", + "report_definition_id": "delivery-v1", + "report_definition_uri": "https://seller.example/defs/delivery-v1.json", + "report_definition_sha256": "0" * 64, + "reporting_profile": { + "id": "delivery", + "version": "1", + "schema_uri": "https://seller.example/defs/delivery-v1.schema.json", + "schema_sha256": "0" * 64, + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "package_day", + "primary_keys": ["account_id", "media_buy_id", "package_id", "period_start"], + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H", + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + } + ], + "automated_recovery_window_seconds": 21600, + "status_retention_days": 400, +} + + +def _errors(block: dict) -> list[str]: + validator = schema_loader.get_named_validator( + "core/reporting-delivery-capabilities.json", version=VERSION + ) + return [error.message for error in validator.iter_errors(block)] + + +def test_base_block_is_valid_and_unchanged_without_support() -> None: + assert _errors(BASE) == [] + assert subscription_capabilities(BASE, None) == BASE + assert advertise_reporting_subscriptions({"experimental_features": []}, None) == { + "experimental_features": [] + } + + +def test_subscriptions_alongside_account_configuration() -> None: + block = subscription_capabilities( + BASE, + ReportingSubscriptionSupport( + max_accounts=500, backfill_on_regrant=True, dimensions=("package", "creative") + ), + ) + assert _errors(block) == [] + assert block["configuration_tasks"] == ["sync_principal", "sync_accounts"] + assert block["configuration_task"] == "sync_accounts" + assert block["subscriptions"] == { + "supported": True, + "max_accounts": 500, + "backfill_on_regrant": True, + "dimensions": ["package", "creative"], + } + + +def test_subscriptions_only_drops_the_deprecated_field() -> None: + block = subscription_capabilities( + BASE, ReportingSubscriptionSupport(account_configuration=False) + ) + assert _errors(block) == [] + assert block["configuration_tasks"] == ["sync_principal"] + assert "configuration_task" not in block + assert block["subscriptions"] == {"supported": True} + + +def test_schema_rejects_the_shapes_the_helper_never_emits() -> None: + # configuration_task without sync_accounts listed, and subscriptions + # without sync_principal listed, are both invalid. + assert _errors( + {**BASE, "configuration_tasks": ["sync_principal"], "subscriptions": {"supported": True}} + ) + assert _errors({**BASE, "subscriptions": {"supported": True}}) + + +def test_capabilities_response_gets_feature_and_principal_section() -> None: + response = { + "experimental_features": ["media_buy.reporting_delivery", "protocol.principal"], + "adcp": { + "major_versions": [3], + "principal": { + "supported": True, + "sync_task": "sync_principal", + "read_task": "get_principal", + "supported_sections": ["reporting_destinations"], + }, + }, + } + advertised = advertise_reporting_subscriptions(response, ReportingSubscriptionSupport()) + assert advertised["experimental_features"] == [ + "media_buy.reporting_delivery", + "protocol.principal", + REPORTING_SUBSCRIPTIONS_FEATURE, + ] + assert advertised["adcp"]["principal"]["supported_sections"] == [ + "reporting_destinations", + REPORTING_SUBSCRIPTIONS_SECTION, + ] + assert advertise_reporting_subscriptions(advertised, ReportingSubscriptionSupport()) == ( + advertised + ) + assert response["adcp"]["principal"]["supported_sections"] == ["reporting_destinations"] + + +def test_configuration_tasks_and_support_validation() -> None: + assert reporting_configuration_tasks(subscriptions=False) == ["sync_accounts"] + with pytest.raises(ValueError): + reporting_configuration_tasks(subscriptions=False, account_configuration=False) + with pytest.raises(ValueError): + ReportingSubscriptionSupport(max_accounts=0) + with pytest.raises(ValueError): + ReportingSubscriptionSupport(dimensions=("Package",)) + support = ReportingSubscriptionSupport(dimensions=("package", "dma_zone", "package")) + assert support.dimensions == ("package", "dma_zone") + assert support.standard_dimensions == ("package",) diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py new file mode 100644 index 000000000..3066a77be --- /dev/null +++ b/tests/test_reporting_subscription_coverage.py @@ -0,0 +1,1260 @@ +"""Table tests for the reporting-subscription coverage rule (AdCP 3.3). + +Every expected period below is written out by hand from the rule in +``core/reporting-subscription.json`` (coverage, cutover) and +``core/reporting-subscription-coverage.json``. The seller runtime owes exactly +what :func:`expected_subscription_periods` returns, so these tables are the +billing contract: change one only with a spec citation. + +Unless a test says otherwise the account is in ``America/New_York`` in +October 2026 (EDT, UTC-4), so a local day runs from 04:00Z to 04:00Z. +""" + +from __future__ import annotations + +import itertools +import random +from datetime import datetime, timedelta, timezone + +import pytest + +from adcp.reporting import ExpectedReportingPeriod +from adcp.reporting.subscriptions import ( + AccountTimezoneChange, + ExpectedSubscriptionPeriod, + SubscriptionAuthorization, + SubscriptionBuy, + SubscriptionBuyFlight, + SubscriptionBuyPackage, + SubscriptionDestination, + SubscriptionException, + SubscriptionOffering, + SubscriptionVersionWindow, + expected_subscription_periods, + resolve_subscription_offerings, + subscription_exceptions, + subscription_version_windows, +) +from adcp.types.domains.core.reporting_subscription_coverage import ( + ReportingSubscriptionCoverage, +) +from adcp.types.domains.core.reporting_subscription_state import ReportingSubscriptionState + +NY = "America/New_York" +UTC = timezone.utc + + +def at(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def offering( + offering_id: str, + *, + dimensions: tuple[str, ...] = ("package",), + unmapped: tuple[str, ...] = (), + duration: str = "P1D", + alignment: str = "account_timezone", + sla: str = "PT6H", + feed_purpose: str = "analytics", + finality: tuple[str, ...] = ("official",), + reconciliation_mode: str = "delivery_only", + method_pattern: str | None = None, + method_transport: str | None = None, + period_timezone: str | None = None, + period_anchor: datetime | None = None, + available_from: datetime | None = None, + withdrawn_at: datetime | None = None, +) -> SubscriptionOffering: + return SubscriptionOffering( + offering_id=offering_id, + report_definition_id=f"def-{offering_id}", + reporting_profile="profile-1", + feed_purpose=feed_purpose, + period_duration=duration, + alignment=alignment, + delivery_sla=sla, + supported_finality=frozenset(finality), + reconciliation_mode=reconciliation_mode, + dimensions=frozenset(dimensions), + unmapped_dimensions=frozenset(unmapped), + method_pattern=method_pattern, + method_transport=method_transport, + period_timezone=period_timezone, + period_anchor=period_anchor, + available_from=available_from, + withdrawn_at=withdrawn_at, + ) + + +DAILY = offering("daily-pkg") +HOURLY_UTC = offering("hourly-pkg", duration="PT1H", alignment="utc", sla="PT2H") + + +def subscription_config( + *, + version: int = 1, + duration: str = "P1D", + dimensions: list[str] | None = None, + match: str | None = None, + scope: dict | None = None, + alignment: str | None = None, + exclude: list[str] | None = None, + operator_domains: list[str] | None = None, + destination: dict | None = None, + period_timezone: str | None = None, +) -> dict: + selector: dict = { + "feed_purpose": "analytics", + "period_duration": duration, + "required_finality": "official", + } + if dimensions is not None: + selector["dimensions"] = dimensions + if match is not None: + selector["match"] = match + if alignment is not None: + selector["alignment"] = alignment + if period_timezone is not None: + selector["period_timezone"] = period_timezone + config: dict = { + "subscription_id": "analytics-daily", + "subscription_version": version, + "active": True, + "scope": scope or {"all_authorized_accounts": True}, + "selector": selector, + "reconciliation_mode": "delivery_only", + } + if exclude: + config["exclude_account_ids"] = exclude + if operator_domains: + config["operator_domains"] = operator_domains + if destination: + config["destination"] = destination + return config + + +def state( + accepted_at: str, + *, + config: dict | None = None, + version: int = 1, + prior: list[tuple[int, str, str]] = (), + deactivated_at: str | None = None, +) -> dict: + payload: dict = { + "subscription_id": "analytics-daily", + "subscription_version": version, + "state": "inactive" if deactivated_at else "active", + "accepted_at": accepted_at, + "configuration": config or subscription_config(version=version), + } + if prior: + payload["prior_versions"] = [ + {"subscription_version": v, "accepted_at": a, "superseded_at": s} for v, a, s in prior + ] + if deactivated_at: + payload["deactivated_at"] = deactivated_at + return payload + + +def buy( + media_buy_id: str = "mb-1", + *, + confirmed_at: str = "2026-10-01T00:00:00Z", + start_time: str = "2026-10-01T04:00:00Z", + end_time: str = "2026-10-31T04:00:00Z", + canceled_at: str | None = None, + completed_at: str | None = None, + offering_ids: tuple[str, ...] = ("daily-pkg",), + packages: tuple[SubscriptionBuyPackage, ...] = (), + superseded: tuple[SubscriptionBuyFlight, ...] = (), +) -> SubscriptionBuy: + return SubscriptionBuy( + media_buy_id=media_buy_id, + confirmed_at=at(confirmed_at) if confirmed_at else None, + start_time=at(start_time), + end_time=at(end_time), + canceled_at=at(canceled_at) if canceled_at else None, + completed_at=at(completed_at) if completed_at else None, + reporting_delivery_offering_ids=offering_ids, + packages=packages, + superseded_flights=superseded, + ) + + +def periods(**kwargs) -> list[tuple[str, str]]: + return [(item.period_start, item.period_end) for item in run(**kwargs)] + + +def starts(**kwargs) -> list[str]: + return [item.period_start for item in run(**kwargs)] + + +def run(**kwargs) -> list[ExpectedSubscriptionPeriod]: + kwargs.setdefault("subscription", state("2026-10-01T00:00:00Z")) + kwargs.setdefault("account_id", "acct-1") + kwargs.setdefault("offerings", [DAILY, HOURLY_UTC]) + kwargs.setdefault("account_timezone", NY) + kwargs.setdefault("now", at("2026-12-31T00:00:00Z")) + return expected_subscription_periods(**kwargs) + + +def days(first: str, count: int) -> list[str]: + """``count`` consecutive EDT local-midnight starts from ``first`` (YYYY-MM-DD).""" + day = datetime.fromisoformat(first) + return [(day + timedelta(days=index)).strftime("%Y-%m-%dT04:00:00Z") for index in range(count)] + + +# -- the start rule ------------------------------------------------------------ + + +class TestCoverageStart: + def test_whole_flight_after_acceptance(self) -> None: + result = run(buys=[buy(end_time="2026-10-04T04:00:00Z")]) + assert [(p.period_start, p.period_end) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T04:00:00Z"), + ("2026-10-02T04:00:00Z", "2026-10-03T04:00:00Z"), + ("2026-10-03T04:00:00Z", "2026-10-04T04:00:00Z"), + ] + first = result[0] + assert isinstance(first, ExpectedReportingPeriod) + assert first.delivery_config_id == "analytics-daily" + assert first.delivery_config_version == 1 + assert first.offering_id == "daily-pkg" + assert first.report_definition_id == "def-daily-pkg" + assert first.source_timezone == NY + assert first.media_buy_ids == ("mb-1",) + assert first.expected_at == "2026-10-02T10:00:00Z" + assert first.withheld is False + + def test_mid_period_confirmation_starts_at_next_boundary(self) -> None: + # Confirmed 11:00 local on Oct 5 with a flight that began Oct 3: the + # Oct 5 local day started before confirmation and is never owed. + result = starts( + buys=[ + buy( + confirmed_at="2026-10-05T15:00:00Z", + start_time="2026-10-03T04:00:00Z", + end_time="2026-10-08T04:00:00Z", + ) + ] + ) + assert result == days("2026-10-06", 2) + + def test_confirmation_on_a_boundary_owes_that_period(self) -> None: + result = starts( + buys=[ + buy( + confirmed_at="2026-10-05T04:00:00Z", + start_time="2026-10-03T04:00:00Z", + end_time="2026-10-07T04:00:00Z", + ) + ] + ) + assert result == days("2026-10-05", 2) + + def test_mid_period_acceptance_starts_at_next_boundary(self) -> None: + result = starts( + subscription=state("2026-10-02T12:00:00Z"), + buys=[buy(end_time="2026-10-05T04:00:00Z")], + ) + assert result == days("2026-10-03", 2) + + def test_authorization_later_than_acceptance_and_confirmation(self) -> None: + result = starts( + buys=[buy(end_time="2026-10-06T04:00:00Z")], + authorizations=[SubscriptionAuthorization(at("2026-10-03T09:00:00Z"))], + ) + assert result == days("2026-10-04", 2) + + def test_confirmed_early_with_a_later_flight_owes_nothing_before_the_flight(self) -> None: + # A buy confirmed Oct 1 with an Oct 20 start owes nothing before Oct 20. + result = starts( + buys=[ + buy( + confirmed_at="2026-10-01T00:00:00Z", + start_time="2026-10-20T04:00:00Z", + end_time="2026-10-22T04:00:00Z", + ) + ] + ) + assert result == days("2026-10-20", 2) + + def test_mid_period_flight_start_owes_the_whole_containing_period(self) -> None: + # No clipping: a flight starting at 15:00Z overlaps the Oct 10 local day. + result = periods( + buys=[buy(start_time="2026-10-10T15:00:00Z", end_time="2026-10-11T15:00:00Z")] + ) + assert result == [ + ("2026-10-10T04:00:00Z", "2026-10-11T04:00:00Z"), + ("2026-10-11T04:00:00Z", "2026-10-12T04:00:00Z"), + ] + + def test_unconfirmed_buy_is_not_covered(self) -> None: + unconfirmed = SubscriptionBuy( + media_buy_id="mb-1", + confirmed_at=None, + start_time=at("2026-10-01T04:00:00Z"), + end_time=at("2026-10-03T04:00:00Z"), + reporting_delivery_offering_ids=("daily-pkg",), + ) + assert run(buys=[unconfirmed]) == [] + + def test_periods_are_owed_only_once_resolved(self) -> None: + now = at("2026-10-03T10:00:00Z") + assert starts(buys=[buy()], now=now) == days("2026-10-01", 2) + unresolved = run( + buys=[buy(end_time="2026-10-05T04:00:00Z")], now=now, include_unresolved=True + ) + assert [p.period_start for p in unresolved] == days("2026-10-01", 4) + + +# -- the stop rule ------------------------------------------------------------- + + +class TestCoverageStop: + def test_end_time_on_a_boundary(self) -> None: + assert starts(buys=[buy(end_time="2026-10-03T04:00:00Z")]) == days("2026-10-01", 2) + + def test_end_time_mid_period_owes_that_period_in_full(self) -> None: + result = periods(buys=[buy(end_time="2026-10-03T10:00:00Z")]) + assert result[-1] == ("2026-10-03T04:00:00Z", "2026-10-04T04:00:00Z") + assert len(result) == 3 + + def test_cancellation_mid_flight(self) -> None: + # A buy canceled mid-period owes that period and none after it. + result = starts( + buys=[buy(canceled_at="2026-10-04T17:30:00Z", end_time="2026-10-31T04:00:00Z")] + ) + assert result == days("2026-10-01", 4) + + def test_cancellation_on_a_boundary_owes_nothing_from_it(self) -> None: + result = starts(buys=[buy(canceled_at="2026-10-04T04:00:00Z")]) + assert result == days("2026-10-01", 3) + + def test_early_completion(self) -> None: + result = starts(buys=[buy(completed_at="2026-10-02T23:00:00Z")]) + assert result == days("2026-10-01", 2) + + def test_earliest_of_end_cancel_and_complete_wins(self) -> None: + result = starts( + buys=[ + buy( + end_time="2026-10-06T04:00:00Z", + canceled_at="2026-10-05T12:00:00Z", + completed_at="2026-10-03T12:00:00Z", + ) + ] + ) + assert result == days("2026-10-01", 3) + + def test_canceled_before_start_owes_nothing(self) -> None: + result = run( + buys=[ + buy( + start_time="2026-10-10T04:00:00Z", + canceled_at="2026-10-05T00:00:00Z", + ) + ] + ) + assert result == [] + + +class TestFlightAsOfResolution: + def test_extension_adds_only_unresolved_periods(self) -> None: + # Flight ended Oct 4; on Oct 7 at 12:00Z it was extended to Oct 10. + # Periods that resolved before the extension (Oct 4..6 local days) + # are never owed retroactively; the Oct 7 day resolves after it. + extended = buy( + end_time="2026-10-10T04:00:00Z", + superseded=( + SubscriptionBuyFlight( + superseded_at=at("2026-10-07T12:00:00Z"), + start_time=at("2026-10-01T04:00:00Z"), + end_time=at("2026-10-04T04:00:00Z"), + ), + ), + ) + assert starts(buys=[extended]) == [*days("2026-10-01", 3), *days("2026-10-07", 3)] + + def test_extension_effective_exactly_at_resolution_is_visible(self) -> None: + extended = buy( + end_time="2026-10-06T04:00:00Z", + superseded=( + SubscriptionBuyFlight( + superseded_at=at("2026-10-05T04:00:00Z"), + start_time=at("2026-10-01T04:00:00Z"), + end_time=at("2026-10-04T04:00:00Z"), + ), + ), + ) + # Oct 4 local day resolves at Oct 5 04:00Z, the instant of the change. + assert starts(buys=[extended]) == days("2026-10-01", 5) + + def test_reinstatement_never_revives_elapsed_periods(self) -> None: + reinstated = buy( + end_time="2026-10-08T04:00:00Z", + superseded=( + SubscriptionBuyFlight( + superseded_at=at("2026-10-06T00:00:00Z"), + start_time=at("2026-10-01T04:00:00Z"), + end_time=at("2026-10-08T04:00:00Z"), + canceled_at=at("2026-10-03T00:00:00Z"), + ), + ), + ) + # Canceled during the Oct 2 local day; reinstated Oct 6 00:00Z (Oct 5 + # local). The Oct 5 day resolves after the reinstatement. + assert starts(buys=[reinstated]) == [*days("2026-10-01", 2), *days("2026-10-05", 3)] + + def test_shortened_flight_keeps_already_resolved_periods(self) -> None: + shortened = buy( + end_time="2026-10-03T04:00:00Z", + superseded=( + SubscriptionBuyFlight( + superseded_at=at("2026-10-06T00:00:00Z"), + start_time=at("2026-10-01T04:00:00Z"), + end_time=at("2026-10-31T04:00:00Z"), + ), + ), + ) + # Days through Oct 4 resolved before the change at Oct 6 00:00Z. + assert starts(buys=[shortened]) == days("2026-10-01", 4) + + +# -- versions and cutovers ----------------------------------------------------- + + +class TestVersionCutovers: + def test_new_version_same_schedule_is_seamless(self) -> None: + subscription = state( + "2026-10-03T12:00:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + result = run(subscription=subscription, buys=[buy(end_time="2026-10-06T04:00:00Z")]) + assert [(p.period_start, p.delivery_config_version) for p in result] == [ + ("2026-10-01T04:00:00Z", 1), + ("2026-10-02T04:00:00Z", 1), + ("2026-10-03T04:00:00Z", 1), # started before T: old version, in full + ("2026-10-04T04:00:00Z", 2), # E = 2026-10-04T04:00Z + ("2026-10-05T04:00:00Z", 2), + ] + + def test_new_version_on_a_boundary(self) -> None: + subscription = state( + "2026-10-03T04:00:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T04:00:00Z")], + ) + result = run(subscription=subscription, buys=[buy(end_time="2026-10-05T04:00:00Z")]) + assert [(p.period_start, p.delivery_config_version) for p in result] == [ + ("2026-10-01T04:00:00Z", 1), + ("2026-10-02T04:00:00Z", 1), + ("2026-10-03T04:00:00Z", 2), + ("2026-10-04T04:00:00Z", 2), + ] + + def test_daily_to_hourly_cutover_with_misaligned_boundaries(self) -> None: + # v1 resolves the local-day offering, v2 the UTC-hourly one. T is + # 2026-10-02T09:30Z: v1 owes the Oct 2 local day in full, ending at + # E = 2026-10-03T04:00Z; v2 owes hours starting at or after E. + subscription = state( + "2026-10-02T09:30:00Z", + version=2, + config=subscription_config(version=2, duration="PT1H"), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-02T09:30:00Z")], + ) + result = run( + subscription=subscription, + version_configurations={1: subscription_config(version=1)}, + buys=[ + buy( + end_time="2026-10-03T07:00:00Z", + offering_ids=("daily-pkg", "hourly-pkg"), + ) + ], + ) + assert [(p.period_start, p.period_end, p.offering_id) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T04:00:00Z", "daily-pkg"), + ("2026-10-02T04:00:00Z", "2026-10-03T04:00:00Z", "daily-pkg"), + ("2026-10-03T04:00:00Z", "2026-10-03T05:00:00Z", "hourly-pkg"), + ("2026-10-03T05:00:00Z", "2026-10-03T06:00:00Z", "hourly-pkg"), + ("2026-10-03T06:00:00Z", "2026-10-03T07:00:00Z", "hourly-pkg"), + ] + + def test_hourly_to_daily_cutover_leaves_an_unowed_stretch(self) -> None: + # T = 10:30Z: v1 owes the 10:00-11:00 hour in full (E = 11:00Z). The + # first local day starting at or after E begins at 04:00Z next day; + # 11:00Z..04:00Z is owed under neither schedule. + subscription = state( + "2026-10-02T10:30:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-02T08:00:00Z", "2026-10-02T10:30:00Z")], + ) + result = run( + subscription=subscription, + version_configurations={1: subscription_config(version=1, duration="PT1H")}, + buys=[ + buy( + end_time="2026-10-04T04:00:00Z", + offering_ids=("daily-pkg", "hourly-pkg"), + ) + ], + ) + assert [(p.period_start, p.period_end, p.delivery_config_version) for p in result] == [ + ("2026-10-02T08:00:00Z", "2026-10-02T09:00:00Z", 1), + ("2026-10-02T09:00:00Z", "2026-10-02T10:00:00Z", 1), + ("2026-10-02T10:00:00Z", "2026-10-02T11:00:00Z", 1), + ("2026-10-03T04:00:00Z", "2026-10-04T04:00:00Z", 2), + ] + + def test_account_added_by_a_new_version_follows_the_ordinary_start(self) -> None: + subscription = state( + "2026-10-03T12:00:00Z", + version=2, + config=subscription_config(version=2, scope={"account_ids": ["acct-1", "acct-2"]}), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + result = run( + subscription=subscription, + version_configurations={1: subscription_config(scope={"account_ids": ["acct-2"]})}, + buys=[buy(end_time="2026-10-06T04:00:00Z")], + ) + assert [(p.period_start, p.delivery_config_version) for p in result] == [ + ("2026-10-04T04:00:00Z", 2), + ("2026-10-05T04:00:00Z", 2), + ] + + def test_account_dropped_by_a_new_version_keeps_started_periods(self) -> None: + subscription = state( + "2026-10-03T12:00:00Z", + version=2, + config=subscription_config(version=2, scope={"account_ids": ["acct-2"]}), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + result = run( + subscription=subscription, + version_configurations={1: subscription_config()}, + buys=[buy(end_time="2026-10-06T04:00:00Z")], + ) + assert [p.period_start for p in result] == days("2026-10-01", 3) + + def test_deactivation_keeps_started_periods_and_creates_no_later_ones(self) -> None: + subscription = state("2026-10-01T00:00:00Z", deactivated_at="2026-10-03T12:00:00Z") + assert starts(subscription=subscription, buys=[buy()]) == days("2026-10-01", 3) + + def test_deactivation_on_a_boundary(self) -> None: + subscription = state("2026-10-01T00:00:00Z", deactivated_at="2026-10-03T04:00:00Z") + assert starts(subscription=subscription, buys=[buy()]) == days("2026-10-01", 2) + + def test_three_versions_back_to_back(self) -> None: + subscription = state( + "2026-10-02T06:00:00Z", + version=3, + config=subscription_config(version=3), + prior=[ + (2, "2026-10-02T05:00:00Z", "2026-10-02T06:00:00Z"), + (1, "2026-10-01T00:00:00Z", "2026-10-02T05:00:00Z"), + ], + ) + result = run(subscription=subscription, buys=[buy(end_time="2026-10-05T04:00:00Z")]) + # v2 is in effect for one hour inside the Oct 2 day, which v1 already + # owes in full; the next boundary belongs to v3. + assert [(p.period_start, p.delivery_config_version) for p in result] == [ + ("2026-10-01T04:00:00Z", 1), + ("2026-10-02T04:00:00Z", 1), + ("2026-10-03T04:00:00Z", 3), + ("2026-10-04T04:00:00Z", 3), + ] + + def test_version_windows_from_a_generated_state_model(self) -> None: + model = ReportingSubscriptionState.model_validate( + state( + "2026-10-03T12:00:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + ) + windows = subscription_version_windows(model) + assert [(w.subscription_version, w.accepted_at, w.ends_at) for w in windows] == [ + (1, at("2026-10-01T00:00:00Z"), at("2026-10-03T12:00:00Z")), + (2, at("2026-10-03T12:00:00Z"), None), + ] + from_model = run(subscription=model, buys=[buy(end_time="2026-10-06T04:00:00Z")]) + from_windows = run(subscription=windows, buys=[buy(end_time="2026-10-06T04:00:00Z")]) + assert from_model == from_windows + + def test_out_of_order_versions_are_rejected(self) -> None: + with pytest.raises(ValueError): + subscription_version_windows( + state( + "2026-10-03T12:00:00Z", + version=2, + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-04T00:00:00Z")], + ) + ) + + +# -- resolution ---------------------------------------------------------------- + + +PACKAGE = offering("off-b-package", dimensions=("package",)) +PACKAGE_CREATIVE = offering("off-a-pkg-creative", dimensions=("package", "creative")) +PACKAGE_COUNTRY = offering("off-c-pkg-country", dimensions=("package", "country")) +PACKAGE_DEVICE = offering("off-d-pkg-device", dimensions=("package",), unmapped=("device",)) +BREAKDOWNS = [PACKAGE, PACKAGE_CREATIVE, PACKAGE_COUNTRY, PACKAGE_DEVICE] +ALL_IDS = tuple(item.offering_id for item in BREAKDOWNS) + + +def resolved(config: dict, *, product: tuple[str, ...] = ALL_IDS, **kwargs) -> tuple[str, ...]: + kwargs.setdefault("account_timezone", NY) + return resolve_subscription_offerings( + config, kwargs.pop("offerings", BREAKDOWNS), product_offering_ids=product, **kwargs + ).offering_ids + + +class TestResolution: + def test_exact_is_the_default_with_dimensions(self) -> None: + assert resolved(subscription_config(dimensions=["package"])) == ("off-b-package",) + + def test_unmapped_native_dimensions_count_as_distinct(self) -> None: + # package x device does not equal package. + only_device = (PACKAGE_DEVICE.offering_id,) + assert resolved(subscription_config(dimensions=["package"]), product=only_device) == () + + def test_finest_is_the_default_without_dimensions_and_ties_go_to_lowest_id(self) -> None: + # Two offerings have two mapped dimensions; byte order picks "off-a...". + assert resolved(subscription_config()) == ("off-a-pkg-creative",) + + def test_finest_with_dimensions(self) -> None: + config = subscription_config(dimensions=["package"], match="finest") + assert resolved(config) == ("off-a-pkg-creative",) + config = subscription_config(dimensions=["country"], match="finest") + assert resolved(config) == ("off-c-pkg-country",) + + def test_all_selects_every_including_offering_in_byte_order(self) -> None: + config = subscription_config(dimensions=["package"], match="all") + assert resolved(config) == ( + "off-a-pkg-creative", + "off-b-package", + "off-c-pkg-country", + "off-d-pkg-device", + ) + + def test_exact_ties_go_to_the_lowest_offering_id_in_byte_order(self) -> None: + upper = offering("Z-package") + lower = offering("a-package") + result = resolved( + subscription_config(dimensions=["package"]), + offerings=[lower, upper], + product=("a-package", "Z-package"), + ) + assert result == ("Z-package",) # b"Z" < b"a" + + def test_only_offerings_the_product_lists_are_candidates(self) -> None: + config = subscription_config(dimensions=["package"], match="all") + assert resolved(config, product=("off-b-package",)) == ("off-b-package",) + + def test_unknown_vocabulary_value_matches_nothing(self) -> None: + assert resolved(subscription_config(dimensions=["dma_zone"])) == () + + def test_period_duration_finality_purpose_and_mode_must_match(self) -> None: + assert resolved(subscription_config(duration="PT1H")) == () + snapshot_only = offering("snap", finality=("snapshot",)) + assert resolved(subscription_config(), offerings=[snapshot_only], product=("snap",)) == () + billing = offering("bill", feed_purpose="billing") + assert resolved(subscription_config(), offerings=[billing], product=("bill",)) == () + receipt = offering("rcpt", reconciliation_mode="consumer_receipt") + assert resolved(subscription_config(), offerings=[receipt], product=("rcpt",)) == () + + def test_alignment_filter(self) -> None: + utc_daily = offering("utc-daily", alignment="utc") + both = [DAILY, utc_daily] + product = ("daily-pkg", "utc-daily") + config = subscription_config(dimensions=["package"], alignment="utc") + assert resolved(config, offerings=both, product=product) == ("utc-daily",) + + def test_destination_selects_managed_offerings_only(self) -> None: + managed = offering("managed", method_pattern="file_transfer", method_transport="s3") + both = [DAILY, managed] + product = ("daily-pkg", "managed") + core = subscription_config(dimensions=["package"]) + assert resolved(core, offerings=both, product=product) == ("daily-pkg",) + config = subscription_config( + dimensions=["package"], destination={"destination_id": "warehouse"} + ) + s3 = SubscriptionDestination(pattern="file_transfer", transport="s3") + gcs = SubscriptionDestination(pattern="file_transfer", transport="gcs") + assert resolved(config, offerings=both, product=product, destination=s3) == ("managed",) + assert resolved(config, offerings=both, product=product, destination=gcs) == () + with pytest.raises(ValueError): + resolved(config, offerings=both, product=product) + + def test_period_timezone_selects_offerings_already_in_that_zone(self) -> None: + source = offering("src", alignment="source_timezone", period_timezone="America/Chicago") + offers = [DAILY, source] + product = ("daily-pkg", "src") + chicago = subscription_config( + dimensions=["package"], + scope={"account_ids": ["acct-1"]}, + period_timezone="America/Chicago", + ) + assert resolved(chicago, offerings=offers, product=product) == ("src",) + new_york = subscription_config( + dimensions=["package"], scope={"account_ids": ["acct-1"]}, period_timezone=NY + ) + assert resolved(new_york, offerings=offers, product=product) == ("daily-pkg",) + + def test_unschedulable_offerings_are_not_candidates(self) -> None: + monthly = offering("monthly", duration="P1M") + configurable = offering("cycle", alignment="billing_cycle") + config = subscription_config(duration="P1M") + assert resolved(config, offerings=[monthly], product=("monthly",)) == () + assert resolved(subscription_config(), offerings=[configurable], product=("cycle",)) == () + + def test_from_wire_offering(self) -> None: + wire = { + "offering_id": "gam-daily", + "feed_purpose": "analytics", + "report_definition_id": "gam-delivery-v1", + "report_definition_uri": "https://seller.example/defs/gam-delivery-v1.json", + "report_definition_sha256": "0" * 64, + "reporting_profile": {"id": "delivery", "version": "1"}, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "period_timezone_policy": "fixed", + "period_timezone": "America/Los_Angeles", + "delivery_sla": "PT12H", + }, + "supported_finality": ["snapshot", "official"], + "reconciliation_mode": "delivery_only", + } + projected = SubscriptionOffering.from_offering(wire, dimensions=("package", "creative")) + assert projected.reporting_profile == "delivery" + assert projected.zone(NY) == "America/Los_Angeles" + assert projected.schedulable + start, end = next( + iter( + (p.period_start, p.period_end) + for p in run( + offerings=[projected], + subscription=state( + "2026-10-01T00:00:00Z", + config=subscription_config(dimensions=["package", "creative"]), + ), + buys=[buy(offering_ids=("gam-daily",), end_time="2026-10-03T07:00:00Z")], + ) + ) + ) + # Local midnight in Los Angeles (PDT, UTC-7). + assert (start, end) == ("2026-10-01T07:00:00Z", "2026-10-02T07:00:00Z") + + +class TestResolutionInCoverage: + def test_all_owes_each_offering_its_own_periods(self) -> None: + config = subscription_config(dimensions=["package"], match="all") + result = run( + subscription=state("2026-10-01T00:00:00Z", config=config), + offerings=[PACKAGE, PACKAGE_CREATIVE], + buys=[ + buy( + end_time="2026-10-03T04:00:00Z", + offering_ids=(PACKAGE.offering_id, PACKAGE_CREATIVE.offering_id), + ) + ], + ) + assert [(p.period_start, p.offering_id) for p in result] == [ + ("2026-10-01T04:00:00Z", "off-a-pkg-creative"), + ("2026-10-01T04:00:00Z", "off-b-package"), + ("2026-10-02T04:00:00Z", "off-a-pkg-creative"), + ("2026-10-02T04:00:00Z", "off-b-package"), + ] + + def test_a_new_finer_offering_takes_over_from_the_next_boundary(self) -> None: + # Under finest, a finer offering published at 2026-10-03T12:00Z is a + # resolution change: the old offering keeps the Oct 3 day. + finer = offering( + "off-a-pkg-creative", + dimensions=("package", "creative"), + available_from=at("2026-10-03T12:00:00Z"), + ) + config = subscription_config(dimensions=["package"], match="finest") + result = run( + subscription=state("2026-10-01T00:00:00Z", config=config), + offerings=[PACKAGE, finer], + buys=[ + buy( + end_time="2026-10-06T04:00:00Z", + offering_ids=(PACKAGE.offering_id, finer.offering_id), + ) + ], + ) + assert [(p.period_start, p.offering_id) for p in result] == [ + ("2026-10-01T04:00:00Z", "off-b-package"), + ("2026-10-02T04:00:00Z", "off-b-package"), + ("2026-10-03T04:00:00Z", "off-b-package"), + ("2026-10-04T04:00:00Z", "off-a-pkg-creative"), + ("2026-10-05T04:00:00Z", "off-a-pkg-creative"), + ] + + def test_withdrawn_offering_with_no_successor_stops_after_started_periods(self) -> None: + withdrawn = offering("daily-pkg", withdrawn_at=at("2026-10-03T12:00:00Z")) + result = starts(offerings=[withdrawn], buys=[buy()]) + assert result == days("2026-10-01", 3) + + def test_packages_resolve_separately_and_union_per_buy(self) -> None: + config = subscription_config(dimensions=["package"]) + mixed = buy( + end_time="2026-10-02T04:00:00Z", + packages=( + SubscriptionBuyPackage("pkg-1", (PACKAGE.offering_id,)), + SubscriptionBuyPackage("pkg-2", (PACKAGE_CREATIVE.offering_id,)), + ), + ) + result = run( + subscription=state("2026-10-01T00:00:00Z", config=config), + offerings=[PACKAGE, PACKAGE_CREATIVE], + buys=[mixed], + ) + # pkg-2 has no exact match: the buy stays covered for pkg-1. + assert [(p.period_start, p.offering_id) for p in result] == [ + ("2026-10-01T04:00:00Z", "off-b-package"), + ] + + def test_buys_share_one_expected_period_per_offering(self) -> None: + result = run( + buys=[ + buy("mb-2", end_time="2026-10-03T04:00:00Z"), + buy("mb-1", end_time="2026-10-02T04:00:00Z"), + ] + ) + assert [(p.period_start, p.media_buy_ids) for p in result] == [ + ("2026-10-01T04:00:00Z", ("mb-1", "mb-2")), + ("2026-10-02T04:00:00Z", ("mb-2",)), + ] + + def test_scope_filters(self) -> None: + excluded = state("2026-10-01T00:00:00Z", config=subscription_config(exclude=["acct-1"])) + assert run(subscription=excluded, buys=[buy()]) == [] + named = state( + "2026-10-01T00:00:00Z", config=subscription_config(scope={"account_ids": ["acct-2"]}) + ) + assert run(subscription=named, buys=[buy()]) == [] + operator = state( + "2026-10-01T00:00:00Z", + config=subscription_config(operator_domains=["agency.example"]), + ) + assert run(subscription=operator, buys=[buy()], account_operator="other.example") == [] + assert run(subscription=operator, buys=[buy()], account_operator="agency.example") + with pytest.raises(ValueError): + run(subscription=operator, buys=[buy()]) + + +# -- exceptions ---------------------------------------------------------------- + + +class TestExceptions: + def test_account_level_exception_removes_periods_in_from_until(self) -> None: + result = starts( + buys=[buy(end_time="2026-10-08T04:00:00Z")], + exceptions=[ + { + "reason": "capacity", + "from": "2026-10-03T04:00:00Z", + "until": "2026-10-05T04:00:00Z", + } + ], + ) + assert result == [*days("2026-10-01", 2), *days("2026-10-05", 3)] + + def test_open_exception_removes_everything_from_it(self) -> None: + result = starts( + buys=[buy(end_time="2026-10-08T04:00:00Z")], + exceptions=[ + { + "reason": "overridden", + "from": "2026-10-04T04:00:00Z", + "overridden_by": {"delivery_config_id": "acme"}, + } + ], + ) + assert result == days("2026-10-01", 3) + + def test_buy_level_exception_only_touches_that_buy(self) -> None: + result = run( + buys=[ + buy("mb-1", end_time="2026-10-03T04:00:00Z"), + buy("mb-2", end_time="2026-10-03T04:00:00Z"), + ], + exceptions=[ + { + "media_buy_id": "mb-2", + "reason": "offering_unsupported", + "from": "2026-10-02T04:00:00Z", + } + ], + ) + assert [(p.period_start, p.media_buy_ids) for p in result] == [ + ("2026-10-01T04:00:00Z", ("mb-1", "mb-2")), + ("2026-10-02T04:00:00Z", ("mb-1",)), + ] + + def test_package_exceptions_never_remove_periods(self) -> None: + exceptions = [ + { + "media_buy_id": "mb-1", + "reason": "offering_unsupported", + "from": "2026-10-01T04:00:00Z", + "package_ids": ["pkg-9"], + } + ] + with_exception = run(buys=[buy(end_time="2026-10-03T04:00:00Z")], exceptions=exceptions) + assert with_exception == run(buys=[buy(end_time="2026-10-03T04:00:00Z")]) + + def test_generated_coverage_model_round_trips(self) -> None: + coverage = ReportingSubscriptionCoverage.model_validate( + { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "status": "active", + "excluded": [ + { + "reason": "capacity", + "from": "2026-10-03T04:00:00Z", + "until": "2026-10-04T04:00:00Z", + } + ], + "gaps": [ + { + "from": "2026-10-05T04:00:00Z", + "until": "2026-10-06T04:00:00Z", + "reason": "unauthorized", + } + ], + } + ) + normalized = subscription_exceptions(coverage.excluded, coverage.gaps or ()) + assert [(e.reason, e.from_, e.until) for e in normalized] == [ + ("capacity", at("2026-10-03T04:00:00Z"), at("2026-10-04T04:00:00Z")), + ("unauthorized", at("2026-10-05T04:00:00Z"), at("2026-10-06T04:00:00Z")), + ] + result = starts( + buys=[buy(end_time="2026-10-07T04:00:00Z")], + exceptions=coverage.excluded, + gaps=coverage.gaps or (), + ) + assert result == [*days("2026-10-01", 2), "2026-10-04T04:00:00Z", "2026-10-06T04:00:00Z"] + + def test_exceptions_only_ever_remove_periods(self) -> None: + rng = random.Random(8543) + buys = [ + buy("mb-1", end_time="2026-10-09T04:00:00Z"), + buy("mb-2", start_time="2026-10-03T10:00:00Z", end_time="2026-10-12T04:00:00Z"), + ] + baseline = run(buys=buys) + owed = {(p.period_start, b) for p in baseline for b in p.media_buy_ids} + instants = [ + f"2026-10-{day:02d}T{hour:02d}:00:00Z" for day in range(1, 13) for hour in (0, 4, 13) + ] + for _ in range(200): + entries = [] + for _ in range(rng.randint(1, 4)): + lo, hi = sorted(rng.sample(instants, 2)) + entry: dict = { + "reason": rng.choice(["capacity", "excluded_by_buyer", "offering_unsupported"]), + "from": lo, + } + if rng.random() < 0.7: + entry["until"] = hi + if rng.random() < 0.5: + entry["media_buy_id"] = rng.choice(["mb-1", "mb-2"]) + entry["reason"] = "offering_unsupported" + entries.append(entry) + trimmed = run(buys=buys, exceptions=entries) + remaining = {(p.period_start, b) for p in trimmed for b in p.media_buy_ids} + assert remaining <= owed + removed = owed - remaining + for start, media_buy_id in removed: + assert any( + e["from"] <= start + and ("until" not in e or start < e["until"]) + and e.get("media_buy_id", media_buy_id) == media_buy_id + for e in entries + ) + + +# -- lost authority -------------------------------------------------------------- + + +GRANT = at("2026-10-01T00:00:00Z") + + +class TestLostAuthority: + def test_revocation_still_in_effect(self) -> None: + # Lost at 2026-10-04T10:00Z: the Oct 4 day started before the loss + # but ends after it, so it is not owed. Oct 3 ended before the loss + # but its expected_at (Oct 4 10:00Z) is not after the loss, so it + # stays due at its original deadline. + result = run( + buys=[buy(end_time="2026-10-08T04:00:00Z")], + authorizations=[SubscriptionAuthorization(GRANT, at("2026-10-04T10:00:00Z"))], + ) + assert [(p.period_start, p.expected_at, p.withheld) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T10:00:00Z", False), + ("2026-10-02T04:00:00Z", "2026-10-03T10:00:00Z", False), + ("2026-10-03T04:00:00Z", "2026-10-04T10:00:00Z", False), + ] + + def test_period_pending_at_the_loss_is_withheld(self) -> None: + result = run( + buys=[buy(end_time="2026-10-08T04:00:00Z")], + authorizations=[SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z"))], + ) + assert [(p.period_start, p.expected_at, p.withheld) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T10:00:00Z", False), + ("2026-10-02T04:00:00Z", "2026-10-03T10:00:00Z", False), + ("2026-10-03T04:00:00Z", None, True), + ] + + def test_regrant_without_backfill_records_a_gap(self) -> None: + # Lost 2026-10-04T08:00Z, re-granted 2026-10-06T15:00Z. Gap from the + # last boundary at or before the loss (Oct 4 04:00Z) until the first + # boundary at or after the re-grant (Oct 7 04:00Z). + result = run( + buys=[buy(end_time="2026-10-09T04:00:00Z")], + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), + ], + ) + assert [(p.period_start, p.expected_at) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T10:00:00Z"), + ("2026-10-02T04:00:00Z", "2026-10-03T10:00:00Z"), + # withheld at the loss, due at re-grant + delivery_sla + ("2026-10-03T04:00:00Z", "2026-10-06T21:00:00Z"), + ("2026-10-07T04:00:00Z", "2026-10-08T10:00:00Z"), + ("2026-10-08T04:00:00Z", "2026-10-09T10:00:00Z"), + ] + assert not any(p.withheld for p in result) + + def test_regrant_with_backfill_keeps_the_revoked_interval_owed(self) -> None: + result = run( + buys=[buy(end_time="2026-10-09T04:00:00Z")], + backfill_on_regrant=True, + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), + ], + ) + assert [(p.period_start, p.expected_at) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T10:00:00Z"), + ("2026-10-02T04:00:00Z", "2026-10-03T10:00:00Z"), + ("2026-10-03T04:00:00Z", "2026-10-06T21:00:00Z"), + ("2026-10-04T04:00:00Z", "2026-10-06T21:00:00Z"), + ("2026-10-05T04:00:00Z", "2026-10-06T21:00:00Z"), + ("2026-10-06T04:00:00Z", "2026-10-07T10:00:00Z"), + ("2026-10-07T04:00:00Z", "2026-10-08T10:00:00Z"), + ("2026-10-08T04:00:00Z", "2026-10-09T10:00:00Z"), + ] + + def test_regrant_after_retention_is_a_fresh_start_without_backfill(self) -> None: + result = run( + buys=[buy(end_time="2026-10-09T04:00:00Z")], + backfill_on_regrant=True, + status_retention_days=1, + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), + ], + ) + assert [(p.period_start, p.expected_at, p.withheld) for p in result] == [ + ("2026-10-01T04:00:00Z", "2026-10-02T10:00:00Z", False), + ("2026-10-02T04:00:00Z", "2026-10-03T10:00:00Z", False), + ("2026-10-03T04:00:00Z", None, True), + ("2026-10-07T04:00:00Z", "2026-10-08T10:00:00Z", False), + ("2026-10-08T04:00:00Z", "2026-10-09T10:00:00Z", False), + ] + + def test_loss_on_a_boundary(self) -> None: + result = starts( + buys=[buy(end_time="2026-10-09T04:00:00Z")], + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T04:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T04:00:00Z")), + ], + ) + assert result == [*days("2026-10-01", 3), *days("2026-10-06", 3)] + + def test_overlapping_grants_are_rejected(self) -> None: + with pytest.raises(ValueError): + run( + buys=[buy()], + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-05T00:00:00Z")), + SubscriptionAuthorization(at("2026-10-04T00:00:00Z")), + ], + ) + + +# -- timezones and clocks -------------------------------------------------------- + + +class TestTimezones: + def test_account_timezone_sets_the_boundaries(self) -> None: + result = periods( + account_timezone="America/Los_Angeles", + buys=[buy(start_time="2026-10-01T07:00:00Z", end_time="2026-10-03T07:00:00Z")], + ) + assert result == [ + ("2026-10-01T07:00:00Z", "2026-10-02T07:00:00Z"), + ("2026-10-02T07:00:00Z", "2026-10-03T07:00:00Z"), + ] + + def test_daily_periods_follow_local_midnight_across_fall_back(self) -> None: + # 2026-11-01 is the US fall-back day: it lasts 25 hours. + result = periods( + subscription=state("2026-10-30T00:00:00Z"), + buys=[ + buy( + confirmed_at="2026-10-30T00:00:00Z", + start_time="2026-10-31T04:00:00Z", + end_time="2026-11-03T05:00:00Z", + ) + ], + ) + assert result == [ + ("2026-10-31T04:00:00Z", "2026-11-01T04:00:00Z"), + ("2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z"), + ("2026-11-02T05:00:00Z", "2026-11-03T05:00:00Z"), + ] + + def test_local_hourly_periods_skip_the_spring_forward_gap(self) -> None: + local_hourly = offering("local-hourly", duration="PT1H", sla="PT1H") + result = periods( + subscription=state("2026-03-08T00:00:00Z", config=subscription_config(duration="PT1H")), + offerings=[local_hourly], + buys=[ + buy( + confirmed_at="2026-03-08T00:00:00Z", + start_time="2026-03-08T05:00:00Z", + end_time="2026-03-08T08:00:00Z", + offering_ids=("local-hourly",), + ) + ], + ) + # 00:00 EST, 01:00 EST, then 03:00 EDT: the 02:00 slot has no length. + assert result == [ + ("2026-03-08T05:00:00Z", "2026-03-08T06:00:00Z"), + ("2026-03-08T06:00:00Z", "2026-03-08T07:00:00Z"), + ("2026-03-08T07:00:00Z", "2026-03-08T08:00:00Z"), + ] + + def test_timezone_change_is_a_cutover(self) -> None: + # The spec's example: a daily feed moves from UTC-5 to UTC-8 at 12:00Z. + # The old schedule ends at 05:00Z the next day; the first new-timezone + # day starts at 08:00Z; 05:00Z..08:00Z is owed under neither. + result = run( + account_timezone="Etc/GMT+5", + timezone_changes=[AccountTimezoneChange(at("2026-10-03T12:00:00Z"), "Etc/GMT+8")], + buys=[buy(start_time="2026-10-02T05:00:00Z", end_time="2026-10-05T08:00:00Z")], + ) + assert [(p.period_start, p.period_end, p.source_timezone) for p in result] == [ + ("2026-10-02T05:00:00Z", "2026-10-03T05:00:00Z", "Etc/GMT+5"), + ("2026-10-03T05:00:00Z", "2026-10-04T05:00:00Z", "Etc/GMT+5"), + ("2026-10-04T08:00:00Z", "2026-10-05T08:00:00Z", "Etc/GMT+8"), + ] + + def test_timezone_change_does_not_touch_utc_offerings(self) -> None: + kwargs = dict( + subscription=state("2026-10-03T00:00:00Z", config=subscription_config(duration="PT1H")), + buys=[ + buy( + start_time="2026-10-03T10:00:00Z", + end_time="2026-10-03T14:00:00Z", + offering_ids=("hourly-pkg",), + ) + ], + ) + moved = run( + timezone_changes=[AccountTimezoneChange(at("2026-10-03T12:30:00Z"), "Etc/GMT+8")], + **kwargs, + ) + assert moved == run(**kwargs) + + def test_billing_cycle_anchor(self) -> None: + cycle = offering( + "weekly-cycle", + duration="P7D", + alignment="billing_cycle", + period_timezone=NY, + period_anchor=at("2026-09-28T04:00:00Z"), # a Monday, local midnight + ) + result = periods( + subscription=state("2026-10-01T00:00:00Z", config=subscription_config(duration="P7D")), + offerings=[cycle], + buys=[buy(end_time="2026-10-13T04:00:00Z", offering_ids=("weekly-cycle",))], + ) + assert result == [ + ("2026-10-05T04:00:00Z", "2026-10-12T04:00:00Z"), + ("2026-10-12T04:00:00Z", "2026-10-19T04:00:00Z"), + ] + + def test_naive_timestamps_are_rejected(self) -> None: + with pytest.raises(ValueError): + run(buys=[buy()], now=datetime(2026, 12, 1)) + + +def test_explicit_windows_and_exception_values() -> None: + windows = [ + SubscriptionVersionWindow(1, at("2026-10-01T00:00:00Z"), None, subscription_config()) + ] + result = run( + subscription=windows, + buys=[buy(end_time="2026-10-04T04:00:00Z")], + exceptions=[ + SubscriptionException( + "capacity", at("2026-10-02T04:00:00Z"), at("2026-10-03T04:00:00Z") + ) + ], + ) + assert [p.period_start for p in result] == ["2026-10-01T04:00:00Z", "2026-10-03T04:00:00Z"] + + +@pytest.mark.parametrize( + "confirmed, accepted, granted", + list( + itertools.product( + ["2026-10-02T00:00:00Z", "2026-10-02T04:00:00Z", "2026-10-03T13:00:00Z"], + ["2026-10-01T00:00:00Z", "2026-10-03T04:00:00Z", "2026-10-03T23:59:00Z"], + ["2026-09-01T00:00:00Z", "2026-10-02T16:00:00Z", "2026-10-04T04:00:00Z"], + ) + ), +) +def test_start_is_the_first_boundary_after_the_latest_of_three( + confirmed: str, accepted: str, granted: str +) -> None: + latest = max(at(confirmed), at(accepted), at(granted)) + expected = [ + start + for start in days("2026-09-30", 10) + if at(start) >= latest and at(start) < at("2026-10-08T04:00:00Z") + ] + result = starts( + subscription=state(accepted), + authorizations=[SubscriptionAuthorization(at(granted))], + buys=[ + buy( + confirmed_at=confirmed, + start_time="2026-09-30T04:00:00Z", + end_time="2026-10-08T04:00:00Z", + ) + ], + ) + assert result == expected From 39ca1b3d4e375261c1d72d3aa9c8a4a451732dc2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 10:10:34 +0000 Subject: [PATCH 2/8] fix(reporting): derive subscription periods exactly as the TypeScript SDK does Align expected_subscription_periods with adcontextprotocol/adcp-client#3505 so that both SDKs owe identical periods. The spec leaves each of these choices open: - A dedicated period calendar. Years, months and days are local civil arithmetic with the day clamped from the origin. Hours, minutes and seconds are exact elapsed time. Calendar months (P1M billing cycles) are supported, and numeric UTC offsets are refused. - Cutovers are paired per buy over the union of its packages. A continuing offering hands off at E of its own outgoing calendar. A newly resolved one starts at the latest E of everything the previous regime resolved. - A period_timezone override moves account_timezone and account-resolved schedules into its zone, while utc and fixed-zone schedules match only their own zone. - Versions may carry in-place inactive intervals and their destination method. The coverage entry's timezone_cutovers are applied. - A re-grant continues authority only before unauthorized_at plus status_retention_days, and a re-grant without retention is an error. The TypeScript SDK's 46-case table is vendored and run as a parity test. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/_clock.py | 276 +- src/adcp/reporting/subscriptions/coverage.py | 574 +- .../reporting/subscriptions/resolution.py | 135 +- .../coverage-cases.json | 6360 +++++++++++++++++ tests/test_reporting_subscription_coverage.py | 97 +- tests/test_reporting_subscription_parity.py | 175 + 6 files changed, 7183 insertions(+), 434 deletions(-) create mode 100644 tests/fixtures/reporting_subscriptions/coverage-cases.json create mode 100644 tests/test_reporting_subscription_parity.py diff --git a/src/adcp/reporting/subscriptions/_clock.py b/src/adcp/reporting/subscriptions/_clock.py index dfc5ef7fb..33b2e5415 100644 --- a/src/adcp/reporting/subscriptions/_clock.py +++ b/src/adcp/reporting/subscriptions/_clock.py @@ -1,35 +1,40 @@ -"""Period clock shared by subscription coverage on both sides of the wire. - -A reporting subscription owes the complete periods of the resolved offering's -published schedule. The boundaries come from ``core/reporting-schedule.json`` -``period_generation``: ``utc`` counts from 1970-01-01T00:00:00Z, and -``account_timezone`` and ``source_timezone`` count from *local* midnight on -1970-01-01 in their zone. ``billing_cycle`` counts from its explicit anchor in -its zone. - -The ledger's :class:`~adcp.reporting.ledger.models.ReportingScheduleSpec` -defaults a civil-time schedule without an anchor to the UTC epoch expressed in -local time, which for ``America/New_York`` puts every daily boundary at 19:00 -local. Production admission already refuses that phase. Subscriptions never -rely on that default: every civil-time schedule built here carries the -normative local-midnight anchor explicitly, so the seller's obligations and the -buyer's expectations use one derivation. +"""The period calendar shared by subscription coverage on both sides of the wire. + +``core/reporting-schedule.json`` ``period_generation``: every boundary is +calculated directly from the schedule's origin and the interval ordinal by +multiplying each ISO 8601 duration component by the ordinal and applying +years, months, days, hours, minutes, then seconds. + +* Origins: ``utc`` counts from 1970-01-01T00:00:00Z; ``account_timezone`` and + ``source_timezone`` from local midnight on 1970-01-01 in their zone; + ``billing_cycle`` from its explicit ``period_anchor`` expressed in its zone. +* Years, months and days are local civil arithmetic in the zone. The day is + clamped from the origin's day (a March 31 anchor gives February 28, then + March 31 again). A nonexistent local time advances by the gap; an ambiguous + one takes the earlier instant. +* Hours, minutes and seconds are exact elapsed time added after the civil part, + so ``PT24H`` differs from ``P1D`` across a DST change. The spec does not say + whether time components are civil; this matches the TypeScript SDK + (adcontextprotocol/adcp-client#3505), so both SDKs derive identical periods. +* A zero-length period (a skipped civil date) is never a period. + +The ledger's ``ReportingScheduleSpec`` treats every component as civil time and +defaults an anchorless civil schedule to the UTC epoch in local time; this +calendar does neither. """ from __future__ import annotations +import calendar as _calendar +import re from collections.abc import Iterator +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from adcp.reporting.ledger.models import ( - ReportingScheduleSpec, - _period_instants, - first_ordinal_after, - iso_duration_to_timedelta, -) - UTC = timezone.utc +_DURATION = re.compile(r"P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?") +_SLA = re.compile(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?") def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime: @@ -39,107 +44,136 @@ def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime: return value.astimezone(UTC) -def require_zone(name: str, *, field: str = "timezone") -> ZoneInfo: +def require_zone(name: str | None, *, field: str = "timezone") -> ZoneInfo: + """A recognized IANA zone; numeric offsets and unknown names are refused.""" + if not name or name[0] in "+-" or name[0].isdigit(): + raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") try: return ZoneInfo(name) except (ZoneInfoNotFoundError, ValueError) as error: raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") from error -def local_midnight_origin(zone_name: str) -> datetime: - """The normative origin of a civil-time schedule: 1970-01-01T00:00 local.""" - zone = require_zone(zone_name) - return datetime(1970, 1, 1, tzinfo=zone).astimezone(UTC) - - -def civil_schedule( - *, - period_duration: str, - delivery_sla: str, - zone_name: str, - anchor: datetime | None = None, -) -> ReportingScheduleSpec: - """Build a ledger schedule with an explicit, normative origin. - - ``zone_name == "UTC"`` without an anchor is the ``utc`` alignment. - """ - duration = iso_duration_to_timedelta(period_duration) - if duration <= timedelta(0): +def parse_period_duration(value: str) -> tuple[int, int, int, int]: + """``(years, months, days, seconds)`` of a strictly positive duration.""" + match = _DURATION.fullmatch(value) + if match is None or value in {"P", "PT"} or value.endswith("T"): + raise ValueError(f"{value!r} is not an ISO 8601 period duration") + years, months, days, hours, minutes, seconds = (int(item or 0) for item in match.groups()) + exact = hours * 3600 + minutes * 60 + seconds + if not (years or months or days or exact): raise ValueError("period_duration must be positive") - iso_duration_to_timedelta(delivery_sla) - if anchor is None and zone_name == "UTC": - return ReportingScheduleSpec( - period_duration=period_duration, delivery_sla=delivery_sla, alignment="utc" - ) - require_zone(zone_name) - return ReportingScheduleSpec( - period_duration=period_duration, - delivery_sla=delivery_sla, - alignment="custom_timezone", - period_timezone=zone_name, - period_anchor=( - utc_instant(anchor, field="period_anchor") + return years, months, days, exact + + +def parse_delivery_sla(value: str) -> timedelta: + """A non-negative ``delivery_sla`` as exact elapsed time (days are 24 hours).""" + match = _SLA.fullmatch(value) + if match is None or value in {"P", "PT"} or value.endswith("T"): + raise ValueError(f"{value!r} is not a supported delivery_sla") + days, hours, minutes, seconds = (int(item or 0) for item in match.groups()) + return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + + +def _resolve(local: datetime, zone: ZoneInfo) -> datetime: + """Local civil time to an instant: a gap advances, a fold takes the earlier.""" + return local.replace(tzinfo=zone, fold=0).astimezone(UTC) + + +def _add_months(local: datetime, months: int, origin_day: int) -> datetime: + index = local.year * 12 + (local.month - 1) + months + year, month = divmod(index, 12) + month += 1 + day = min(origin_day, _calendar.monthrange(year, month)[1]) + return local.replace(year=year, month=month, day=day) + + +@dataclass(frozen=True) +class PeriodCalendar: + """Ordered half-open periods of one schedule in one zone.""" + + period_duration: str + zone_name: str + origin: datetime # naive local civil time + delivery_sla: timedelta = timedelta(0) + _parts: tuple[int, int, int, int] = field(init=False, repr=False, compare=False) + _zone: ZoneInfo = field(init=False, repr=False, compare=False) + _approx: float = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + parts = parse_period_duration(self.period_duration) + object.__setattr__(self, "_parts", parts) + object.__setattr__(self, "_zone", require_zone(self.zone_name)) + if self.origin.tzinfo is not None: + raise ValueError("origin is local civil time and must be naive") + years, months, days, exact = parts + approx = years * 365.2425 * 86400 + months * 30.436875 * 86400 + days * 86400 + exact + object.__setattr__(self, "_approx", approx) + + @classmethod + def for_schedule( + cls, + *, + period_duration: str, + delivery_sla: str, + zone_name: str, + anchor: datetime | None = None, + ) -> PeriodCalendar: + """``anchor`` is an instant (``billing_cycle``); otherwise local midnight 1970-01-01.""" + zone = require_zone(zone_name) + origin = ( + utc_instant(anchor, field="period_anchor").astimezone(zone).replace(tzinfo=None) if anchor is not None - else local_midnight_origin(zone_name) - ), - ) - - -def _zone_name(schedule: ReportingScheduleSpec) -> str: - return schedule.timezone_name("UTC") - - -def period_bounds(schedule: ReportingScheduleSpec, ordinal: int) -> tuple[datetime, datetime]: - return _period_instants(schedule, _zone_name(schedule), ordinal) - - -def first_ordinal_at_or_after(schedule: ReportingScheduleSpec, instant: datetime) -> int: - return first_ordinal_after( - schedule, account_timezone=_zone_name(schedule), activated_at=utc_instant(instant) - ) - - -def first_boundary_at_or_after(schedule: ReportingScheduleSpec, instant: datetime) -> datetime: - """The cutover instant E for an outgoing schedule at change instant T. - - E is T when T falls on a boundary, and otherwise the end of the period - containing T: the end of the last period that starts before T. - """ - return period_bounds(schedule, first_ordinal_at_or_after(schedule, instant))[0] - - -def last_boundary_at_or_before(schedule: ReportingScheduleSpec, instant: datetime) -> datetime: - """The start of the period containing ``instant``, or ``instant`` on a boundary.""" - at = utc_instant(instant) - ordinal = first_ordinal_at_or_after(schedule, at) - start = period_bounds(schedule, ordinal)[0] - if start == at: - return start - return period_bounds(schedule, ordinal - 1)[0] - - -def iter_periods( - schedule: ReportingScheduleSpec, *, starts_from: datetime, starts_before: datetime -) -> Iterator[tuple[datetime, datetime]]: - """Yield every nonempty period whose start lies in ``[starts_from, starts_before)``. - - A spring-forward gap can collapse a civil slot to zero elapsed time; that - slot is not a period. A nonexistent local boundary otherwise advances by - the gap, as ``period_generation`` requires, so the period is kept. - """ - upper = utc_instant(starts_before) - ordinal = first_ordinal_at_or_after(schedule, starts_from) - while True: - start, end = period_bounds(schedule, ordinal) - if start >= upper: - return - if end > start: - yield start, end - ordinal += 1 - - -def delivery_sla(schedule: ReportingScheduleSpec) -> timedelta: - return iso_duration_to_timedelta(schedule.delivery_sla) + else datetime(1970, 1, 1) + ) + return cls(period_duration, zone_name, origin, parse_delivery_sla(delivery_sla)) + + def boundary(self, ordinal: int) -> datetime: + years, months, days, exact = self._parts + local = self.origin + if years or months: + local = _add_months(local, (years * 12 + months) * ordinal, self.origin.day) + if days: + local = local + timedelta(days=days * ordinal) + return _resolve(local, self._zone) + timedelta(seconds=exact * ordinal) + + def bounds(self, ordinal: int) -> tuple[datetime, datetime]: + return self.boundary(ordinal), self.boundary(ordinal + 1) + + def first_ordinal_at_or_after(self, instant: datetime) -> int: + at = utc_instant(instant) + origin = self.boundary(0) + ordinal = int((at - origin).total_seconds() // self._approx) + while self.boundary(ordinal) < at: + ordinal += 1 + while self.boundary(ordinal - 1) >= at: + ordinal -= 1 + return ordinal + + def ceil(self, instant: datetime) -> datetime: + """The first boundary at or after ``instant`` (E for a cutover at it).""" + return self.boundary(self.first_ordinal_at_or_after(instant)) + + def floor(self, instant: datetime) -> datetime: + """The last boundary at or before ``instant``.""" + at = utc_instant(instant) + ordinal = self.first_ordinal_at_or_after(at) + start = self.boundary(ordinal) + return start if start == at else self.boundary(ordinal - 1) + + def periods( + self, *, starts_from: datetime, starts_before: datetime + ) -> Iterator[tuple[datetime, datetime]]: + """Every nonempty period whose start lies in ``[starts_from, starts_before)``.""" + upper = utc_instant(starts_before) + ordinal = self.first_ordinal_at_or_after(starts_from) + start = self.boundary(ordinal) + while start < upper: + end = self.boundary(ordinal + 1) + if end > start: + yield start, end + ordinal += 1 + start = end def iso_z(value: datetime) -> str: @@ -151,14 +185,10 @@ def iso_z(value: datetime) -> str: __all__ = [ - "civil_schedule", - "delivery_sla", - "first_boundary_at_or_after", + "PeriodCalendar", "iso_z", - "iter_periods", - "last_boundary_at_or_before", - "local_midnight_origin", - "period_bounds", + "parse_delivery_sla", + "parse_period_duration", "require_zone", "utc_instant", ] diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index c7b62346a..a7c3c40c4 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -28,32 +28,28 @@ authority removes periods the same way unless ``backfill_on_regrant`` keeps them. -Pairing rule for cutovers (where the spec is silent) - Cutovers are paired per product. An offering present on both sides of T - continues from E of its own outgoing schedule. When offerings leave and - others arrive at the same T (a resolution or selector change), each - arriving offering starts at the latest E among the leaving ones, so no - period is owed twice. An offering that arrives with nothing leaving starts - at T, under the ordinary start rule. +Rules where the spec is silent (identical in the TypeScript SDK) + Time is split per buy at every version ``accepted_at`` / ``superseded_at``, + deactivation and reactivation instant, offering ``available_from`` / + ``withdrawn_at`` and account timezone cutover. Each stretch with an active, + in-scope version and an unchanged resolution (the union over the buy's + packages) is a regime. An offering continuing into the next regime hands + off at E of its own outgoing calendar; a newly resolved offering starts at + the latest E across everything the previous regime resolved; after a regime + that resolved nothing, or no active version, the ordinary start rule applies. + A re-grant before ``unauthorized_at + status_retention_days`` continues the + authority chain; a later one starts a new chain. """ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import Any from adcp.reporting._reconcile import ExpectedReportingPeriod -from adcp.reporting.ledger.models import ReportingScheduleSpec -from adcp.reporting.subscriptions._clock import ( - delivery_sla, - first_boundary_at_or_after, - iso_z, - iter_periods, - last_boundary_at_or_before, - utc_instant, -) +from adcp.reporting.subscriptions._clock import PeriodCalendar, iso_z, require_zone, utc_instant from adcp.reporting.subscriptions.resolution import ( SubscriptionOffering, SubscriptionSelector, @@ -62,7 +58,6 @@ resolve_subscription_offerings, ) -_FAR_FUTURE = datetime(9999, 1, 1, tzinfo=timezone.utc) #: Exclusion reasons that remove periods. ``package_ids`` entries never do. _REASONS = frozenset( {"offering_unsupported", "capacity", "overridden", "excluded_by_buyer", "action_required"} @@ -313,6 +308,12 @@ class SubscriptionVersionWindow: accepted_at: datetime ends_at: datetime | None configuration: Any + #: ``(from, until)`` stretches during which the version was inactive in + #: place (``active: false`` then ``true`` without a new version); ``until`` + #: ``None`` is still inactive. Reactivation is a fresh start at ``until``. + inactive_intervals: tuple[tuple[datetime, datetime | None], ...] = () + #: The pattern and transport of the destination this version names. + destination: Any | None = None def __post_init__(self) -> None: object.__setattr__(self, "accepted_at", utc_instant(self.accepted_at, field="accepted_at")) @@ -320,9 +321,26 @@ def __post_init__(self) -> None: object.__setattr__(self, "ends_at", utc_instant(self.ends_at, field="ends_at")) if self.ends_at < self.accepted_at: raise ValueError("a version cannot end before it was accepted") + intervals = [] + for start, until in self.inactive_intervals: + start = utc_instant(start, field="inactive_intervals.from") + if until is not None: + until = utc_instant(until, field="inactive_intervals.until") + if until <= start: + raise ValueError("an inactive interval must end after it starts") + intervals.append((start, until)) + object.__setattr__(self, "inactive_intervals", tuple(intervals)) + + def active_at(self, instant: datetime) -> bool: + if not (self.accepted_at <= instant and (self.ends_at is None or instant < self.ends_at)): + return False + return not any( + start <= instant and (until is None or instant < until) + for start, until in self.inactive_intervals + ) def contains(self, instant: datetime) -> bool: - return self.accepted_at <= instant and (self.ends_at is None or instant < self.ends_at) + return self.active_at(instant) def subscription_version_windows( @@ -368,8 +386,8 @@ def subscription_version_windows( for earlier, later in zip(windows, windows[1:]): if earlier.subscription_version >= later.subscription_version: raise ValueError("subscription versions must increase with accepted_at") - if earlier.ends_at is None or earlier.ends_at > later.accepted_at: - raise ValueError("a prior version must be superseded no later than its successor") + if earlier.ends_at != later.accepted_at: + raise ValueError("superseded_at must equal the next version's accepted_at") return tuple(windows) @@ -379,7 +397,8 @@ def subscription_covers_account( """Whether a subscription version's scope selects the account. ``exclude_account_ids`` and ``operator_domains`` narrow the scope; the - seller also reports an excluded account as ``excluded_by_buyer``. + seller also reports an excluded account as ``excluded_by_buyer``. With + ``operator_domains``, an account whose operator is unknown is not selected. """ if hasattr(configuration, "model_dump"): configuration = configuration.model_dump(mode="json", exclude_none=True) @@ -395,11 +414,9 @@ def subscription_covers_account( return False domains = _value(configuration, "operator_domains") if domains: - if account_operator is None: - raise ValueError( - "the subscription filters by operator_domains; pass the account's operator" - ) - return account_operator in {_text(item) for item in domains} + return account_operator is not None and account_operator in { + _text(item) for item in domains + } return True @@ -433,33 +450,32 @@ class ExpectedSubscriptionPeriod(ExpectedReportingPeriod): @dataclass(frozen=True) -class _Slot: - """One (version, offering, zone) feed a product resolves to in one segment.""" - - version: int +class _Resolved: offering: SubscriptionOffering - zone: str - schedule: ReportingScheduleSpec + calendar: PeriodCalendar - def same_feed(self, other: _Slot) -> bool: - return ( - self.version == other.version - and self.offering.offering_id == other.offering.offering_id - and self.zone == other.zone - ) + @property + def key(self) -> tuple[str, str, datetime]: + return (self.offering.offering_id, self.calendar.zone_name, self.calendar.origin) @dataclass -class _SlotState: - slot: _Slot - lower: datetime +class _Regime: + start: datetime + until: datetime | None + version: int + resolved: tuple[_Resolved, ...] -@dataclass(frozen=True) -class _Loss: - unauthorized_at: datetime - regranted_at: datetime | None - expired: bool +@dataclass +class _Chain: + """Continuous authority: a first grant, retained interruptions, an optional end.""" + + start: datetime + interruptions: list[tuple[datetime, datetime]] = field(default_factory=list) + end: datetime | None = None + #: The re-grant that started this chain after an expired entry, if any. + after_expiry_of: datetime | None = None @dataclass @@ -474,48 +490,109 @@ class _Owed: media_buy_ids: set[str] = field(default_factory=set) -def _losses( +def _chains( authorizations: Sequence[SubscriptionAuthorization], retention: timedelta | None -) -> tuple[datetime | None, tuple[_Loss, ...]]: +) -> list[_Chain]: ordered = sorted(authorizations, key=lambda item: item.authorized_at) - for earlier, later in zip(ordered, ordered[1:]): - if earlier.unauthorized_at is None or earlier.unauthorized_at > later.authorized_at: - raise ValueError("authorization grants must not overlap") - losses: list[_Loss] = [] + chains: list[_Chain] = [] + previous_end: datetime | None = None for index, grant in enumerate(ordered): - if grant.unauthorized_at is None: + if index > 0 and previous_end is None: + raise ValueError("only the last grant may be unrevoked") + if previous_end is not None and grant.authorized_at < previous_end: + raise ValueError("authorization grants must not overlap") + if chains and previous_end is not None: + if retention is None: + raise ValueError("status_retention_days is required to evaluate a re-grant") + if grant.authorized_at < previous_end + retention: + chains[-1].interruptions.append((previous_end, grant.authorized_at)) + chains[-1].end = None + else: + chains.append(_Chain(grant.authorized_at, after_expiry_of=previous_end)) + else: + chains.append(_Chain(grant.authorized_at)) + if grant.unauthorized_at is not None: + chains[-1].end = grant.unauthorized_at + previous_end = grant.unauthorized_at + return chains + + +def _authorized( + chains: Sequence[_Chain], calendar: PeriodCalendar, start: datetime, backfill: bool +) -> bool: + if not chains: + return True # authorized_at is not advertised and authority never lapsed + for chain in chains: + if start < calendar.ceil(chain.start): continue - regranted = ordered[index + 1].authorized_at if index + 1 < len(ordered) else None - expired = ( - regranted is not None - and retention is not None - and regranted - grant.unauthorized_at > retention - ) - losses.append(_Loss(grant.unauthorized_at, regranted, expired)) - return (ordered[0].authorized_at if ordered else None), tuple(losses) + if chain.end is not None and start >= calendar.floor(chain.end): + continue + if not backfill and any( + calendar.floor(lost) <= start < calendar.ceil(regranted) + for lost, regranted in chain.interruptions + ): + continue + return True + return False -def _zone_at( - instant: datetime, account_timezone: str, changes: Sequence[AccountTimezoneChange] -) -> str: - zone = account_timezone +def _expected_at( + chains: Sequence[_Chain], + calendar: PeriodCalendar, + start: datetime, + end: datetime, + backfill: bool, +) -> tuple[datetime | None, bool]: + """When a period is due, and whether it is withheld for lost authority.""" + sla = calendar.delivery_sla + expected: datetime = end + sla + for chain in chains: + for lost, regranted in chain.interruptions: + pending = end <= calendar.floor(lost) and expected > lost + backfilled = backfill and calendar.floor(lost) <= start < calendar.ceil(regranted) + if pending or backfilled: + expected = max(expected, regranted + sla) + if chain.end is not None and end <= calendar.floor(chain.end) and expected > chain.end: + return None, True + return expected, False + + +def _timezone_at( + instant: datetime, initial: str | None, changes: Sequence[AccountTimezoneChange] +) -> str | None: + zone = initial for change in changes: if change.changed_at <= instant: zone = change.timezone return zone +def _windows( + subscription: Any, version_configurations: Mapping[int, Any] | None +) -> tuple[SubscriptionVersionWindow, ...]: + if isinstance(subscription, Sequence) and not isinstance(subscription, (str, bytes, Mapping)): + windows = tuple(subscription) + for earlier, later in zip(windows, windows[1:]): + if later.subscription_version <= earlier.subscription_version or ( + earlier.ends_at != later.accepted_at + ): + raise ValueError("versions must be ordered and each superseded at the next") + return windows + return subscription_version_windows(subscription, version_configurations=version_configurations) + + def expected_subscription_periods( *, subscription: Any, account_id: str, buys: Sequence[SubscriptionBuy], offerings: Sequence[SubscriptionOffering], - account_timezone: str, + account_timezone: str | None, now: datetime, authorizations: Sequence[SubscriptionAuthorization] = (), exceptions: Sequence[Any] = (), gaps: Sequence[Any] = (), + coverage: Any | None = None, backfill_on_regrant: bool = False, status_retention_days: int | None = None, destinations: Mapping[str, Any] | None = None, @@ -532,8 +609,9 @@ def expected_subscription_periods( :param buys: the confirmed buys in the account the caller can see. A buy without ``confirmed_at`` is not covered. :param offerings: offerings with the seller's dimension mapping. - :param account_timezone: the account's timezone before the first entry of - ``timezone_changes`` (its current timezone when there are none). + :param account_timezone: the account's timezone before the first timezone + change (its current timezone when there are none). Required when an + ``account_timezone`` offering resolves without a ``period_timezone``. :param now: periods are owed once resolved, at their end; a period ending after ``now`` is returned only with ``include_unresolved``. :param authorizations: the account's grant history. Empty means the seller @@ -541,30 +619,68 @@ def expected_subscription_periods( :param exceptions: ``subscription_coverage[].excluded`` entries, or :class:`SubscriptionException` values. :param gaps: ``subscription_coverage[].gaps`` entries. + :param coverage: a whole ``subscription_coverage[]`` entry; its + ``excluded``, ``gaps`` and ``timezone_cutovers`` are applied too. :param backfill_on_regrant: the seller's ``subscriptions.backfill_on_regrant``. - :param status_retention_days: the seller's status retention; a re-grant - later than this after a loss is a fresh coverage start. + :param status_retention_days: the seller's status retention; required when + a grant was revoked and re-granted. :param destinations: the caller's ``reporting_destinations`` by ``destination_id``, needed when a version names a destination. - :param account_operator: the account's operator, needed with - ``operator_domains``. + :param account_operator: the account's operator, for ``operator_domains``. + :param timezone_changes: account timezone changes, each a cutover at its + ``changed_at`` (``timezone_cutovers[].effective_at``). """ now = utc_instant(now, field="now") - windows = ( - tuple(subscription) - if isinstance(subscription, Sequence) - and not isinstance(subscription, (str, bytes, Mapping)) - else subscription_version_windows( - subscription, version_configurations=version_configurations - ) - ) + windows = _windows(subscription, version_configurations) if not windows: return [] subscription_id = _subscription_id(subscription, windows) retention = None if status_retention_days is None else timedelta(days=status_retention_days) - chain_start, losses = _losses(authorizations, retention) - removals = subscription_exceptions(exceptions, gaps) - tz_changes = tuple(sorted(timezone_changes, key=lambda item: item.changed_at)) + chains = _chains(authorizations, retention) + excluded = list(exceptions) + gap_entries = list(gaps) + changes = list(timezone_changes) + if coverage is not None: + excluded.extend(_value(coverage, "excluded") or ()) + gap_entries.extend(_value(coverage, "gaps") or ()) + for entry in _value(coverage, "timezone_cutovers") or (): + changes.append( + AccountTimezoneChange( + _require_instant(_value(entry, "effective_at"), field="effective_at"), + _text(_value(entry, "timezone")), + ) + ) + if account_timezone is not None: + require_zone(account_timezone, field="account_timezone") + for change in changes: + require_zone(change.timezone, field="timezone_cutovers.timezone") + if changes and account_timezone is None: + raise ValueError("account_timezone is required with timezone changes") + changes.sort(key=lambda item: item.changed_at) + removals = subscription_exceptions(excluded, gap_entries) + ids = [item.offering_id for item in offerings] + if len(set(ids)) != len(ids): + raise ValueError("offering_id values must be unique") + buy_ids = [item.media_buy_id for item in buys] + if len(set(buy_ids)) != len(buy_ids): + raise ValueError("media_buy_id values must be unique") + + points: set[datetime] = set() + for window in windows: + points.add(window.accepted_at) + if window.ends_at is not None: + points.add(window.ends_at) + for start, until in window.inactive_intervals: + points.add(start) + if until is not None: + points.add(until) + for item in offerings: + if item.available_from is not None: + points.add(item.available_from) + if item.withdrawn_at is not None: + points.add(item.withdrawn_at) + points.update(change.changed_at for change in changes) + ordered = sorted(points) selectors = { window.subscription_version: SubscriptionSelector.from_subscription(window.configuration) for window in windows @@ -575,69 +691,66 @@ def expected_subscription_periods( ) for window in windows } - instants = {window.accepted_at for window in windows} - instants.update(window.ends_at for window in windows if window.ends_at is not None) - instants.update(item.available_from for item in offerings if item.available_from is not None) - instants.update(item.withdrawn_at for item in offerings if item.withdrawn_at is not None) - instants.update(change.changed_at for change in tz_changes) - first = windows[0].accepted_at - boundaries = sorted(instant for instant in instants if instant >= first) owed: dict[tuple[int, str, str, datetime], _Owed] = {} for buy in buys: if buy.confirmed_at is None: continue - floor = max(item for item in (buy.confirmed_at, chain_start) if item is not None) - ceiling = buy._latest_stop() - if not include_unresolved: - ceiling = min(ceiling, now) - for _package_id, offering_ids in buy.product_offering_ids(): - previous: dict[str, _SlotState] = {} - for index, segment_start in enumerate(boundaries): - segment_end = boundaries[index + 1] if index + 1 < len(boundaries) else None - window = next((item for item in windows if item.contains(segment_start)), None) - slots: dict[str, _Slot] = {} - if window is not None and in_scope[window.subscription_version]: - zone_name = _zone_at(segment_start, account_timezone, tz_changes) - selector = selectors[window.subscription_version] - destination = None - if selector.destination_id is not None: - if destinations is None or selector.destination_id not in destinations: - raise ValueError( - f"destination {selector.destination_id!r} is not in destinations" - ) - destination = destinations[selector.destination_id] - resolution = resolve_subscription_offerings( - selector, - offerings, - product_offering_ids=offering_ids, - account_timezone=zone_name, - destination=destination, - at=segment_start, - ) - for offering in resolution.offerings: - zone = offering.zone(zone_name) - slots[offering.offering_id] = _Slot( - window.subscription_version, - offering, - zone, - offering.schedule(zone_name), + regimes = _regimes( + buy, + ordered, + windows, + selectors, + in_scope, + offerings, + destinations, + account_timezone, + changes, + ) + flights_start = buy._earliest_start() + latest_stop = buy._latest_stop() + for index, regime in enumerate(regimes): + if regime is None: + continue + predecessor = regimes[index - 1] if index > 0 else None + for resolved in regime.resolved: + calendar = resolved.calendar + handoff = _cutover_end(regime.start, predecessor, resolved) + lower = max( + calendar.ceil(max(handoff, buy.confirmed_at)), + calendar.floor(flights_start), + ) + upper = latest_stop if regime.until is None else min(regime.until, latest_stop) + if not include_unresolved: + upper = min(upper, now) + if lower >= upper: + continue + for start, end in calendar.periods(starts_from=lower, starts_before=upper): + if not include_unresolved and end > now: + break + flight_start, flight_stop = buy.flight_at(end) + if not (start < flight_stop and end > flight_start): + continue + if not _authorized(chains, calendar, start, backfill_on_regrant): + continue + if any(item.removes(start, buy.media_buy_id) for item in removals): + continue + key = (regime.version, resolved.offering.offering_id, calendar.zone_name, start) + entry = owed.get(key) + if entry is None: + expected_at, withheld = _expected_at( + chains, calendar, start, end, backfill_on_regrant + ) + entry = owed[key] = _Owed( + regime.version, + resolved.offering, + calendar.zone_name, + start, + end, + expected_at, + withheld, ) - current = _pair(previous, slots, segment_start) - for state in current.values(): - _collect( - owed, - state, - buy=buy, - floor=floor, - starts_before=min(segment_end or _FAR_FUTURE, ceiling), - now=now, - include_unresolved=include_unresolved, - removals=removals, - losses=losses, - backfill=backfill_on_regrant, - ) - previous = current + entry.media_buy_ids.add(buy.media_buy_id) periods = [ ExpectedSubscriptionPeriod( @@ -646,7 +759,7 @@ def expected_subscription_periods( report_definition_id=item.offering.report_definition_id, feed_purpose=item.offering.feed_purpose, reporting_profile=item.offering.reporting_profile, - media_buy_ids=tuple(sorted(item.media_buy_ids)), + media_buy_ids=tuple(sorted(item.media_buy_ids, key=lambda value: value.encode())), period_start=iso_z(item.start), period_end=iso_z(item.end), source_timezone=item.zone, @@ -660,15 +773,81 @@ def expected_subscription_periods( ] periods.sort( key=lambda item: ( - item.period_start, - item.period_end, - item.offering_id.encode("utf-8"), + utc_instant(datetime.fromisoformat(item.period_start.replace("Z", "+00:00"))), item.delivery_config_version, + item.offering_id.encode("utf-8"), ) ) return periods +def _regimes( + buy: SubscriptionBuy, + ordered: Sequence[datetime], + windows: Sequence[SubscriptionVersionWindow], + selectors: Mapping[int, SubscriptionSelector], + in_scope: Mapping[int, bool], + offerings: Sequence[SubscriptionOffering], + destinations: Mapping[str, Any] | None, + account_timezone: str | None, + changes: Sequence[AccountTimezoneChange], +) -> list[_Regime | None]: + """Maximal stretches with one active, in-scope version and one resolution.""" + regimes: list[_Regime | None] = [] + for index, start in enumerate(ordered): + until = ordered[index + 1] if index + 1 < len(ordered) else None + window = next((item for item in windows if item.active_at(start)), None) + if window is None or not in_scope[window.subscription_version]: + if regimes and regimes[-1] is not None: + regimes.append(None) + continue + selector = selectors[window.subscription_version] + destination = window.destination + if destination is None and selector.destination_id is not None: + if destinations is None or selector.destination_id not in destinations: + raise ValueError( + f"version {window.subscription_version} names destination " + f"{selector.destination_id!r}; pass its pattern and transport" + ) + destination = destinations[selector.destination_id] + zone = _timezone_at(start, account_timezone, changes) + found: dict[str, SubscriptionOffering] = {} + for _package_id, offering_ids in buy.product_offering_ids(): + resolution = resolve_subscription_offerings( + selector, + offerings, + product_offering_ids=offering_ids, + destination=destination, + at=start, + ) + for item in resolution.offerings: + found[item.offering_id] = item + resolved = tuple( + _Resolved(item, item.calendar(zone, selector.period_timezone)) + for item in sorted(found.values(), key=lambda value: value.offering_id.encode()) + ) + previous = regimes[-1] if regimes else None + if ( + previous is not None + and previous.version == window.subscription_version + and [item.key for item in previous.resolved] == [item.key for item in resolved] + ): + previous.until = until + continue + regimes.append(_Regime(start, until, window.subscription_version, resolved)) + return regimes + + +def _cutover_end(handoff: datetime, predecessor: _Regime | None, incoming: _Resolved) -> datetime: + """E for an incoming offering at ``handoff``; the regime start with no predecessor.""" + if predecessor is None or not predecessor.resolved: + return handoff + for item in predecessor.resolved: + if item.offering.offering_id == incoming.offering.offering_id: + return item.calendar.ceil(handoff) + return max(item.calendar.ceil(handoff) for item in predecessor.resolved) + + def _subscription_id(subscription: Any, windows: Sequence[SubscriptionVersionWindow]) -> str: value = None if not isinstance(subscription, Sequence) or isinstance(subscription, Mapping): @@ -683,111 +862,6 @@ def _subscription_id(subscription: Any, windows: Sequence[SubscriptionVersionWin return _text(value) -def _pair( - previous: Mapping[str, _SlotState], slots: Mapping[str, _Slot], at: datetime -) -> dict[str, _SlotState]: - """Apply the cutover rule between consecutive segments of one product.""" - leaving = [state for key, state in previous.items() if key not in slots] - current: dict[str, _SlotState] = {} - for key, slot in slots.items(): - before = previous.get(key) - if before is not None: - if before.slot.same_feed(slot): - lower = max(at, before.lower) - else: - lower = max(first_boundary_at_or_after(before.slot.schedule, at), before.lower) - elif leaving: - lower = max( - max(first_boundary_at_or_after(state.slot.schedule, at), state.lower) - for state in leaving - ) - else: - lower = at - current[key] = _SlotState(slot, lower) - return current - - -def _collect( - owed: dict[tuple[int, str, str, datetime], _Owed], - state: _SlotState, - *, - buy: SubscriptionBuy, - floor: datetime, - starts_before: datetime, - now: datetime, - include_unresolved: bool, - removals: Sequence[SubscriptionException], - losses: Sequence[_Loss], - backfill: bool, -) -> None: - slot = state.slot - schedule = slot.schedule - lower = max(state.lower, floor) - # Skip straight to the period containing the earliest flight start; a - # period that ends at or before every flight start can never overlap. - earliest = buy._earliest_start() - if earliest > lower: - lower = max(lower, last_boundary_at_or_before(schedule, earliest)) - if lower >= starts_before: - return - sla = delivery_sla(schedule) - gates = [ - ( - loss, - last_boundary_at_or_before(schedule, loss.unauthorized_at), - ( - None - if loss.regranted_at is None - else first_boundary_at_or_after(schedule, loss.regranted_at) - ), - ) - for loss in losses - ] - for start, end in iter_periods(schedule, starts_from=lower, starts_before=starts_before): - if not include_unresolved and end > now: - break - flight_start, flight_stop = buy.flight_at(end) - if not (start < flight_stop and end > flight_start): - continue - if any(item.removes(start, buy.media_buy_id) for item in removals): - continue - expected_at: datetime | None = end + sla - withheld = False - removed = False - for loss, gap_from, gap_until in gates: - if start >= gap_from: - if gap_until is None: - removed = True - break - if start < gap_until: - if backfill and not loss.expired: - assert loss.regranted_at is not None - expected_at = _later(expected_at, loss.regranted_at + sla) - else: - removed = True - break - elif expected_at is not None and expected_at > loss.unauthorized_at: - # Owed before the loss but not yet due when authority ended: - # withheld, and due again only on a retained re-grant. - if loss.regranted_at is None or loss.expired: - expected_at, withheld = None, True - else: - expected_at = _later(expected_at, loss.regranted_at + sla) - if removed: - continue - key = (slot.version, slot.offering.offering_id, slot.zone, start) - entry = owed.get(key) - if entry is None: - entry = owed[key] = _Owed( - slot.version, slot.offering, slot.zone, start, end, expected_at, withheld - ) - entry.media_buy_ids.add(buy.media_buy_id) - - -def _later(current: datetime | None, candidate: datetime) -> datetime | None: - return None if current is None else max(current, candidate) - - __all__ = [ "AccountTimezoneChange", "ExpectedSubscriptionPeriod", diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index d7ecfc7bc..0ba15d835 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -31,8 +31,7 @@ from datetime import datetime from typing import Any, Literal -from adcp.reporting.ledger.models import ReportingScheduleSpec, iso_duration_to_timedelta -from adcp.reporting.subscriptions._clock import civil_schedule, require_zone, utc_instant +from adcp.reporting.subscriptions._clock import PeriodCalendar, require_zone, utc_instant #: The 3.3 standard breakdown vocabulary (``enums/reporting-dimension.json``). #: It is extensible: a selector naming a value no offering maps matches nothing. @@ -42,6 +41,29 @@ SubscriptionMatch = Literal["exact", "finest", "all"] _ALIGNMENTS = frozenset({"utc", "account_timezone", "source_timezone", "billing_cycle"}) +#: Zone names a ``utc`` schedule can produce periods in under ``period_timezone``. +UTC_ZONE_NAMES: frozenset[str] = frozenset( + { + "UTC", + "Etc/UTC", + "Etc/UCT", + "UCT", + "Etc/Universal", + "Universal", + "Etc/Zulu", + "Zulu", + "GMT", + "Etc/GMT", + "Etc/GMT+0", + "Etc/GMT-0", + "Etc/GMT0", + "GMT+0", + "GMT-0", + "GMT0", + "Etc/Greenwich", + "Greenwich", + } +) def _value(item: Any, name: str, default: Any = None) -> Any: @@ -73,8 +95,11 @@ class SubscriptionOffering: ``period_timezone`` is the schedule's IANA zone for ``source_timezone`` (fixed, or the seller's account-resolved upstream zone) and fixed - ``billing_cycle`` offerings. ``available_from`` and ``withdrawn_at`` bound - when the offering is a candidate; a change in resolution is a cutover. + ``billing_cycle`` offerings. ``period_timezone_policy`` is the + ``source_timezone`` policy: an ``account_resolved`` schedule can produce + periods in any zone a ``period_timezone`` override names. + ``available_from`` and ``withdrawn_at`` bound when the offering is a + candidate; a change in resolution is a cutover. """ offering_id: str @@ -91,6 +116,7 @@ class SubscriptionOffering: period_timezone: str | None = None period_anchor: datetime | None = None period_anchor_policy: str | None = None + period_timezone_policy: str | None = None method_pattern: str | None = None method_transport: str | None = None available_from: datetime | None = None @@ -169,6 +195,7 @@ def from_offering( datetime.fromisoformat(str(anchor).replace("Z", "+00:00")) if anchor else None ), period_anchor_policy=schedule.get("period_anchor_policy"), + period_timezone_policy=schedule.get("period_timezone_policy"), method_pattern=method.get("pattern"), method_transport=method.get("transport"), available_from=available_from, @@ -184,42 +211,72 @@ def available_at(self, instant: datetime) -> bool: def schedulable(self) -> bool: """Whether a subscription can derive this offering's boundaries. - A subscription carries no billing anchor, so a ``configurable`` - ``billing_cycle`` offering (which needs one per configuration) and a - ``source_timezone`` offering whose zone is unresolved cannot be - scheduled, and calendar months and years are not derivable by the - shared clock. + A subscription carries no billing anchor, so a ``billing_cycle`` + offering whose anchor is not fixed is never resolvable. """ - if self.alignment == "billing_cycle" and ( - self.period_anchor is None or self.period_timezone is None - ): - return False - if self.alignment == "source_timezone" and self.period_timezone is None: - return False - try: - iso_duration_to_timedelta(self.period_duration) - iso_duration_to_timedelta(self.delivery_sla) - except ValueError: + return self.alignment != "billing_cycle" or ( + self.period_anchor is not None and self.period_timezone is not None + ) + + def can_produce(self, period_timezone: str | None) -> bool: + """Whether the schedule can produce periods in a ``period_timezone`` override. + + A ``utc`` schedule only in a UTC alias; a fixed-timezone schedule only + in that zone; an ``account_timezone`` or ``account_resolved`` schedule + in any zone. + """ + if not self.schedulable: return False - return True + if period_timezone is None: + return True + if self.alignment == "utc": + return period_timezone in UTC_ZONE_NAMES + if self.alignment == "account_timezone": + return True + if self.alignment == "source_timezone": + return self.period_timezone_policy != "fixed" or self.period_timezone == period_timezone + return self.period_timezone == period_timezone - def zone(self, account_timezone: str) -> str: - """The IANA zone this offering's periods use for an account.""" + def zone(self, account_timezone: str | None, period_timezone: str | None = None) -> str: + """The IANA zone this offering's periods use for an account. + + ``period_timezone`` is a single-account subscription's override; it + applies to ``account_timezone`` and ``account_resolved`` schedules. + """ if self.alignment == "utc": return "UTC" if self.alignment == "account_timezone": - return account_timezone + zone = period_timezone or account_timezone + if zone is None: + raise ValueError( + f"offering {self.offering_id!r} is account_timezone aligned; " + "the account timezone is required" + ) + return zone + if self.alignment == "source_timezone": + zone = ( + self.period_timezone + if self.period_timezone_policy == "fixed" + else (period_timezone or self.period_timezone) + ) + if zone is None: + raise ValueError( + f"offering {self.offering_id!r} resolves its source timezone per account; " + "pass the resolved zone as period_timezone" + ) + return zone if self.period_timezone is None: - raise ValueError(f"offering {self.offering_id!r} has no resolved period_timezone") + raise ValueError(f"offering {self.offering_id!r} has no period_timezone") return self.period_timezone - def schedule(self, account_timezone: str) -> ReportingScheduleSpec: - """The ledger schedule with the normative origin for this offering.""" - zone = self.zone(account_timezone) - return civil_schedule( + def calendar( + self, account_timezone: str | None, period_timezone: str | None = None + ) -> PeriodCalendar: + """This offering's normative period calendar for an account.""" + return PeriodCalendar.for_schedule( period_duration=self.period_duration, delivery_sla=self.delivery_sla, - zone_name=zone, + zone_name=self.zone(account_timezone, period_timezone), anchor=self.period_anchor if self.alignment == "billing_cycle" else None, ) @@ -320,7 +377,6 @@ def _is_candidate( selector: SubscriptionSelector, *, destination: SubscriptionDestination | None, - account_timezone: str, ) -> bool: if ( offering.feed_purpose != selector.feed_purpose @@ -334,17 +390,12 @@ def _is_candidate( if destination is None: if offering.method_pattern is not None: return False - elif offering.method_pattern != destination.pattern or ( - destination.transport is not None and offering.method_transport != destination.transport + elif ( + offering.method_pattern != destination.pattern + or offering.method_transport != destination.transport ): return False - if not offering.schedulable: - return False - if selector.period_timezone is not None: - # The override selects only offerings whose published schedule already - # produces periods in that zone; the SDK never re-buckets a schedule. - return offering.zone(account_timezone) == selector.period_timezone - return True + return offering.can_produce(selector.period_timezone) def resolve_subscription_offerings( @@ -352,7 +403,6 @@ def resolve_subscription_offerings( offerings: Sequence[SubscriptionOffering], *, product_offering_ids: Iterable[str], - account_timezone: str, destination: Any | None = None, at: datetime | None = None, ) -> SubscriptionResolution: @@ -382,9 +432,7 @@ def resolve_subscription_offerings( for offering in offerings if offering.offering_id in listed and (instant is None or offering.available_at(instant)) - and _is_candidate( - offering, selector, destination=target, account_timezone=account_timezone - ) + and _is_candidate(offering, selector, destination=target) ), key=lambda item: _byte_key(item.offering_id), ) @@ -412,6 +460,7 @@ def resolve_subscription_offerings( __all__ = [ "STANDARD_REPORTING_DIMENSIONS", + "UTC_ZONE_NAMES", "SubscriptionDestination", "SubscriptionMatch", "SubscriptionOffering", diff --git a/tests/fixtures/reporting_subscriptions/coverage-cases.json b/tests/fixtures/reporting_subscriptions/coverage-cases.json new file mode 100644 index 000000000..ef9613f14 --- /dev/null +++ b/tests/fixtures/reporting_subscriptions/coverage-cases.json @@ -0,0 +1,6360 @@ +{ + "$source": "adcontextprotocol/adcp-client@0f98ba1c0e17d05887db61153020f8b790045439:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions). Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "cases": [ + { + "name": "baseline", + "rule": "Owed from the first boundary at or after max(accepted_at 11-01T12, confirmed_at 11-02T15, authorized_at 10-01) = 11-03, through the period containing the stop time.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "mid-period confirmed_at", + "rule": "confirmed_at 11-03T10 falls inside the first flight day: coverage starts at the next boundary, 11-04, though the flight began 11-03.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "confirmed long before the flight", + "rule": "A buy confirmed before accepted_at with a later start owes nothing before start_time.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-20T00:00:00Z", + "start_time": "2026-11-10T00:00:00Z", + "end_time": "2026-11-12T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ] + }, + { + "name": "stop time mid-period is owed in full", + "rule": "end_time 11-05T12 is inside the 11-05 period, which is owed in full: periods are never clipped.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "cancellation mid-flight", + "rule": "canceled_at 11-05T06 is the stop time (earlier than end_time 11-10); its period is owed and none after.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ], + "canceled_at": "2026-11-05T06:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "early completion on a boundary", + "rule": "completed_at 11-04T00 stops the flight on a boundary: the 11-04 period does not overlap [start, stop).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ], + "completed_at": "2026-11-04T00:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + } + ] + }, + { + "name": "flight extension adds only unresolved periods", + "rule": "end_time moved 11-06 -> 11-08 at 11-07T12. The 11-06 period resolved at 11-07T00 under the old end_time and is never owed retroactively; 11-07 resolves after the extension.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ], + "prior_flights": [ + { + "replaced_at": "2026-11-07T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z" + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "reinstatement after cancellation", + "rule": "Canceled at 11-04T12, reinstated at 11-06T12. 11-05 resolved while canceled and stays unowed; 11-06 onward resolve after reinstatement.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ], + "prior_flights": [ + { + "replaced_at": "2026-11-06T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "canceled_at": "2026-11-04T12:00:00Z" + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "only resolved periods are returned", + "rule": "With now at 11-05T12, the 11-05 period has not ended.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-11-05T12:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "unconfirmed buy", + "rule": "A buy with confirmed_at null is not covered.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": null, + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "late authorization (authorized_at refinement)", + "rule": "authorized_at 11-04T10 is the latest of the three start terms: coverage starts at 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-11-04T10:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "new subscription version, aligned schedules", + "rule": "v2 accepted 11-04T12. v1 owes periods starting before T in full (11-03, 11-04); E = 11-05 and v2 owes from there. Contiguous, nothing twice.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": [ + "acct_other" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "new subscription version, misaligned boundaries", + "rule": "v1 resolves a UTC day offering, v2 (alignment account_timezone) a New York day offering. v1 owes UTC days starting before 11-04T12 (E = 11-05T00Z); v2 owes New York days starting at or after E, the first being 11-05T05Z. 11-05T00Z-05Z is owed under neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ] + }, + { + "name": "deactivation", + "rule": "deactivated_at 11-04T12: the 11-04 period started before it and stays owed in full; nothing later.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": false, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "deactivated_at": "2026-11-04T12:00:00Z" + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "deactivation then reactivation in place", + "rule": "Inactive [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "inactive_intervals": [ + { + "from": "2026-11-04T12:00:00Z", + "until": "2026-11-06T12:00:00Z" + } + ] + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-09T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + } + ] + }, + { + "name": "account new in a later version", + "rule": "v1 names another account; v2 (all accounts, accepted 11-04T12) brings acct_1 in under the ordinary start rule: 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": [ + "acct_other" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "excluded_by_buyer scope", + "rule": "An all-accounts subscription that lists the account in exclude_account_ids covers nothing for it.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": [ + "acct_1" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "operator_domains scope", + "rule": "operator_domains keeps only accounts whose operator is listed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "operator_domains": [ + "agency.example" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ], + "operator": "other-agency.example" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "revocation", + "rule": "unauthorized_at 11-05T06: periods starting at or after the last boundary at or before it (11-05) are not owed. 11-03 and 11-04 ended before it and stay owed (withheld until re-grant if undelivered).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "re-grant without backfill", + "rule": "Re-granted 11-06T18 within status_retention_days: the gap [11-05, 11-07) is not owed; coverage resumes at 11-07 and the earlier periods stay owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "re-grant with backfill_on_regrant", + "rule": "With backfill_on_regrant no gap is recorded and every period in the revoked interval stays owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "re-grant after the unauthorized entry expired", + "rule": "status_retention_days 1: the entry expired at 11-06T06, so the 11-06T18 re-grant is a fresh start from authorized_at (11-07) and backfill does not apply.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "seller gaps also remove periods", + "rule": "A gaps[] entry the seller published removes the periods starting in [from, until), like an exception.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "gaps": [ + { + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "reason": "unauthorized" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "exceptions only remove periods", + "rule": "Account-level capacity [11-04, 11-06) removes 11-04 and 11-05 for every buy; a buy-level offering_unsupported removes mb_2 from 11-03; a package_ids entry removes packages, not periods; an exception before coverage adds nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + }, + { + "media_buy_id": "mb_2", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-06T00:00:00Z" + }, + { + "reason": "offering_unsupported", + "media_buy_id": "mb_2", + "from": "2026-11-03T00:00:00Z" + }, + { + "reason": "offering_unsupported", + "media_buy_id": "mb_1", + "package_ids": [ + "pkg_9" + ], + "from": "2026-11-03T00:00:00Z" + }, + { + "reason": "excluded_by_buyer", + "from": "2026-10-01T00:00:00Z", + "until": "2026-10-05T00:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "max_accounts capacity exception", + "rule": "Beyond subscriptions.max_accounts the seller records an open account-level capacity exception; nothing is owed from its from.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T00:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + } + ] + }, + { + "name": "precedence over an account-level configuration", + "rule": "The caller's own sync_accounts configuration takes the account from 11-04 to 11-05 (overridden), after which replace_account_configs hands it back.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "overridden", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "overridden_by": { + "delivery_config_id": "acme-analytics" + } + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match exact (default with dimensions)", + "rule": "exact [package] picks the offering whose dimension set equals it; package x device (unmapped device) is distinct.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match exact with no equal offering", + "rule": "exact [package] against package x creative, package x country and package x device (unmapped) resolves nothing: offering_unsupported, no periods.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "match exact on two dimensions", + "rule": "exact [package, country] picks package x country.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package", + "country" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match finest", + "rule": "finest [package] picks the most mapped dimensions among offerings including package; package x creative and package x country tie at two, so the lowest offering_id in byte order wins.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match defaults to finest without dimensions", + "rule": "No dimensions: match defaults to finest over every candidate.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match all", + "rule": "all [package] picks every offering including package, each owed its own periods (package x device included: it includes package).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "product eligibility gates candidates", + "rule": "Only offerings in the product's reporting_delivery_offering_ids are candidates; a package with none leaves the buy covered by its other packages.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + }, + { + "package_id": "pkg_2", + "reporting_delivery_offering_ids": [] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "finality, purpose and duration must match", + "rule": "An offering advertising only snapshot finality, another feed_purpose, or another period_duration is never a candidate.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg", + "off_c_pkg", + "off_d_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "snapshot" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_b_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_c_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_d_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "resolution change is a cutover", + "rule": "finest [package]: package x creative becomes available at 11-04T12, changing the resolution. The old offering owes periods starting before it (11-03, 11-04); the new one owes from E = 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package", + "creative" + ], + "available_from": "2026-11-04T12:00:00Z" + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "account timezone", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + } + ] + }, + { + "name": "account timezone across a DST change", + "rule": "New York falls back on 2026-11-01: the 11-01 local day is 25 hours, 04:00Z to 05:00Z the next day.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-10-31T12:00:00Z", + "end_time": "2026-11-02T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-10-31T04:00:00Z", + "period_end": "2026-11-01T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + } + ] + }, + { + "name": "account timezone change is a cutover", + "rule": "The account moves to Los Angeles; timezone_cutovers.effective_at 11-05T05Z is E. New York days starting before it are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T05:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ] + }, + { + "name": "period_timezone override", + "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T15:00:00Z", + "period_end": "2026-11-03T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T15:00:00Z", + "period_end": "2026-11-04T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T15:00:00Z", + "period_end": "2026-11-05T15:00:00Z" + } + ] + }, + { + "name": "hourly periods are never clipped", + "rule": "Flight 10:30-13:15, confirmed 10:20: coverage starts at 11:00 (the 10:00 period started before the confirmation boundary) and the 13:00 period containing the stop is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:20:00Z", + "start_time": "2026-11-03T10:30:00Z", + "end_time": "2026-11-03T13:15:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_h_pkg_hourly" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H" + }, + "supported_finality": [ + "snapshot" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z" + } + ] + }, + { + "name": "monthly billing cycle", + "rule": "A fixed billing_cycle anchored at New York midnight: month boundaries move between 04:00Z (EDT) and 05:00Z (EST). The month containing the stop is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-20T00:00:00Z", + "start_time": "2026-11-10T00:00:00Z", + "end_time": "2027-01-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_m_billing_monthly" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m_billing_monthly", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m_billing_monthly", + "report_definition_uri": "https://seller.example/report-definitions/off_m_billing_monthly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "consumer_receipt", + "dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ] + }, + { + "name": "destination selects managed offerings only", + "rule": "With a destination only offerings whose method matches its pattern and transport are candidates; without one only Core (method-less) offerings are.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "destination": { + "destination_id": "archive", + "path_template": "a/{account_id}" + } + }, + "destination_method": { + "pattern": "file_transfer", + "transport": "gcs" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_s3_pkg", + "off_t_gcs_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + }, + { + "offering_id": "off_s3_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s3_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_s3_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + }, + { + "offering_id": "off_t_gcs_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_t_gcs_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_t_gcs_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "gcs", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "multiple buys group into obligations", + "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + }, + { + "media_buy_id": "mb_0", + "confirmed_at": "2026-11-03T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_0", + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "numeric offset timezone is refused", + "rule": "iana_timezone: a numeric offset defines no DST rules.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "+05:00", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "account_timezone offering requires the account timezone", + "rule": "Boundaries cannot be derived without the account timezone.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "re-grant requires status_retention_days", + "rule": "Whether a re-grant continues coverage depends on the retention window.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + } + ] +} \ No newline at end of file diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index 3066a77be..4f284c03a 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -63,6 +63,7 @@ def offering( method_transport: str | None = None, period_timezone: str | None = None, period_anchor: datetime | None = None, + period_timezone_policy: str | None = None, available_from: datetime | None = None, withdrawn_at: datetime | None = None, ) -> SubscriptionOffering: @@ -82,6 +83,7 @@ def offering( method_transport=method_transport, period_timezone=period_timezone, period_anchor=period_anchor, + period_timezone_policy=period_timezone_policy, available_from=available_from, withdrawn_at=withdrawn_at, ) @@ -613,7 +615,6 @@ def test_out_of_order_versions_are_rejected(self) -> None: def resolved(config: dict, *, product: tuple[str, ...] = ALL_IDS, **kwargs) -> tuple[str, ...]: - kwargs.setdefault("account_timezone", NY) return resolve_subscription_offerings( config, kwargs.pop("offerings", BREAKDOWNS), product_offering_ids=product, **kwargs ).offering_ids @@ -696,27 +697,50 @@ def test_destination_selects_managed_offerings_only(self) -> None: with pytest.raises(ValueError): resolved(config, offerings=both, product=product) - def test_period_timezone_selects_offerings_already_in_that_zone(self) -> None: - source = offering("src", alignment="source_timezone", period_timezone="America/Chicago") - offers = [DAILY, source] - product = ("daily-pkg", "src") - chicago = subscription_config( - dimensions=["package"], - scope={"account_ids": ["acct-1"]}, + def test_period_timezone_override(self) -> None: + # account_timezone and account-resolved schedules can produce periods + # in any zone; fixed-zone schedules only in theirs; utc only in UTC. + source = offering( + "src", + alignment="source_timezone", period_timezone="America/Chicago", + period_timezone_policy="fixed", + ) + utc_daily = offering("utc-daily", alignment="utc") + offers = [DAILY, source, utc_daily] + product = ("daily-pkg", "src", "utc-daily") + + def override(zone: str) -> dict: + return subscription_config( + dimensions=["package"], + match="all", + scope={"account_ids": ["acct-1"]}, + period_timezone=zone, + ) + + assert resolved(override("America/Chicago"), offerings=offers, product=product) == ( + "daily-pkg", + "src", ) - assert resolved(chicago, offerings=offers, product=product) == ("src",) - new_york = subscription_config( - dimensions=["package"], scope={"account_ids": ["acct-1"]}, period_timezone=NY + assert resolved(override("Etc/UTC"), offerings=offers, product=product) == ( + "daily-pkg", + "utc-daily", ) - assert resolved(new_york, offerings=offers, product=product) == ("daily-pkg",) + # The override moves an account_timezone schedule into its zone. + result = periods( + subscription=state("2026-10-01T00:00:00Z", config=override("America/Chicago")), + offerings=[DAILY], + buys=[buy(end_time="2026-10-03T05:00:00Z")], + ) + assert result[0] == ("2026-10-01T05:00:00Z", "2026-10-02T05:00:00Z") - def test_unschedulable_offerings_are_not_candidates(self) -> None: - monthly = offering("monthly", duration="P1M") + def test_configurable_billing_cycles_are_never_candidates(self) -> None: + # A subscription has no way to supply a billing anchor. configurable = offering("cycle", alignment="billing_cycle") - config = subscription_config(duration="P1M") - assert resolved(config, offerings=[monthly], product=("monthly",)) == () assert resolved(subscription_config(), offerings=[configurable], product=("cycle",)) == () + monthly = offering("monthly", duration="P1M") + config = subscription_config(duration="P1M") + assert resolved(config, offerings=[monthly], product=("monthly",)) == ("monthly",) def test_from_wire_offering(self) -> None: wire = { @@ -853,8 +877,8 @@ def test_scope_filters(self) -> None: ) assert run(subscription=operator, buys=[buy()], account_operator="other.example") == [] assert run(subscription=operator, buys=[buy()], account_operator="agency.example") - with pytest.raises(ValueError): - run(subscription=operator, buys=[buy()]) + # An account whose operator is unknown is not selected. + assert run(subscription=operator, buys=[buy()]) == [] # -- exceptions ---------------------------------------------------------------- @@ -1029,6 +1053,7 @@ def test_regrant_without_backfill_records_a_gap(self) -> None: # boundary at or after the re-grant (Oct 7 04:00Z). result = run( buys=[buy(end_time="2026-10-09T04:00:00Z")], + status_retention_days=30, authorizations=[ SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), @@ -1048,6 +1073,7 @@ def test_regrant_with_backfill_keeps_the_revoked_interval_owed(self) -> None: result = run( buys=[buy(end_time="2026-10-09T04:00:00Z")], backfill_on_regrant=True, + status_retention_days=30, authorizations=[ SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), @@ -1085,6 +1111,7 @@ def test_regrant_after_retention_is_a_fresh_start_without_backfill(self) -> None def test_loss_on_a_boundary(self) -> None: result = starts( buys=[buy(end_time="2026-10-09T04:00:00Z")], + status_retention_days=30, authorizations=[ SubscriptionAuthorization(GRANT, at("2026-10-04T04:00:00Z")), SubscriptionAuthorization(at("2026-10-06T04:00:00Z")), @@ -1092,6 +1119,40 @@ def test_loss_on_a_boundary(self) -> None: ) assert result == [*days("2026-10-01", 3), *days("2026-10-06", 3)] + def test_regrant_needs_status_retention_days(self) -> None: + with pytest.raises(ValueError): + run( + buys=[buy()], + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T15:00:00Z")), + ], + ) + + def test_regrant_exactly_at_retention_is_a_fresh_start(self) -> None: + # A re-grant continues the chain only before unauthorized_at + retention. + kwargs = dict( + buys=[buy(end_time="2026-10-09T04:00:00Z")], + backfill_on_regrant=True, + status_retention_days=2, + ) + inside = run( + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T07:59:59Z")), + ], + **kwargs, + ) + at_limit = run( + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(at("2026-10-06T08:00:00Z")), + ], + **kwargs, + ) + assert "2026-10-04T04:00:00Z" in [p.period_start for p in inside] + assert "2026-10-04T04:00:00Z" not in [p.period_start for p in at_limit] + def test_overlapping_grants_are_rejected(self) -> None: with pytest.raises(ValueError): run( diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py new file mode 100644 index 000000000..221016dfd --- /dev/null +++ b/tests/test_reporting_subscription_parity.py @@ -0,0 +1,175 @@ +"""Cross-SDK parity: the TypeScript SDK's coverage table, run against Python. + +``tests/fixtures/reporting_subscriptions/coverage-cases.json`` is copied from +the TypeScript SDK (its ``$source`` names the commit). Each case gives plain +input and the exact owed ``(subscription_version, media_buy_id, offering_id, +period)`` rows, derived by hand from the spec rule, or an expected error. Both +SDKs must produce identical periods, so this test maps the shared input onto +:func:`expected_subscription_periods` and compares instants row by row. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest + +from adcp.reporting.subscriptions import ( + SubscriptionAuthorization, + SubscriptionBuy, + SubscriptionBuyFlight, + SubscriptionBuyPackage, + SubscriptionDestination, + SubscriptionOffering, + SubscriptionVersionWindow, + expected_subscription_periods, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "reporting_subscriptions" / "coverage-cases.json" +CASES = json.loads(FIXTURE.read_text())["cases"] + + +def at(value: str | None) -> datetime | None: + return None if value is None else datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def windows(versions: list[dict[str, Any]]) -> list[SubscriptionVersionWindow]: + result = [] + for index, record in enumerate(versions): + following = versions[index + 1] if index + 1 < len(versions) else None + ends_at = at(record.get("superseded_at")) or ( + at(following["accepted_at"]) if following else None + ) + inactive = [ + (at(item["from"]), at(item.get("until"))) + for item in record.get("inactive_intervals", ()) + ] + if "deactivated_at" in record: + inactive.append((at(record["deactivated_at"]), None)) + method = record.get("destination_method") + result.append( + SubscriptionVersionWindow( + subscription_version=record["subscription_version"], + accepted_at=at(record["accepted_at"]), + ends_at=ends_at, + configuration=record["configuration"], + inactive_intervals=tuple(inactive), + destination=( + None + if method is None + else SubscriptionDestination(method["pattern"], method.get("transport")) + ), + ) + ) + return result + + +def offerings(input_: dict[str, Any]) -> list[SubscriptionOffering]: + source_timezone = input_["account"].get("source_timezone") + result = [] + for wire in input_["offerings"]: + schedule = wire["schedule"] + resolved = ( + source_timezone + if schedule["alignment"] == "source_timezone" + and schedule.get("period_timezone_policy") != "fixed" + else None + ) + result.append( + SubscriptionOffering.from_offering( + wire, + dimensions=wire.get("dimensions", ()), + unmapped_dimensions=wire.get("unmapped_dimensions", ()), + period_timezone=resolved, + available_from=at(wire.get("available_from")), + withdrawn_at=at(wire.get("withdrawn_at")), + ) + ) + return result + + +def buys(input_: dict[str, Any]) -> list[SubscriptionBuy]: + result = [] + for buy in input_["buys"]: + result.append( + SubscriptionBuy( + media_buy_id=buy["media_buy_id"], + confirmed_at=at(buy.get("confirmed_at")), + start_time=at(buy["start_time"]), + end_time=at(buy["end_time"]), + canceled_at=at(buy.get("canceled_at")), + completed_at=at(buy.get("completed_at")), + packages=tuple( + SubscriptionBuyPackage( + item["package_id"], tuple(item["reporting_delivery_offering_ids"]) + ) + for item in buy["packages"] + ), + superseded_flights=tuple( + SubscriptionBuyFlight( + superseded_at=at(flight["replaced_at"]), + start_time=at(flight["start_time"]), + end_time=at(flight["end_time"]), + canceled_at=at(flight.get("canceled_at")), + completed_at=at(flight.get("completed_at")), + ) + for flight in buy.get("prior_flights", ()) + ), + ) + ) + return result + + +def run(input_: dict[str, Any]) -> list[tuple]: + account = input_["account"] + periods = expected_subscription_periods( + subscription=windows(input_["versions"]), + account_id=account["account_id"], + buys=buys(input_), + offerings=offerings(input_), + account_timezone=account.get("timezone"), + account_operator=account.get("operator"), + now=at(input_["now"]), + authorizations=[ + SubscriptionAuthorization(at(grant["authorized_at"]), at(grant.get("unauthorized_at"))) + for grant in account.get("grants", ()) + ], + coverage=input_.get("coverage"), + backfill_on_regrant=bool(input_.get("backfill_on_regrant", False)), + status_retention_days=input_.get("status_retention_days"), + ) + assert {p.delivery_config_id for p in periods} <= {input_["subscription_id"]} + rows = [ + ( + at(p.period_start), + p.delivery_config_version, + p.offering_id.encode(), + media_buy_id.encode(), + at(p.period_end), + ) + for p in periods + for media_buy_id in p.media_buy_ids + ] + return sorted(rows) + + +@pytest.mark.parametrize("case", CASES, ids=[case["name"] for case in CASES]) +def test_typescript_parity(case: dict[str, Any]) -> None: + if "expected_error" in case: + with pytest.raises((ValueError, TypeError)): + run(case["input"]) + return + expected = sorted( + ( + at(row["period_start"]), + row["subscription_version"], + row["offering_id"].encode(), + row["media_buy_id"].encode(), + at(row["period_end"]), + ) + for row in case["expected"] + ) + assert run(case["input"]) == expected, case.get("rule") From b47b7e58d9fd241888f289219a5e485e0dee565b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 14:20:54 +0000 Subject: [PATCH 3/8] feat(reporting): adopt the reporting-subscription rules settled in adcp#8585 adcontextprotocol/adcp#8585 settles the ambiguities both SDKs hit. - Offerings publish standard_dimensions and unmapped_dimensions. SubscriptionOffering.from_offering reads them from the wire, and sellers use with_subscription_dimensions() to publish them. subscription_capabilities() refuses an offering without them. - Coverage consumes the readback's inactive_intervals. - timezone_cutovers[] carry previous_timezone and an optional offering_id, and gaps[] carry an optional offering_id. An entry with an offering_id applies to that offering only, and previous_timezone supplies the zone before the first cutover. - period_timezone follows the normative text: a utc offering matches only UTC or Etc/UTC, a source_timezone offering only its published zone (never an account-resolved one), and an account_timezone offering takes the override. The beta.1 models close these objects, so _reporting_subscription_preview adds the fields to the generated classes until a 3.3.0-beta.2 bundle carries them. The vendored TypeScript parity table keeps a scope valid under maxProperties 1 in three cases, matching the fix pending upstream. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/__init__.py | 2 + .../reporting/subscriptions/capabilities.py | 39 +++++- src/adcp/reporting/subscriptions/coverage.py | 68 ++++++++-- .../reporting/subscriptions/resolution.py | 78 +++++------ src/adcp/types/_forward_compat.py | 5 + .../types/_reporting_subscription_preview.py | 117 ++++++++++++++++ .../coverage-cases.json | 9 +- ...est_reporting_subscription_capabilities.py | 44 +++++- tests/test_reporting_subscription_coverage.py | 128 ++++++++++++++++++ 9 files changed, 425 insertions(+), 65 deletions(-) create mode 100644 src/adcp/types/_reporting_subscription_preview.py diff --git a/src/adcp/reporting/subscriptions/__init__.py b/src/adcp/reporting/subscriptions/__init__.py index 9f72f54e6..99ad3c28a 100644 --- a/src/adcp/reporting/subscriptions/__init__.py +++ b/src/adcp/reporting/subscriptions/__init__.py @@ -23,6 +23,7 @@ advertise_reporting_subscriptions, reporting_configuration_tasks, subscription_capabilities, + with_subscription_dimensions, ) from adcp.reporting.subscriptions.coverage import ( AccountTimezoneChange, @@ -74,4 +75,5 @@ "subscription_covers_account", "subscription_exceptions", "subscription_version_windows", + "with_subscription_dimensions", ] diff --git a/src/adcp/reporting/subscriptions/capabilities.py b/src/adcp/reporting/subscriptions/capabilities.py index 59adb2f65..36ac661bc 100644 --- a/src/adcp/reporting/subscriptions/capabilities.py +++ b/src/adcp/reporting/subscriptions/capabilities.py @@ -89,17 +89,53 @@ def reporting_configuration_tasks( return tasks +def with_subscription_dimensions(offering: Mapping[str, Any] | Any, mapping: Any) -> dict[str, Any]: + """An offering's wire form with ``standard_dimensions`` and ``unmapped_dimensions``. + + ``mapping`` is the offering's + :class:`~adcp.reporting.subscriptions.SubscriptionOffering` (or anything + with ``dimensions`` and ``unmapped_dimensions``). Sellers advertising + ``media_buy.reporting_subscriptions`` MUST publish ``standard_dimensions`` + on every offering (adcp#8585); ``unmapped_dimensions`` appears only when + any exist. Values are sorted so the declaration is stable. + """ + payload: dict[str, Any] = ( + dict(offering) + if isinstance(offering, Mapping) + else offering.model_dump(mode="json", exclude_none=True) + ) + payload["standard_dimensions"] = sorted(getattr(mapping, "dimensions")) + unmapped = sorted(getattr(mapping, "unmapped_dimensions", ()) or ()) + if unmapped: + payload["unmapped_dimensions"] = unmapped + else: + payload.pop("unmapped_dimensions", None) + return payload + + def subscription_capabilities( reporting_delivery: Mapping[str, Any], support: ReportingSubscriptionSupport | None ) -> dict[str, Any]: """Return ``reporting_delivery`` with the registration fields for ``support``. ``None`` returns an unchanged copy, so a seller without subscriptions - advertises exactly what it did before. + advertises exactly what it did before. With ``support``, every offering + must already carry ``standard_dimensions`` (see + :func:`with_subscription_dimensions`). """ payload = dict(reporting_delivery) if support is None: return payload + missing = [ + str(item.get("offering_id")) + for item in payload.get("offerings") or () + if "standard_dimensions" not in item + ] + if missing: + raise ValueError( + "sellers advertising reporting subscriptions must publish standard_dimensions " + f"on every offering; missing on {missing}" + ) tasks = reporting_configuration_tasks( subscriptions=True, account_configuration=support.account_configuration ) @@ -154,4 +190,5 @@ def advertise_reporting_subscriptions( "advertise_reporting_subscriptions", "reporting_configuration_tasks", "subscription_capabilities", + "with_subscription_dimensions", ] diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index a7c3c40c4..a3ae04040 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -28,7 +28,7 @@ authority removes periods the same way unless ``backfill_on_regrant`` keeps them. -Rules where the spec is silent (identical in the TypeScript SDK) +Rules settled by adcp#8585 (identical in the TypeScript SDK) Time is split per buy at every version ``accepted_at`` / ``superseded_at``, deactivation and reactivation instant, offering ``available_from`` / ``withdrawn_at`` and account timezone cutover. Each stretch with an active, @@ -38,13 +38,15 @@ the latest E across everything the previous regime resolved; after a regime that resolved nothing, or no active version, the ordinary start rule applies. A re-grant before ``unauthorized_at + status_retention_days`` continues the - authority chain; a later one starts a new chain. + authority chain; a later one starts a new chain. Reactivation is a fresh + start (``inactive_intervals``), and ``gaps[]`` and ``timezone_cutovers[]`` + entries with an ``offering_id`` apply to that offering only. """ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from typing import Any @@ -222,10 +224,17 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class AccountTimezoneChange: - """An account timezone change at ``changed_at`` (the instant T of the cutover).""" + """An account timezone change at ``changed_at`` (the instant T of the cutover). + + ``offering_id`` limits it to one offering, as a ``timezone_cutovers[]`` + entry with ``offering_id`` does; ``previous_timezone`` is the zone before + the change, which every seller entry carries. + """ changed_at: datetime timezone: str + offering_id: str | None = None + previous_timezone: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "changed_at", utc_instant(self.changed_at, field="changed_at")) @@ -242,15 +251,20 @@ class SubscriptionException: from_: datetime until: datetime | None = None media_buy_id: str | None = None + #: A ``gaps[]`` entry for one offering's boundaries; ``None`` is every offering. + offering_id: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "from_", utc_instant(self.from_, field="from")) if self.until is not None: object.__setattr__(self, "until", utc_instant(self.until, field="until")) - def removes(self, start: datetime, media_buy_id: str) -> bool: + def removes(self, start: datetime, media_buy_id: str, offering_id: str | None = None) -> bool: if self.media_buy_id is not None and self.media_buy_id != media_buy_id: return False + if self.offering_id is not None and offering_id is not None: + if self.offering_id != offering_id: + return False return self.from_ <= start and (self.until is None or start < self.until) @@ -286,11 +300,13 @@ def subscription_exceptions( for gap in gaps: if hasattr(gap, "model_dump"): gap = gap.model_dump(mode="json", exclude_none=True, by_alias=True) + offering_id = _value(gap, "offering_id") result.append( SubscriptionException( reason="unauthorized", from_=_require_instant(_value(gap, "from", _value(gap, "from_")), field="from"), until=_require_instant(_value(gap, "until"), field="until"), + offering_id=None if offering_id is None else _text(offering_id), ) ) return tuple(result) @@ -349,7 +365,7 @@ def subscription_version_windows( """Derive version windows from a ``core/reporting-subscription-state.json`` readback. The readback keeps ``prior_versions`` (version, ``accepted_at``, - ``superseded_at``) but not their configurations. Supply each prior + ``superseded_at``) and ``inactive_intervals`` but not prior configurations. Supply each prior version's configuration in ``version_configurations`` when it differs from the current one; otherwise the current configuration is assumed. """ @@ -382,6 +398,17 @@ def subscription_version_windows( configuration=configurations.get(version, current_configuration), ) ) + inactive = tuple( + ( + _require_instant(_value(item, "deactivated_at"), field="deactivated_at"), + _instant(_value(item, "reactivated_at"), field="reactivated_at"), + ) + for item in _value(subscription, "inactive_intervals", ()) or () + ) + if inactive: + # The readback lists them per subscription; they apply to whichever + # version was current, so every window carries them. + windows = [replace(window, inactive_intervals=inactive) for window in windows] windows.sort(key=lambda item: (item.accepted_at, item.subscription_version)) for earlier, later in zip(windows, windows[1:]): if earlier.subscription_version >= later.subscription_version: @@ -558,11 +585,14 @@ def _expected_at( def _timezone_at( - instant: datetime, initial: str | None, changes: Sequence[AccountTimezoneChange] + instant: datetime, + initial: str | None, + changes: Sequence[AccountTimezoneChange], + offering_id: str, ) -> str | None: zone = initial for change in changes: - if change.changed_at <= instant: + if change.changed_at <= instant and change.offering_id in {None, offering_id}: zone = change.timezone return zone @@ -644,12 +674,20 @@ def expected_subscription_periods( excluded.extend(_value(coverage, "excluded") or ()) gap_entries.extend(_value(coverage, "gaps") or ()) for entry in _value(coverage, "timezone_cutovers") or (): + offering_id = _value(entry, "offering_id") + previous = _value(entry, "previous_timezone") changes.append( AccountTimezoneChange( _require_instant(_value(entry, "effective_at"), field="effective_at"), _text(_value(entry, "timezone")), + offering_id=None if offering_id is None else _text(offering_id), + previous_timezone=None if previous is None else _text(previous), ) ) + changes.sort(key=lambda item: item.changed_at) + if account_timezone is None and changes and changes[0].previous_timezone is not None: + # The zone in effect before the first cutover, as the seller reports it. + account_timezone = changes[0].previous_timezone if account_timezone is not None: require_zone(account_timezone, field="account_timezone") for change in changes: @@ -733,7 +771,10 @@ def expected_subscription_periods( continue if not _authorized(chains, calendar, start, backfill_on_regrant): continue - if any(item.removes(start, buy.media_buy_id) for item in removals): + if any( + item.removes(start, buy.media_buy_id, resolved.offering.offering_id) + for item in removals + ): continue key = (regime.version, resolved.offering.offering_id, calendar.zone_name, start) entry = owed.get(key) @@ -810,7 +851,6 @@ def _regimes( f"{selector.destination_id!r}; pass its pattern and transport" ) destination = destinations[selector.destination_id] - zone = _timezone_at(start, account_timezone, changes) found: dict[str, SubscriptionOffering] = {} for _package_id, offering_ids in buy.product_offering_ids(): resolution = resolve_subscription_offerings( @@ -823,7 +863,13 @@ def _regimes( for item in resolution.offerings: found[item.offering_id] = item resolved = tuple( - _Resolved(item, item.calendar(zone, selector.period_timezone)) + _Resolved( + item, + item.calendar( + _timezone_at(start, account_timezone, changes, item.offering_id), + selector.period_timezone, + ), + ) for item in sorted(found.values(), key=lambda value: value.offering_id.encode()) ) previous = regimes[-1] if regimes else None diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index 0ba15d835..ee2d1721a 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -17,11 +17,10 @@ order. ``match`` defaults to ``exact`` when ``dimensions`` is present and to ``finest`` when it is absent. -Offerings do not publish their mapped dimensions on the wire; the seller maps -each report definition's native dimensions. :class:`SubscriptionOffering` -therefore carries the mapping explicitly, so a buyer that knows the mapping -(for example from the seller's documentation or from the obligations it has -already received) resolves exactly what the seller resolves. +Offerings publish the seller's mapping as ``standard_dimensions`` and +``unmapped_dimensions`` (adcp#8585), so +:meth:`SubscriptionOffering.from_offering` reads them from the wire and a +buyer resolves exactly what the seller resolves. """ from __future__ import annotations @@ -41,29 +40,9 @@ SubscriptionMatch = Literal["exact", "finest", "all"] _ALIGNMENTS = frozenset({"utc", "account_timezone", "source_timezone", "billing_cycle"}) -#: Zone names a ``utc`` schedule can produce periods in under ``period_timezone``. -UTC_ZONE_NAMES: frozenset[str] = frozenset( - { - "UTC", - "Etc/UTC", - "Etc/UCT", - "UCT", - "Etc/Universal", - "Universal", - "Etc/Zulu", - "Zulu", - "GMT", - "Etc/GMT", - "Etc/GMT+0", - "Etc/GMT-0", - "Etc/GMT0", - "GMT+0", - "GMT-0", - "GMT0", - "Etc/Greenwich", - "Greenwich", - } -) +#: The UTC aliases a ``utc`` schedule matches under ``period_timezone`` +#: (``core/reporting-subscription.json``: "a UTC alias (UTC or Etc/UTC)"). +UTC_ZONE_NAMES: frozenset[str] = frozenset({"UTC", "Etc/UTC"}) def _value(item: Any, name: str, default: Any = None) -> Any: @@ -155,15 +134,18 @@ def from_offering( cls, offering: Any, *, - dimensions: Iterable[str] = (), - unmapped_dimensions: Iterable[str] = (), + dimensions: Iterable[str] | None = None, + unmapped_dimensions: Iterable[str] | None = None, period_timezone: str | None = None, available_from: datetime | None = None, withdrawn_at: datetime | None = None, ) -> SubscriptionOffering: """Project a ``ReportingDeliveryOffering`` (model or wire mapping). - ``period_timezone`` supplies the account-resolved zone of an + Dimensions come from the offering's ``standard_dimensions`` and + ``unmapped_dimensions`` (adcp#8585); ``dimensions`` and + ``unmapped_dimensions`` override them for a seller that has not yet + published them. ``period_timezone`` supplies the account-resolved zone of an ``account_resolved`` ``source_timezone`` offering; a fixed zone on the offering wins when both are present and they must agree. """ @@ -188,8 +170,14 @@ def from_offering( delivery_sla=str(schedule["delivery_sla"]), supported_finality=frozenset(str(item) for item in offering["supported_finality"]), reconciliation_mode=str(offering["reconciliation_mode"]), - dimensions=frozenset(dimensions), - unmapped_dimensions=frozenset(unmapped_dimensions), + dimensions=frozenset( + offering.get("standard_dimensions") or () if dimensions is None else dimensions + ), + unmapped_dimensions=frozenset( + offering.get("unmapped_dimensions") or () + if unmapped_dimensions is None + else unmapped_dimensions + ), period_timezone=fixed_zone or period_timezone, period_anchor=( datetime.fromisoformat(str(anchor).replace("Z", "+00:00")) if anchor else None @@ -219,11 +207,15 @@ def schedulable(self) -> bool: ) def can_produce(self, period_timezone: str | None) -> bool: - """Whether the schedule can produce periods in a ``period_timezone`` override. - - A ``utc`` schedule only in a UTC alias; a fixed-timezone schedule only - in that zone; an ``account_timezone`` or ``account_resolved`` schedule - in any zone. + """Whether the offering matches a ``period_timezone`` override. + + ``core/reporting-subscription.json`` ``selector.period_timezone``: an + ``account_timezone`` offering matches and uses the override in place of + the account's zone; a ``utc`` offering matches only a UTC alias (UTC or + Etc/UTC); a ``source_timezone`` or fixed ``billing_cycle`` offering + matches only when its published ``period_timezone`` equals it; a + ``billing_cycle`` offering whose anchor the buyer configures never + matches a subscription. """ if not self.schedulable: return False @@ -234,7 +226,9 @@ def can_produce(self, period_timezone: str | None) -> bool: if self.alignment == "account_timezone": return True if self.alignment == "source_timezone": - return self.period_timezone_policy != "fixed" or self.period_timezone == period_timezone + return ( + self.period_timezone_policy == "fixed" and self.period_timezone == period_timezone + ) return self.period_timezone == period_timezone def zone(self, account_timezone: str | None, period_timezone: str | None = None) -> str: @@ -254,11 +248,7 @@ def zone(self, account_timezone: str | None, period_timezone: str | None = None) ) return zone if self.alignment == "source_timezone": - zone = ( - self.period_timezone - if self.period_timezone_policy == "fixed" - else (period_timezone or self.period_timezone) - ) + zone = self.period_timezone if zone is None: raise ValueError( f"offering {self.offering_id!r} resolves its source timezone per account; " diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index a31b2c90b..37942a763 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -535,5 +535,10 @@ def _apply_forward_compat() -> None: ) BundledCapabilitiesPortfolio.model_rebuild(force=True) + # Reporting-subscription fields settled after 3.3.0-beta.1 (adcp#8585). + from adcp.types._reporting_subscription_preview import apply_reporting_subscription_preview + + apply_reporting_subscription_preview() + _apply_forward_compat() diff --git a/src/adcp/types/_reporting_subscription_preview.py b/src/adcp/types/_reporting_subscription_preview.py new file mode 100644 index 000000000..17b7418fb --- /dev/null +++ b/src/adcp/types/_reporting_subscription_preview.py @@ -0,0 +1,117 @@ +"""AdCP 3.3 reporting-subscription fields settled after 3.3.0-beta.1 (adcp#8585). + +adcontextprotocol/adcp#8585 (merged to the spec's main, not yet in a signed +bundle) adds these experimental fields: + +* ``core/reporting-delivery-offering.json``: ``standard_dimensions`` and + ``unmapped_dimensions``, so a buyer resolves a selector's ``match`` from the + wire; +* ``core/reporting-subscription-state.json``: ``inactive_intervals[]``, so a + reactivation in place can be recomputed after local-state loss; +* ``core/reporting-subscription-coverage.json``: ``previous_timezone`` and + ``offering_id`` on ``timezone_cutovers[]``, and ``offering_id`` on ``gaps[]``. + +The pinned 3.3.0-beta.1 models close those objects +(``additionalProperties: false``), so the fields are added to the generated +classes here, the same way :mod:`adcp.types._forward_compat` carries other +forward-looking surfaces. TODO(3.3.0-beta.2): drop this module when the SDK +adopts a bundle that contains adcp#8585 and the fields are generated. +""" + +from __future__ import annotations + +from typing import Annotated, Any, cast + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.fields import FieldInfo + +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from adcp.types.domains.bundled.protocol import ( + get_adcp_capabilities_response as _bundled_capabilities, +) +from adcp.types.domains.core import reporting_delivery_offering as _offering +from adcp.types.domains.core import reporting_subscription_coverage as _coverage +from adcp.types.domains.core import reporting_subscription_state as _state +from adcp.types.domains.enums.reporting_dimension import ReportingDimension + + +class ReportingSubscriptionInactiveInterval(AdCPBaseModel): + """One stretch during which a subscription was inactive (adcp#8585).""" + + model_config = ConfigDict(extra="forbid") + deactivated_at: Annotated[WireDatetime, Field(description="When active false was applied.")] + reactivated_at: Annotated[ + WireDatetime | None, + Field( + description=( + "When active true was applied again. Absent for the current interval " + "while the subscription is inactive." + ) + ), + NoExplicitNull, + ] = None + + +def _add_field(model: type[BaseModel], name: str, annotation: Any, field: FieldInfo) -> None: + if name in model.model_fields: + return # the generated model already declares it: this preview is stale + field.annotation = annotation + model.model_fields[name] = field + model.__annotations__[name] = annotation + model.model_rebuild(force=True) + + +def _optional(annotation: Any, **kwargs: Any) -> tuple[Any, FieldInfo]: + wrapped = Annotated[annotation | None, Field(**kwargs), NoExplicitNull] + return wrapped, FieldInfo.from_annotated_attribute(cast(Any, wrapped), None) + + +def apply_reporting_subscription_preview() -> None: + """Add the adcp#8585 fields to the pinned 3.3.0-beta.1 models (idempotent).""" + dimensions_doc = ( + "Experimental (media_buy.reporting_subscriptions). The offering's breakdown " + "mapped onto the standard reporting dimension vocabulary." + ) + unmapped_doc = ( + "Experimental (media_buy.reporting_subscriptions). Native report-definition " + "dimensions with no standard mapping." + ) + for offering_model in (_offering.ReportingDeliveryOffering, _bundled_capabilities.Offering): + _add_field( + offering_model, + "standard_dimensions", + *_optional(list[ReportingDimension], max_length=16, description=dimensions_doc), + ) + _add_field( + offering_model, + "unmapped_dimensions", + *_optional(list[str], max_length=16, description=unmapped_doc), + ) + _add_field( + _state.ReportingSubscriptionState, + "inactive_intervals", + *_optional( + list[ReportingSubscriptionInactiveInterval], + max_length=64, + description="Intervals during which the subscription was inactive, oldest first.", + ), + ) + _add_field( + _coverage.TimezoneCutover, + "previous_timezone", + *_optional(str, min_length=1, max_length=255, description="The zone before the change."), + ) + for model in (_coverage.TimezoneCutover, _coverage.Gap): + _add_field( + model, + "offering_id", + *_optional( + str, + min_length=1, + max_length=255, + description="The offering this entry applies to; absent for every offering.", + ), + ) + + +__all__ = ["ReportingSubscriptionInactiveInterval", "apply_reporting_subscription_preview"] diff --git a/tests/fixtures/reporting_subscriptions/coverage-cases.json b/tests/fixtures/reporting_subscriptions/coverage-cases.json index ef9613f14..282fe6e3b 100644 --- a/tests/fixtures/reporting_subscriptions/coverage-cases.json +++ b/tests/fixtures/reporting_subscriptions/coverage-cases.json @@ -1,5 +1,5 @@ { - "$source": "adcontextprotocol/adcp-client@0f98ba1c0e17d05887db61153020f8b790045439:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", + "$source": "adcontextprotocol/adcp-client@0f98ba1c0e17d05887db61153020f8b790045439:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505); local fix for scope maxProperties 1 (pending the same fix upstream): account new in a later version v1: dropped all_authorized_accounts; period_timezone override v1: dropped all_authorized_accounts; monthly billing cycle v1: dropped account_ids", "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions). Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { @@ -1780,7 +1780,6 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, "account_ids": [ "acct_other" ] @@ -5353,7 +5352,6 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, "account_ids": [ "acct_1" ] @@ -5588,10 +5586,7 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, - "account_ids": [ - "acct_1" - ] + "all_authorized_accounts": true }, "selector": { "feed_purpose": "billing", diff --git a/tests/test_reporting_subscription_capabilities.py b/tests/test_reporting_subscription_capabilities.py index 72f4b3eef..4ed0d9ec2 100644 --- a/tests/test_reporting_subscription_capabilities.py +++ b/tests/test_reporting_subscription_capabilities.py @@ -13,9 +13,11 @@ REPORTING_SUBSCRIPTIONS_FEATURE, REPORTING_SUBSCRIPTIONS_SECTION, ReportingSubscriptionSupport, + SubscriptionOffering, advertise_reporting_subscriptions, reporting_configuration_tasks, subscription_capabilities, + with_subscription_dimensions, ) from adcp.validation import schema_loader @@ -58,13 +60,30 @@ } +PREVIEW_OFFERING_FIELDS = ("standard_dimensions", "unmapped_dimensions") + + def _errors(block: dict) -> list[str]: + # TODO(3.3.0-beta.2): validate the adcp#8585 offering fields against the + # bundle once it carries them; beta.1 closes the offering object. + block = { + **block, + "offerings": [ + {k: v for k, v in item.items() if k not in PREVIEW_OFFERING_FIELDS} + for item in block["offerings"] + ], + } validator = schema_loader.get_named_validator( "core/reporting-delivery-capabilities.json", version=VERSION ) return [error.message for error in validator.iter_errors(block)] +def _published(block: dict) -> dict: + mapping = SubscriptionOffering.from_offering(block["offerings"][0], dimensions=("package",)) + return {**block, "offerings": [with_subscription_dimensions(block["offerings"][0], mapping)]} + + def test_base_block_is_valid_and_unchanged_without_support() -> None: assert _errors(BASE) == [] assert subscription_capabilities(BASE, None) == BASE @@ -75,7 +94,7 @@ def test_base_block_is_valid_and_unchanged_without_support() -> None: def test_subscriptions_alongside_account_configuration() -> None: block = subscription_capabilities( - BASE, + _published(BASE), ReportingSubscriptionSupport( max_accounts=500, backfill_on_regrant=True, dimensions=("package", "creative") ), @@ -93,7 +112,7 @@ def test_subscriptions_alongside_account_configuration() -> None: def test_subscriptions_only_drops_the_deprecated_field() -> None: block = subscription_capabilities( - BASE, ReportingSubscriptionSupport(account_configuration=False) + _published(BASE), ReportingSubscriptionSupport(account_configuration=False) ) assert _errors(block) == [] assert block["configuration_tasks"] == ["sync_principal"] @@ -150,3 +169,24 @@ def test_configuration_tasks_and_support_validation() -> None: support = ReportingSubscriptionSupport(dimensions=("package", "dma_zone", "package")) assert support.dimensions == ("package", "dma_zone") assert support.standard_dimensions == ("package",) + + +def test_offerings_must_publish_standard_dimensions() -> None: + with pytest.raises(ValueError, match="standard_dimensions"): + subscription_capabilities(BASE, ReportingSubscriptionSupport()) + published = _published(BASE)["offerings"][0] + assert published["standard_dimensions"] == ["package"] + assert "unmapped_dimensions" not in published + mapping = SubscriptionOffering.from_offering( + BASE["offerings"][0], dimensions=("package",), unmapped_dimensions=("device",) + ) + published = with_subscription_dimensions(BASE["offerings"][0], mapping) + assert published["unmapped_dimensions"] == ["device"] + # The published fields validate on the generated (patched) offering model + # and round-trip into the same mapping a buyer resolves from. + from adcp.types import ReportingDeliveryOffering + + model = ReportingDeliveryOffering.model_validate(published) + again = SubscriptionOffering.from_offering(model) + assert again.dimensions == frozenset({"package"}) + assert again.unmapped_dimensions == frozenset({"device"}) diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index 4f284c03a..ec5928a4f 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -1319,3 +1319,131 @@ def test_start_is_the_first_boundary_after_the_latest_of_three( ], ) assert result == expected + + +# -- fields settled by adcp#8585 ---------------------------------------------------- + + +class TestSettledFields: + def test_readback_inactive_intervals_are_a_fresh_start(self) -> None: + readback = state("2026-10-01T00:00:00Z") + readback["inactive_intervals"] = [ + {"deactivated_at": "2026-10-03T12:00:00Z", "reactivated_at": "2026-10-05T12:00:00Z"} + ] + model = ReportingSubscriptionState.model_validate(readback) + assert starts(subscription=model, buys=[buy(end_time="2026-10-08T04:00:00Z")]) == [ + *days("2026-10-01", 3), + *days("2026-10-06", 2), + ] + + def test_timezone_cutover_from_coverage_with_previous_timezone(self) -> None: + coverage = ReportingSubscriptionCoverage.model_validate( + { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "status": "active", + "excluded": [], + "timezone_cutovers": [ + { + "effective_at": "2026-10-04T05:00:00Z", + "timezone": "Etc/GMT+8", + "previous_timezone": "Etc/GMT+5", + } + ], + } + ) + # No account_timezone: the zone before the cutover is previous_timezone. + result = run( + account_timezone=None, + coverage=coverage, + buys=[buy(start_time="2026-10-02T05:00:00Z", end_time="2026-10-05T08:00:00Z")], + ) + assert [(p.period_start, p.source_timezone) for p in result] == [ + ("2026-10-02T05:00:00Z", "Etc/GMT+5"), + ("2026-10-03T05:00:00Z", "Etc/GMT+5"), + ("2026-10-04T08:00:00Z", "Etc/GMT+8"), + ] + + def test_per_offering_cutovers_and_gaps(self) -> None: + local_hourly = offering("local-hourly", duration="PT1H", sla="PT1H") + config = subscription_config(dimensions=["package"], match="all") + config["selector"]["period_duration"] = "P1D" + daily_buy = buy(end_time="2026-10-05T04:00:00Z", offering_ids=("daily-pkg",)) + gaps = [ + { + "from": "2026-10-02T04:00:00Z", + "until": "2026-10-03T04:00:00Z", + "reason": "unauthorized", + "offering_id": "some-other-offering", + } + ] + # A gap naming another offering leaves this one's periods owed. + assert starts( + subscription=state("2026-10-01T00:00:00Z", config=config), + offerings=[DAILY, local_hourly], + buys=[daily_buy], + gaps=gaps, + ) == days("2026-10-01", 4) + gaps[0]["offering_id"] = "daily-pkg" + assert starts( + subscription=state("2026-10-01T00:00:00Z", config=config), + offerings=[DAILY], + buys=[daily_buy], + gaps=gaps, + ) == ["2026-10-01T04:00:00Z", *days("2026-10-03", 2)] + # A timezone cutover naming another offering leaves this one's zone. + moved = run( + offerings=[DAILY], + buys=[daily_buy], + timezone_changes=[ + AccountTimezoneChange( + at("2026-10-02T04:00:00Z"), "America/Los_Angeles", offering_id="other" + ) + ], + ) + assert {p.source_timezone for p in moved} == {NY} + + def test_period_timezone_matching_is_as_specified(self) -> None: + utc_daily = offering("utc-daily", alignment="utc") + resolved_source = offering( + "src-resolved", + alignment="source_timezone", + period_timezone="Asia/Tokyo", + period_timezone_policy="account_resolved", + ) + + def override(zone: str) -> dict: + return subscription_config( + dimensions=["package"], + match="all", + scope={"account_ids": ["acct-1"]}, + period_timezone=zone, + ) + + product = ("utc-daily", "src-resolved") + offers = [utc_daily, resolved_source] + # Only UTC and Etc/UTC are UTC aliases; an account-resolved source + # zone is not a published zone, so it never matches an override. + assert resolved(override("UTC"), offerings=offers, product=product) == ("utc-daily",) + assert resolved(override("Etc/Zulu"), offerings=offers, product=product) == () + assert resolved(override("Asia/Tokyo"), offerings=offers, product=product) == () + + def test_wire_offering_dimensions_drive_matching(self) -> None: + wire = { + "offering_id": "gam-daily", + "feed_purpose": "analytics", + "report_definition_id": "gam-delivery-v1", + "reporting_profile": {"id": "delivery"}, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"], + } + projected = SubscriptionOffering.from_offering(wire) + assert projected.dimensions == frozenset({"package"}) + assert projected.unmapped_dimensions == frozenset({"device"}) + exact = subscription_config(dimensions=["package"]) + finest = subscription_config(dimensions=["package"], match="finest") + assert resolved(exact, offerings=[projected], product=("gam-daily",)) == () + assert resolved(finest, offerings=[projected], product=("gam-daily",)) == ("gam-daily",) From 1b2d5ed0953d4c4a4f1b65de168bc343b04a57ef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 15:00:55 +0000 Subject: [PATCH 4/8] fix(reporting): take each offering's prior zone from its own timezone cutover Follow adcp#8585: the account's timezone is its current zone, as list_accounts shows it. Before a timezone change, an offering's account-timezone periods use that change's previous_timezone, considering only the changes that apply to the offering. An offering with no applicable change uses the current zone throughout. A timezone change without previous_timezone is now refused, and so is a wire offering without standard_dimensions. Previously the first cutover's previous_timezone became the base zone for every offering, so a cutover scoped to one offering could shift another offering's boundaries. Re-vendor the TypeScript parity table at f8b2fddd (54 cases, with the scope fix now upstream). Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/coverage.py | 39 +- .../reporting/subscriptions/resolution.py | 18 +- .../coverage-cases.json | 1276 +++++++++++++++-- tests/test_reporting_subscription_coverage.py | 45 +- tests/test_reporting_subscription_parity.py | 13 +- 5 files changed, 1252 insertions(+), 139 deletions(-) diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index a3ae04040..fe9871198 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -228,7 +228,8 @@ class AccountTimezoneChange: ``offering_id`` limits it to one offering, as a ``timezone_cutovers[]`` entry with ``offering_id`` does; ``previous_timezone`` is the zone before - the change, which every seller entry carries. + the change, which every seller entry carries and the coverage rule + requires. """ changed_at: datetime @@ -586,13 +587,22 @@ def _expected_at( def _timezone_at( instant: datetime, - initial: str | None, + current: str | None, changes: Sequence[AccountTimezoneChange], offering_id: str, ) -> str | None: - zone = initial - for change in changes: - if change.changed_at <= instant and change.offering_id in {None, offering_id}: + """The zone an offering's account-timezone periods use at ``instant``. + + Only changes that apply to the offering count. Before the first of them + the zone is that change's ``previous_timezone``; with none, it is the + account's current zone throughout (adcp#8585). + """ + applicable = [item for item in changes if item.offering_id in {None, offering_id}] + if not applicable: + return current + zone = applicable[0].previous_timezone + for change in applicable: + if change.changed_at <= instant: zone = change.timezone return zone @@ -639,9 +649,10 @@ def expected_subscription_periods( :param buys: the confirmed buys in the account the caller can see. A buy without ``confirmed_at`` is not covered. :param offerings: offerings with the seller's dimension mapping. - :param account_timezone: the account's timezone before the first timezone - change (its current timezone when there are none). Required when an - ``account_timezone`` offering resolves without a ``period_timezone``. + :param account_timezone: the account's current timezone, as + ``list_accounts`` shows it. Before a timezone change the zone is that + change's ``previous_timezone``. Required when an ``account_timezone`` + offering resolves without a ``period_timezone`` and no change applies. :param now: periods are owed once resolved, at their end; a period ending after ``now`` is returned only with ``include_unresolved``. :param authorizations: the account's grant history. Empty means the seller @@ -685,15 +696,17 @@ def expected_subscription_periods( ) ) changes.sort(key=lambda item: item.changed_at) - if account_timezone is None and changes and changes[0].previous_timezone is not None: - # The zone in effect before the first cutover, as the seller reports it. - account_timezone = changes[0].previous_timezone if account_timezone is not None: require_zone(account_timezone, field="account_timezone") for change in changes: require_zone(change.timezone, field="timezone_cutovers.timezone") - if changes and account_timezone is None: - raise ValueError("account_timezone is required with timezone changes") + for change in changes: + if change.previous_timezone is None: + raise ValueError( + "a timezone change needs previous_timezone: the account's current timezone " + "already shows the new zone" + ) + require_zone(change.previous_timezone, field="timezone_cutovers.previous_timezone") changes.sort(key=lambda item: item.changed_at) removals = subscription_exceptions(excluded, gap_entries) ids = [item.offering_id for item in offerings] diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index ee2d1721a..d4779a1fd 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -143,9 +143,9 @@ def from_offering( """Project a ``ReportingDeliveryOffering`` (model or wire mapping). Dimensions come from the offering's ``standard_dimensions`` and - ``unmapped_dimensions`` (adcp#8585); ``dimensions`` and - ``unmapped_dimensions`` override them for a seller that has not yet - published them. ``period_timezone`` supplies the account-resolved zone of an + ``unmapped_dimensions`` (adcp#8585); an offering without + ``standard_dimensions`` is refused unless ``dimensions`` supplies the + mapping explicitly. ``period_timezone`` supplies the account-resolved zone of an ``account_resolved`` ``source_timezone`` offering; a fixed zone on the offering wins when both are present and they must agree. """ @@ -153,6 +153,14 @@ def from_offering( offering = offering.model_dump(mode="json", exclude_none=True) if not isinstance(offering, Mapping): raise TypeError("offering must be a ReportingDeliveryOffering or a mapping") + if dimensions is None: + if "standard_dimensions" not in offering: + # adcp#8585: sellers advertising subscriptions MUST publish it, + # and matching never guesses a breakdown. + raise ValueError( + f"offering {offering.get('offering_id')!r} has no standard_dimensions" + ) + dimensions = offering["standard_dimensions"] schedule = offering["schedule"] fixed_zone = schedule.get("period_timezone") if fixed_zone is not None and period_timezone is not None and fixed_zone != period_timezone: @@ -170,9 +178,7 @@ def from_offering( delivery_sla=str(schedule["delivery_sla"]), supported_finality=frozenset(str(item) for item in offering["supported_finality"]), reconciliation_mode=str(offering["reconciliation_mode"]), - dimensions=frozenset( - offering.get("standard_dimensions") or () if dimensions is None else dimensions - ), + dimensions=frozenset(dimensions), unmapped_dimensions=frozenset( offering.get("unmapped_dimensions") or () if unmapped_dimensions is None diff --git a/tests/fixtures/reporting_subscriptions/coverage-cases.json b/tests/fixtures/reporting_subscriptions/coverage-cases.json index 282fe6e3b..4259fe4f0 100644 --- a/tests/fixtures/reporting_subscriptions/coverage-cases.json +++ b/tests/fixtures/reporting_subscriptions/coverage-cases.json @@ -1,6 +1,6 @@ { - "$source": "adcontextprotocol/adcp-client@0f98ba1c0e17d05887db61153020f8b790045439:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505); local fix for scope maxProperties 1 (pending the same fix upstream): account new in a later version v1: dropped all_authorized_accounts; period_timezone override v1: dropped all_authorized_accounts; monthly billing cycle v1: dropped account_ids", - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions). Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$source": "adcontextprotocol/adcp-client@f8b2fddd24bc0912356fdfa30197bfb8c6003fbf:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", @@ -84,7 +84,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -197,7 +197,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -303,7 +303,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -409,7 +409,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -523,7 +523,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -637,7 +637,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -743,7 +743,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -871,7 +871,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -991,7 +991,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1097,7 +1097,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1188,7 +1188,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1319,7 +1319,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1463,7 +1463,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -1495,7 +1495,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1616,7 +1616,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1642,7 +1642,7 @@ }, { "name": "deactivation then reactivation in place", - "rule": "Inactive [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", + "rule": "Readback inactive_intervals [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -1665,13 +1665,7 @@ ] }, "reconciliation_mode": "delivery_only" - }, - "inactive_intervals": [ - { - "from": "2026-11-04T12:00:00Z", - "until": "2026-11-06T12:00:00Z" - } - ] + } } ], "account": { @@ -1728,12 +1722,18 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } ], - "now": "2026-12-01T00:00:00Z" + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T12:00:00Z" + } + ] }, "expected": [ { @@ -1872,7 +1872,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -1981,7 +1981,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2076,7 +2076,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2168,7 +2168,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2278,7 +2278,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2396,7 +2396,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2529,7 +2529,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2644,7 +2644,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2773,7 +2773,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -2906,7 +2906,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -3013,7 +3013,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -3134,7 +3134,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -3166,7 +3166,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -3199,7 +3199,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -3232,7 +3232,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -3350,7 +3350,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -3382,7 +3382,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -3415,7 +3415,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -3448,7 +3448,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -3546,7 +3546,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -3578,7 +3578,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -3611,7 +3611,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -3644,7 +3644,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -3764,7 +3764,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -3796,7 +3796,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -3829,7 +3829,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -3862,7 +3862,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -3978,7 +3978,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4010,7 +4010,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -4043,7 +4043,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -4076,7 +4076,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -4196,7 +4196,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4228,7 +4228,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -4261,7 +4261,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -4294,7 +4294,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -4477,7 +4477,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4509,7 +4509,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ] @@ -4542,7 +4542,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "country" ] @@ -4575,7 +4575,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "unmapped_dimensions": [ @@ -4694,7 +4694,7 @@ "snapshot" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4726,7 +4726,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4758,7 +4758,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4790,7 +4790,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -4905,7 +4905,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -4937,7 +4937,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package", "creative" ], @@ -5059,7 +5059,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -5172,7 +5172,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -5205,7 +5205,7 @@ }, { "name": "account timezone change is a cutover", - "rule": "The account moves to Los Angeles; timezone_cutovers.effective_at 11-05T05Z is E. New York days starting before it are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", + "rule": "The account moves to Los Angeles (account.timezone is the current zone); timezone_cutovers.effective_at 11-05T05Z is E and previous_timezone New York governs before it. New York days starting before E are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5233,7 +5233,7 @@ ], "account": { "account_id": "acct_1", - "timezone": "America/New_York", + "timezone": "America/Los_Angeles", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -5285,7 +5285,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -5295,7 +5295,8 @@ "timezone_cutovers": [ { "effective_at": "2026-11-05T05:00:00Z", - "timezone": "America/Los_Angeles" + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" } ] } @@ -5424,7 +5425,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -5537,7 +5538,7 @@ "snapshot" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -5586,7 +5587,9 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true + "account_ids": [ + "acct_1" + ] }, "selector": { "feed_purpose": "billing", @@ -5660,7 +5663,7 @@ "official" ], "reconciliation_mode": "consumer_receipt", - "dimensions": [ + "standard_dimensions": [ "package" ], "method": { @@ -5794,7 +5797,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] }, @@ -5826,7 +5829,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "method": { @@ -5869,7 +5872,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ], "method": { @@ -6007,7 +6010,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } @@ -6076,8 +6079,8 @@ ] }, { - "name": "numeric offset timezone is refused", - "rule": "iana_timezone: a numeric offset defines no DST rules.", + "name": "timezone cutover scoped to one offering", + "rule": "A timezone_cutovers entry with offering_id applies to that offering only; an offering with no applicable entry uses account.timezone (Los Angeles) throughout.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6097,7 +6100,8 @@ "required_finality": "official", "dimensions": [ "package" - ] + ], + "match": "all" }, "reconciliation_mode": "delivery_only" } @@ -6105,7 +6109,7 @@ ], "account": { "account_id": "acct_1", - "timezone": "+05:00", + "timezone": "America/Los_Angeles", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -6116,13 +6120,14 @@ { "media_buy_id": "mb_1", "confirmed_at": "2026-11-02T15:00:00Z", - "start_time": "2026-11-03T00:00:00Z", - "end_time": "2026-11-06T00:00:00Z", + "start_time": "2026-11-03T08:00:00Z", + "end_time": "2026-11-06T08:00:00Z", "packages": [ { "package_id": "pkg_1", "reporting_delivery_offering_ids": [ - "off_a_pkg" + "off_e_pkg_acct_tz", + "off_f_pkg_acct_tz" ] } ] @@ -6130,10 +6135,10 @@ ], "offerings": [ { - "offering_id": "off_a_pkg", + "offering_id": "off_e_pkg_acct_tz", "feed_purpose": "analytics", - "report_definition_id": "rd_off_a_pkg", - "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "reporting_profile": { "id": "adcp.delivery.v1", @@ -6150,25 +6155,110 @@ }, "schedule": { "period_duration": "P1D", - "alignment": "utc", + "alignment": "account_timezone", "delivery_sla": "PT6H" }, "supported_finality": [ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ + "package" + ] + }, + { + "offering_id": "off_f_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_f_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_f_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ "package" ] } ], - "now": "2026-12-01T00:00:00Z" + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "offering_id": "off_e_pkg_acct_tz", + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" + } + ] + } }, - "expected_error": "TypeError" + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-03T08:00:00Z", + "period_end": "2026-11-04T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-04T08:00:00Z", + "period_end": "2026-11-05T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + } + ] }, { - "name": "account_timezone offering requires the account timezone", - "rule": "Boundaries cannot be derived without the account timezone.", + "name": "gap scoped to one offering", + "rule": "A gaps[] entry with offering_id removes only that offering's periods; an entry without offering_id applies to every offering.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6188,7 +6278,8 @@ "required_finality": "official", "dimensions": [ "package" - ] + ], + "match": "all" }, "reconciliation_mode": "delivery_only" } @@ -6196,6 +6287,7 @@ ], "account": { "account_id": "acct_1", + "timezone": "America/New_York", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -6212,7 +6304,8 @@ { "package_id": "pkg_1", "reporting_delivery_offering_ids": [ - "off_e_pkg_acct_tz" + "off_a_pkg", + "off_b_pkg_creative" ] } ] @@ -6220,10 +6313,10 @@ ], "offerings": [ { - "offering_id": "off_e_pkg_acct_tz", + "offering_id": "off_a_pkg", "feed_purpose": "analytics", - "report_definition_id": "rd_off_e_pkg_acct_tz", - "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "reporting_profile": { "id": "adcp.delivery.v1", @@ -6240,16 +6333,979 @@ }, "schedule": { "period_duration": "P1D", - "alignment": "account_timezone", + "alignment": "utc", "delivery_sla": "PT6H" }, "supported_finality": [ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "creative" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "gaps": [ + { + "offering_id": "off_a_pkg", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "reason": "unauthorized" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match exact without dimensions", + "rule": "exact with no dimensions: every candidate qualifies and the lowest offering_id in byte order wins.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "match": "exact" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "period_timezone never matches an account-resolved source_timezone offering", + "rule": "A source_timezone offering matches a period_timezone override only when its published period_timezone equals it; an account_resolved offering publishes none.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ], + "source_timezone": "America/New_York" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_s_resolved" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_resolved", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_resolved", + "report_definition_uri": "https://seller.example/report-definitions/off_s_resolved.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "delivery_sla": "PT6H", + "period_timezone_policy": "account_resolved" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "period_timezone matches a fixed source_timezone offering in the same zone", + "rule": "A fixed source_timezone offering publishing America/New_York matches a New York override and keeps its own boundaries (05:00Z in November).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_s_fixed" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_fixed", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_fixed", + "report_definition_uri": "https://seller.example/report-definitions/off_s_fixed.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "delivery_sla": "PT6H", + "period_timezone_policy": "fixed", + "period_timezone": "America/New_York" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + } + ] + }, + { + "name": "a utc offering matches only UTC or Etc/UTC", + "rule": "period_timezone GMT is not one of the UTC aliases core/reporting-subscription.json names, so the utc offering does not match.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "period_timezone": "GMT" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "numeric offset timezone is refused", + "rule": "iana_timezone: a numeric offset defines no DST rules.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "+05:00", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "account_timezone offering requires the account timezone", + "rule": "Boundaries cannot be derived without the account timezone.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "a timezone cutover without previous_timezone is refused", + "rule": "Sellers MUST include previous_timezone: account.timezone already shows the new zone, so the old schedule cannot be recovered without it.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles" + } + ] + } + }, + "expected_error": "TypeError" + }, + { + "name": "an offering without standard_dimensions cannot be resolved", + "rule": "Sellers advertising subscriptions MUST publish standard_dimensions on every offering; matching never guesses.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only" } ], "now": "2026-12-01T00:00:00Z" @@ -6342,7 +7398,7 @@ "official" ], "reconciliation_mode": "delivery_only", - "dimensions": [ + "standard_dimensions": [ "package" ] } diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index ec5928a4f..4bc42ee47 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -1222,8 +1222,12 @@ def test_timezone_change_is_a_cutover(self) -> None: # The old schedule ends at 05:00Z the next day; the first new-timezone # day starts at 08:00Z; 05:00Z..08:00Z is owed under neither. result = run( - account_timezone="Etc/GMT+5", - timezone_changes=[AccountTimezoneChange(at("2026-10-03T12:00:00Z"), "Etc/GMT+8")], + account_timezone="Etc/GMT+8", # the current zone, as list_accounts shows it + timezone_changes=[ + AccountTimezoneChange( + at("2026-10-03T12:00:00Z"), "Etc/GMT+8", previous_timezone="Etc/GMT+5" + ) + ], buys=[buy(start_time="2026-10-02T05:00:00Z", end_time="2026-10-05T08:00:00Z")], ) assert [(p.period_start, p.period_end, p.source_timezone) for p in result] == [ @@ -1244,7 +1248,9 @@ def test_timezone_change_does_not_touch_utc_offerings(self) -> None: ], ) moved = run( - timezone_changes=[AccountTimezoneChange(at("2026-10-03T12:30:00Z"), "Etc/GMT+8")], + timezone_changes=[ + AccountTimezoneChange(at("2026-10-03T12:30:00Z"), "Etc/GMT+8", previous_timezone=NY) + ], **kwargs, ) assert moved == run(**kwargs) @@ -1352,9 +1358,10 @@ def test_timezone_cutover_from_coverage_with_previous_timezone(self) -> None: ], } ) - # No account_timezone: the zone before the cutover is previous_timezone. + # account_timezone is the current zone; before the cutover the zone is + # previous_timezone. result = run( - account_timezone=None, + account_timezone="Etc/GMT+8", coverage=coverage, buys=[buy(start_time="2026-10-02T05:00:00Z", end_time="2026-10-05T08:00:00Z")], ) @@ -1397,7 +1404,10 @@ def test_per_offering_cutovers_and_gaps(self) -> None: buys=[daily_buy], timezone_changes=[ AccountTimezoneChange( - at("2026-10-02T04:00:00Z"), "America/Los_Angeles", offering_id="other" + at("2026-10-02T04:00:00Z"), + "America/Los_Angeles", + offering_id="other", + previous_timezone=NY, ) ], ) @@ -1447,3 +1457,26 @@ def test_wire_offering_dimensions_drive_matching(self) -> None: finest = subscription_config(dimensions=["package"], match="finest") assert resolved(exact, offerings=[projected], product=("gam-daily",)) == () assert resolved(finest, offerings=[projected], product=("gam-daily",)) == ("gam-daily",) + + +def test_timezone_change_requires_previous_timezone() -> None: + with pytest.raises(ValueError, match="previous_timezone"): + run( + buys=[buy()], + timezone_changes=[AccountTimezoneChange(at("2026-10-03T12:00:00Z"), "Etc/GMT+8")], + ) + + +def test_wire_offering_without_standard_dimensions_is_refused() -> None: + wire = { + "offering_id": "legacy", + "feed_purpose": "analytics", + "report_definition_id": "legacy-v1", + "reporting_profile": {"id": "delivery"}, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + } + with pytest.raises(ValueError, match="standard_dimensions"): + SubscriptionOffering.from_offering(wire) + assert SubscriptionOffering.from_offering(wire, dimensions=()).dimensions == frozenset() diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py index 221016dfd..2adb1e2c2 100644 --- a/tests/test_reporting_subscription_parity.py +++ b/tests/test_reporting_subscription_parity.py @@ -36,7 +36,13 @@ def at(value: str | None) -> datetime | None: return None if value is None else datetime.fromisoformat(value.replace("Z", "+00:00")) -def windows(versions: list[dict[str, Any]]) -> list[SubscriptionVersionWindow]: +def windows( + versions: list[dict[str, Any]], readback_intervals: list[dict[str, Any]] = () +) -> list[SubscriptionVersionWindow]: + # The readback's inactive_intervals (adcp#8585) apply to every version. + shared = [ + (at(item["deactivated_at"]), at(item.get("reactivated_at"))) for item in readback_intervals + ] result = [] for index, record in enumerate(versions): following = versions[index + 1] if index + 1 < len(versions) else None @@ -47,6 +53,7 @@ def windows(versions: list[dict[str, Any]]) -> list[SubscriptionVersionWindow]: (at(item["from"]), at(item.get("until"))) for item in record.get("inactive_intervals", ()) ] + inactive.extend(shared) if "deactivated_at" in record: inactive.append((at(record["deactivated_at"]), None)) method = record.get("destination_method") @@ -81,8 +88,6 @@ def offerings(input_: dict[str, Any]) -> list[SubscriptionOffering]: result.append( SubscriptionOffering.from_offering( wire, - dimensions=wire.get("dimensions", ()), - unmapped_dimensions=wire.get("unmapped_dimensions", ()), period_timezone=resolved, available_from=at(wire.get("available_from")), withdrawn_at=at(wire.get("withdrawn_at")), @@ -126,7 +131,7 @@ def buys(input_: dict[str, Any]) -> list[SubscriptionBuy]: def run(input_: dict[str, Any]) -> list[tuple]: account = input_["account"] periods = expected_subscription_periods( - subscription=windows(input_["versions"]), + subscription=windows(input_["versions"], input_.get("inactive_intervals", [])), account_id=account["account_id"], buys=buys(input_), offerings=offerings(input_), From bcd1b5322264f413efdc7eca01c9e589096b6106 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 15:17:51 +0000 Subject: [PATCH 5/8] feat(reporting): serve adcp#8585 subscription fields only to 3.3.0-beta.2 callers The server validates responses against the release it serves, and the pinned 3.3.0-beta.1 schemas close the objects adcp#8585 extends. The new wire module gates the new fields on 3.3.0-beta.2 or later, as the TypeScript SDK does: - standard_dimensions and unmapped_dimensions on offerings; - inactive_intervals on the readback; - previous_timezone and offering_id on timezone_cutovers[] and gaps[]. For older callers, per-offering gaps collapse into one account-wide gap spanning them, and per-offering cutovers into one entry at the latest effective_at. The parity test now also validates every subscription in the TypeScript table against core/reporting-subscription.json. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/__init__.py | 12 ++ .../reporting/subscriptions/capabilities.py | 4 +- .../reporting/subscriptions/resolution.py | 2 +- src/adcp/reporting/subscriptions/wire.py | 147 ++++++++++++++++++ tests/test_reporting_subscription_parity.py | 13 ++ tests/test_reporting_subscription_wire.py | 93 +++++++++++ 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/adcp/reporting/subscriptions/wire.py create mode 100644 tests/test_reporting_subscription_wire.py diff --git a/src/adcp/reporting/subscriptions/__init__.py b/src/adcp/reporting/subscriptions/__init__.py index 99ad3c28a..7eec11a06 100644 --- a/src/adcp/reporting/subscriptions/__init__.py +++ b/src/adcp/reporting/subscriptions/__init__.py @@ -48,6 +48,13 @@ SubscriptionSelector, resolve_subscription_offerings, ) +from adcp.reporting.subscriptions.wire import ( + project_offering, + project_reporting_delivery, + project_subscription_coverage, + project_subscription_state, + serves_settled_subscription_fields, +) __all__ = [ "REPORTING_SUBSCRIPTIONS_FEATURE", @@ -76,4 +83,9 @@ "subscription_exceptions", "subscription_version_windows", "with_subscription_dimensions", + "project_offering", + "project_reporting_delivery", + "project_subscription_coverage", + "project_subscription_state", + "serves_settled_subscription_fields", ] diff --git a/src/adcp/reporting/subscriptions/capabilities.py b/src/adcp/reporting/subscriptions/capabilities.py index 36ac661bc..7bac48a35 100644 --- a/src/adcp/reporting/subscriptions/capabilities.py +++ b/src/adcp/reporting/subscriptions/capabilities.py @@ -97,7 +97,9 @@ def with_subscription_dimensions(offering: Mapping[str, Any] | Any, mapping: Any with ``dimensions`` and ``unmapped_dimensions``). Sellers advertising ``media_buy.reporting_subscriptions`` MUST publish ``standard_dimensions`` on every offering (adcp#8585); ``unmapped_dimensions`` appears only when - any exist. Values are sorted so the declaration is stable. + any exist. Values are sorted so the declaration is stable. Serve the block + through :func:`~adcp.reporting.subscriptions.wire.project_reporting_delivery` + so callers pinned below 3.3.0-beta.2 do not receive these fields. """ payload: dict[str, Any] = ( dict(offering) diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index d4779a1fd..c1f96413e 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -160,7 +160,7 @@ def from_offering( raise ValueError( f"offering {offering.get('offering_id')!r} has no standard_dimensions" ) - dimensions = offering["standard_dimensions"] + dimensions = tuple(offering["standard_dimensions"] or ()) schedule = offering["schedule"] fixed_zone = schedule.get("period_timezone") if fixed_zone is not None and period_timezone is not None and fixed_zone != period_timezone: diff --git a/src/adcp/reporting/subscriptions/wire.py b/src/adcp/reporting/subscriptions/wire.py new file mode 100644 index 000000000..3a70ce100 --- /dev/null +++ b/src/adcp/reporting/subscriptions/wire.py @@ -0,0 +1,147 @@ +"""Version-gated wire projection for the fields adcp#8585 added after 3.3.0-beta.1. + +adcontextprotocol/adcp#8585 adds ``standard_dimensions`` and +``unmapped_dimensions`` on offerings, ``inactive_intervals`` on the +subscription readback, and ``previous_timezone`` and ``offering_id`` on +``timezone_cutovers[]`` and ``gaps[]``. The pinned 3.3.0-beta.1 schemas close +those objects, and the server validates responses against the release it +serves, so these fields reach only callers served 3.3.0-beta.2 or later (the +same gate the TypeScript SDK applies). For older callers: + +* the new fields are dropped; +* per-offering ``gaps[]`` collapse into one account-wide gap spanning them + (it removes at least the periods any offering's gap removes); +* per-offering ``timezone_cutovers[]`` for one change collapse into one entry + at the latest ``effective_at``. + +TODO(3.3.0-beta.2): once the SDK pins a bundle containing adcp#8585 these +projections only matter for callers pinned to 3.3.0-beta.1. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +_VERSION = re.compile(r"^(\d+)\.(\d+)(?:\.\d+)?(?:-([A-Za-z]+)\.?(\d+)?)?$") +_OFFERING_FIELDS = ("standard_dimensions", "unmapped_dimensions") + + +def serves_settled_subscription_fields(adcp_version: str | None) -> bool: + """Whether a response served at ``adcp_version`` may carry the adcp#8585 fields. + + True for 3.3.0-beta.2 and later prereleases, any 3.3 release candidate or + final release, and anything later. An absent or unparseable version is + treated as 3.3.0-beta.1, the pinned default. + """ + if not adcp_version: + return False + match = _VERSION.match(adcp_version.strip()) + if match is None: + return False + major, minor, tag, number = match.groups() + release = (int(major), int(minor)) + if release != (3, 3): + return release > (3, 3) + if tag is None: + return True + tag = tag.lower() + if tag == "beta": + return int(number or 0) >= 2 + return tag == "rc" + + +def project_offering(offering: Mapping[str, Any], adcp_version: str | None) -> dict[str, Any]: + """A reporting-delivery offering as served at ``adcp_version``.""" + payload = dict(offering) + if not serves_settled_subscription_fields(adcp_version): + for name in _OFFERING_FIELDS: + payload.pop(name, None) + return payload + + +def project_reporting_delivery( + block: Mapping[str, Any], adcp_version: str | None +) -> dict[str, Any]: + """A ``reporting_delivery`` capability block as served at ``adcp_version``.""" + payload = dict(block) + if "offerings" in payload: + payload["offerings"] = [ + project_offering(item, adcp_version) for item in payload["offerings"] + ] + return payload + + +def project_subscription_state( + state: Mapping[str, Any], adcp_version: str | None +) -> dict[str, Any]: + """A ``core/reporting-subscription-state.json`` readback as served.""" + payload = dict(state) + if not serves_settled_subscription_fields(adcp_version): + payload.pop("inactive_intervals", None) + return payload + + +def project_subscription_coverage( + coverage: Mapping[str, Any], adcp_version: str | None +) -> dict[str, Any]: + """A ``subscription_coverage[]`` entry as served at ``adcp_version``.""" + payload = dict(coverage) + if serves_settled_subscription_fields(adcp_version): + return payload + if payload.get("gaps"): + payload["gaps"] = _account_wide_gaps(payload["gaps"]) + if payload.get("timezone_cutovers"): + payload["timezone_cutovers"] = _account_wide_cutovers(payload["timezone_cutovers"]) + return payload + + +def _instant(value: Any) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + + +def _account_wide_gaps(gaps: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Drop ``offering_id``; merge overlapping per-offering gaps into their span.""" + plain = [dict(gap) for gap in gaps if "offering_id" not in gap] + scoped = sorted( + ( + {k: v for k, v in gap.items() if k != "offering_id"} + for gap in gaps + if "offering_id" in gap + ), + key=lambda item: _instant(item["from"]), + ) + merged: list[dict[str, Any]] = [] + for entry in scoped: + last = merged[-1] if merged else None + if last is not None and _instant(entry["from"]) <= _instant(last["until"]): + if _instant(entry["until"]) > _instant(last["until"]): + last["until"] = entry["until"] + continue + merged.append(entry) + result = plain + [entry for entry in merged if entry not in plain] + return sorted(result, key=lambda item: _instant(item["from"])) + + +def _account_wide_cutovers(cutovers: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + by_change: dict[tuple[Any, Any], dict[str, Any]] = {} + for cutover in cutovers: + key = (cutover.get("timezone"), cutover.get("previous_timezone")) + entry = {"effective_at": cutover["effective_at"], "timezone": cutover["timezone"]} + current = by_change.get(key) + if current is None or _instant(entry["effective_at"]) > _instant(current["effective_at"]): + by_change[key] = entry + return sorted(by_change.values(), key=lambda item: _instant(item["effective_at"])) + + +__all__ = [ + "project_offering", + "project_reporting_delivery", + "project_subscription_coverage", + "project_subscription_state", + "serves_settled_subscription_fields", +] diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py index 2adb1e2c2..7f9661d0a 100644 --- a/tests/test_reporting_subscription_parity.py +++ b/tests/test_reporting_subscription_parity.py @@ -178,3 +178,16 @@ def test_typescript_parity(case: dict[str, Any]) -> None: for row in case["expected"] ) assert run(case["input"]) == expected, case.get("rule") + + +def test_every_subscription_in_the_table_is_schema_valid() -> None: + """The table's subscriptions validate against core/reporting-subscription.json.""" + from adcp.validation import schema_loader + + validator = schema_loader.get_named_validator( + "core/reporting-subscription.json", version="3.3.0-beta.1" + ) + for case in CASES: + for version in case["input"]["versions"]: + errors = [error.message for error in validator.iter_errors(version["configuration"])] + assert errors == [], (case["name"], version["subscription_version"], errors) diff --git a/tests/test_reporting_subscription_wire.py b/tests/test_reporting_subscription_wire.py new file mode 100644 index 000000000..fa7a022aa --- /dev/null +++ b/tests/test_reporting_subscription_wire.py @@ -0,0 +1,93 @@ +"""Version-gated projection of the adcp#8585 reporting-subscription fields.""" + +from __future__ import annotations + +import pytest + +from adcp.reporting.subscriptions import ( + project_reporting_delivery, + project_subscription_coverage, + project_subscription_state, + serves_settled_subscription_fields, +) + + +@pytest.mark.parametrize( + "version, served", + [ + (None, False), + ("3.2", False), + ("3.3-beta.1", False), + ("3.3.0-beta.1", False), + ("3.3-beta.2", True), + ("3.3.0-beta.10", True), + ("3.3-rc.1", True), + ("3.3", True), + ("3.4", True), + ("4.0", True), + ("not-a-version", False), + ], +) +def test_gate(version: str | None, served: bool) -> None: + assert serves_settled_subscription_fields(version) is served + + +def test_offering_dimensions_and_inactive_intervals_are_dropped_below_beta_2() -> None: + block = { + "offerings": [ + { + "offering_id": "a", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"], + } + ] + } + assert project_reporting_delivery(block, "3.3-beta.1") == {"offerings": [{"offering_id": "a"}]} + assert project_reporting_delivery(block, "3.3-beta.2") == block + state = {"subscription_id": "s", "inactive_intervals": [{"deactivated_at": "x"}]} + assert "inactive_intervals" not in project_subscription_state(state, "3.3-beta.1") + assert project_subscription_state(state, "3.3-beta.2") == state + + +def test_per_offering_entries_become_account_wide_below_beta_2() -> None: + coverage = { + "subscription_id": "s", + "gaps": [ + { + "offering_id": "daily", + "from": "2026-10-03T04:00:00Z", + "until": "2026-10-05T04:00:00Z", + "reason": "unauthorized", + }, + { + "offering_id": "utc", + "from": "2026-10-03T00:00:00Z", + "until": "2026-10-05T00:00:00Z", + "reason": "unauthorized", + }, + ], + "timezone_cutovers": [ + { + "offering_id": "daily", + "effective_at": "2026-10-04T04:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York", + }, + { + "offering_id": "hourly", + "effective_at": "2026-10-03T13:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York", + }, + ], + } + projected = project_subscription_coverage(coverage, "3.3-beta.1") + # One account-wide gap spanning both offerings' gaps. + assert projected["gaps"] == [ + {"from": "2026-10-03T00:00:00Z", "until": "2026-10-05T04:00:00Z", "reason": "unauthorized"} + ] + # One cutover per change, at the latest effective_at, without the new fields. + assert projected["timezone_cutovers"] == [ + {"effective_at": "2026-10-04T04:00:00Z", "timezone": "America/Los_Angeles"} + ] + assert project_subscription_coverage(coverage, "3.3-beta.2") == coverage From ac21bd9b60461f1ead0c0eed39e4164d675e9953 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 18:40:57 +0000 Subject: [PATCH 6/8] fix(reporting): address the coverage-rule review and adopt adcp#8588 periods Fixes from the expert review of the coverage rule: - An empty flight (stopped at or before it started) overlaps no period, so a buy canceled before its flight owes nothing. - Each regime carries its per-offering handoff. E for a continuing or new offering is the latest of the outgoing calendar's boundary and the outgoing regime's own handoff, so two cutovers inside one period never owe a period twice. A timezone change passed at T or at its E now gives the same result. - The extensible reporting-dimension vocabulary parses on selectors, offerings and the capability block; an unknown selector value matches nothing. - Zone names must be IANA zones (posixrules and right/... are refused), and offerings with a year or month delivery_sla are refused at construction. - The beta.1 projection keeps distinct timezone changes distinct and drops prior_versions[].configuration. The feature id is refused without the principal section. Stale docstrings are corrected. adcp#8588 (pending for 3.3.0-beta.2): - Sub-daily periods under a local alignment restart at each local midnight on wall-clock multiples, so a DST day has 23 or 25 hourly periods. The duration must divide 24 hours and cannot mix date and time components. - prior_versions[].configuration is consumed, and require_prior_configurations makes a missing one an error. A version accepted while inactive has an empty window. The parity runner now checks row order, expected_obligations and the error class. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/_clock.py | 103 ++++++- .../reporting/subscriptions/capabilities.py | 24 +- src/adcp/reporting/subscriptions/coverage.py | 72 +++-- .../reporting/subscriptions/resolution.py | 21 +- src/adcp/reporting/subscriptions/wire.py | 34 ++- .../types/_reporting_subscription_preview.py | 60 ++++- ...est_reporting_subscription_capabilities.py | 7 + tests/test_reporting_subscription_coverage.py | 251 ++++++++++++++++++ tests/test_reporting_subscription_parity.py | 56 +++- tests/test_reporting_subscription_wire.py | 34 +++ 10 files changed, 602 insertions(+), 60 deletions(-) diff --git a/src/adcp/reporting/subscriptions/_clock.py b/src/adcp/reporting/subscriptions/_clock.py index 33b2e5415..cc7ab7be6 100644 --- a/src/adcp/reporting/subscriptions/_clock.py +++ b/src/adcp/reporting/subscriptions/_clock.py @@ -17,6 +17,14 @@ whether time components are civil; this matches the TypeScript SDK (adcontextprotocol/adcp-client#3505), so both SDKs derive identical periods. * A zero-length period (a skipped civil date) is never a period. +* Sub-daily local periods (adcp#8588): under ``account_timezone``, + ``source_timezone`` or ``billing_cycle`` alignment, a duration of hours, + minutes and seconds only MUST divide 24 hours, and its boundaries are the + instants whose local wall-clock time is a whole multiple of the duration + after local midnight, counted afresh each local day. A nonexistent wall time + yields no boundary and a repeated one yields one boundary per offset, so a + 23-hour DST day has 23 hourly periods and a 25-hour day 25. Local durations + cannot mix date and time components. The ledger's ``ReportingScheduleSpec`` treats every component as civil time and defaults an anchorless civil schedule to the UTC epoch in local time; this @@ -29,8 +37,9 @@ import re from collections.abc import Iterator from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from datetime import date, datetime, time, timedelta, timezone +from functools import lru_cache +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones UTC = timezone.utc _DURATION = re.compile(r"P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?") @@ -44,9 +53,15 @@ def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime: return value.astimezone(UTC) +@lru_cache(maxsize=1) +def _iana_zones() -> frozenset[str]: + return frozenset(available_timezones()) + + def require_zone(name: str | None, *, field: str = "timezone") -> ZoneInfo: - """A recognized IANA zone; numeric offsets and unknown names are refused.""" - if not name or name[0] in "+-" or name[0].isdigit(): + """A recognized IANA zone; numeric offsets, unknown names and keys that are + not IANA zone names (``posixrules``, ``right/UTC``, ``posix/...``) are refused.""" + if not name or name[0] in "+-" or name[0].isdigit() or name not in _iana_zones(): raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") try: return ZoneInfo(name) @@ -67,10 +82,17 @@ def parse_period_duration(value: str) -> tuple[int, int, int, int]: def parse_delivery_sla(value: str) -> timedelta: - """A non-negative ``delivery_sla`` as exact elapsed time (days are 24 hours).""" + """A non-negative ``delivery_sla`` as exact elapsed time (days are 24 hours). + + A ``delivery_sla`` with years or months, which the offering schema allows, + has no agreed arithmetic across SDKs and is refused. + """ match = _SLA.fullmatch(value) if match is None or value in {"P", "PT"} or value.endswith("T"): - raise ValueError(f"{value!r} is not a supported delivery_sla") + raise ValueError( + f"delivery_sla {value!r} is not supported: use days, hours, minutes and " + "seconds only (years and months have no agreed arithmetic)" + ) days, hours, minutes, seconds = (int(item or 0) for item in match.groups()) return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) @@ -96,6 +118,9 @@ class PeriodCalendar: zone_name: str origin: datetime # naive local civil time delivery_sla: timedelta = timedelta(0) + #: Local alignment (anything but ``utc``): sub-daily durations restart at + #: each local midnight (adcp#8588). + local: bool = False _parts: tuple[int, int, int, int] = field(init=False, repr=False, compare=False) _zone: ZoneInfo = field(init=False, repr=False, compare=False) _approx: float = field(init=False, repr=False, compare=False) @@ -107,6 +132,16 @@ def __post_init__(self) -> None: if self.origin.tzinfo is not None: raise ValueError("origin is local civil time and must be naive") years, months, days, exact = parts + if self.local and exact and (years or months or days): + raise ValueError( + f"period_duration {self.period_duration!r} mixes date and time components, " + "which a local alignment does not allow" + ) + if self.local and exact and not (years or months or days) and 86400 % exact: + raise ValueError( + f"period_duration {self.period_duration!r} must divide 24 hours " + "under a local alignment" + ) approx = years * 365.2425 * 86400 + months * 30.436875 * 86400 + days * 86400 + exact object.__setattr__(self, "_approx", approx) @@ -118,15 +153,50 @@ def for_schedule( delivery_sla: str, zone_name: str, anchor: datetime | None = None, + local: bool = False, ) -> PeriodCalendar: - """``anchor`` is an instant (``billing_cycle``); otherwise local midnight 1970-01-01.""" + """``anchor`` is an instant (``billing_cycle``); otherwise local midnight 1970-01-01. + + ``local`` marks an ``account_timezone``, ``source_timezone`` or + ``billing_cycle`` schedule. + """ zone = require_zone(zone_name) origin = ( utc_instant(anchor, field="period_anchor").astimezone(zone).replace(tzinfo=None) if anchor is not None else datetime(1970, 1, 1) ) - return cls(period_duration, zone_name, origin, parse_delivery_sla(delivery_sla)) + return cls( + period_duration, zone_name, origin, parse_delivery_sla(delivery_sla), local=local + ) + + @property + def _subdaily_local(self) -> bool: + years, months, days, exact = self._parts + return self.local and bool(exact) and not (years or months or days) + + def _day_boundaries(self, day: date) -> list[datetime]: + """Every boundary on one local date: wall-clock multiples after local midnight.""" + step = self._parts[3] + found: set[datetime] = set() + midnight = datetime.combine(day, time()) + for index in range(86400 // step): + wall = midnight + timedelta(seconds=index * step) + for fold in (0, 1): + instant = wall.replace(tzinfo=self._zone, fold=fold).astimezone(UTC) + if instant.astimezone(self._zone).replace(tzinfo=None) == wall: + found.add(instant) + return sorted(found) + + def _boundaries_from(self, instant: datetime) -> Iterator[datetime]: + """Sub-daily local boundaries at or after ``instant``, in order.""" + at = utc_instant(instant) + day = at.astimezone(self._zone).date() - timedelta(days=1) + while True: + for boundary in self._day_boundaries(day): + if boundary >= at: + yield boundary + day += timedelta(days=1) def boundary(self, ordinal: int) -> datetime: years, months, days, exact = self._parts @@ -152,11 +222,20 @@ def first_ordinal_at_or_after(self, instant: datetime) -> int: def ceil(self, instant: datetime) -> datetime: """The first boundary at or after ``instant`` (E for a cutover at it).""" + if self._subdaily_local: + return next(self._boundaries_from(instant)) return self.boundary(self.first_ordinal_at_or_after(instant)) def floor(self, instant: datetime) -> datetime: """The last boundary at or before ``instant``.""" at = utc_instant(instant) + if self._subdaily_local: + day = at.astimezone(self._zone).date() + timedelta(days=1) + while True: + earlier = [item for item in self._day_boundaries(day) if item <= at] + if earlier: + return earlier[-1] + day -= timedelta(days=1) ordinal = self.first_ordinal_at_or_after(at) start = self.boundary(ordinal) return start if start == at else self.boundary(ordinal - 1) @@ -166,6 +245,14 @@ def periods( ) -> Iterator[tuple[datetime, datetime]]: """Every nonempty period whose start lies in ``[starts_from, starts_before)``.""" upper = utc_instant(starts_before) + if self._subdaily_local: + boundaries = self._boundaries_from(starts_from) + start = next(boundaries) + while start < upper: + end = next(boundaries) + yield start, end + start = end + return ordinal = self.first_ordinal_at_or_after(starts_from) start = self.boundary(ordinal) while start < upper: diff --git a/src/adcp/reporting/subscriptions/capabilities.py b/src/adcp/reporting/subscriptions/capabilities.py index 7bac48a35..1fdc1689d 100644 --- a/src/adcp/reporting/subscriptions/capabilities.py +++ b/src/adcp/reporting/subscriptions/capabilities.py @@ -163,9 +163,9 @@ def advertise_reporting_subscriptions( """Add the feature id and principal section to a capabilities response. Expects ``media_buy.reporting_delivery`` already projected through - :func:`subscription_capabilities`. The ``adcp.principal`` block is touched - only when present: a seller advertising subscriptions must already - advertise the principal layer, which owns its own block. + :func:`subscription_capabilities`. A seller advertising subscriptions must + already advertise the principal layer, so an absent ``adcp.principal`` + block raises rather than producing the feature id without the section. """ payload = dict(capabilities) if support is None: @@ -174,14 +174,18 @@ def advertise_reporting_subscriptions( payload.get("experimental_features") or (), REPORTING_SUBSCRIPTIONS_FEATURE ) adcp = payload.get("adcp") - if isinstance(adcp, Mapping) and isinstance(adcp.get("principal"), Mapping): - principal = dict(adcp["principal"]) - principal["supported_sections"] = _append_unique( - principal.get("supported_sections") or (), REPORTING_SUBSCRIPTIONS_SECTION + if not (isinstance(adcp, Mapping) and isinstance(adcp.get("principal"), Mapping)): + # The feature id without the reporting_subscriptions section is a + # non-conformant advertisement. + raise ValueError( + "advertising reporting subscriptions requires the adcp.principal block, which " + "carries the reporting_subscriptions section" ) - adcp = dict(adcp) - adcp["principal"] = principal - payload["adcp"] = adcp + principal = dict(adcp["principal"]) + principal["supported_sections"] = _append_unique( + principal.get("supported_sections") or (), REPORTING_SUBSCRIPTIONS_SECTION + ) + payload["adcp"] = {**adcp, "principal": principal} return payload diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index fe9871198..b83a3b632 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -361,14 +361,19 @@ def contains(self, instant: datetime) -> bool: def subscription_version_windows( - subscription: Any, *, version_configurations: Mapping[int, Any] | None = None + subscription: Any, + *, + version_configurations: Mapping[int, Any] | None = None, + require_prior_configurations: bool = False, ) -> tuple[SubscriptionVersionWindow, ...]: """Derive version windows from a ``core/reporting-subscription-state.json`` readback. - The readback keeps ``prior_versions`` (version, ``accepted_at``, - ``superseded_at``) and ``inactive_intervals`` but not prior configurations. Supply each prior - version's configuration in ``version_configurations`` when it differs - from the current one; otherwise the current configuration is assumed. + Each prior version's configuration comes from ``version_configurations`` + when given, else from the readback's ``prior_versions[].configuration`` + (adcp#8588). A seller on 3.3.0-beta.1 does not send it; then the current + configuration is assumed, unless ``require_prior_configurations`` makes + the gap an error. A version accepted while the subscription was already + inactive has an empty window. """ if hasattr(subscription, "model_dump"): subscription = subscription.model_dump(mode="json", exclude_none=True) @@ -381,22 +386,34 @@ def subscription_version_windows( deactivated_at = _instant(_value(subscription, "deactivated_at"), field="deactivated_at") if state == "inactive" and deactivated_at is None: raise ValueError("an inactive subscription must carry deactivated_at") + accepted_at = _require_instant(_value(subscription, "accepted_at"), field="accepted_at") windows = [ SubscriptionVersionWindow( subscription_version=current_version, - accepted_at=_require_instant(_value(subscription, "accepted_at"), field="accepted_at"), - ends_at=deactivated_at if state == "inactive" else None, + accepted_at=accepted_at, + ends_at=( + max(accepted_at, deactivated_at) + if state == "inactive" and deactivated_at is not None + else None + ), configuration=configurations.get(current_version, current_configuration), ) ] for prior in _value(subscription, "prior_versions", ()) or (): version = int(_value(prior, "subscription_version")) + configuration = configurations.get(version, _value(prior, "configuration")) + if configuration is None: + if require_prior_configurations: + raise ValueError( + f"no configuration is known for prior subscription_version {version}" + ) + configuration = current_configuration windows.append( SubscriptionVersionWindow( subscription_version=version, accepted_at=_require_instant(_value(prior, "accepted_at"), field="accepted_at"), ends_at=_require_instant(_value(prior, "superseded_at"), field="superseded_at"), - configuration=configurations.get(version, current_configuration), + configuration=configuration, ) ) inactive = tuple( @@ -493,6 +510,10 @@ class _Regime: until: datetime | None version: int resolved: tuple[_Resolved, ...] + #: Per offering: the instant from which this regime owes periods (E, or + #: the regime start). Carried so a later cutover inside the same outgoing + #: period never owes a period twice. + handoffs: dict[str, datetime] = field(default_factory=dict) @dataclass @@ -763,10 +784,9 @@ def expected_subscription_periods( for index, regime in enumerate(regimes): if regime is None: continue - predecessor = regimes[index - 1] if index > 0 else None for resolved in regime.resolved: calendar = resolved.calendar - handoff = _cutover_end(regime.start, predecessor, resolved) + handoff = regime.handoffs[resolved.offering.offering_id] lower = max( calendar.ceil(max(handoff, buy.confirmed_at)), calendar.floor(flights_start), @@ -780,7 +800,11 @@ def expected_subscription_periods( if not include_unresolved and end > now: break flight_start, flight_stop = buy.flight_at(end) - if not (start < flight_stop and end > flight_start): + # An empty flight (stopped at or before it started) + # overlaps no period. + if flight_stop <= flight_start or not ( + start < flight_stop and end > flight_start + ): continue if not _authorized(chains, calendar, start, backfill_on_regrant): continue @@ -893,18 +917,36 @@ def _regimes( ): previous.until = until continue - regimes.append(_Regime(start, until, window.subscription_version, resolved)) + regime = _Regime(start, until, window.subscription_version, resolved) + predecessor = regimes[-1] if regimes else None + regime.handoffs = { + item.offering.offering_id: _cutover_end(start, predecessor, item) for item in resolved + } + regimes.append(regime) return regimes def _cutover_end(handoff: datetime, predecessor: _Regime | None, incoming: _Resolved) -> datetime: - """E for an incoming offering at ``handoff``; the regime start with no predecessor.""" + """E for an incoming offering at ``handoff``; the regime start with no predecessor. + + E is the end of the outgoing offering's last period that starts before T. + When the outgoing regime itself owed nothing before T (its own handoff + lies after T, as with two cutovers inside one period), that last period + belongs to an earlier regime and ends at the outgoing regime's handoff. + """ if predecessor is None or not predecessor.resolved: return handoff + + def outgoing_end(item: _Resolved) -> datetime: + return max( + item.calendar.ceil(handoff), + predecessor.handoffs.get(item.offering.offering_id, handoff), + ) + for item in predecessor.resolved: if item.offering.offering_id == incoming.offering.offering_id: - return item.calendar.ceil(handoff) - return max(item.calendar.ceil(handoff) for item in predecessor.resolved) + return outgoing_end(item) + return max(outgoing_end(item) for item in predecessor.resolved) def _subscription_id(subscription: Any, windows: Sequence[SubscriptionVersionWindow]) -> str: diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index c1f96413e..c02513c51 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -30,7 +30,13 @@ from datetime import datetime from typing import Any, Literal -from adcp.reporting.subscriptions._clock import PeriodCalendar, require_zone, utc_instant +from adcp.reporting.subscriptions._clock import ( + PeriodCalendar, + parse_delivery_sla, + parse_period_duration, + require_zone, + utc_instant, +) #: The 3.3 standard breakdown vocabulary (``enums/reporting-dimension.json``). #: It is extensible: a selector naming a value no offering maps matches nothing. @@ -75,8 +81,8 @@ class SubscriptionOffering: ``period_timezone`` is the schedule's IANA zone for ``source_timezone`` (fixed, or the seller's account-resolved upstream zone) and fixed ``billing_cycle`` offerings. ``period_timezone_policy`` is the - ``source_timezone`` policy: an ``account_resolved`` schedule can produce - periods in any zone a ``period_timezone`` override names. + ``source_timezone`` policy: an ``account_resolved`` schedule has no + published zone, so it never matches a ``period_timezone`` override. ``available_from`` and ``withdrawn_at`` bound when the offering is a candidate; a change in resolution is a cutover. """ @@ -106,6 +112,11 @@ def __post_init__(self) -> None: raise ValueError("offering_id is required") if self.alignment not in _ALIGNMENTS: raise ValueError(f"unknown schedule alignment {self.alignment!r}") + # Refuse what the shared calendar cannot derive, up front rather than + # mid-evaluation: a delivery_sla with years or months, or an invalid + # duration. + parse_delivery_sla(self.delivery_sla) + parse_period_duration(self.period_duration) object.__setattr__(self, "supported_finality", frozenset(self.supported_finality)) object.__setattr__(self, "dimensions", frozenset(self.dimensions)) object.__setattr__(self, "unmapped_dimensions", frozenset(self.unmapped_dimensions)) @@ -241,7 +252,8 @@ def zone(self, account_timezone: str | None, period_timezone: str | None = None) """The IANA zone this offering's periods use for an account. ``period_timezone`` is a single-account subscription's override; it - applies to ``account_timezone`` and ``account_resolved`` schedules. + applies to ``account_timezone`` schedules only (other alignments match + an override only when their published zone already equals it). """ if self.alignment == "utc": return "UTC" @@ -274,6 +286,7 @@ def calendar( delivery_sla=self.delivery_sla, zone_name=self.zone(account_timezone, period_timezone), anchor=self.period_anchor if self.alignment == "billing_cycle" else None, + local=self.alignment != "utc", ) diff --git a/src/adcp/reporting/subscriptions/wire.py b/src/adcp/reporting/subscriptions/wire.py index 3a70ce100..241f04f89 100644 --- a/src/adcp/reporting/subscriptions/wire.py +++ b/src/adcp/reporting/subscriptions/wire.py @@ -12,7 +12,12 @@ * per-offering ``gaps[]`` collapse into one account-wide gap spanning them (it removes at least the periods any offering's gap removes); * per-offering ``timezone_cutovers[]`` for one change collapse into one entry - at the latest ``effective_at``. + at the latest ``effective_at``; distinct changes stay distinct; +* ``prior_versions[].configuration`` (adcp#8588) is dropped. + +Known limitation: without ``inactive_intervals`` a beta.1 reader cannot see an +in-place reactivation, so it expects the periods of the inactive stretch. +3.3.0-beta.1 has no way to express it. TODO(3.3.0-beta.2): once the SDK pins a bundle containing adcp#8585 these projections only matter for callers pinned to 3.3.0-beta.1. @@ -81,6 +86,11 @@ def project_subscription_state( payload = dict(state) if not serves_settled_subscription_fields(adcp_version): payload.pop("inactive_intervals", None) + if payload.get("prior_versions"): + payload["prior_versions"] = [ + {key: value for key, value in item.items() if key != "configuration"} + for item in payload["prior_versions"] + ] return payload @@ -128,14 +138,24 @@ def _account_wide_gaps(gaps: Sequence[Mapping[str, Any]]) -> list[dict[str, Any] def _account_wide_cutovers(cutovers: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: - by_change: dict[tuple[Any, Any], dict[str, Any]] = {} - for cutover in cutovers: + """One entry per change, at the latest of its per-offering ``effective_at``. + + The per-offering entries of one change share ``timezone`` and + ``previous_timezone`` and sit next to each other in time order; a later + change, even back to an earlier zone, starts a new group. + """ + ordered = sorted(cutovers, key=lambda item: _instant(item["effective_at"])) + result: list[dict[str, Any]] = [] + previous_key: tuple[Any, Any] | None = None + for cutover in ordered: key = (cutover.get("timezone"), cutover.get("previous_timezone")) entry = {"effective_at": cutover["effective_at"], "timezone": cutover["timezone"]} - current = by_change.get(key) - if current is None or _instant(entry["effective_at"]) > _instant(current["effective_at"]): - by_change[key] = entry - return sorted(by_change.values(), key=lambda item: _instant(item["effective_at"])) + if result and key == previous_key and "offering_id" in cutover: + result[-1] = entry # a later effective_at of the same change + else: + result.append(entry) + previous_key = key + return result __all__ = [ diff --git a/src/adcp/types/_reporting_subscription_preview.py b/src/adcp/types/_reporting_subscription_preview.py index 17b7418fb..ada5a66b3 100644 --- a/src/adcp/types/_reporting_subscription_preview.py +++ b/src/adcp/types/_reporting_subscription_preview.py @@ -1,4 +1,4 @@ -"""AdCP 3.3 reporting-subscription fields settled after 3.3.0-beta.1 (adcp#8585). +"""AdCP 3.3 reporting-subscription fields settled after 3.3.0-beta.1 (adcp#8585, adcp#8588). adcontextprotocol/adcp#8585 (merged to the spec's main, not yet in a signed bundle) adds these experimental fields: @@ -9,7 +9,10 @@ * ``core/reporting-subscription-state.json``: ``inactive_intervals[]``, so a reactivation in place can be recomputed after local-state loss; * ``core/reporting-subscription-coverage.json``: ``previous_timezone`` and - ``offering_id`` on ``timezone_cutovers[]``, and ``offering_id`` on ``gaps[]``. + ``offering_id`` on ``timezone_cutovers[]``, and ``offering_id`` on ``gaps[]``; +* ``prior_versions[].configuration`` on the readback (adcp#8588, pending); +* the extensible ``reporting-dimension`` vocabulary on selectors, offerings and + the capability block, which the generator closed. The pinned 3.3.0-beta.1 models close those objects (``additionalProperties: false``), so the fields are added to the generated @@ -31,8 +34,14 @@ ) from adcp.types.domains.core import reporting_delivery_offering as _offering from adcp.types.domains.core import reporting_subscription_coverage as _coverage +from adcp.types.domains.core import reporting_delivery_capabilities as _capabilities +from adcp.types.domains.core import reporting_subscription as _subscription from adcp.types.domains.core import reporting_subscription_state as _state -from adcp.types.domains.enums.reporting_dimension import ReportingDimension + +#: One value of the extensible ``enums/reporting-dimension.json`` vocabulary. +#: Receivers MUST accept unknown values (``x-extensible``); a selector naming a +#: value no offering maps simply matches nothing. +ReportingDimensionToken = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]*$", max_length=64)] class ReportingSubscriptionInactiveInterval(AdCPBaseModel): @@ -61,6 +70,13 @@ def _add_field(model: type[BaseModel], name: str, annotation: Any, field: FieldI model.model_rebuild(force=True) +def _replace_field(model: type[BaseModel], name: str, annotation: Any, field: FieldInfo) -> None: + field.annotation = annotation + model.model_fields[name] = field + model.__annotations__[name] = annotation + model.model_rebuild(force=True) + + def _optional(annotation: Any, **kwargs: Any) -> tuple[Any, FieldInfo]: wrapped = Annotated[annotation | None, Field(**kwargs), NoExplicitNull] return wrapped, FieldInfo.from_annotated_attribute(cast(Any, wrapped), None) @@ -80,13 +96,43 @@ def apply_reporting_subscription_preview() -> None: _add_field( offering_model, "standard_dimensions", - *_optional(list[ReportingDimension], max_length=16, description=dimensions_doc), + *_optional(list[ReportingDimensionToken], max_length=16, description=dimensions_doc), ) _add_field( offering_model, "unmapped_dimensions", *_optional(list[str], max_length=16, description=unmapped_doc), ) + # The generated models close the extensible dimension vocabulary. + _replace_field( + _subscription.Selector, + "dimensions", + *_optional( + list[ReportingDimensionToken], + min_length=1, + max_length=16, + description="Breakdown in the standard (extensible) vocabulary.", + ), + ) + for subscriptions_model in (_capabilities.Subscriptions, _bundled_capabilities.Subscriptions): + _replace_field( + subscriptions_model, + "dimensions", + *_optional( + list[ReportingDimensionToken], + min_length=1, + description="Standard dimensions this seller maps its report definitions onto.", + ), + ) + # adcp#8588: each prior version carries the configuration it had. + _add_field( + _state.PriorVersion, + "configuration", + *_optional( + _subscription.ReportingSubscription, + description="The configuration this version had.", + ), + ) _add_field( _state.ReportingSubscriptionState, "inactive_intervals", @@ -114,4 +160,8 @@ def apply_reporting_subscription_preview() -> None: ) -__all__ = ["ReportingSubscriptionInactiveInterval", "apply_reporting_subscription_preview"] +__all__ = [ + "ReportingDimensionToken", + "ReportingSubscriptionInactiveInterval", + "apply_reporting_subscription_preview", +] diff --git a/tests/test_reporting_subscription_capabilities.py b/tests/test_reporting_subscription_capabilities.py index 4ed0d9ec2..520fcb6ae 100644 --- a/tests/test_reporting_subscription_capabilities.py +++ b/tests/test_reporting_subscription_capabilities.py @@ -190,3 +190,10 @@ def test_offerings_must_publish_standard_dimensions() -> None: again = SubscriptionOffering.from_offering(model) assert again.dimensions == frozenset({"package"}) assert again.unmapped_dimensions == frozenset({"device"}) + + +def test_feature_id_without_the_principal_section_is_refused() -> None: + with pytest.raises(ValueError, match="adcp.principal"): + advertise_reporting_subscriptions( + {"experimental_features": []}, ReportingSubscriptionSupport() + ) diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index 4bc42ee47..f9ac832db 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -1480,3 +1480,254 @@ def test_wire_offering_without_standard_dimensions_is_refused() -> None: with pytest.raises(ValueError, match="standard_dimensions"): SubscriptionOffering.from_offering(wire) assert SubscriptionOffering.from_offering(wire, dimensions=()).dimensions == frozenset() + + +# -- expert review fixes --------------------------------------------------------- + + +class TestReviewFixes: + def test_buy_canceled_before_its_flight_owes_nothing(self) -> None: + monthly = offering("monthly-utc", duration="P1M", alignment="utc") + canceled = buy( + confirmed_at="2026-10-01T00:00:00Z", + start_time="2026-11-15T00:00:00Z", + end_time="2026-12-15T00:00:00Z", + canceled_at="2026-11-10T00:00:00Z", + offering_ids=("monthly-utc",), + ) + assert ( + run( + subscription=state( + "2026-10-01T00:00:00Z", config=subscription_config(duration="P1M") + ), + offerings=[monthly], + buys=[canceled], + now=at("2027-01-01T00:00:00Z"), + ) + == [] + ) + # P1D: starts 06:00 local and is canceled at 02:00 local the same day. + same_day = buy( + start_time="2026-10-05T10:00:00Z", + end_time="2026-10-09T04:00:00Z", + canceled_at="2026-10-05T06:00:00Z", + ) + assert run(buys=[same_day]) == [] + # A zero-length flight overlaps nothing either. + empty = buy(start_time="2026-10-05T10:00:00Z", end_time="2026-10-05T10:00:00Z") + assert run(buys=[empty]) == [] + + def test_two_cutovers_inside_one_period_never_overlap(self) -> None: + # The account moves LA -> NY at 10-03T12Z (E = 10-04T07Z) and a new + # version is accepted at 10-03T13Z, inside the same outgoing LA day. + subscription = state( + "2026-10-03T13:00:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T13:00:00Z")], + ) + kwargs = dict( + subscription=subscription, + account_timezone=NY, + buys=[buy(start_time="2026-10-02T07:00:00Z", end_time="2026-10-07T04:00:00Z")], + ) + at_change = run( + timezone_changes=[ + AccountTimezoneChange( + at("2026-10-03T12:00:00Z"), NY, previous_timezone="America/Los_Angeles" + ) + ], + **kwargs, + ) + at_effective = run( + timezone_changes=[ + AccountTimezoneChange( + at("2026-10-04T07:00:00Z"), NY, previous_timezone="America/Los_Angeles" + ) + ], + **kwargs, + ) + assert at_change == at_effective + spans = [(at(p.period_start), at(p.period_end)) for p in at_change] + assert all(end <= start for (_, end), (start, _) in zip(spans, spans[1:])) + assert [(p.period_start, p.delivery_config_version) for p in at_change] == [ + ("2026-10-02T07:00:00Z", 1), + ("2026-10-03T07:00:00Z", 1), # the LA day containing both changes + ("2026-10-05T04:00:00Z", 2), # first NY day starting at or after 10-04T07Z + ("2026-10-06T04:00:00Z", 2), + ] + + def test_two_version_bumps_inside_one_period(self) -> None: + subscription = state( + "2026-10-03T15:00:00Z", + version=3, + config=subscription_config(version=3), + prior=[ + (2, "2026-10-03T14:00:00Z", "2026-10-03T15:00:00Z"), + (1, "2026-10-01T00:00:00Z", "2026-10-03T14:00:00Z"), + ], + ) + result = run( + subscription=subscription, + version_configurations={2: subscription_config(version=2, duration="PT1H")}, + buys=[buy(end_time="2026-10-06T04:00:00Z", offering_ids=("daily-pkg", "hourly-pkg"))], + ) + spans = [(at(p.period_start), at(p.period_end)) for p in result] + assert all(end <= start for (_, end), (start, _) in zip(spans, spans[1:])) + assert [(p.period_start, p.delivery_config_version) for p in result][2:] == [ + ("2026-10-03T04:00:00Z", 1), + ("2026-10-04T04:00:00Z", 3), + ("2026-10-05T04:00:00Z", 3), + ] + + def test_unknown_dimension_values_parse_and_match_nothing(self) -> None: + from adcp.types import SyncPrincipalRequest # noqa: F401 (applies the preview) + from adcp.types.domains.core.reporting_subscription import ReportingSubscription + + config = ReportingSubscription.model_validate(subscription_config(dimensions=["device"])) + assert [str(item) for item in config.selector.dimensions] == ["device"] + assert resolved(subscription_config(dimensions=["device"])) == () + wire = { + "offering_id": "pkg-device", + "feed_purpose": "analytics", + "report_definition_id": "d", + "reporting_profile": {"id": "delivery"}, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "device"], + } + projected = SubscriptionOffering.from_offering(wire) + assert projected.dimensions == frozenset({"package", "device"}) + device = subscription_config(dimensions=["device"]) + assert resolved(device, offerings=[projected], product=("pkg-device",)) == () + finest = subscription_config(dimensions=["device"], match="finest") + assert resolved(finest, offerings=[projected], product=("pkg-device",)) == ("pkg-device",) + + def test_offerings_with_month_slas_are_refused_at_construction(self) -> None: + with pytest.raises(ValueError, match="delivery_sla"): + offering("monthly-sla", duration="P1M", sla="P1M") + + def test_non_iana_zone_keys_are_refused(self) -> None: + for zone in ("posixrules", "right/UTC", "posix/America/New_York", "+05:00"): + with pytest.raises(ValueError): + run(account_timezone=zone, buys=[buy()]) + + def test_prior_versions_carry_their_configuration(self) -> None: + readback = state( + "2026-10-03T12:00:00Z", + version=2, + config=subscription_config(version=2, scope={"account_ids": ["acct-2"]}), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + readback["prior_versions"][0]["configuration"] = subscription_config() + model = ReportingSubscriptionState.model_validate(readback) + # v1 covered acct-1; the readback says so without buyer records. + assert starts(subscription=model, buys=[buy()]) == days("2026-10-01", 3) + bare = state( + "2026-10-03T12:00:00Z", + version=2, + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-03T12:00:00Z")], + ) + with pytest.raises(ValueError, match="prior subscription_version 1"): + subscription_version_windows(bare, require_prior_configurations=True) + + def test_version_accepted_while_inactive_has_an_empty_window(self) -> None: + readback = state( + "2026-10-05T00:00:00Z", + version=2, + config=subscription_config(version=2), + prior=[(1, "2026-10-01T00:00:00Z", "2026-10-05T00:00:00Z")], + deactivated_at="2026-10-03T12:00:00Z", + ) + # The readback carries the still-open interval from v1's deactivation. + readback["inactive_intervals"] = [{"deactivated_at": "2026-10-03T12:00:00Z"}] + assert starts(subscription=readback, buys=[buy()]) == days("2026-10-01", 3) + + +class TestSubDailyLocalPeriods: + """adcp#8588: sub-daily local periods restart at each local midnight.""" + + def hours(self, zone: str, start: str, until: str, duration: str = "PT1H") -> list[str]: + local = offering("local", duration=duration, sla="PT1H") + return [ + p.period_start + for p in run( + subscription=state( + "2026-01-01T00:00:00Z", config=subscription_config(duration=duration) + ), + offerings=[local], + account_timezone=zone, + buys=[ + buy( + confirmed_at="2026-01-01T00:00:00Z", + start_time=start, + end_time=until, + offering_ids=("local",), + ) + ], + now=at("2027-01-01T00:00:00Z"), + ) + ] + + def test_hours_start_on_the_local_hour_whatever_the_1970_offset(self) -> None: + # Asia/Singapore was UTC+07:30 in 1970; its hours still start on :00. + result = self.hours("Asia/Singapore", "2026-10-01T00:00:00Z", "2026-10-01T03:00:00Z") + assert result == ["2026-10-01T00:00:00Z", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z"] + kathmandu = self.hours("Asia/Kathmandu", "2026-10-01T00:15:00Z", "2026-10-01T02:15:00Z") + assert kathmandu == ["2026-10-01T00:15:00Z", "2026-10-01T01:15:00Z"] + + def test_dst_days_have_23_and_25_hours(self) -> None: + spring = self.hours(NY, "2026-03-08T05:00:00Z", "2026-03-09T04:00:00Z") + fall = self.hours(NY, "2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z") + assert (len(spring), len(fall)) == (23, 25) + # Both 01:00 wall times are boundaries on the fall-back day. + assert fall[1:3] == ["2026-11-01T05:00:00Z", "2026-11-01T06:00:00Z"] + + def test_six_hour_periods_do_not_drift_with_dst(self) -> None: + summer = self.hours(NY, "2026-07-01T04:00:00Z", "2026-07-02T04:00:00Z", "PT6H") + winter = self.hours(NY, "2026-01-05T05:00:00Z", "2026-01-06T05:00:00Z", "PT6H") + assert summer == [ + "2026-07-01T04:00:00Z", + "2026-07-01T10:00:00Z", + "2026-07-01T16:00:00Z", + "2026-07-01T22:00:00Z", + ] + assert winter == [ + "2026-01-05T05:00:00Z", + "2026-01-05T11:00:00Z", + "2026-01-05T17:00:00Z", + "2026-01-05T23:00:00Z", + ] + + def test_local_durations_must_divide_a_day_and_not_mix_components(self) -> None: + for duration in ("PT7H", "P1DT1H"): + with pytest.raises(ValueError): + self.hours(NY, "2026-07-01T04:00:00Z", "2026-07-02T04:00:00Z", duration) + + def test_utc_alignment_is_unchanged(self) -> None: + result = periods( + subscription=state("2026-10-03T00:00:00Z", config=subscription_config(duration="PT1H")), + buys=[ + buy( + start_time="2026-10-03T10:00:00Z", + end_time="2026-10-03T12:00:00Z", + offering_ids=("hourly-pkg",), + ) + ], + ) + assert result == [ + ("2026-10-03T10:00:00Z", "2026-10-03T11:00:00Z"), + ("2026-10-03T11:00:00Z", "2026-10-03T12:00:00Z"), + ] + + +def test_a_bare_3_3_version_cannot_be_served_while_beta_1_is_pinned() -> None: + # The gate would serve 3.3 the adcp#8585 fields, but the dispatcher never + # resolves a bare "3.3" while only the 3.3.0-beta.1 bundle is pinned. + from adcp._version import get_supported_adcp_versions, resolve_adcp_version + from adcp.exceptions import ConfigurationError + + with pytest.raises(ConfigurationError): + resolve_adcp_version("3.3") + assert "3.3" not in get_supported_adcp_versions() diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py index 7f9661d0a..ef983d37e 100644 --- a/tests/test_reporting_subscription_parity.py +++ b/tests/test_reporting_subscription_parity.py @@ -128,9 +128,9 @@ def buys(input_: dict[str, Any]) -> list[SubscriptionBuy]: return result -def run(input_: dict[str, Any]) -> list[tuple]: +def periods(input_: dict[str, Any]): account = input_["account"] - periods = expected_subscription_periods( + return expected_subscription_periods( subscription=windows(input_["versions"], input_.get("inactive_intervals", [])), account_id=account["account_id"], buys=buys(input_), @@ -146,8 +146,11 @@ def run(input_: dict[str, Any]) -> list[tuple]: backfill_on_regrant=bool(input_.get("backfill_on_regrant", False)), status_retention_days=input_.get("status_retention_days"), ) - assert {p.delivery_config_id for p in periods} <= {input_["subscription_id"]} - rows = [ + + +def rows(owed) -> list[tuple]: + """Per-buy rows in the table's order: start, version, offering, buy (byte order).""" + return [ ( at(p.period_start), p.delivery_config_version, @@ -155,19 +158,28 @@ def run(input_: dict[str, Any]) -> list[tuple]: media_buy_id.encode(), at(p.period_end), ) - for p in periods + for p in owed for media_buy_id in p.media_buy_ids ] - return sorted(rows) + + +#: The table names JavaScript error classes. Python raises ValueError for the +#: malformed input TypeScript reports as TypeError (and RangeError). +ERROR_CLASSES = {"TypeError": ValueError, "RangeError": ValueError} @pytest.mark.parametrize("case", CASES, ids=[case["name"] for case in CASES]) def test_typescript_parity(case: dict[str, Any]) -> None: if "expected_error" in case: - with pytest.raises((ValueError, TypeError)): - run(case["input"]) + expected_class = ERROR_CLASSES[case["expected_error"]] + with pytest.raises(Exception) as raised: + periods(case["input"]) + # Strict: malformed input, not an incidental KeyError or AttributeError. + assert isinstance(raised.value, expected_class), (case["name"], raised.value) return - expected = sorted( + owed = periods(case["input"]) + assert {p.delivery_config_id for p in owed} <= {case["input"]["subscription_id"]} + expected = [ ( at(row["period_start"]), row["subscription_version"], @@ -176,8 +188,30 @@ def test_typescript_parity(case: dict[str, Any]) -> None: at(row["period_end"]), ) for row in case["expected"] - ) - assert run(case["input"]) == expected, case.get("rule") + ] + # Strict: the same rows in the same order, not just the same set. + assert rows(owed) == expected, case.get("rule") + if "expected_obligations" in case: + obligations = [ + ( + p.delivery_config_version, + p.offering_id, + at(p.period_start), + at(p.period_end), + list(p.media_buy_ids), + ) + for p in owed + ] + assert obligations == [ + ( + item["subscription_version"], + item["offering_id"], + at(item["period_start"]), + at(item["period_end"]), + item["media_buy_ids"], + ) + for item in case["expected_obligations"] + ], case.get("rule") def test_every_subscription_in_the_table_is_schema_valid() -> None: diff --git a/tests/test_reporting_subscription_wire.py b/tests/test_reporting_subscription_wire.py index fa7a022aa..b7a7f485c 100644 --- a/tests/test_reporting_subscription_wire.py +++ b/tests/test_reporting_subscription_wire.py @@ -91,3 +91,37 @@ def test_per_offering_entries_become_account_wide_below_beta_2() -> None: {"effective_at": "2026-10-04T04:00:00Z", "timezone": "America/Los_Angeles"} ] assert project_subscription_coverage(coverage, "3.3-beta.2") == coverage + + +def test_distinct_changes_back_to_a_zone_stay_distinct_below_beta_2() -> None: + cutovers = [ + { + "effective_at": "2026-01-10T05:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York", + }, + { + "effective_at": "2026-03-10T08:00:00Z", + "timezone": "America/New_York", + "previous_timezone": "America/Los_Angeles", + }, + { + "effective_at": "2026-06-10T04:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York", + }, + ] + projected = project_subscription_coverage({"timezone_cutovers": cutovers}, "3.3-beta.1") + assert [item["effective_at"] for item in projected["timezone_cutovers"]] == [ + "2026-01-10T05:00:00Z", + "2026-03-10T08:00:00Z", + "2026-06-10T04:00:00Z", + ] + + +def test_prior_version_configuration_is_dropped_below_beta_2() -> None: + state = {"prior_versions": [{"subscription_version": 1, "configuration": {"x": 1}}]} + assert project_subscription_state(state, "3.3-beta.1")["prior_versions"] == [ + {"subscription_version": 1} + ] + assert project_subscription_state(state, "3.3-beta.2") == state From 2396b0b521bf98324fcb463903f30da343d952c4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 19:07:14 +0000 Subject: [PATCH 7/8] fix(reporting): accept undated continuation re-grants and re-vendor the parity table Re-vendor the TypeScript coverage table at a99597a4: 79 cases, adding - empty flights; - two cutovers inside one period; - sub-daily local periods; - DST gaps and folds at midnight; - a skipped civil date; - PT24H versus P1D; - month-end clamps; - three-grant chains; - simultaneous changes; - unknown dimensions. All pass under the strict runner. Match TS on one rule the table pins: a grant whose authorized_at is at or before the previous loss continues the chain at an unknown instant. This happens because adcp#8585 keeps authorized_at at the chain start after a re-grant inside retention. The buyer removes no gap of its own; the seller's gaps[] decide. Grants are read in input order. The schema check accepts dimension values outside the beta.1 enum, because the vocabulary is x-extensible. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/coverage.py | 41 +- .../types/_reporting_subscription_preview.py | 4 +- .../coverage-cases.json | 6016 ++++++++++++++++- tests/test_reporting_subscription_coverage.py | 25 +- tests/test_reporting_subscription_parity.py | 8 +- 5 files changed, 5968 insertions(+), 126 deletions(-) diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index b83a3b632..43b7faa75 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -203,6 +203,10 @@ class SubscriptionAuthorization: Pass the account's grant history, oldest first. A loss followed by a re-grant within ``status_retention_days`` continues coverage, with a gap unless the seller backfills; a later re-grant is a fresh coverage start. + A grant whose ``authorized_at`` is at or before the previous loss, such as + one listed with the chain-start ``authorized_at`` that ``list_accounts`` + keeps after a re-grant inside retention (adcp#8585), continues the chain + at an unknown instant: the seller's ``gaps[]`` say which periods it removed. """ authorized_at: datetime @@ -521,7 +525,9 @@ class _Chain: """Continuous authority: a first grant, retained interruptions, an optional end.""" start: datetime - interruptions: list[tuple[datetime, datetime]] = field(default_factory=list) + #: (lost, regranted); regranted is None when only the chain-start + #: authorized_at says authority returned (the re-grant instant is unknown). + interruptions: list[tuple[datetime, datetime | None]] = field(default_factory=list) end: datetime | None = None #: The re-grant that started this chain after an expired entry, if any. after_expiry_of: datetime | None = None @@ -542,15 +548,18 @@ class _Owed: def _chains( authorizations: Sequence[SubscriptionAuthorization], retention: timedelta | None ) -> list[_Chain]: - ordered = sorted(authorizations, key=lambda item: item.authorized_at) chains: list[_Chain] = [] previous_end: datetime | None = None - for index, grant in enumerate(ordered): + for index, grant in enumerate(authorizations): if index > 0 and previous_end is None: raise ValueError("only the last grant may be unrevoked") - if previous_end is not None and grant.authorized_at < previous_end: - raise ValueError("authorization grants must not overlap") - if chains and previous_end is not None: + if chains and previous_end is not None and grant.authorized_at <= previous_end: + # adcp#8585 keeps authorized_at at the chain start after a re-grant + # inside retention, so a grant listed with it continues the chain + # at an unknown instant; the seller's gaps[] says what it removed. + chains[-1].interruptions.append((previous_end, None)) + chains[-1].end = None + elif chains and previous_end is not None: if retention is None: raise ValueError("status_retention_days is required to evaluate a re-grant") if grant.authorized_at < previous_end + retention: @@ -579,6 +588,7 @@ def _authorized( if not backfill and any( calendar.floor(lost) <= start < calendar.ceil(regranted) for lost, regranted in chain.interruptions + if regranted is not None ): continue return True @@ -597,6 +607,8 @@ def _expected_at( expected: datetime = end + sla for chain in chains: for lost, regranted in chain.interruptions: + if regranted is None: + continue # an undated re-grant: the original deadline stands pending = end <= calendar.floor(lost) and expected > lost backfilled = backfill and calendar.floor(lost) <= start < calendar.ceil(regranted) if pending or backfilled: @@ -788,7 +800,7 @@ def expected_subscription_periods( calendar = resolved.calendar handoff = regime.handoffs[resolved.offering.offering_id] lower = max( - calendar.ceil(max(handoff, buy.confirmed_at)), + _first_owed_start(calendar, handoff, buy.confirmed_at), calendar.floor(flights_start), ) upper = latest_stop if regime.until is None else min(regime.until, latest_stop) @@ -926,6 +938,21 @@ def _regimes( return regimes +def _first_owed_start( + calendar: PeriodCalendar, handoff: datetime, confirmed_at: datetime +) -> datetime: + """The earliest start a buy's owed period may have under one regime. + + The coverage rule: the first boundary at or after the latest of the + regime's handoff (``accepted_at`` or E) and the buy's ``confirmed_at``. + ``authorized_at`` is applied by the authority chains. This is the one + place that decides whether the period containing ``confirmed_at`` is owed + (it is not, today), so an owner decision changing that rule changes only + this function. + """ + return calendar.ceil(max(handoff, confirmed_at)) + + def _cutover_end(handoff: datetime, predecessor: _Regime | None, incoming: _Resolved) -> datetime: """E for an incoming offering at ``handoff``; the regime start with no predecessor. diff --git a/src/adcp/types/_reporting_subscription_preview.py b/src/adcp/types/_reporting_subscription_preview.py index ada5a66b3..87ed25da8 100644 --- a/src/adcp/types/_reporting_subscription_preview.py +++ b/src/adcp/types/_reporting_subscription_preview.py @@ -32,10 +32,10 @@ from adcp.types.domains.bundled.protocol import ( get_adcp_capabilities_response as _bundled_capabilities, ) -from adcp.types.domains.core import reporting_delivery_offering as _offering -from adcp.types.domains.core import reporting_subscription_coverage as _coverage from adcp.types.domains.core import reporting_delivery_capabilities as _capabilities +from adcp.types.domains.core import reporting_delivery_offering as _offering from adcp.types.domains.core import reporting_subscription as _subscription +from adcp.types.domains.core import reporting_subscription_coverage as _coverage from adcp.types.domains.core import reporting_subscription_state as _state #: One value of the extensible ``enums/reporting-dimension.json`` vocabulary. diff --git a/tests/fixtures/reporting_subscriptions/coverage-cases.json b/tests/fixtures/reporting_subscriptions/coverage-cases.json index 4259fe4f0..ac9d7727e 100644 --- a/tests/fixtures/reporting_subscriptions/coverage-cases.json +++ b/tests/fixtures/reporting_subscriptions/coverage-cases.json @@ -1,6 +1,6 @@ { - "$source": "adcontextprotocol/adcp-client@f8b2fddd24bc0912356fdfa30197bfb8c6003fbf:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$source": "adcontextprotocol/adcp-client@a99597a46551c467ec58c68cfdb70d994e88a1a1:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585 and #8588 (sub-daily local periods). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", @@ -113,6 +113,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -219,6 +248,26 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -325,6 +374,26 @@ "period_start": "2026-11-11T00:00:00Z", "period_end": "2026-11-12T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -438,6 +507,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -552,6 +650,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -652,6 +779,17 @@ "period_start": "2026-11-03T00:00:00Z", "period_end": "2026-11-04T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -779,6 +917,47 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ], + "non_wire_inputs": [ + "buys[].prior_flights" ] }, { @@ -907,6 +1086,47 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ], + "non_wire_inputs": [ + "buys[].prior_flights" ] }, { @@ -1013,6 +1233,26 @@ "period_start": "2026-11-04T00:00:00Z", "period_end": "2026-11-05T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1104,7 +1344,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "late authorization (authorized_at refinement)", @@ -1210,6 +1451,26 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1355,12 +1616,50 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } - ] - }, - { - "name": "new subscription version, misaligned boundaries", - "rule": "v1 resolves a UTC day offering, v2 (alignment account_timezone) a New York day offering. v1 owes UTC days starting before 11-04T12 (E = 11-05T00Z); v2 owes New York days starting at or after E, the first being 11-05T05Z. 11-05T00Z-05Z is owed under neither.", - "input": { + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "new subscription version, misaligned boundaries", + "rule": "v1 resolves a UTC day offering, v2 (alignment account_timezone) a New York day offering. v1 owes UTC days starting before 11-04T12 (E = 11-05T00Z); v2 owes New York days starting at or after E, the first being 11-05T05Z. 11-05T00Z-05Z is owed under neither.", + "input": { "subscription_id": "analytics-daily", "versions": [ { @@ -1531,6 +1830,44 @@ "period_start": "2026-11-06T05:00:00Z", "period_end": "2026-11-07T05:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1638,6 +1975,26 @@ "period_start": "2026-11-04T00:00:00Z", "period_end": "2026-11-05T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1764,6 +2121,44 @@ "period_start": "2026-11-08T00:00:00Z", "period_end": "2026-11-09T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1894,6 +2289,26 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -1988,7 +2403,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "operator_domains scope", @@ -2083,7 +2499,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "revocation", @@ -2190,6 +2607,26 @@ "period_start": "2026-11-04T00:00:00Z", "period_end": "2026-11-05T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -2308,6 +2745,35 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -2441,6 +2907,53 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -2560,15 +3073,44 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } - ] - }, - { - "name": "seller gaps also remove periods", - "rule": "A gaps[] entry the seller published removes the periods starting in [from, until), like an exception.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "seller gaps also remove periods", + "rule": "A gaps[] entry the seller published removes the periods starting in [from, until), like an exception.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { "subscription_version": 1, "accepted_at": "2026-11-01T12:00:00Z", "configuration": { @@ -2675,6 +3217,26 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -2822,6 +3384,26 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -2929,6 +3511,17 @@ "period_start": "2026-11-03T00:00:00Z", "period_end": "2026-11-04T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -3047,6 +3640,26 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -3264,6 +3877,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -3458,7 +4100,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "match exact on two dimensions", @@ -3676,6 +4319,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -3894,6 +4566,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -4108,6 +4809,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -4389,39 +5119,149 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } - ] - }, - { - "name": "product eligibility gates candidates", - "rule": "Only offerings in the product's reporting_delivery_offering_ids are candidates; a package with none leaves the buy covered by its other packages.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { - "subscription_version": 1, - "accepted_at": "2026-11-01T12:00:00Z", - "configuration": { - "subscription_id": "analytics-daily", - "subscription_version": 1, - "active": true, - "scope": { - "all_authorized_accounts": true - }, - "selector": { - "feed_purpose": "analytics", - "period_duration": "P1D", - "required_finality": "official", - "dimensions": [ - "package" - ] - }, - "reconciliation_mode": "delivery_only" - } - } - ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "product eligibility gates candidates", + "rule": "Only offerings in the product's reporting_delivery_offering_ids are candidates; a package with none leaves the buy covered by its other packages.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -4607,6 +5447,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -4819,6 +5688,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_d_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -4975,6 +5873,47 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ], + "non_wire_inputs": [ + "offerings[].available_from/withdrawn_at" ] }, { @@ -5088,23 +6027,52 @@ "period_start": "2026-11-05T05:00:00Z", "period_end": "2026-11-06T05:00:00Z" } - ] - }, - { - "name": "account timezone across a DST change", - "rule": "New York falls back on 2026-11-01: the 11-01 local day is 25 hours, 04:00Z to 05:00Z the next day.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { - "subscription_version": 1, - "accepted_at": "2026-10-30T00:00:00Z", - "configuration": { - "subscription_id": "analytics-daily", - "subscription_version": 1, - "active": true, - "scope": { - "all_authorized_accounts": true + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "account timezone across a DST change", + "rule": "New York falls back on 2026-11-01: the 11-01 local day is 25 hours, 04:00Z to 05:00Z the next day.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true }, "selector": { "feed_purpose": "analytics", @@ -5201,6 +6169,35 @@ "period_start": "2026-11-02T05:00:00Z", "period_end": "2026-11-03T05:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-10-31T04:00:00Z", + "period_end": "2026-11-01T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -5337,6 +6334,53 @@ "period_start": "2026-11-07T08:00:00Z", "period_end": "2026-11-08T08:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -5454,6 +6498,35 @@ "period_start": "2026-11-04T15:00:00Z", "period_end": "2026-11-05T15:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T15:00:00Z", + "period_end": "2026-11-03T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T15:00:00Z", + "period_end": "2026-11-04T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T15:00:00Z", + "period_end": "2026-11-05T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -5567,6 +6640,35 @@ "period_start": "2026-11-03T13:00:00Z", "period_end": "2026-11-03T14:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -5703,6 +6805,35 @@ "period_start": "2027-01-01T05:00:00Z", "period_end": "2027-02-01T05:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -5912,6 +7043,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -6254,44 +7414,100 @@ "period_start": "2026-11-05T08:00:00Z", "period_end": "2026-11-06T08:00:00Z" } - ] - }, - { - "name": "gap scoped to one offering", - "rule": "A gaps[] entry with offering_id removes only that offering's periods; an entry without offering_id applies to every offering.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { - "subscription_version": 1, - "accepted_at": "2026-11-01T12:00:00Z", - "configuration": { - "subscription_id": "analytics-daily", - "subscription_version": 1, - "active": true, - "scope": { - "all_authorized_accounts": true - }, - "selector": { - "feed_purpose": "analytics", - "period_duration": "P1D", - "required_finality": "official", - "dimensions": [ - "package" - ], - "match": "all" - }, - "reconciliation_mode": "delivery_only" - } - } - ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-03T08:00:00Z", + "period_end": "2026-11-04T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-04T08:00:00Z", + "period_end": "2026-11-05T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "gap scoped to one offering", + "rule": "A gaps[] entry with offering_id removes only that offering's periods; an entry without offering_id applies to every offering.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } ] }, "buys": [ @@ -6426,6 +7642,53 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -6641,6 +7904,35 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -6737,7 +8029,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "period_timezone matches a fixed source_timezone offering in the same zone", @@ -6848,6 +8141,26 @@ "period_start": "2026-11-04T05:00:00Z", "period_end": "2026-11-05T05:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } ] }, { @@ -6942,7 +8255,4488 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] + }, + { + "name": "empty flight: canceled before start_time", + "rule": "canceled_at 11-05T02 is before start_time 11-05T06: the flight [start, stop) is empty and overlaps nothing, though the 11-05 period contains both instants.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-05T06:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ], + "canceled_at": "2026-11-05T02:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "name": "empty flight: stop equal to start", + "rule": "A zero-length flight (end_time equal to start_time at 11-03T12) is empty and overlaps nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T12:00:00Z", + "end_time": "2026-11-03T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "name": "two cutovers inside one period", + "rule": "v2 (11-04T12Z) switches to a Tokyo period_timezone and v3 (11-04T13Z) changes nothing else. v1 owes its New York day starting 11-04T05Z, through 11-05T05Z. v2 owes from that E and starts nothing before 13Z. v3 must carry v2's own start forward: it owes Tokyo days from the first boundary at or after 11-05T05Z (11-05T15Z), never the overlapping 11-04T15Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "superseded_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "superseded_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 3, + "accepted_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 3, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo", + "match": "exact" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "simultaneous version and timezone change at the same T", + "rule": "v2 is accepted at 11-05T05Z, exactly the timezone cutover effective_at (a New York boundary). v1 owes New York days starting before it; v2 owes Los Angeles days from the first boundary at or after it (11-05T08Z).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T05:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": [ + "acct_other" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Los_Angeles", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "billing cycle anchored on the 31st clamps to month end", + "rule": "P1M from 2026-01-31 New York midnight: the 31st clamps to Feb 28 and returns to the 31st in March (a clamped February boundary does not shift the anchor day).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-01-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2025-12-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-01-20T00:00:00Z", + "start_time": "2026-02-10T00:00:00Z", + "end_time": "2026-04-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_m31" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m31", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m31", + "report_definition_uri": "https://seller.example/report-definitions/off_m31.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-31T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + } + ], + "now": "2026-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "billing cycle anchored on February 29", + "rule": "P1Y from 2024-02-29T00:00Z: non-leap years clamp to Feb 28 and 2028 returns to Feb 29.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-12-01T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1Y", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2025-11-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-12-15T00:00:00Z", + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2027-06-01T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_y29" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_y29", + "feed_purpose": "billing", + "report_definition_id": "rd_off_y29", + "report_definition_uri": "https://seller.example/report-definitions/off_y29.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1Y", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2024-02-29T00:00:00Z", + "period_timezone": "UTC" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + } + ], + "now": "2028-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "DST fold at midnight (America/Santiago)", + "rule": "Chile falls back at 24:00 on 2026-04-04 to 23:00, so April 4 is a 25-hour local day; local midnight April 5 occurs once, at 04:00Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Santiago", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-04-03T00:00:00Z", + "end_time": "2026-04-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_s_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "DST gap at midnight (America/Santiago)", + "rule": "Chile springs forward at 24:00 on 2026-09-05 to 01:00: local midnight September 6 does not exist and the boundary advances by the gap to 04:00Z, so September 6 is a 23-hour local day.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Santiago", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-09-05T12:00:00Z", + "end_time": "2026-09-07T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_s_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "zero-length period on a skipped civil date (Pacific/Apia)", + "rule": "Samoa skipped 2011-12-30: that local day has zero length (its midnight and the next coincide at 10:00Z) and is never owed; the surrounding days are.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2011-12-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "Pacific/Apia", + "grants": [ + { + "authorized_at": "2011-11-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2011-12-01T00:00:00Z", + "start_time": "2011-12-28T12:00:00Z", + "end_time": "2011-12-31T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2012-02-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "PT24H under a local alignment restarts at local midnight (#8588)", + "rule": "Under account_timezone a duration of only hours restarts at each local midnight (adcp#8588), so PT24H in New York across the 2026-03-08 spring-forward has a 23-hour period, exactly like P1D.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "PT24H", + "required_finality": "official", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-01T00:00:00Z", + "start_time": "2026-03-07T12:00:00Z", + "end_time": "2026-03-09T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_h24_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_h24_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_h24_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_h24_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT24H", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "PT24H and P1D under utc are the same days", + "rule": "UTC has no DST, so PT24H and P1D yield identical boundaries under utc alignment.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "PT24H", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_u24" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_u24", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_u24", + "report_definition_uri": "https://seller.example/report-definitions/off_u24.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT24H", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "sub-daily local periods start on the local hour (Asia/Singapore)", + "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z: first boundary 01:00Z (09:00 local).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "Asia/Singapore", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T00:05:00Z", + "start_time": "2026-11-03T00:10:00Z", + "end_time": "2026-11-03T02:30:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_p_hourly_local" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "account_timezone", + "delivery_sla": "PT1H" + }, + "supported_finality": [ + "snapshot" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "sub-daily local periods on a 25-hour day (America/New_York fall-back)", + "rule": "adcp#8588: the repeated 01:00 yields one boundary per offset, so 2026-11-01 in New York has 25 hourly periods.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": [ + "package" + ], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-11-01T04:00:00Z", + "end_time": "2026-11-02T05:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_p_hourly_local" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "account_timezone", + "delivery_sla": "PT1H" + }, + "supported_finality": [ + "snapshot" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "re-grant chain of three grants without backfill", + "rule": "Revoked 11-04T06 and re-granted 11-05T18, revoked 11-07T06 and re-granted 11-08T18, all inside retention: one chain with gaps [11-04, 11-06) and [11-07, 11-09).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-05T18:00:00Z", + "unauthorized_at": "2026-11-07T06:00:00Z" + }, + { + "authorized_at": "2026-11-08T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "re-grant chain of three grants with backfill_on_regrant", + "rule": "The same chain with backfill_on_regrant: no gap, every period owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-05T18:00:00Z", + "unauthorized_at": "2026-11-07T06:00:00Z" + }, + { + "authorized_at": "2026-11-08T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "re-grant seen only through the chain-start authorized_at", + "rule": "The wire keeps authorized_at at the chain start after a re-grant inside retention (adcp#8585), so the buyer lists the continuation with that value; the seller's gaps[] says which periods the interruption removed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "coverage": { + "gaps": [ + { + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-06T00:00:00Z", + "reason": "unauthorized" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "two authorization chains", + "rule": "Revoked 11-04T06; re-granted 11-10 after retention (1 day) expired, a new chain from 11-10; revoked again 11-12T06.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-10T00:00:00Z", + "unauthorized_at": "2026-11-12T06:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "match all adds an offering on a different alignment", + "rule": "v2 (11-04T12Z) moves from exact to all and adds a New York offering. The UTC offering continues (E = 11-05T00Z); the New York offering is new and starts at the latest outgoing end, so its first period is 11-05T05Z and 00Z-05Z is owed by neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_e_pkg_acct_tz" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "version change exactly on a boundary", + "rule": "v2 accepted at 11-05T00Z, itself a boundary: E is T, v1 owes 11-03 and 11-04, v2 from 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": [ + "acct_other" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "reactivation exactly on a boundary", + "rule": "Inactive [11-04T12, 11-06T00): 11-04 started before deactivation and stays owed; coverage resumes at 11-06 itself.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T00:00:00Z" + } + ] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "inactive interval spanning a version change", + "rule": "Deactivated under v1 at 11-04T12, v2 accepted while inactive at 11-05T12, reactivated 11-06T12: v1 owes 11-03 and 11-04, v2 starts fresh at 11-07.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": [ + "acct_other" + ] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-09T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T12:00:00Z" + } + ] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "exception bounds between boundaries", + "rule": "capacity [11-04T12, 11-06T12) removes the periods whose start is in it, 11-05 and 11-06; 11-04 started before from and stays owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T12:00:00Z", + "until": "2026-11-06T12:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "finest tie in byte order (B before a)", + "rule": "Two offerings tie at two mapped dimensions; byte order puts uppercase B (0x42) before lowercase a (0x61), unlike locale order.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "a", + "B" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "a", + "feed_purpose": "analytics", + "report_definition_id": "rd_a", + "report_definition_uri": "https://seller.example/report-definitions/a.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "B", + "feed_purpose": "analytics", + "report_definition_id": "rd_B", + "report_definition_uri": "https://seller.example/report-definitions/B.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "creative" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "unknown dimension values parse and match only where published", + "rule": "The dimension vocabulary is extensible: an offering may publish placement, and a selector naming it matches that offering. A value no offering publishes (audience_segment) matches nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package", + "placement" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_q_pkg_placement", + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_q_pkg_placement", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_q_pkg_placement", + "report_definition_uri": "https://seller.example/report-definitions/off_q_pkg_placement.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "placement" + ] + }, + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "an unknown selector dimension no offering publishes matches nothing", + "rule": "A selector naming a value the seller does not map matches no offering.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package", + "audience_segment" + ], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "creative" + ] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package", + "country" + ] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ], + "unmapped_dimensions": [ + "device" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] }, { "name": "numeric offset timezone is refused", diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index f9ac832db..d1b1e1c07 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -1153,14 +1153,29 @@ def test_regrant_exactly_at_retention_is_a_fresh_start(self) -> None: assert "2026-10-04T04:00:00Z" in [p.period_start for p in inside] assert "2026-10-04T04:00:00Z" not in [p.period_start for p in at_limit] - def test_overlapping_grants_are_rejected(self) -> None: + def test_a_grant_repeating_the_chain_start_continues_the_chain(self) -> None: + # adcp#8585: list_accounts keeps authorized_at at the chain start after + # a re-grant inside retention, so the re-grant instant is unknown and + # the seller's gaps[] decide what the interruption removed. + result = starts( + buys=[buy(end_time="2026-10-08T04:00:00Z")], + authorizations=[ + SubscriptionAuthorization(GRANT, at("2026-10-04T08:00:00Z")), + SubscriptionAuthorization(GRANT), + ], + gaps=[ + { + "from": "2026-10-04T04:00:00Z", + "until": "2026-10-06T04:00:00Z", + "reason": "unauthorized", + } + ], + ) + assert result == [*days("2026-10-01", 3), *days("2026-10-06", 2)] with pytest.raises(ValueError): run( buys=[buy()], - authorizations=[ - SubscriptionAuthorization(GRANT, at("2026-10-05T00:00:00Z")), - SubscriptionAuthorization(at("2026-10-04T00:00:00Z")), - ], + authorizations=[SubscriptionAuthorization(GRANT), SubscriptionAuthorization(GRANT)], ) diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py index ef983d37e..f422dc208 100644 --- a/tests/test_reporting_subscription_parity.py +++ b/tests/test_reporting_subscription_parity.py @@ -223,5 +223,11 @@ def test_every_subscription_in_the_table_is_schema_valid() -> None: ) for case in CASES: for version in case["input"]["versions"]: - errors = [error.message for error in validator.iter_errors(version["configuration"])] + errors = [ + error.message + for error in validator.iter_errors(version["configuration"]) + # The dimension vocabulary is x-extensible: receivers MUST + # accept values the beta.1 enum does not list. + if not (error.validator == "enum" and "dimensions" in list(error.absolute_path)) + ] assert errors == [], (case["name"], version["subscription_version"], errors) From 4688e835dab1c17ec395bda6a26a805c4aa07cf6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 19:47:00 +0000 Subject: [PATCH 8/8] fix(reporting): owe the period containing confirmed_at in full, per adcp#8593 Owner decision for 3.3.0-beta.2: - The start bound is the first boundary at or after the later of the version's accepted_at and the account's authorized_at. - A buy is then owed every period that ends after its confirmed_at, so the confirmation period is owed in full. - An empty flight still owes nothing. The rule lives in the one function that holds it, _first_owed_start. Re-vendor the TypeScript parity table at f8b48c0c. It has 81 cases, conformed to the same decision, and adds an ASAP buy confirmed mid-day and a monthly billing buy confirmed mid-month. All pass under the strict runner. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/subscriptions/coverage.py | 22 +- .../coverage-cases.json | 445 +++++++++++++++++- tests/test_reporting_subscription_coverage.py | 17 +- 3 files changed, 459 insertions(+), 25 deletions(-) diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index 43b7faa75..8d376204a 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -9,9 +9,10 @@ For each covered buy and resolved offering, the owed periods are the complete periods of the offering's published schedule that -1. start on or after the first boundary at or after the latest of the - subscription version's ``accepted_at``, the buy's ``confirmed_at`` and the - account's ``authorized_at``; and +1. start on or after the first boundary at or after the later of the + subscription version's ``accepted_at`` and the account's ``authorized_at``, + and end after the buy's ``confirmed_at`` (adcp#8593: the confirmation + period is owed in full); and 2. overlap the buy's flight from ``start_time`` to its stop time, the earliest of ``end_time``, ``canceled_at`` and ``completed_at``, with the flight taken as of the period's ``scope_resolved_at`` (its end). @@ -943,14 +944,15 @@ def _first_owed_start( ) -> datetime: """The earliest start a buy's owed period may have under one regime. - The coverage rule: the first boundary at or after the latest of the - regime's handoff (``accepted_at`` or E) and the buy's ``confirmed_at``. - ``authorized_at`` is applied by the authority chains. This is the one - place that decides whether the period containing ``confirmed_at`` is owed - (it is not, today), so an owner decision changing that rule changes only - this function. + adcp#8593 (owner decision for 3.3.0-beta.2): the start bound is the first + boundary at or after the later of the version's ``accepted_at`` (or the + cutover E, the regime's handoff) and the account's ``authorized_at``, + which the authority chains apply. A buy is then owed every period that + ends after its ``confirmed_at``, so the period containing the + confirmation is owed in full. This is the one place the confirmation rule + lives. """ - return calendar.ceil(max(handoff, confirmed_at)) + return max(calendar.ceil(handoff), calendar.floor(confirmed_at)) def _cutover_end(handoff: datetime, predecessor: _Regime | None, incoming: _Resolved) -> datetime: diff --git a/tests/fixtures/reporting_subscriptions/coverage-cases.json b/tests/fixtures/reporting_subscriptions/coverage-cases.json index ac9d7727e..34e0a2eab 100644 --- a/tests/fixtures/reporting_subscriptions/coverage-cases.json +++ b/tests/fixtures/reporting_subscriptions/coverage-cases.json @@ -1,10 +1,10 @@ { - "$source": "adcontextprotocol/adcp-client@a99597a46551c467ec58c68cfdb70d994e88a1a1:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585 and #8588 (sub-daily local periods). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$source": "adcontextprotocol/adcp-client@f8b48c0c8594944dd367b774585f685a47380d57:test/fixtures/reporting-subscriptions/coverage-cases.json (PR #3505)", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585, #8588 (sub-daily local periods) and #8593 (the start bound is the later of accepted_at and authorized_at; a buy is owed every period ending after its confirmed_at). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", - "rule": "Owed from the first boundary at or after max(accepted_at 11-01T12, confirmed_at 11-02T15, authorized_at 10-01) = 11-03, through the period containing the stop time.", + "rule": "adcp#8593: the start bound is the first boundary at or after max(accepted_at 11-01T12, authorized_at 10-01) = 11-02; owed are the periods from it that end after confirmed_at 11-02T15 and overlap the flight (11-03 on), through the period containing the stop time.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -146,7 +146,7 @@ }, { "name": "mid-period confirmed_at", - "rule": "confirmed_at 11-03T10 falls inside the first flight day: coverage starts at the next boundary, 11-04, though the flight began 11-03.", + "rule": "adcp#8593: confirmed_at 11-03T10 falls inside the first flight day. That period ends after confirmed_at, so it is owed in full; confirmed_at no longer moves the start to the next boundary.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -234,6 +234,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -250,6 +257,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_a_pkg", @@ -5918,7 +5934,7 @@ }, { "name": "account timezone", - "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). accepted_at 11-01T12Z: first boundary 11-02T05Z. The 11-02T05Z day contains confirmed_at 11-02T15Z and overlaps the flight from 11-03T00Z, so it is owed (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6006,6 +6022,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -6029,6 +6052,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_e_pkg_acct_tz", @@ -6385,7 +6417,7 @@ }, { "name": "period_timezone override", - "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary.", + "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary, so the period ending at it is not owed and the one starting at it is.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6531,7 +6563,7 @@ }, { "name": "hourly periods are never clipped", - "rule": "Flight 10:30-13:15, confirmed 10:20: coverage starts at 11:00 (the 10:00 period started before the confirmation boundary) and the 13:00 period containing the stop is owed in full.", + "rule": "Flight 10:30-13:15, confirmed 10:20: the 10:00 period ends after confirmed_at and overlaps the flight, so it is owed in full (adcp#8593), and the 13:00 period containing the stop is owed in full.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6619,6 +6651,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -6642,6 +6681,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_h_pkg_hourly", @@ -6836,6 +6884,313 @@ } ] }, + { + "name": "ASAP buy confirmed mid-day owes the confirmation day", + "rule": "adcp#8593: an ASAP buy starts when it is confirmed, 11-03T14:30Z. The 11-03 period ends after confirmed_at and overlaps the flight, so it is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T14:30:00Z", + "start_time": "2026-11-03T14:30:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "delivery_only", + "standard_dimensions": [ + "package" + ] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, + { + "name": "monthly billing buy confirmed on the 12th owes that month", + "rule": "adcp#8593: the version was accepted 10-15, so the start bound is the November boundary (11-01T04Z). The buy is confirmed and starts on 11-12; the November month ends after confirmed_at and overlaps the flight, so it is owed in full rather than starting in December.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": [ + "acct_1" + ] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": [ + "package" + ] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-12T15:00:00Z", + "start_time": "2026-11-12T15:00:00Z", + "end_time": "2027-01-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_m_billing_monthly" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m_billing_monthly", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m_billing_monthly", + "report_definition_uri": "https://seller.example/report-definitions/off_m_billing_monthly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": [ + "account_id", + "package_id" + ] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": [ + "official" + ], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": [ + "package" + ], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": [ + "existing" + ], + "formats": [ + "jsonl" + ] + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + } + ] + }, { "name": "destination selects managed offerings only", "rule": "With a destination only offerings whose method matches its pattern and transport are candidates; without one only Core (method-less) offerings are.", @@ -7076,7 +7431,7 @@ }, { "name": "multiple buys group into obligations", - "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids.", + "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids. mb_0, confirmed 11-03T12, is owed the 11-03 period it was confirmed in (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -7178,6 +7533,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -7214,6 +7576,7 @@ "period_start": "2026-11-03T00:00:00Z", "period_end": "2026-11-04T00:00:00Z", "media_buy_ids": [ + "mb_0", "mb_1" ] }, @@ -8127,6 +8490,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -8143,6 +8513,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_s_fixed", @@ -8589,6 +8968,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -8626,6 +9012,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_e_pkg_acct_tz", @@ -8799,6 +9194,13 @@ } }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -8836,6 +9238,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_e_pkg_acct_tz", @@ -9961,7 +10372,7 @@ }, { "name": "sub-daily local periods start on the local hour (Asia/Singapore)", - "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z: first boundary 01:00Z (09:00 local).", + "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z falls in the 00:00Z (08:00 local) hour, which ends after it and overlaps the flight, so it is owed (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -10050,6 +10461,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -10066,6 +10484,15 @@ } ], "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z", + "media_buy_ids": [ + "mb_1" + ] + }, { "subscription_version": 1, "offering_id": "off_p_hourly_local", diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index d1b1e1c07..cef1d43cd 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -230,9 +230,10 @@ def test_whole_flight_after_acceptance(self) -> None: assert first.expected_at == "2026-10-02T10:00:00Z" assert first.withheld is False - def test_mid_period_confirmation_starts_at_next_boundary(self) -> None: - # Confirmed 11:00 local on Oct 5 with a flight that began Oct 3: the - # Oct 5 local day started before confirmation and is never owed. + def test_mid_period_confirmation_owes_that_period_in_full(self) -> None: + # adcp#8593: confirmed 11:00 local on Oct 5, with a flight that began + # Oct 3. A buy is owed every period that ends after its confirmed_at, + # so the Oct 5 local day is owed in full; Oct 3 and 4 are not. result = starts( buys=[ buy( @@ -242,7 +243,7 @@ def test_mid_period_confirmation_starts_at_next_boundary(self) -> None: ) ] ) - assert result == days("2026-10-06", 2) + assert result == days("2026-10-05", 3) def test_confirmation_on_a_boundary_owes_that_period(self) -> None: result = starts( @@ -1322,11 +1323,15 @@ def test_explicit_windows_and_exception_values() -> None: def test_start_is_the_first_boundary_after_the_latest_of_three( confirmed: str, accepted: str, granted: str ) -> None: - latest = max(at(confirmed), at(accepted), at(granted)) + # adcp#8593: periods start at or after the later of accepted_at and + # authorized_at, and end after confirmed_at. + bound = max(at(accepted), at(granted)) expected = [ start for start in days("2026-09-30", 10) - if at(start) >= latest and at(start) < at("2026-10-08T04:00:00Z") + if at(start) >= bound + and at(start) + timedelta(days=1) > at(confirmed) + and at(start) < at("2026-10-08T04:00:00Z") ] result = starts( subscription=state(accepted),