From 97f555ac2ff556f5e943e9c285062cd00bbb1544 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 12:25:18 +0000 Subject: [PATCH 1/3] feat(reporting): conform subscription coverage to the AdCP 3.3.0-beta.3 rules Re-pin the cross-SDK parity test to the spec's coverage vectors at the signed 3.3.0-beta.3 source commit d5f8de383df1, which adcp#8617, #8618, #8619 and #8622 changed. All 120 cases pass with tzdata still pinned to 2026f, and each case's subscriptions, offerings and flight_history now validate against the 3.3.0-beta.3 schemas, as the spec's own vector test checks. The shared coverage rule, which the buyer and the seller engine both run: - Day start: under a local alignment each local date's first instant is a boundary, so a nonexistent midnight still starts the day, days nest in every sub-daily schedule, and PT24H equals P1D. - A sub-daily billing_cycle is refused as invalid_period_duration. - The cancellation's delivery stop is cancellation_effective_at when present. - Flights come from the buy's flight_history. - Resolution is per package and sticky: a package resolves at the later of the version's accepted_at and the buy's confirmed_at, and only a withdrawal of a resolved offering re-resolves it. - Reactivation resumes after the end of the last period owed before the deactivation, so a change made while inactive never owes twice. - A cutover onto a billing feed owes the stretch from E to the first incoming boundary as one stub period, flagged stub. Read the beta.3 wire: SubscriptionBuy.from_media_buy takes a get_media_buys buy with its per-package offering snapshot, flight_history and cancellation_effective_at, and SubscriptionOffering.from_offering reads available_from and withdrawn_at. The managed path uses both, and refuses an offering delivered to destinations whose primary_keys omit account_id. On the seller side, stub obligations keep the flag through every ledger store and serve stub true on core/reporting-obligation.json. list_accounts serves authorization.regranted_at from the grant journal, computed in the same write as authorized_at. A partial grant that keeps the account covered refuses a task it does not list with SCOPE_INSUFFICIENT instead of ACCOUNT_NOT_FOUND. Billing subscriptions must use match exact with dimensions. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reliable-reporting-service.md | 4 +- docs/reporting-subscriptions-coverage.md | 18 +- docs/reporting-subscriptions-seller.md | 6 +- src/adcp/reporting/advanced.py | 2 + src/adcp/reporting/ledger/models.py | 14 + src/adcp/reporting/ledger/status.py | 3 + src/adcp/reporting/service_subscriptions.py | 15 +- src/adcp/reporting/subscriptions/__init__.py | 2 + src/adcp/reporting/subscriptions/_clock.py | 15 +- src/adcp/reporting/subscriptions/coverage.py | 317 +++- src/adcp/reporting/subscriptions/engine.py | 22 +- src/adcp/reporting/subscriptions/ledger.py | 2 + src/adcp/reporting/subscriptions/managed.py | 78 +- src/adcp/reporting/subscriptions/registry.py | 13 +- .../reporting/subscriptions/resolution.py | 38 +- .../reporting_subscriptions/spec/README.md | 11 +- .../spec/case.schema.json | 46 +- .../reporting_subscriptions/spec/vectors.json | 1351 ++++++++++++++--- .../test_principal_reporting_subscriptions.py | 20 + ...test_reporting_subscription_beta3_rules.py | 267 ++++ tests/test_reporting_subscription_coverage.py | 11 +- ...st_reporting_subscription_grant_journal.py | 29 +- tests/test_reporting_subscription_grants.py | 23 +- tests/test_reporting_subscription_parity.py | 128 +- 24 files changed, 2084 insertions(+), 351 deletions(-) create mode 100644 tests/test_reporting_subscription_beta3_rules.py diff --git a/docs/reliable-reporting-service.md b/docs/reliable-reporting-service.md index 080cbb438..e0b7f8d80 100644 --- a/docs/reliable-reporting-service.md +++ b/docs/reliable-reporting-service.md @@ -582,7 +582,9 @@ grant's `authorization.allowed_tasks`. - `sync_reporting_status`; - `sync_reporting_receipts`. - A task the grant does not list fails with `ACCOUNT_NOT_FOUND`. + A task the grant does not list fails with `SCOPE_INSUFFICIENT` while the + grant keeps the account covered (adcp#8619), and with `ACCOUNT_NOT_FOUND` + once no covering grant is held. - There is no window after a loss of authority: a revoked account is unknown to every task, receipts and statuses included. - To let a buyer finish reconciliation, keep a reduced grant (for example diff --git a/docs/reporting-subscriptions-coverage.md b/docs/reporting-subscriptions-coverage.md index 7cfe02d9d..242dfe4e4 100644 --- a/docs/reporting-subscriptions-coverage.md +++ b/docs/reporting-subscriptions-coverage.md @@ -43,16 +43,28 @@ A period that ends at or before the buy's `confirmed_at` is not owed (`before_co ### Cancellation mid-period -A buy stops at the earliest of `end_time`, `canceled_at` and `completed_at`. A period that starts at or after the stop is not owed (`after_stop`). The period containing the stop is owed in full. +A buy stops at the earliest of `end_time`, the cancellation's delivery stop and `completed_at`. The delivery stop is `cancellation_effective_at` when the seller delivers through a notice period, else `cancellation.canceled_at` (3.3.0-beta.3). A period that starts at or after the stop is not owed (`after_stop`). The period containing the stop is owed in full. Flight values are read as of each period's end from the buy's `flight_history`, so a retroactive edit never makes an elapsed period owed. ### Re-grant gap -A re-grant inside `status_retention_days` continues the chain, so `authorized_at` is kept. Unless the seller advertises `backfill_on_regrant`, the periods from the last boundary at or before `unauthorized_at` to the first boundary at or after the re-grant are a gap (`gap:unauthorized`). +A re-grant inside `status_retention_days` continues the chain, so `authorized_at` is kept and `authorization.regranted_at` carries the re-grant. Unless the seller advertises `backfill_on_regrant`, the periods from the last boundary at or before `unauthorized_at` to the first boundary at or after `regranted_at` are a gap (`gap:unauthorized`). Withheld periods are due at `regranted_at` plus `delivery_sla`. ### Timezone cutover A timezone change takes effect at its `effective_at`, and each zone owes only its own span. A previous-zone period starting at or after `effective_at`, or a new-zone period starting before it, is not owed (`cutover_stretch`). +### Billing stub + +When a cutover hands a billing feed to a schedule with no boundary at E, the stretch from E to the first incoming boundary is owed as one stub period. Its obligation carries `stub: true` (`OwedSubscriptionPeriod.stub`, `ReportingObligationRecord.stub`). Pacing and analytics feeds leave that stretch unowed. + +### Sticky resolution + +A package resolves once, when its (buy, version) pair first does: at the later of the version's `accepted_at` and the buy's `confirmed_at`, among the offerings available then. An offering published later never re-resolves it. A resolved offering's `withdrawn_at` is the one change: the package re-resolves among the offerings available then, as a cutover. + +### Reactivation + +Coverage resumes at the first boundary at or after the later of the reactivation instant and the end of the last period owed before the deactivation, so a schedule change made while inactive never owes a period twice. + ### DST -`P1D` periods follow the local calendar, so DST days are 23 or 25 hours long. `PT24H` periods are always 24 hours. +`P1D` periods follow the local calendar, so DST days are 23 or 25 hours long. Under a local alignment each local date's first instant is always a boundary: local midnight, or the first instant after a DST gap at midnight (Havana, Santiago, Cairo). Days therefore nest in every sub-daily schedule, and `PT24H` equals `P1D`. Under `utc`, `PT24H` periods are always 24 hours. A sub-daily `billing_cycle` is refused (`invalid_period_duration`). diff --git a/docs/reporting-subscriptions-seller.md b/docs/reporting-subscriptions-seller.md index fecdad747..ffc2dfb14 100644 --- a/docs/reporting-subscriptions-seller.md +++ b/docs/reporting-subscriptions-seller.md @@ -24,9 +24,9 @@ The SDK reads these surfaces under each principal's identity. A principal sees e | Input | Read from | Must be present | Override | |---|---|---|---| | Authorized accounts | `list_accounts` (a decisioning platform's `accounts.list`) | `account_id`, `timezone` (IANA), `operator`, `authorization.allowed_tasks` | `SubscriptionOptions.accounts` | -| Visible buys | `get_media_buys` | `confirmed_at`, `start_time`, `end_time`, `cancellation.canceled_at`, `completed_at`, `packages[].package_id` | `SubscriptionOptions.buys` | -| Package offerings | every registered offering | | `SubscriptionOptions.buy_offerings` | -| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering | none | +| Visible buys | `get_media_buys` | `confirmed_at`, `start_time`, `end_time`, `cancellation.canceled_at`, `cancellation_effective_at`, `completed_at`, `flight_history`, `packages[].package_id` | `SubscriptionOptions.buys` | +| Package offerings | `packages[].reporting_delivery_offering_ids` (fixed at confirmation), else every registered offering | | `SubscriptionOptions.buy_offerings` | +| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering; `available_from` / `withdrawn_at` when they apply; `account_id` in `reporting_profile.primary_keys` on every offering delivered to destinations (`method`) | none | | Report content | `account_context`, called with `configuration.subscription` set | | `SubscriptionOptions.configure` | A decisioning `Account` carries `timezone=` and `operator=`. An account without a timezone is refused rather than read as UTC. diff --git a/src/adcp/reporting/advanced.py b/src/adcp/reporting/advanced.py index 3f44b8b3d..00564508e 100644 --- a/src/adcp/reporting/advanced.py +++ b/src/adcp/reporting/advanced.py @@ -45,6 +45,7 @@ SubscriptionException, SubscriptionVersionWindow, authorization_chain_start, + authorization_regranted_at, expected_subscription_periods, owed_subscription_periods, subscription_covers_account, @@ -128,6 +129,7 @@ "SubscriptionException", "SubscriptionVersionWindow", "authorization_chain_start", + "authorization_regranted_at", "expected_subscription_periods", "owed_subscription_periods", "subscription_covers_account", diff --git a/src/adcp/reporting/ledger/models.py b/src/adcp/reporting/ledger/models.py index 969474a68..b522f4894 100644 --- a/src/adcp/reporting/ledger/models.py +++ b/src/adcp/reporting/ledger/models.py @@ -484,6 +484,11 @@ def generation_key(self) -> ReportingConfigurationGenerationKey: ) +#: ``period_key`` suffix of a billing cutover stub obligation (adcp#8617). The +#: flag rides on the persisted key, so every store keeps it without a column. +STUB_PERIOD_KEY_SUFFIX = "_stub" + + @dataclass(frozen=True) class ReportingObligationRecord: """What *should* exist for one configuration generation and period. @@ -529,6 +534,15 @@ def generation_key(self) -> ReportingConfigurationGenerationKey: offering_id=self.offering_id, ) + @property + def stub(self) -> bool: + """A billing cutover stub: ``[E, first incoming boundary)``, off the schedule's grid. + + ``core/reporting-obligation.json`` ``stub`` (adcp#8617). It is + otherwise an ordinary obligation. + """ + return self.period.period_key.endswith(STUB_PERIOD_KEY_SUFFIX) + def __post_init__(self) -> None: principal_reference(self.account_id) consumer_reference(self.consumer_id) diff --git a/src/adcp/reporting/ledger/status.py b/src/adcp/reporting/ledger/status.py index ba4a0103d..98d991aa6 100644 --- a/src/adcp/reporting/ledger/status.py +++ b/src/adcp/reporting/ledger/status.py @@ -850,6 +850,9 @@ def _obligation_to_wire( # 3.3 reporting-obligation.json: the subscription's resolved offering, # authoritative for the obligation's timing. payload["offering_id"] = obligation.offering_id + if obligation.stub: + # adcp#8617: a billing cutover stub, present and true only on one. + payload["stub"] = True if statuses: current = next((item for item in statuses if not item.superseded), None) payload["consumer_status_count"] = len(statuses) diff --git a/src/adcp/reporting/service_subscriptions.py b/src/adcp/reporting/service_subscriptions.py index 35b85aea1..897686915 100644 --- a/src/adcp/reporting/service_subscriptions.py +++ b/src/adcp/reporting/service_subscriptions.py @@ -223,9 +223,12 @@ async def require_subscription_grant( Applies to a caller holding subscription generations on the account. Access means access: there is no window after a loss of authority, so a revoked - account is unknown to every task, receipts and statuses included. A seller - that wants a buyer to finish reconciliation keeps a reduced grant listing - the reconciliation tasks instead of revoking. + account is unknown to every task (``ACCOUNT_NOT_FOUND``), receipts and + statuses included. A seller that wants a buyer to finish reconciliation + keeps a reduced grant listing the reconciliation tasks instead of + revoking. A partial grant that keeps the account covered refuses a task it + does not list with ``SCOPE_INSUFFICIENT``, not as an unknown identifier + (adcp#8619). """ from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal from adcp.reporting.ledger.store import LedgerConflictError @@ -241,6 +244,12 @@ async def require_subscription_grant( "resolve_principal and the caller resolver disagree on the requesting principal" ) if not await options.engine.grants_task(principal, account_id, task): + holds_grant = getattr(options.engine, "holds_grant", None) + if holds_grant is not None and await holds_grant(principal, account_id): + raise LedgerConflictError( + "SCOPE_INSUFFICIENT", + f"this caller's grant on the account does not list {task}", + ) raise LedgerConflictError( "ACCOUNT_NOT_FOUND", "the account is not available to this caller" ) diff --git a/src/adcp/reporting/subscriptions/__init__.py b/src/adcp/reporting/subscriptions/__init__.py index a1407a023..db577c844 100644 --- a/src/adcp/reporting/subscriptions/__init__.py +++ b/src/adcp/reporting/subscriptions/__init__.py @@ -56,6 +56,7 @@ SubscriptionException, SubscriptionVersionWindow, authorization_chain_start, + authorization_regranted_at, expected_subscription_periods, owed_subscription_periods, subscription_covers_account, @@ -118,6 +119,7 @@ __all__ = [ "REPORTING_WIND_DOWN_TASKS", "authorization_chain_start", + "authorization_regranted_at", "wind_down_grant", "COVERAGE_REASON_CODES", "COVERAGE_RULE_ORDER", diff --git a/src/adcp/reporting/subscriptions/_clock.py b/src/adcp/reporting/subscriptions/_clock.py index 50b86d3ec..ff1b95401 100644 --- a/src/adcp/reporting/subscriptions/_clock.py +++ b/src/adcp/reporting/subscriptions/_clock.py @@ -25,6 +25,12 @@ 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. +* Day start (3.3.0-beta.3, adcp#8622): each local date's first instant is + always a boundary of a local schedule, local midnight or the first instant + after a DST gap at midnight (Havana, Santiago, Cairo). Days therefore nest + in every sub-daily schedule and ``PT24H`` equals ``P1D``. +* ``billing_cycle`` needs a date component: a sub-daily duration would restart + at each local midnight and ignore its anchor, so it is refused. The ledger's ``ReportingScheduleSpec`` treats every component as civil time and defaults an anchorless civil schedule to the UTC epoch in local time; this @@ -254,10 +260,15 @@ def _subdaily_local(self) -> bool: 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.""" + """Every boundary on one local date: its day start, then wall-clock multiples + of the duration after local midnight. + + The day start is local midnight, or the first instant after a DST gap + at midnight, so a nonexistent midnight still starts the day. + """ step = self._parts[3] - found: set[datetime] = set() midnight = datetime.combine(day, time()) + found: set[datetime] = {_resolve(midnight, self._zone)} for index in range(86400 // step): wall = midnight + timedelta(seconds=index * step) for fold in (0, 1): diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index bdaa4457e..121b88ccf 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -14,8 +14,10 @@ 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). + of ``end_time``, the cancellation's delivery stop (``cancellation_effective_at``, + or ``cancellation.canceled_at`` when it is absent) and ``completed_at``, with + the flight taken as of the period's ``scope_resolved_at`` (its end) from the + buy's ``flight_history``. 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 @@ -23,6 +25,9 @@ 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. +When the incoming offering is a billing feed and E is not one of its +boundaries, the stretch from E to its first boundary is owed as one stub +period (``stub``), so no billable time goes unreported (adcp#8617). Exceptions only remove periods: each ``excluded[]`` entry and each ``gaps[]`` entry removes the periods whose start falls in ``[from, until)``. Lost @@ -42,10 +47,22 @@ 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. + +Rules settled for 3.3.0-beta.3 (adcp#8617, #8618, #8619, #8622) + Resolution is per package and sticky: a package resolves when its (buy, + version) pair first does, at the later of the version's ``accepted_at`` + and the buy's ``confirmed_at``, among the offerings available then, and + keeps that result until a resolved offering is withdrawn, when it + re-resolves among the offerings available at ``withdrawn_at``. A newly + available offering never re-resolves it. Reactivation resumes at the first + boundary at or after the later of the reactivation instant and the end of + the last period owed before the deactivation, so a change made while + inactive never owes a period twice. """ from __future__ import annotations +import itertools from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace from datetime import datetime, timedelta @@ -77,7 +94,16 @@ 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")) + from adcp.reporting._timestamp import aware_timestamp + + # Wire instants carry 1-6 fractional digits, which Python 3.10's ISO + # parser refuses. + try: + value = aware_timestamp(value) + except ValueError as error: + raise CoverageInputError( + "invalid_instant", f"{field} {value!r} is not an RFC 3339 instant" + ) from error if not isinstance(value, datetime): raise TypeError(f"{field} must be a datetime or an RFC 3339 string") return utc_instant(value, field=field) @@ -105,7 +131,8 @@ class SubscriptionBuyFlight: 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. + an elapsed period owed after the fact. Build it from a ``get_media_buys`` + ``flight_history[]`` entry with :meth:`from_flight_history`. """ superseded_at: datetime @@ -124,6 +151,25 @@ def __post_init__(self) -> None: def stop_time(self) -> datetime: return _stop(self.end_time, self.canceled_at, self.completed_at) + @classmethod + def from_flight_history(cls, entry: Any) -> SubscriptionBuyFlight: + """One ``core/media-buy.json`` ``flight_history[]`` entry (adcp#8618). + + The entry records a retroactive flight edit at ``recorded_at`` with the + ``start_time`` and ``stop_time`` in force before it. + """ + if hasattr(entry, "model_dump"): + entry = entry.model_dump(mode="json", exclude_none=True) + return cls( + superseded_at=_require_instant( + _value(entry, "recorded_at"), field="flight_history.recorded_at" + ), + start_time=_require_instant( + _value(entry, "start_time"), field="flight_history.start_time" + ), + end_time=_require_instant(_value(entry, "stop_time"), field="flight_history.stop_time"), + ) + def _stop( end_time: datetime, canceled_at: datetime | None, completed_at: datetime | None @@ -151,9 +197,14 @@ 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. + records earlier flights and when a change replaced each (the buy's + ``flight_history``). ``cancellation_effective_at`` is when delivery stops + under a cancellation (adcp#8617); when present it, not ``canceled_at``, + is the cancellation's term in the stop time. Each package carries the + offering set fixed at confirmation (``packages[].reporting_delivery_offering_ids``, + adcp#8618). Without ``packages``, ``reporting_delivery_offering_ids`` is the + buy's products' offering ids, resolved as one product. Build it from a + ``get_media_buys`` media buy with :meth:`from_media_buy`. """ media_buy_id: str @@ -165,11 +216,19 @@ class SubscriptionBuy: packages: tuple[SubscriptionBuyPackage, ...] = () reporting_delivery_offering_ids: tuple[str, ...] = () superseded_flights: tuple[SubscriptionBuyFlight, ...] = () + cancellation_effective_at: datetime | None = None def __post_init__(self) -> None: if not self.media_buy_id: raise CoverageInputError("invalid_input", "media_buy_id is required") - for name in ("confirmed_at", "start_time", "end_time", "canceled_at", "completed_at"): + for name in ( + "confirmed_at", + "start_time", + "end_time", + "canceled_at", + "completed_at", + "cancellation_effective_at", + ): value = getattr(self, name) if value is not None: object.__setattr__(self, name, utc_instant(value, field=name)) @@ -182,7 +241,59 @@ def __post_init__(self) -> None: @property def stop_time(self) -> datetime: - return _stop(self.end_time, self.canceled_at, self.completed_at) + delivery_stop = ( + self.cancellation_effective_at + if self.cancellation_effective_at is not None + else self.canceled_at + ) + return _stop(self.end_time, delivery_stop, self.completed_at) + + @classmethod + def from_media_buy( + cls, + media_buy: Any, + *, + package_offering_ids: Mapping[str, Sequence[str]] | None = None, + ) -> SubscriptionBuy: + """Project a ``get_media_buys`` media buy (model or wire mapping). + + Reads ``confirmed_at``, the flight, ``cancellation.canceled_at``, + ``cancellation_effective_at``, ``completed_at``, ``flight_history[]`` + and each package's ``reporting_delivery_offering_ids``. + ``package_offering_ids`` supplies a package's offering set when the + seller's response does not carry it. + """ + if hasattr(media_buy, "model_dump"): + media_buy = media_buy.model_dump(mode="json", exclude_none=True) + if not isinstance(media_buy, Mapping): + raise TypeError("media_buy must be a MediaBuy or a mapping") + overrides = {str(key): tuple(value) for key, value in (package_offering_ids or {}).items()} + packages = [] + for package in _value(media_buy, "packages") or (): + package_id = _text(_value(package, "package_id", "")) + offering_ids = _value(package, "reporting_delivery_offering_ids") + if offering_ids is None: + offering_ids = overrides.get(package_id, ()) + packages.append( + SubscriptionBuyPackage(package_id, tuple(_text(item) for item in offering_ids)) + ) + cancellation = _value(media_buy, "cancellation") or {} + return cls( + media_buy_id=_text(_value(media_buy, "media_buy_id", "")), + confirmed_at=_instant(_value(media_buy, "confirmed_at"), field="confirmed_at"), + start_time=_require_instant(_value(media_buy, "start_time"), field="start_time"), + end_time=_require_instant(_value(media_buy, "end_time"), field="end_time"), + canceled_at=_instant(_value(cancellation, "canceled_at"), field="canceled_at"), + cancellation_effective_at=_instant( + _value(media_buy, "cancellation_effective_at"), field="cancellation_effective_at" + ), + completed_at=_instant(_value(media_buy, "completed_at"), field="completed_at"), + packages=tuple(packages), + superseded_flights=tuple( + SubscriptionBuyFlight.from_flight_history(entry) + for entry in _value(media_buy, "flight_history") or () + ), + ) def flight_at(self, resolved_at: datetime) -> tuple[datetime, datetime]: """``(start_time, stop_time)`` as of a period's ``scope_resolved_at``. @@ -521,14 +632,20 @@ class OwedSubscriptionPeriod(ExpectedReportingPeriod): ``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``. + re-grant it is due at the re-grant time (``authorization.regranted_at``) + plus the offering's ``delivery_sla``, never earlier than its original + ``expected_at``. + + ``stub`` marks a billing cutover stub (``core/reporting-obligation.json`` + ``stub``, adcp#8617): the stretch from a cutover's E to the incoming billing + schedule's first boundary, owed as one period. """ account_id: str subscription_id: str offering_id: str withheld: bool = False + stub: bool = False # -- the rule ---------------------------------------------------------------- @@ -554,6 +671,9 @@ class _Regime: #: 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) + #: Whether this regime took over from one that resolved offerings: a + #: cutover, after which a billing offering owes a stub (adcp#8617). + cutover: bool = False @dataclass @@ -578,6 +698,7 @@ class _Owed: end: datetime expected_at: datetime | None withheld: bool + stub: bool = False media_buy_ids: set[str] = field(default_factory=set) @@ -597,6 +718,23 @@ def authorization_chain_start( return chains[-1].start +def authorization_regranted_at( + authorizations: Sequence[SubscriptionAuthorization], *, status_retention_days: int +) -> datetime | None: + """The ``authorization.regranted_at`` a seller serves for a grant history (adcp#8618). + + The latest re-grant within the current authority chain: withheld periods + are due at it plus ``delivery_sla``, and an unbackfilled gap ends at the + first boundary at or after it. ``None`` while the caller holds no grant or + the chain was never interrupted. + """ + if not authorizations or authorizations[-1].unauthorized_at is not None: + return None + chains = _chains(authorizations, timedelta(days=status_retention_days)) + regrants = [regranted for _lost, regranted in chains[-1].interruptions if regranted] + return max(regrants) if regrants else None + + def _chains( authorizations: Sequence[SubscriptionAuthorization], retention: timedelta | None ) -> list[_Chain]: @@ -630,18 +768,25 @@ def _chains( return chains -def _authorized( - chains: Sequence[_Chain], calendar: PeriodCalendar, start: datetime, backfill: bool -) -> bool: +def _authorized(chains: Sequence[_Chain], start: datetime, end: datetime, backfill: bool) -> bool: + """Whether the period ``[start, end)`` falls under the caller's authority. + + It starts at or after a chain's start (the first boundary at or after + ``authorized_at``), ends by the chain's end (nothing is owed for periods + ending after ``unauthorized_at``), and, without backfill, does not overlap + an interruption: the periods that end after ``unauthorized_at`` and start + before the re-grant are a gap. Stated on the period, not on a calendar, so + a billing stub is judged like any other period. + """ if not chains: return True # authorized_at is not advertised and authority never lapsed for chain in chains: - if start < calendar.ceil(chain.start): + if start < chain.start: continue - if chain.end is not None and start >= calendar.floor(chain.end): + if chain.end is not None and end > chain.end: continue if not backfill and any( - calendar.floor(lost) <= start < calendar.ceil(regranted) + end > lost and start < regranted for lost, regranted in chain.interruptions if regranted is not None ): @@ -657,18 +802,23 @@ def _expected_at( end: datetime, backfill: bool, ) -> tuple[datetime | None, bool]: - """When a period is due, and whether it is withheld for lost authority.""" + """When a period is due, and whether it is withheld for lost authority. + + A period owed before a loss and not yet due is due at the re-grant + (``authorization.regranted_at``) plus ``delivery_sla``; so is a period + backfilled across the interruption. + """ sla = calendar.delivery_sla 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) + pending = end <= lost and expected > lost + backfilled = backfill and end > lost and start < 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: + if chain.end is not None and end <= chain.end and expected > chain.end: return None, True return expected, False @@ -850,6 +1000,7 @@ def owed_subscription_periods( for buy in buys: if buy.confirmed_at is None: continue + confirmed_at = buy.confirmed_at regimes = _regimes( buy, ordered, @@ -876,9 +1027,16 @@ def owed_subscription_periods( 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: + candidates: Iterable[tuple[datetime, datetime, bool]] = ( + (start, end, False) + for start, end in calendar.periods(starts_from=lower, starts_before=upper) + ) + stub = _billing_stub(regime, resolved, handoff) + if stub is not None: + candidates = itertools.chain((stub,), candidates) + elif lower >= upper: continue - for start, end in calendar.periods(starts_from=lower, starts_before=upper): + for start, end, is_stub in candidates: iterations += 1 if iterations > max_iterations: raise CoverageInputError.budget_exceeded( @@ -889,6 +1047,10 @@ def owed_subscription_periods( ) if not include_unresolved and end > now: break + if is_stub and ( + end <= confirmed_at or (regime.until is not None and start >= regime.until) + ): + continue # the stub's own side and confirmation checks flight_start, flight_stop = buy.flight_at(end) # An empty flight (stopped at or before it started) # overlaps no period. @@ -896,7 +1058,7 @@ def owed_subscription_periods( start < flight_stop and end > flight_start ): continue - if not _authorized(chains, calendar, start, backfill_on_regrant): + if not _authorized(chains, start, end, backfill_on_regrant): continue if any( item.removes(start, buy.media_buy_id, resolved.offering.offering_id) @@ -917,6 +1079,7 @@ def owed_subscription_periods( end, expected_at, withheld, + is_stub, ) if buy.media_buy_id not in entry.media_buy_ids: entry.media_buy_ids.add(buy.media_buy_id) @@ -945,6 +1108,7 @@ def owed_subscription_periods( subscription_id=subscription_id, offering_id=item.offering.offering_id, withheld=item.withheld, + stub=item.stub, ) for item in owed.values() ] @@ -988,16 +1152,16 @@ def _regimes( f"{selector.destination_id!r}; pass its pattern and transport", ) destination = destinations[selector.destination_id] + # adcp#8619: each package resolves when its (buy, version) pair first + # does and keeps that result; a buy owes the union of its packages'. + first_resolved = window.accepted_at + if buy.confirmed_at is not None: + first_resolved = max(first_resolved, buy.confirmed_at) 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: + for item in _sticky_resolution( + selector, offerings, offering_ids, destination, first_resolved, start + ): found[item.offering_id] = item resolved = tuple( _Resolved( @@ -1019,13 +1183,95 @@ def _regimes( continue 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 - } + before_deactivation = ( + _regime_before_deactivation(regimes, window, start) if predecessor is None else None + ) + if before_deactivation is not None: + deactivated_at, last = before_deactivation + # Reactivation (adcp#8622): resume at the first boundary at or after + # the later of the reactivation instant and the end of the last + # period owed before the deactivation, computed like a cutover's E + # at the deactivation, so nothing is owed twice. + regime.handoffs = { + item.offering.offering_id: max(start, _cutover_end(deactivated_at, last, item)) + for item in resolved + } + else: + regime.handoffs = { + item.offering.offering_id: _cutover_end(start, predecessor, item) + for item in resolved + } + regime.cutover = predecessor is not None and bool(predecessor.resolved) regimes.append(regime) return regimes +def _sticky_resolution( + selector: SubscriptionSelector, + offerings: Sequence[SubscriptionOffering], + offering_ids: Sequence[str], + destination: Any, + first_resolved: datetime, + at: datetime, +) -> tuple[SubscriptionOffering, ...]: + """The offerings one package resolves to at ``at`` (adcp#8619). + + The package resolves once, at ``first_resolved``, among the offerings + available then; an offering added later never re-resolves it. Withdrawal of + a resolved offering is the one change: the package re-resolves among the + offerings available at ``withdrawn_at``. + """ + resolved_at = first_resolved + while True: + chosen = resolve_subscription_offerings( + selector, + offerings, + product_offering_ids=offering_ids, + destination=destination, + at=resolved_at, + ).offerings + withdrawals = [ + item.withdrawn_at + for item in chosen + if item.withdrawn_at is not None and resolved_at < item.withdrawn_at <= at + ] + if not withdrawals: + return chosen + resolved_at = min(withdrawals) + + +def _regime_before_deactivation( + regimes: Sequence[_Regime | None], window: SubscriptionVersionWindow, start: datetime +) -> tuple[datetime, _Regime] | None: + """For a reactivation at ``start``: the deactivation instant and the regime in force + just before it, when that regime resolved offerings.""" + deactivated_at = next( + (begin for begin, until in window.inactive_intervals if until == start), None + ) + if deactivated_at is None: + return None + last = next((item for item in reversed(regimes) if item is not None), None) + if last is None or not last.resolved or last.until != deactivated_at: + return None + return deactivated_at, last + + +def _billing_stub( + regime: _Regime, resolved: _Resolved, handoff: datetime +) -> tuple[datetime, datetime, bool] | None: + """The stub ``[E, first incoming boundary)`` a cutover owes a billing feed (adcp#8617). + + Pacing and analytics feeds leave that stretch unowed; it is empty when E + is already an incoming boundary. + """ + if not regime.cutover or resolved.offering.feed_purpose != "billing": + return None + first = resolved.calendar.ceil(handoff) + if first <= handoff: + return None + return handoff, first, True + + def _first_owed_start( calendar: PeriodCalendar, handoff: datetime, confirmed_at: datetime ) -> datetime: @@ -1111,6 +1357,7 @@ def expected_subscription_periods(**kwargs: Any) -> list[OwedSubscriptionPeriod] "SubscriptionException", "SubscriptionVersionWindow", "authorization_chain_start", + "authorization_regranted_at", "expected_subscription_periods", "owed_subscription_periods", "subscription_covers_account", diff --git a/src/adcp/reporting/subscriptions/engine.py b/src/adcp/reporting/subscriptions/engine.py index 45672aded..68c55d5d2 100644 --- a/src/adcp/reporting/subscriptions/engine.py +++ b/src/adcp/reporting/subscriptions/engine.py @@ -53,6 +53,7 @@ SubscriptionException, SubscriptionVersionWindow, authorization_chain_start, + authorization_regranted_at, owed_subscription_periods, subscription_covers_account, subscription_version_windows, @@ -889,11 +890,14 @@ async def observe_grants( observations: Sequence[GrantObservation], *, at: datetime | None = None, + regrants: dict[str, datetime | None] | None = None, ) -> dict[str, datetime | None]: """:meth:`observe_grant` for a whole response page, in one state write. Returns ``account_id`` -> the chain start to serve (``None`` without a - grant). One load and at most one compare-and-swap per call. + grant). One load and at most one compare-and-swap per call. Pass + ``regrants`` to receive each account's ``authorization.regranted_at`` + (adcp#8618) from the same write. """ instant = self._now(at) prepared = [] @@ -931,12 +935,18 @@ def change(state: _PrincipalState) -> dict[str, datetime | None]: if item.operator is not None: account.operator = item.operator served[item.account_id] = self._chain_start(account) + latest[item.account_id] = authorization_regranted_at( + account.grants, status_retention_days=self._retention_days + ) return served + latest: dict[str, datetime | None] = {} if not prepared: return {} served = await self._mutate(principal.subject, change, now=instant) self._report_regressions(regressions) + if regrants is not None: + regrants.update(latest) return served async def refresh(self, principal: PrincipalLike, *, now: datetime | None = None) -> None: @@ -2049,6 +2059,16 @@ async def grants_task(self, identity: PrincipalLike, account_id: str, task: str) account = (await self._read(identity.subject)).accounts.get(account_id) return account is not None and account.grants_task(task) + async def holds_grant(self, identity: PrincipalLike, account_id: str) -> bool: + """Whether the principal holds a covering grant on the account, whatever its tasks. + + A grant that lists ``get_reporting_status`` keeps the account covered; + a task it does not list is refused with ``SCOPE_INSUFFICIENT`` + (adcp#8619), while an account without such a grant is unknown. + """ + account = (await self._read(identity.subject)).accounts.get(account_id) + return account is not None and account.authorized + async def authorizes_delivery(self, principal: PrincipalLike, account_id: str) -> bool: """Whether a principal's current grant still carries its subscriptions on the account. diff --git a/src/adcp/reporting/subscriptions/ledger.py b/src/adcp/reporting/subscriptions/ledger.py index e2e16a209..66ab3bc55 100644 --- a/src/adcp/reporting/subscriptions/ledger.py +++ b/src/adcp/reporting/subscriptions/ledger.py @@ -38,6 +38,7 @@ from adcp.reporting.currency import validate_currency from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal from adcp.reporting.ledger.models import ( + STUB_PERIOD_KEY_SUFFIX, ReportingConfiguration, ReportingConfigurationGenerationKey, ReportingDefinitionBinding, @@ -319,6 +320,7 @@ async def plan( period=ReportingPeriodBoundary( period_key=( f"{start.strftime('%Y-%m-%dT%H:%M:%SZ')}_{offering.period_duration}" + + (STUB_PERIOD_KEY_SUFFIX if period.stub else "") ), start=start, end=end, diff --git a/src/adcp/reporting/subscriptions/managed.py b/src/adcp/reporting/subscriptions/managed.py index e72633e8c..8fbc26d1e 100644 --- a/src/adcp/reporting/subscriptions/managed.py +++ b/src/adcp/reporting/subscriptions/managed.py @@ -34,14 +34,14 @@ import inspect import logging from collections.abc import Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any from adcp._principal_ref import PrincipalRef from adcp.reporting._errors import ReliableReportingConfigurationError from adcp.reporting.subscriptions._clock import utc_instant -from adcp.reporting.subscriptions.coverage import SubscriptionBuy, SubscriptionBuyPackage +from adcp.reporting.subscriptions.coverage import SubscriptionBuy from adcp.reporting.subscriptions.engine import ( AccountConfigsEnder, AccountDeliveryConfig, @@ -618,6 +618,17 @@ def _offerings( code="SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS", config_field="capability_offerings", ) + profile = offering.get("reporting_profile") + keys = profile.get("primary_keys") if isinstance(profile, Mapping) else None + if offering.get("method") is not None and "account_id" not in (keys or ()): + # adcp#8619: consolidated destinations validate and never + # collide only when every delivered row carries its account. + raise ReliableReportingConfigurationError( + f"offering {offering_id!r} is delivered to destinations, so its row " + "schema must declare account_id as a required string and list it in " + "reporting_profile.primary_keys.", + config_field="capability_offerings", + ) try: mapped.append(SubscriptionOffering.from_offering(offering)) except (CoverageInputError, KeyError, ValueError) as error: @@ -966,36 +977,38 @@ async def _platform_buys( raise RuntimeError(f"get_media_buys for account {account_id!r} did not finish paging") def _buy(self, raw: Mapping[str, Any]) -> SubscriptionBuy | None: - start, end = _instant(raw.get("start_time")), _instant(raw.get("end_time")) - if start is None or end is None: + """One ``get_media_buys`` buy as coverage reads it (adcp#8617, #8618). + + The flight, ``cancellation_effective_at`` and ``flight_history`` come + from the wire. Each package's offering set is ``buy_offerings`` when + given, else the package's ``reporting_delivery_offering_ids`` (fixed at + confirmation), else every registered offering. + """ + if raw.get("start_time") is None or raw.get("end_time") is None: return None # not yet flighted: nothing can be owed for it - cancellation = raw.get("cancellation") or {} + every = tuple(item.offering_id for item in self.engine.offerings) + media_buy = dict(raw) offerings = self.options.buy_offerings if offerings is not None: - by_package = {str(key): tuple(value) for key, value in offerings(raw).items()} + media_buy["packages"] = [ + {"package_id": str(key), "reporting_delivery_offering_ids": list(value)} + for key, value in sorted(offerings(raw).items()) + ] else: - every = tuple(item.offering_id for item in self.engine.offerings) - by_package = { - str(package["package_id"]): every + media_buy["packages"] = [ + { + **package, + "reporting_delivery_offering_ids": list( + package.get("reporting_delivery_offering_ids") or every + ), + } for package in raw.get("packages") or () if package.get("package_id") - } - packages = tuple( - SubscriptionBuyPackage(package_id, ids) - for package_id, ids in sorted(by_package.items()) - ) - return SubscriptionBuy( - media_buy_id=str(raw["media_buy_id"]), - confirmed_at=_instant(raw.get("confirmed_at")), - start_time=start, - end_time=end, - canceled_at=_instant(cancellation.get("canceled_at")), - completed_at=_instant(raw.get("completed_at")), - packages=packages, - reporting_delivery_offering_ids=( - () if packages else tuple(item.offering_id for item in self.engine.offerings) - ), - ) + ] + buy = SubscriptionBuy.from_media_buy(media_buy) + if not buy.packages: + buy = replace(buy, reporting_delivery_offering_ids=every) + return buy # -- authorized_at projection ---------------------------------------------- @@ -1052,15 +1065,24 @@ async def _project_page(self, principal: PrincipalRef, page: list[Any]) -> list[ raw.get("operator"), ) ) - served = await self.engine.observe_grants(principal, observations) + regrants: dict[str, datetime | None] = {} + served = await self.engine.observe_grants(principal, observations, regrants=regrants) projected: list[Any] = [] for raw in page: - start = served.get(str(raw.get("account_id"))) if isinstance(raw, Mapping) else None + account_id = str(raw.get("account_id")) if isinstance(raw, Mapping) else "" + start = served.get(account_id) if start is None: projected.append(raw) continue authorization = dict(raw["authorization"]) authorization["authorized_at"] = iso_z(start) + regranted = regrants.get(account_id) + if regranted is not None: + # adcp#8618: the latest re-grant within the chain, so a buyer + # computes withheld deadlines and gap ends from the wire. + authorization["regranted_at"] = iso_z(regranted) + else: + authorization.pop("regranted_at", None) projected.append({**raw, "authorization": authorization}) return projected diff --git a/src/adcp/reporting/subscriptions/registry.py b/src/adcp/reporting/subscriptions/registry.py index 35b93d5be..c54bc7c73 100644 --- a/src/adcp/reporting/subscriptions/registry.py +++ b/src/adcp/reporting/subscriptions/registry.py @@ -11,8 +11,9 @@ ``account_ids``; ``exclude_account_ids`` only with all-accounts scope; * ``period_timezone`` only when ``account_ids`` names exactly one account, and it must be a recognized IANA zone; -* billing requires ``official`` finality and ``consumer_receipt``, and - ``consumer_receipt`` requires a destination; +* billing requires ``official`` finality, ``consumer_receipt`` and + ``match: exact`` with ``dimensions`` (adcp#8619), and ``consumer_receipt`` + requires a destination; * ``destination_id`` names a destination that exists after the same request applies; ``path_template`` uses only the four placeholders, contains ``{account_id}`` when the scope can cover several accounts or another @@ -160,6 +161,14 @@ def _validate_one( raise ReportingSubscriptionValidationError( f"{where}: billing subscriptions require official finality and consumer_receipt" ) + if selector["feed_purpose"] == "billing" and ( + not selector.get("dimensions") or selector.get("match", "exact") != "exact" + ): + # adcp#8619: a billing feed's breakdown never depends on which + # offerings happen to exist. + raise ReportingSubscriptionValidationError( + f"{where}: billing subscriptions require match exact with dimensions" + ) destination = configuration.get("destination") if mode == "consumer_receipt" and destination is None: raise ReportingSubscriptionValidationError( diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index 8552cce4c..c78b83cfc 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -66,6 +66,20 @@ def _text(value: Any) -> str: return str(inner) +def _wire_instant(value: Any, name: str) -> datetime | None: + """An RFC 3339 wire instant (1-6 fractional digits) as an aware datetime.""" + if value is None or isinstance(value, datetime): + return value + from adcp.reporting._timestamp import aware_timestamp + + try: + return aware_timestamp(str(value)) + except ValueError as error: + raise CoverageInputError( + "invalid_instant", f"{name} {value!r} is not an instant" + ) from error + + def _byte_key(offering_id: str) -> bytes: return offering_id.encode("utf-8") @@ -119,7 +133,15 @@ def __post_init__(self) -> None: # 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) + years, months, days, _exact = parse_period_duration(self.period_duration) + if self.alignment == "billing_cycle" and not (years or months or days): + # period_generation (adcp#8622): a sub-daily billing_cycle restarts + # at each local midnight and would ignore its anchor. + raise CoverageInputError( + "invalid_period_duration", + f"offering {self.offering_id!r}: billing_cycle alignment needs a " + f"period_duration with a date component, not {self.period_duration!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)) @@ -162,6 +184,8 @@ def from_offering( 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. + ``available_from`` and ``withdrawn_at`` come from the offering as + published (adcp#8618) unless given. """ if hasattr(offering, "model_dump"): offering = offering.model_dump(mode="json", exclude_none=True) @@ -208,8 +232,16 @@ def from_offering( period_timezone_policy=schedule.get("period_timezone_policy"), method_pattern=method.get("pattern"), method_transport=method.get("transport"), - available_from=available_from, - withdrawn_at=withdrawn_at, + available_from=( + available_from + if available_from is not None + else _wire_instant(offering.get("available_from"), "available_from") + ), + withdrawn_at=( + withdrawn_at + if withdrawn_at is not None + else _wire_instant(offering.get("withdrawn_at"), "withdrawn_at") + ), ) def available_at(self, instant: datetime) -> bool: diff --git a/tests/fixtures/reporting_subscriptions/spec/README.md b/tests/fixtures/reporting_subscriptions/spec/README.md index 834382301..1efc90fb5 100644 --- a/tests/fixtures/reporting_subscriptions/spec/README.md +++ b/tests/fixtures/reporting_subscriptions/spec/README.md @@ -84,8 +84,9 @@ checks that evaluation against zic(8) output for the same release. For each case: 1. Build your SDK's coverage input from `input`. `case.schema.json` says where - each field comes from on the wire. Fields listed in `non_wire_inputs` are - ones a buyer must retain itself. + each field comes from on the wire. Every field is on the wire, including + offering availability (`available_from`, `withdrawn_at`) and a buy's + `flight_history`, so a buyer needs no state of its own. 2. When the case has `expected_error`, assert your SDK raises that `code` with that `detail`. 3. Otherwise compare your rows with `expected`, and your grouped obligations @@ -96,10 +97,10 @@ For each case: | Group | What the cases check | |---|---| -| Start bound and flights | The later of `accepted_at` and `authorized_at`, the confirmation period owed in full, the stop-time period owed in full, cancellation, completion, empty flights, late confirmation, and flight values as of each period end (extension and reinstatement never add an elapsed period). | -| Cutovers | New versions on aligned and misaligned schedules, a version change on a boundary, two cutovers inside one period, deactivation, reactivation, and resolution changes. Compound changes at one instant: a version change with a timezone change, a reactivation, a deactivation, or a revocation. | +| Start bound and flights | The later of `accepted_at` and `authorized_at`, the confirmation period owed in full, the stop-time period owed in full, cancellation (including delivery through a notice period to `cancellation_effective_at`), completion, empty flights, late confirmation, and flight values as of each period end (extension and reinstatement never add an elapsed period). | +| Cutovers | New versions on aligned and misaligned schedules, a billing cutover that owes a stub period, a version change on a boundary, two cutovers inside one period, deactivation, reactivation (including after a schedule change made while inactive), and resolution changes. Compound changes at one instant: a version change with a timezone change, a reactivation, a deactivation, or a revocation. | | Authority | Revocation, re-grant with and without `backfill_on_regrant`, expired `accounts_unauthorized` entries, a re-grant exactly at the retention limit, and chains of three and four grants. | | Exceptions | `excluded[]` and `gaps[]` remove periods by start, offering-scoped entries (including `max_accounts` capacity on offerings with different boundaries), `package_ids` entries, precedence. | | Selection | `exact`, `finest`, and `all`, byte-order ties, unmapped and unknown dimensions, finality, purpose, duration, destination, product eligibility, per-product resolution, and `period_timezone` matching. | -| Period generation | DST gaps and folds at midnight (America/Santiago, America/Havana, Africa/Cairo), Australia/Lord_Howe's 30-minute DST, sub-daily local periods in Asia/Singapore and Asia/Kathmandu, a skipped civil date (Pacific/Apia), PT24H against P1D, utc sub-daily periods counted from 1970, month-end clamps from 31st and February 29 anchors, P3M, weeks (P7D, starting Thursday from the 1970 origin), and years. | +| Period generation | DST gaps and folds at midnight (America/Santiago, America/Havana, Africa/Cairo), day starts that nest PT24H and PT2H in a day whose midnight does not exist, Australia/Lord_Howe's 30-minute DST, sub-daily local periods in Asia/Singapore and Asia/Kathmandu, a skipped civil date (Pacific/Apia), PT24H against P1D, utc sub-daily periods counted from 1970, month-end clamps from 31st and February 29 anchors, P3M, weeks (P7D, starting Thursday from the 1970 origin), and years. | | Input errors | Numeric offsets, missing zones, a missing `previous_timezone`, missing `standard_dimensions`, a missing retention window, P1W (the duration pattern has no W), and invalid local durations. | diff --git a/tests/fixtures/reporting_subscriptions/spec/case.schema.json b/tests/fixtures/reporting_subscriptions/spec/case.schema.json index cbf0750ad..cc54c675c 100644 --- a/tests/fixtures/reporting_subscriptions/spec/case.schema.json +++ b/tests/fixtures/reporting_subscriptions/spec/case.schema.json @@ -57,18 +57,6 @@ }, "description": "IANA zones the expected rows depend on, as named in the input. Empty when every schedule is utc-aligned." }, - "non_wire_inputs": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "string", - "enum": [ - "buys[].prior_flights", - "offerings[].available_from/withdrawn_at" - ] - }, - "description": "Input fields no AdCP response carries; a buyer must retain them to agree with the seller." - }, "input": { "$ref": "#/definitions/input" }, @@ -353,6 +341,10 @@ "$ref": "#/definitions/instant", "description": "cancellation.canceled_at." }, + "cancellation_effective_at": { + "$ref": "#/definitions/instant", + "description": "cancellation_effective_at, the delivery stop under the cancellation. When present it, not canceled_at, is the cancellation's term in the stop time." + }, "completed_at": { "$ref": "#/definitions/instant" }, @@ -380,30 +372,24 @@ "additionalProperties": false } }, - "prior_flights": { + "flight_history": { "type": "array", - "description": "Not on the wire. Flight values before an update replaced them; a value applies to periods resolved before its replaced_at.", + "description": "The buy's flight_history from get_media_buys (core/media-buy.json flight_history): its retroactive flight edits, oldest first, each with the flight in force before it. The flight in force at an instant is the one in the earliest entry recorded after it, or the current flight.", "items": { "type": "object", "required": [ - "replaced_at", + "recorded_at", "start_time", - "end_time" + "stop_time" ], "properties": { - "replaced_at": { + "recorded_at": { "$ref": "#/definitions/instant" }, "start_time": { "$ref": "#/definitions/instant" }, - "end_time": { - "$ref": "#/definitions/instant" - }, - "canceled_at": { - "$ref": "#/definitions/instant" - }, - "completed_at": { + "stop_time": { "$ref": "#/definitions/instant" } }, @@ -416,7 +402,7 @@ }, "offerings": { "type": "array", - "description": "Seller offerings as published (reporting-delivery-offering.json, with standard_dimensions and unmapped_dimensions). available_from and withdrawn_at are not on the wire: when the offering became or stopped being available, which changes a resolution.", + "description": "Seller offerings as published (reporting-delivery-offering.json, with standard_dimensions, unmapped_dimensions, and the availability window available_from and withdrawn_at, which changes a resolution).", "items": { "type": "object", "required": [ @@ -494,6 +480,11 @@ }, "period_end": { "$ref": "#/definitions/z_instant" + }, + "stub": { + "type": "boolean", + "const": true, + "description": "Present, and true, on a billing cutover stub: the stretch [E, first incoming boundary) a cutover hands to a billing feed whose schedule has no boundary at E (core/reporting-obligation.json stub)." } }, "additionalProperties": false @@ -521,6 +512,11 @@ "period_end": { "$ref": "#/definitions/z_instant" }, + "stub": { + "type": "boolean", + "const": true, + "description": "Present, and true, on a billing cutover stub: the stretch [E, first incoming boundary) a cutover hands to a billing feed whose schedule has no boundary at E (core/reporting-obligation.json stub)." + }, "media_buy_ids": { "type": "array", "minItems": 1, diff --git a/tests/fixtures/reporting_subscriptions/spec/vectors.json b/tests/fixtures/reporting_subscriptions/spec/vectors.json index d71be543a..aa327822d 100644 --- a/tests/fixtures/reporting_subscriptions/spec/vectors.json +++ b/tests/fixtures/reporting_subscriptions/spec/vectors.json @@ -11,11 +11,10 @@ }, "conventions": { "instants": "Every timestamp is an RFC 3339 instant and may carry fractional seconds or a non-Z offset. Compare instants, never strings. Expected rows write instants in Z form, with fractional seconds only when they are non-zero. Inputs carry at most millisecond precision.", - "rows": "expected has one row per (media buy, resolved offering, owed period), ordered by period_start, then subscription_version, then offering_id and media_buy_id in byte order. subscription_id, account_id, and report_definition_id are omitted: they are input.subscription_id, input.account.account_id, and the offering's report_definition_id.", + "rows": "expected has one row per (media buy, resolved offering, owed period), ordered by period_start, then subscription_version, then offering_id and media_buy_id in byte order. subscription_id, account_id, and report_definition_id are omitted: they are input.subscription_id, input.account.account_id, and the offering's report_definition_id. A billing cutover stub row, and its obligation, carry stub: true.", "obligations": "expected_obligations groups expected by subscription_version, offering_id, period_start, and period_end, with media_buy_ids sorted in byte order, the obligation's frozen media_buy_ids. Ordered by period_start, then subscription_version, then offering_id in byte order.", "now": "Only resolved periods are expected: those whose period_end is at or before input.now.", "input": "The input is what the buyer holds: its subscription versions as the get_principal readback shows them, the account's grants and timezone, the buys it can see through get_media_buys, the seller's offerings, and this subscription's get_reporting_status subscription_coverage entry for the account. case.schema.json describes each field.", - "non_wire_inputs": "Lists input fields no AdCP response carries, which a buyer must retain itself to agree with the seller: buys[].prior_flights (flight values before an update) and offerings[].available_from or withdrawn_at (when an offering became or stopped being available, which changes a resolution).", "expected_error": "When the input cannot determine coverage, expected_error gives the error code and detail an SDK raises, from error-codes.json in this directory (COVERAGE_INPUT_INCOMPLETE or COVERAGE_INPUT_INVALID). wire_valid is false when the input also fails AdCP schema validation.", "vocabularies": "error-codes.json freezes the error codes SDKs raise for subscriptions and coverage; reason-codes.json freezes the reasons a period is or is not owed. Both SDKs use them verbatim." }, @@ -549,6 +548,131 @@ } ] }, + { + "id": "cancellation-notice-period-is-owed-through-the-delivery-stop", + "name": "cancellation notice period is owed through the delivery stop", + "origin": "adcp", + "rule": "adcp#8617: canceled_at 11-05T06 records the cancellation, but the seller delivers through a notice period until cancellation_effective_at 11-07T00, the delivery stop. The stop time is the earliest of end_time, the delivery stop, and completed_at, so periods through 11-06 are owed; canceled_at alone would have stopped them at the period containing it.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/media-buy.json#/properties/cancellation_effective_at", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": [], + "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", + "cancellation_effective_at": "2026-11-07T00: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": [ + { + "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" + } + ], + "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"] + } + ] + }, { "id": "early-completion-on-a-boundary", "name": "early completion on a boundary", @@ -640,7 +764,6 @@ "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" ], "timezones": [], - "non_wire_inputs": ["buys[].prior_flights"], "input": { "subscription_id": "analytics-daily", "versions": [ @@ -665,7 +788,7 @@ "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"}] + "flight_history": [{"recorded_at": "2026-11-07T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "stop_time": "2026-11-06T00:00:00Z"}] } ], "offerings": [ @@ -764,7 +887,6 @@ "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" ], "timezones": [], - "non_wire_inputs": ["buys[].prior_flights"], "input": { "subscription_id": "analytics-daily", "versions": [ @@ -789,14 +911,7 @@ "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" - } - ] + "flight_history": [{"recorded_at": "2026-11-06T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "stop_time": "2026-11-04T12:00:00Z"}] } ], "offerings": [ @@ -1442,6 +1557,220 @@ } ] }, + { + "id": "billing-cutover-owes-a-stub", + "name": "billing cutover owes a stub", + "origin": "adcp", + "rule": "adcp#8617: v1 resolves a UTC-day billing offering, v2 (accepted 11-04T12Z, alignment account_timezone) a New York-day one. v1 owes UTC days starting before T, through E = 11-05T00Z. On a billing feed the stretch to the first New York boundary is owed as one stub period [11-05T00Z, 11-05T05Z), flagged stub; New York days follow from 11-05T05Z. Billable time is contiguous.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-obligation.json#/properties/stub", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "billing-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "billing-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1D", + "required_finality": "official", + "alignment": "utc", + "dimensions": ["package"] + }, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "billing-daily", + "subscription_version": 2, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1D", + "required_finality": "official", + "alignment": "account_timezone", + "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-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T05:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_bill_ny", "off_bill_utc"]}] + } + ], + "offerings": [ + { + "offering_id": "off_bill_ny", + "feed_purpose": "billing", + "report_definition_id": "rd_off_bill_ny", + "report_definition_uri": "https://seller.example/report-definitions/off_bill_ny.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"], + "canonicalization_id": "adcp_jcs_rows_v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://seller.example/canonicalization/adcp_jcs_rows_v1.json", + "canonicalization_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "P1D"}, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + }, + { + "offering_id": "off_bill_utc", + "feed_purpose": "billing", + "report_definition_id": "rd_off_bill_utc", + "report_definition_uri": "https://seller.example/report-definitions/off_bill_utc.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"], + "canonicalization_id": "adcp_jcs_rows_v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://seller.example/canonicalization/adcp_jcs_rows_v1.json", + "canonicalization_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "P1D"}, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_bill_utc", + "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_bill_utc", + "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_bill_ny", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "stub": true + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_bill_ny", + "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_bill_ny", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_bill_utc", + "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_bill_utc", + "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_bill_ny", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "stub": true, + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_bill_ny", + "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_bill_ny", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, { "id": "deactivation", "name": "deactivation", @@ -4121,21 +4450,194 @@ "expected_obligations": [ { "subscription_version": 1, - "offering_id": "off_a_pkg", + "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"] + } + ] + }, + { + "id": "finality-purpose-and-duration-must-match", + "name": "finality, purpose and duration must match", + "origin": "adcp-client#3505", + "rule": "An offering advertising only snapshot finality, another feed_purpose, or another period_duration is never a candidate.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/match", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": [], + "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", + "standard_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", + "standard_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", + "standard_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", + "standard_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" + } + ], + "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_a_pkg", + "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_a_pkg", + "offering_id": "off_d_pkg", "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z", "media_buy_ids": ["mb_1"] @@ -4143,10 +4645,10 @@ ] }, { - "id": "finality-purpose-and-duration-must-match", - "name": "finality, purpose and duration must match", + "id": "a-newly-available-offering-never-re-resolves", + "name": "a newly available offering never re-resolves", "origin": "adcp-client#3505", - "rule": "An offering advertising only snapshot finality, another feed_purpose, or another period_duration is never a candidate.", + "rule": "finest [package]: the (buy, version) pair resolves at the later of accepted_at (11-01T12) and confirmed_at (11-02T15), when only package is available. package x creative becomes available at 11-04T12, but an offering added later never re-resolves the pair, so package owes every period of the flight.", "spec_refs": [ "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection", @@ -4165,7 +4667,13 @@ "subscription_version": 1, "active": true, "scope": {"all_authorized_accounts": true}, - "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest" + }, "reconciliation_mode": "delivery_only" } } @@ -4176,8 +4684,8 @@ "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"]}] + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}] } ], "offerings": [ @@ -4198,57 +4706,15 @@ "primary_keys": ["account_id", "package_id"] }, "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, - "supported_finality": ["snapshot"], - "reconciliation_mode": "delivery_only", - "standard_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", - "standard_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", "standard_dimensions": ["package"] }, { - "offering_id": "off_d_pkg", + "offering_id": "off_b_pkg_creative", "feed_purpose": "analytics", - "report_definition_id": "rd_off_d_pkg", - "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg.json", + "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", @@ -4263,7 +4729,8 @@ "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "standard_dimensions": ["package"] + "standard_dimensions": ["package", "creative"], + "available_from": "2026-11-04T12:00:00Z" } ], "now": "2026-12-01T00:00:00Z" @@ -4272,54 +4739,69 @@ { "subscription_version": 1, "media_buy_id": "mb_1", - "offering_id": "off_d_pkg", + "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_d_pkg", + "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_d_pkg", + "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" } ], "expected_obligations": [ { "subscription_version": 1, - "offering_id": "off_d_pkg", + "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_d_pkg", + "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_d_pkg", + "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"] } - ] + ], + "changed_from_origin": "Re-ruled for 3.3.0-beta.3 (adcp#8619): resolution is fixed per (buy, version) when the pair first resolves, so package x creative becoming available at 11-04T12 no longer changes it. The origin case expected a resolution change at that instant." }, { - "id": "resolution-change-is-a-cutover", - "name": "resolution change is a cutover", - "origin": "adcp-client#3505", - "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.", + "id": "withdrawal-of-the-resolved-offering-is-a-cutover", + "name": "withdrawal of the resolved offering is a cutover", + "origin": "adcp", + "rule": "finest [package]: package x creative is available throughout and wins at resolution. It is withdrawn at 11-04T12, the one resolution change within a version: the package re-resolves to package, and the cutover hands off at E = 11-05. package x creative owes 11-03 and 11-04; package owes from 11-05.", "spec_refs": [ "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", @@ -4328,7 +4810,6 @@ "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" ], "timezones": [], - "non_wire_inputs": ["offerings[].available_from/withdrawn_at"], "input": { "subscription_id": "analytics-daily", "versions": [ @@ -4403,7 +4884,7 @@ "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"], - "available_from": "2026-11-04T12:00:00Z" + "withdrawn_at": "2026-11-04T12:00:00Z" } ], "now": "2026-12-01T00:00:00Z" @@ -4412,28 +4893,28 @@ { "subscription_version": 1, "media_buy_id": "mb_1", - "offering_id": "off_a_pkg", + "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_a_pkg", + "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", + "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", + "offering_id": "off_a_pkg", "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } @@ -4441,28 +4922,28 @@ "expected_obligations": [ { "subscription_version": 1, - "offering_id": "off_a_pkg", + "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_a_pkg", + "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", + "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", + "offering_id": "off_a_pkg", "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z", "media_buy_ids": ["mb_1"] @@ -9479,34 +9960,173 @@ "media_buy_ids": ["mb_1"] }, { - "subscription_version": 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"] + } + ] + }, + { + "id": "inactive-interval-spanning-a-version-change", + "name": "inactive interval spanning a version change", + "origin": "adcp-client#3505", + "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.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", + "static/schemas/source/core/reporting-subscription-state.json#/properties/inactive_intervals", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": [], + "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-06T00:00:00Z", - "period_end": "2026-11-07T00:00:00Z", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", "media_buy_ids": ["mb_1"] }, { - "subscription_version": 1, + "subscription_version": 2, "offering_id": "off_a_pkg", - "period_start": "2026-11-07T00:00:00Z", - "period_end": "2026-11-08T00:00:00Z", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", "media_buy_ids": ["mb_1"] } ] }, { - "id": "inactive-interval-spanning-a-version-change", - "name": "inactive interval spanning a version change", - "origin": "adcp-client#3505", - "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.", + "id": "reactivation-after-a-schedule-change-never-owes-twice", + "name": "reactivation after a schedule change never owes twice", + "origin": "adcp", + "rule": "3.3.0-beta.3: v1 resolves a UTC day offering and is deactivated at 11-04T12Z, so its 11-04T00Z day stays owed through 11-05T00Z. While inactive, v2 is accepted at 11-04T13Z with alignment account_timezone in Tokyo (midnight 15:00Z), and the subscription is reactivated at 11-04T14Z. Coverage resumes at the first boundary at or after the later of the reactivation instant and the end of the last period owed before deactivation (11-05T00Z): 11-05T15Z. The fresh start alone would have owed the Tokyo day from 11-04T15Z, overlapping the owed UTC day.", "spec_refs": [ "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", - "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", "static/schemas/source/core/reporting-subscription-state.json#/properties/inactive_intervals", "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" ], - "timezones": [], + "timezones": ["Asia/Tokyo"], "input": { "subscription_id": "analytics-daily", "versions": [ @@ -9521,30 +10141,35 @@ "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" }, - "superseded_at": "2026-11-05T12:00:00Z" + "superseded_at": "2026-11-04T13:00:00Z" }, { "subscription_version": 2, - "accepted_at": "2026-11-05T12:00:00Z", + "accepted_at": "2026-11-04T13: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"] + "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"}]}, + "account": {"account_id": "acct_1", "timezone": "Asia/Tokyo", "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"]}] + "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": [ @@ -9568,10 +10193,31 @@ "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", - "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-06T12:00:00Z"}] + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-04T14:00:00Z"}] }, "expected": [ { @@ -9591,16 +10237,16 @@ { "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" + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15: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" + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z" } ], "expected_obligations": [ @@ -9620,16 +10266,16 @@ }, { "subscription_version": 2, - "offering_id": "off_a_pkg", - "period_start": "2026-11-07T00:00:00Z", - "period_end": "2026-11-08T00:00:00Z", + "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": 2, - "offering_id": "off_a_pkg", - "period_start": "2026-11-08T00:00:00Z", - "period_end": "2026-11-09T00:00:00Z", + "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"] } ] @@ -10890,114 +11536,372 @@ }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T12:00:00Z", - "period_end": "2026-03-08T13:00:00Z", - "media_buy_ids": ["mb_1"] + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T12:00:00Z", + "period_end": "2026-03-08T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T13:00:00Z", + "period_end": "2026-03-08T14:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T14:00:00Z", + "period_end": "2026-03-08T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T15:00:00Z", + "period_end": "2026-03-08T16:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T16:00:00Z", + "period_end": "2026-03-08T17:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T17:00:00Z", + "period_end": "2026-03-08T18:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T18:00:00Z", + "period_end": "2026-03-08T19:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T19:00:00Z", + "period_end": "2026-03-08T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T20:00:00Z", + "period_end": "2026-03-08T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T21:00:00Z", + "period_end": "2026-03-08T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T22:00:00Z", + "period_end": "2026-03-08T23:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T23:00:00Z", + "period_end": "2026-03-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T00:00:00Z", + "period_end": "2026-03-09T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T01:00:00Z", + "period_end": "2026-03-09T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T02:00:00Z", + "period_end": "2026-03-09T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T03:00:00Z", + "period_end": "2026-03-09T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "havana-pt24h-equals-p1d-when-midnight-does-not-exist", + "name": "havana PT24H equals P1D when midnight does not exist", + "origin": "adcp", + "rule": "Each local date's day start, its first instant, is always a boundary of a sub-daily local schedule (3.3.0-beta.3). Havana springs forward at 00:00 on 2026-03-08, so March 8 starts at 01:00 CDT (05:00Z). PT24H has its boundary there, exactly like P1D: March 7 is 24 hours and March 8 is 23. Under the earlier reading the nonexistent midnight yielded no boundary and PT24H had a 47-hour period.", + "spec_refs": [ + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Havana"], + "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": "pacing", + "period_duration": "PT24H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Havana", "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-07T05:00:00Z", + "end_time": "2026-03-10T04:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h24_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_h24_acct_tz", + "feed_purpose": "pacing", + "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": "PT1H"}, + "supported_finality": ["snapshot"], + "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, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T13:00:00Z", - "period_end": "2026-03-08T14:00:00Z", - "media_buy_ids": ["mb_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, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T14:00:00Z", - "period_end": "2026-03-08T15:00:00Z", - "media_buy_ids": ["mb_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_p_hourly_local", - "period_start": "2026-03-08T15:00:00Z", - "period_end": "2026-03-08T16:00:00Z", + "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_p_hourly_local", - "period_start": "2026-03-08T16:00:00Z", - "period_end": "2026-03-08T17:00:00Z", + "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_p_hourly_local", - "period_start": "2026-03-08T17:00:00Z", - "period_end": "2026-03-08T18:00:00Z", + "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"] - }, + } + ] + }, + { + "id": "havana-pt2h-nests-in-the-day", + "name": "havana PT2H nests in the day", + "origin": "adcp", + "rule": "The day start is a boundary of every sub-daily local schedule, so local days nest in two-hour periods even when midnight does not exist. On 2026-03-08 in Havana, 00:00 does not exist and the day starts at 01:00 CDT (05:00Z): the PT2H periods are 05:00Z-06:00Z (one hour, from the day start to 02:00 CDT) and then two-hour periods from 06:00Z. March 7 ends at 05:00Z with 22:00-24:00 CST (03:00Z-05:00Z).", + "spec_refs": [ + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Havana"], + "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": "pacing", + "period_duration": "PT2H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Havana", "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-08T03:00:00Z", + "end_time": "2026-03-08T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p2_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p2_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p2_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p2_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": "PT2H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T18:00:00Z", - "period_end": "2026-03-08T19:00:00Z", - "media_buy_ids": ["mb_1"] + "media_buy_id": "mb_1", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T03:00:00Z", + "period_end": "2026-03-08T05:00:00Z" }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T19:00:00Z", - "period_end": "2026-03-08T20:00:00Z", - "media_buy_ids": ["mb_1"] + "media_buy_id": "mb_1", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-08T06:00:00Z" }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T20:00:00Z", - "period_end": "2026-03-08T21:00:00Z", - "media_buy_ids": ["mb_1"] + "media_buy_id": "mb_1", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T06:00:00Z", + "period_end": "2026-03-08T08:00:00Z" }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T21:00:00Z", - "period_end": "2026-03-08T22:00:00Z", - "media_buy_ids": ["mb_1"] + "media_buy_id": "mb_1", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T08:00:00Z", + "period_end": "2026-03-08T10:00:00Z" }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T22:00:00Z", - "period_end": "2026-03-08T23:00:00Z", - "media_buy_ids": ["mb_1"] - }, + "media_buy_id": "mb_1", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T10:00:00Z", + "period_end": "2026-03-08T12:00:00Z" + } + ], + "expected_obligations": [ { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-08T23:00:00Z", - "period_end": "2026-03-09T00:00:00Z", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T03:00:00Z", + "period_end": "2026-03-08T05:00:00Z", "media_buy_ids": ["mb_1"] }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-09T00:00:00Z", - "period_end": "2026-03-09T01:00:00Z", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-08T06:00:00Z", "media_buy_ids": ["mb_1"] }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-09T01:00:00Z", - "period_end": "2026-03-09T02:00:00Z", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T06:00:00Z", + "period_end": "2026-03-08T08:00:00Z", "media_buy_ids": ["mb_1"] }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-09T02:00:00Z", - "period_end": "2026-03-09T03:00:00Z", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T08:00:00Z", + "period_end": "2026-03-08T10:00:00Z", "media_buy_ids": ["mb_1"] }, { "subscription_version": 1, - "offering_id": "off_p_hourly_local", - "period_start": "2026-03-09T03:00:00Z", - "period_end": "2026-03-09T04:00:00Z", + "offering_id": "off_p2_local", + "period_start": "2026-03-08T10:00:00Z", + "period_end": "2026-03-08T12:00:00Z", "media_buy_ids": ["mb_1"] } ] @@ -13802,6 +14706,88 @@ }, "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_period_duration", "wire_valid": true} }, + { + "id": "sub-daily-billing-cycle-is-refused", + "name": "sub-daily billing cycle is refused", + "origin": "adcp", + "rule": "period_generation (3.3.0-beta.3): billing_cycle alignment requires a period_duration with a date component. A sub-daily duration restarts at each local midnight, so the anchor would have no effect; PT1H under billing_cycle is refused, and the reporting-schedule schema rejects it.", + "spec_refs": ["static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation"], + "timezones": [], + "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": "PT1H", "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"], + "canonicalization_id": "adcp_jcs_rows_v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://seller.example/canonicalization/adcp_jcs_rows_v1.json", + "canonicalization_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "schedule": { + "period_duration": "PT1H", + "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"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_period_duration", "wire_valid": false} + }, { "id": "fractional-seconds-and-offsets", "name": "Fractional seconds and non-Z offsets are instants", @@ -14195,7 +15181,6 @@ "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" ], "timezones": [], - "non_wire_inputs": ["buys[].prior_flights"], "input": { "subscription_id": "analytics-daily", "versions": [ @@ -14220,7 +15205,7 @@ "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"]}], - "prior_flights": [{"replaced_at": "2026-11-04T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "end_time": "2026-11-03T00:00:00Z"}] + "flight_history": [{"recorded_at": "2026-11-04T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "stop_time": "2026-11-03T00:00:00Z"}] } ], "offerings": [ diff --git a/tests/test_principal_reporting_subscriptions.py b/tests/test_principal_reporting_subscriptions.py index 51d52b5ec..d39485aaf 100644 --- a/tests/test_principal_reporting_subscriptions.py +++ b/tests/test_principal_reporting_subscriptions.py @@ -362,6 +362,26 @@ async def test_omitted_subscription_is_deactivated_and_retained() -> None: }, destination={"destination_id": "archive", "path_template": "b/{account_id}"}, ), + "billing without dimensions": subscription( + selector={ + "feed_purpose": "billing", + "period_duration": "P1D", + "required_finality": "official", + }, + reconciliation_mode="consumer_receipt", + destination={"destination_id": "archive", "path_template": "b/{account_id}"}, + ), + "billing under finest": subscription( + selector={ + "feed_purpose": "billing", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest", + }, + reconciliation_mode="consumer_receipt", + destination={"destination_id": "archive", "path_template": "b/{account_id}"}, + ), "receipts without destination": subscription(reconciliation_mode="consumer_receipt"), "missing destination": subscription( destination={"destination_id": "nowhere", "path_template": "x/{account_id}"} diff --git a/tests/test_reporting_subscription_beta3_rules.py b/tests/test_reporting_subscription_beta3_rules.py new file mode 100644 index 000000000..1186ef9cf --- /dev/null +++ b/tests/test_reporting_subscription_beta3_rules.py @@ -0,0 +1,267 @@ +"""Reporting-subscription rules settled for AdCP 3.3.0-beta.3. + +The shared coverage rule is pinned by the spec's vectors +(``test_reporting_subscription_parity``). These tests cover what the vectors +do not reach: reading the beta.3 wire (adcp#8617, #8618), carrying a billing +stub from the owed set through the ledger to ``core/reporting-obligation.json``, +and the seller-side refusals. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime + +import pytest + +from adcp.reporting.ledger import InMemoryReportingLedgerStore +from adcp.reporting.ledger.status import _obligation_to_wire +from adcp.reporting.subscriptions import ( + CoverageInputError, + FrozenOwedSet, + SubscriptionBuy, + SubscriptionBuyPackage, + SubscriptionObligationPlanner, + SubscriptionOffering, + SubscriptionVersionWindow, + owed_subscription_periods, +) +from adcp.reporting.subscriptions.managed import ManagedSubscriptions +from adcp.validation import schema_loader + + +def at(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _offering(offering_id: str, alignment: str, **schedule) -> dict: + return { + "offering_id": offering_id, + "feed_purpose": "billing", + "report_definition_id": "rd", + "reporting_profile": {"id": "delivery", "primary_keys": ["account_id", "package_id"]}, + "schedule": { + "period_duration": "P1D", + "alignment": alignment, + "delivery_sla": "PT6H", + **schedule, + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + } + + +def _billing(version: int, alignment: str) -> dict: + return { + "subscription_id": "billing", + "subscription_version": version, + "active": True, + "scope": {"all_authorized_accounts": True}, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": alignment, + }, + "reconciliation_mode": "delivery_only", + } + + +# -- reading the wire ---------------------------------------------------------- + + +def test_a_media_buy_is_read_from_the_beta3_wire() -> None: + buy = SubscriptionBuy.from_media_buy( + { + "media_buy_id": "mb-1", + "confirmed_at": "2026-11-01T00:00:00.123Z", + "start_time": "2026-11-02T00:00:00Z", + "end_time": "2026-11-30T00:00:00Z", + "cancellation": {"canceled_at": "2026-11-10T00:00:00Z"}, + "cancellation_effective_at": "2026-11-17T00:00:00Z", + "flight_history": [ + { + "recorded_at": "2026-11-05T00:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "stop_time": "2026-11-20T00:00:00Z", + } + ], + "packages": [ + {"package_id": "pkg-1", "reporting_delivery_offering_ids": ["off-a"]}, + {"package_id": "pkg-2"}, + ], + }, + package_offering_ids={"pkg-2": ["off-b"]}, + ) + # adcp#8617: delivery stops at cancellation_effective_at, not canceled_at. + assert buy.stop_time == at("2026-11-17T00:00:00Z") + # adcp#8618: the flight in force before the edit, from flight_history. + assert buy.flight_at(at("2026-11-04T00:00:00Z")) == ( + at("2026-11-03T00:00:00Z"), + at("2026-11-20T00:00:00Z"), + ) + assert buy.flight_at(at("2026-11-06T00:00:00Z")) == ( + at("2026-11-02T00:00:00Z"), + at("2026-11-17T00:00:00Z"), + ) + # The per-package offering snapshot, with the caller's fallback. + assert buy.packages == ( + SubscriptionBuyPackage("pkg-1", ("off-a",)), + SubscriptionBuyPackage("pkg-2", ("off-b",)), + ) + + +def test_without_cancellation_effective_at_canceled_at_stops_delivery() -> None: + buy = SubscriptionBuy.from_media_buy( + { + "media_buy_id": "mb-1", + "confirmed_at": "2026-11-01T00:00:00Z", + "start_time": "2026-11-02T00:00:00Z", + "end_time": "2026-11-30T00:00:00Z", + "cancellation": {"canceled_at": "2026-11-10T00:00:00Z"}, + } + ) + assert buy.stop_time == at("2026-11-10T00:00:00Z") + + +def test_offering_availability_is_read_from_the_wire() -> None: + offering = SubscriptionOffering.from_offering( + { + **_offering("off-a", "utc"), + "available_from": "2026-11-01T00:00:00Z", + "withdrawn_at": "2026-12-01T00:00:00Z", + } + ) + assert offering.available_from == at("2026-11-01T00:00:00Z") + assert offering.withdrawn_at == at("2026-12-01T00:00:00Z") + assert not offering.available_at(at("2026-12-01T00:00:00Z")) + + +def test_a_sub_daily_billing_cycle_is_refused() -> None: + with pytest.raises(CoverageInputError) as refused: + SubscriptionOffering.from_offering( + _offering( + "off-hourly", + "billing_cycle", + period_duration="PT1H", + period_anchor="2026-01-01T00:00:00Z", + period_anchor_policy="fixed", + period_timezone="UTC", + ) + ) + assert (refused.value.code, refused.value.detail) == ( + "COVERAGE_INPUT_INVALID", + "invalid_period_duration", + ) + + +# -- billing stubs through the ledger ------------------------------------------- + + +def test_a_billing_cutover_stub_is_owed_written_and_served() -> None: + utc = SubscriptionOffering.from_offering(_offering("bill-utc", "utc")) + new_york = SubscriptionOffering.from_offering(_offering("bill-ny", "account_timezone")) + windows = [ + SubscriptionVersionWindow( + 1, at("2026-11-01T12:00:00Z"), at("2026-11-04T12:00:00Z"), _billing(1, "utc") + ), + SubscriptionVersionWindow( + 2, at("2026-11-04T12:00:00Z"), None, _billing(2, "account_timezone") + ), + ] + buy = SubscriptionBuy( + "mb-1", + at("2026-11-02T15:00:00Z"), + at("2026-11-03T00:00:00Z"), + at("2026-11-07T05:00:00Z"), + packages=(SubscriptionBuyPackage("pkg-1", ("bill-ny", "bill-utc")),), + ) + owed = owed_subscription_periods( + subscription=windows, + account_id="acct-1", + buys=[buy], + offerings=[utc, new_york], + account_timezone="America/New_York", + now=at("2026-11-08T00:00:00Z"), + ) + assert [(p.period_start, p.period_end, p.offering_id, p.stub) for p in owed] == [ + ("2026-11-03T00:00:00Z", "2026-11-04T00:00:00Z", "bill-utc", False), + ("2026-11-04T00:00:00Z", "2026-11-05T00:00:00Z", "bill-utc", False), + # [E, first New York boundary): billable time stays contiguous. + ("2026-11-05T00:00:00Z", "2026-11-05T05:00:00Z", "bill-ny", True), + ("2026-11-05T05:00:00Z", "2026-11-06T05:00:00Z", "bill-ny", False), + ("2026-11-06T05:00:00Z", "2026-11-07T05:00:00Z", "bill-ny", False), + ] + + async def plan() -> list: + store = InMemoryReportingLedgerStore(clock=lambda: at("2026-11-08T00:00:00Z")) + planner = SubscriptionObligationPlanner(store, offerings=[utc, new_york], currency="USD") + frozen = FrozenOwedSet( + "buyer", + "acct-1", + "billing", + at("2026-11-08T00:00:00Z"), + tuple(owed), + {1: _billing(1, "utc"), 2: _billing(2, "account_timezone")}, + ) + result = await planner.plan(frozen) + return [ + item + for key in result.generations + for item in await store.list_generation_obligations(key) + ] + + obligations = asyncio.run(plan()) + assert [item.stub for item in obligations].count(True) == 1 + (stub,) = [item for item in obligations if item.stub] + wire = _obligation_to_wire( + stub, + revisions=(), + health="healthy", + production_status="awaiting_period_end", + issues=(), + statuses=(), + ) + assert wire["stub"] is True + assert wire["period"]["start"] == "2026-11-05T00:00:00Z" + assert wire["period"]["end"] == "2026-11-05T05:00:00Z" + validator = schema_loader.get_named_validator( + "core/reporting-obligation.json", version="3.3.0-beta.3" + ) + stub_schema = validator.evolve(schema={"$ref": "#/properties/stub"}) + assert stub_schema.is_valid(wire["stub"]) + # An ordinary obligation never carries the flag. + ordinary = next(item for item in obligations if not item.stub) + assert "stub" not in _obligation_to_wire( + ordinary, + revisions=(), + health="healthy", + production_status="awaiting_period_end", + issues=(), + statuses=(), + ) + + +# -- seller refusals ------------------------------------------------------------- + + +def test_a_destination_offering_must_key_rows_by_account_id() -> None: + from adcp.reporting import ReliableReportingConfigurationError + + delivered = { + **_offering("off-files", "utc"), + "method": {"pattern": "file_transfer", "transport": "s3"}, + "reporting_profile": {"id": "delivery", "primary_keys": ["package_id"]}, + } + with pytest.raises(ReliableReportingConfigurationError, match="primary_keys"): + ManagedSubscriptions._offerings([delivered], {}) + keyed = {**delivered, "reporting_profile": {"id": "delivery", "primary_keys": ["account_id"]}} + (offering,) = ManagedSubscriptions._offerings([keyed], {}) + assert offering.method_pattern == "file_transfer" + # An API-delivered offering is not a managed-delivery offering. + (api,) = ManagedSubscriptions._offerings( + [{**_offering("off-api", "utc"), "reporting_profile": {"id": "delivery"}}], {} + ) + assert api.method_pattern is None diff --git a/tests/test_reporting_subscription_coverage.py b/tests/test_reporting_subscription_coverage.py index 9dbc5bb19..40df653e4 100644 --- a/tests/test_reporting_subscription_coverage.py +++ b/tests/test_reporting_subscription_coverage.py @@ -803,9 +803,10 @@ def test_all_owes_each_offering_its_own_periods(self) -> None: ("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. + def test_a_newly_available_finer_offering_never_re_resolves(self) -> None: + # adcp#8619: resolution is sticky per (buy, version). A finer offering + # published at 2026-10-03T12:00Z never takes over from the offering the + # package resolved to at confirmation. finer = offering( "off-a-pkg-creative", dimensions=("package", "creative"), @@ -826,8 +827,8 @@ def test_a_new_finer_offering_takes_over_from_the_next_boundary(self) -> None: ("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"), + ("2026-10-04T04:00:00Z", "off-b-package"), + ("2026-10-05T04:00:00Z", "off-b-package"), ] def test_withdrawn_offering_with_no_successor_stops_after_started_periods(self) -> None: diff --git a/tests/test_reporting_subscription_grant_journal.py b/tests/test_reporting_subscription_grant_journal.py index 5b814c564..df720d62e 100644 --- a/tests/test_reporting_subscription_grant_journal.py +++ b/tests/test_reporting_subscription_grant_journal.py @@ -191,6 +191,30 @@ def test_the_chain_start_holds_inside_retention_and_restarts_after_it() -> None: assert authorization_chain_start([], status_retention_days=30) is None +def test_regranted_at_is_the_latest_regrant_within_the_current_chain() -> None: + from adcp.reporting.subscriptions import authorization_regranted_at + + day = timedelta(days=1) + start = at("2026-08-01T00:00:00Z") + once = [SubscriptionAuthorization(start)] + assert authorization_regranted_at(once, status_retention_days=30) is None + chain = [ + SubscriptionAuthorization(start, start + day), + SubscriptionAuthorization(start + 2 * day, start + 3 * day), + SubscriptionAuthorization(start + 4 * day), + ] + assert authorization_regranted_at(chain, status_retention_days=30) == start + 4 * day + # A re-grant after retention starts a new chain, which has no re-grant yet. + fresh = [ + SubscriptionAuthorization(start, start + day), + SubscriptionAuthorization(start + 60 * day), + ] + assert authorization_regranted_at(fresh, status_retention_days=30) is None + # No grant held: nothing to serve. + lost = [SubscriptionAuthorization(start, start + day)] + assert authorization_regranted_at(lost, status_retention_days=30) is None + + # -- the managed path ---------------------------------------------------------- @@ -232,7 +256,10 @@ async def test_a_regrant_inside_retention_keeps_authorized_at() -> None: ) world.clock.set(START + timedelta(days=3)) listed = await world.platform.list_accounts({}, _ctx()) - assert listed["accounts"][0]["authorization"]["authorized_at"] == "2026-10-01T00:00:00Z" + authorization = listed["accounts"][0]["authorization"] + assert authorization["authorized_at"] == "2026-10-01T00:00:00Z" + # adcp#8618: the latest re-grant within the chain is on the wire too. + assert authorization["regranted_at"] == "2026-11-04T00:00:00Z" @pytest.mark.asyncio diff --git a/tests/test_reporting_subscription_grants.py b/tests/test_reporting_subscription_grants.py index eb6750866..e8cc80db3 100644 --- a/tests/test_reporting_subscription_grants.py +++ b/tests/test_reporting_subscription_grants.py @@ -93,10 +93,29 @@ async def test_a_reduced_reconciliation_grant_keeps_reconciliation_working() -> assert receipts.calls == [{"account_id": ACCOUNT, "consumer_id": AGENCY.subject}] ingested = await seller.service.sync_reporting_status(_status()) assert ingested["results"][0]["result"] in {"recorded", "unchanged"}, ingested - # The grant does not list get_media_buy_delivery, so exact reads are refused. + # The grant does not list get_media_buy_delivery, so exact reads are + # refused. The account stays covered, so the refusal is SCOPE_INSUFFICIENT, + # not an unknown identifier (adcp#8619). with pytest.raises(LedgerConflictError) as refused: await seller.service.get_revision_content({"reporting_revision_id": "rpr_any"}) - assert refused.value.code == "ACCOUNT_NOT_FOUND" + assert refused.value.code == "SCOPE_INSUFFICIENT" + + +@pytest.mark.asyncio +async def test_a_partial_grant_refuses_the_missing_task_with_scope_insufficient() -> None: + # adcp#8619: a grant without sync_reporting_receipts keeps the account + # covered; the seller refuses receipts for that account as out of scope. + seller, receipts = await _subscribed(frozenset({"get_reporting_status"})) + view = await seller.status(AGENCY, "summary") + assert view["subscription_coverage"][0]["status"] == "active" + for call in ( + lambda: seller.service.sync_reporting_receipts({"receipts": []}), + lambda: seller.service.sync_reporting_status(_status()), + ): + with pytest.raises(LedgerConflictError) as refused: + await call() + assert refused.value.code == "SCOPE_INSUFFICIENT" + assert receipts.calls == [] @pytest.mark.asyncio diff --git a/tests/test_reporting_subscription_parity.py b/tests/test_reporting_subscription_parity.py index b5e3f3ab1..53ec5dcdb 100644 --- a/tests/test_reporting_subscription_parity.py +++ b/tests/test_reporting_subscription_parity.py @@ -2,7 +2,8 @@ ``tests/fixtures/reporting_subscriptions/spec/`` vendors ``static/compliance/source/test-vectors/reporting-subscriptions/`` from the spec -repository at :data:`SPEC_COMMIT` (adcp#8616), pinned below by SHA-256. Each +repository at :data:`SPEC_COMMIT` (the signed 3.3.0-beta.3 source commit, with +adcp#8617, #8618, #8619 and #8622), pinned below by SHA-256. Each case gives the buyer-side input and either the exact owed rows, derived from the spec text its ``spec_refs`` cite and reproduced by an independent reference model, or the input error. Both SDKs must produce identical periods, @@ -30,8 +31,6 @@ CoverageInputError, SubscriptionAuthorization, SubscriptionBuy, - SubscriptionBuyFlight, - SubscriptionBuyPackage, SubscriptionDestination, SubscriptionOffering, SubscriptionVersionWindow, @@ -39,11 +38,12 @@ ) SPEC = Path(__file__).parent / "fixtures" / "reporting_subscriptions" / "spec" -#: adcontextprotocol/adcp commit the vendored files come from (adcp#8616). -SPEC_COMMIT = "023bbd26e7c230dcc74bea4087336ea6bc8d0f6a" +#: adcontextprotocol/adcp commit the vendored files come from: the source commit +#: of the signed 3.3.0-beta.3 bundle (schemas/releases/3.3.0-beta.3.json). +SPEC_COMMIT = "d5f8de383df13525e11721ee3d493ee76352210f" PINNED = { - "vectors.json": "b392df771fc2bf635c5c441ff2dd5107e1cda13ab483fe902a6a56661f5e7904", - "case.schema.json": "0550b51097ea2c9a6bbd8721de5b5855acfcbd097dff31edeb0df2f397f7dc42", + "vectors.json": "56cc381bc336b31cb0abc589d73d0f1a8ecd52f5c42532fc47fb9112c3b594a4", + "case.schema.json": "17448cb98b1c8ce671b0c5f354492e4965f833320c78f4ee6722b184ab344c60", "error-codes.json": "4e8e5142ada936fc8923735155d786a0801258922fb85b492785730479713481", "reason-codes.json": "2574b2a32fb8260fc7f9a7d9cd1eac1b588aa22c54a350e54964a893b80e77c4", } @@ -138,42 +138,25 @@ def offerings(input_: dict[str, Any]) -> list[SubscriptionOffering]: SubscriptionOffering.from_offering( wire, 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]: + """Each buy as ``get_media_buys`` returns it (every input field is on the wire).""" 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", ()) - ), - ) - ) + media_buy = { + key: value + for key, value in buy.items() + if key not in {"canceled_at", "cancellation_effective_at"} + } + if "canceled_at" in buy: + media_buy["cancellation"] = {"canceled_at": buy["canceled_at"]} + if "cancellation_effective_at" in buy: + media_buy["cancellation_effective_at"] = buy["cancellation_effective_at"] + result.append(SubscriptionBuy.from_media_buy(media_buy)) return result @@ -206,6 +189,7 @@ def rows(owed) -> list[tuple]: p.offering_id.encode(), media_buy_id.encode(), at(p.period_end), + p.stub, ) for p in owed for media_buy_id in p.media_buy_ids @@ -234,6 +218,7 @@ def test_spec_vectors(case: dict[str, Any]) -> None: row["offering_id"].encode(), row["media_buy_id"].encode(), at(row["period_end"]), + row.get("stub", False), ) for row in case["expected"] ] @@ -246,6 +231,7 @@ def test_spec_vectors(case: dict[str, Any]) -> None: p.offering_id, at(p.period_start), at(p.period_end), + p.stub, list(p.media_buy_ids), ) for p in owed @@ -256,31 +242,75 @@ def test_spec_vectors(case: dict[str, Any]) -> None: item["offering_id"], at(item["period_start"]), at(item["period_end"]), + item.get("stub", False), item["media_buy_ids"], ) for item in case["expected_obligations"] ], case.get("rule") -def test_every_vector_subscription_is_schema_valid() -> None: - """Each vector's subscriptions validate against core/reporting-subscription.json.""" +def _wire_errors(validator, value) -> list[str]: + # The dimension vocabulary is x-extensible: receivers MUST accept values + # the enum does not list. + return [ + error.message + for error in validator.iter_errors(value) + if not ( + error.validator == "enum" + and any("dimensions" in str(part) for part in error.absolute_path) + ) + ] + + +def test_every_vector_wire_object_is_schema_valid() -> None: + """Each input's wire objects validate against the 3.3.0-beta.3 schemas. + + Subscriptions against ``core/reporting-subscription.json``, offerings + (with ``available_from`` / ``withdrawn_at``) against + ``core/reporting-delivery-offering.json``, and each buy's + ``flight_history`` against ``core/media-buy.json`` (adcp#8618): every + input a buyer needs is on the wire. + """ from adcp.validation import schema_loader - validator = schema_loader.get_named_validator( - "core/reporting-subscription.json", version="3.3.0-beta.1" + version = "3.3.0-beta.3" + subscription = schema_loader.get_named_validator( + "core/reporting-subscription.json", version=version ) + offering = schema_loader.get_named_validator( + "core/reporting-delivery-offering.json", version=version + ) + flight_history = schema_loader.get_named_validator( + "core/media-buy.json", version=version + ).evolve(schema={"$ref": "#/properties/flight_history"}) for case in CASES: + input_ = case["input"] + errors = [ + *( + (f"versions[{index}]", _wire_errors(subscription, item["configuration"])) + for index, item in enumerate(input_["versions"]) + ), + *( + (f"offerings[{index}]", _wire_errors(offering, item)) + for index, item in enumerate(input_["offerings"]) + ), + *( + ( + f"buys[{index}].flight_history", + _wire_errors(flight_history, item["flight_history"]), + ) + for index, item in enumerate(input_["buys"]) + if "flight_history" in item + ), + ] + failures = [(where, found) for where, found in errors if found] if case.get("expected_error", {}).get("wire_valid") is False: - continue # the input deliberately fails AdCP schema validation (P1W) - for version in case["input"]["versions"]: - 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) + assert failures, ( + case["id"], + "expected_error.wire_valid is false but every object validates", + ) + continue + assert failures == [], (case["id"], failures) def test_error_codes_match_the_frozen_spec_vocabulary() -> None: From 26f818a402f717cb3e6d830f1c8d50168b5f6b33 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 13:25:14 +0000 Subject: [PATCH 2/3] fix(reporting): warn on a managed offering whose rows omit account_id adcp#8619 requires every managed-delivery offering's row schema to declare account_id and list it in primary_keys. Refusing the configuration broke the production conformance seller, whose reference writer and digest-pinned row contract do not key rows by account_id yet. Log a reporting.subscription.offering_primary_keys warning naming the offering instead, while row contracts migrate. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reporting-subscriptions-seller.md | 2 +- src/adcp/reporting/subscriptions/managed.py | 12 ++++---- ...test_reporting_subscription_beta3_rules.py | 28 +++++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/reporting-subscriptions-seller.md b/docs/reporting-subscriptions-seller.md index ffc2dfb14..903a5730b 100644 --- a/docs/reporting-subscriptions-seller.md +++ b/docs/reporting-subscriptions-seller.md @@ -26,7 +26,7 @@ The SDK reads these surfaces under each principal's identity. A principal sees e | Authorized accounts | `list_accounts` (a decisioning platform's `accounts.list`) | `account_id`, `timezone` (IANA), `operator`, `authorization.allowed_tasks` | `SubscriptionOptions.accounts` | | Visible buys | `get_media_buys` | `confirmed_at`, `start_time`, `end_time`, `cancellation.canceled_at`, `cancellation_effective_at`, `completed_at`, `flight_history`, `packages[].package_id` | `SubscriptionOptions.buys` | | Package offerings | `packages[].reporting_delivery_offering_ids` (fixed at confirmation), else every registered offering | | `SubscriptionOptions.buy_offerings` | -| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering; `available_from` / `withdrawn_at` when they apply; `account_id` in `reporting_profile.primary_keys` on every offering delivered to destinations (`method`) | none | +| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering; `available_from` / `withdrawn_at` when they apply; `account_id` in `reporting_profile.primary_keys` on every offering delivered to destinations (`method`), required by 3.3.0-beta.3 and logged as `reporting.subscription.offering_primary_keys` when missing | none | | Report content | `account_context`, called with `configuration.subscription` set | | `SubscriptionOptions.configure` | A decisioning `Account` carries `timezone=` and `operator=`. An account without a timezone is refused rather than read as UTC. diff --git a/src/adcp/reporting/subscriptions/managed.py b/src/adcp/reporting/subscriptions/managed.py index 8fbc26d1e..ec8b1131e 100644 --- a/src/adcp/reporting/subscriptions/managed.py +++ b/src/adcp/reporting/subscriptions/managed.py @@ -623,11 +623,13 @@ def _offerings( if offering.get("method") is not None and "account_id" not in (keys or ()): # adcp#8619: consolidated destinations validate and never # collide only when every delivered row carries its account. - raise ReliableReportingConfigurationError( - f"offering {offering_id!r} is delivered to destinations, so its row " - "schema must declare account_id as a required string and list it in " - "reporting_profile.primary_keys.", - config_field="capability_offerings", + # Warned, not refused, while existing row contracts migrate. + logger.warning( + "offering %s is delivered to destinations but its reporting_profile." + "primary_keys omit account_id; AdCP 3.3.0-beta.3 requires its row schema " + "to declare account_id as a required string and list it in primary_keys", + offering_id, + extra={"event": "reporting.subscription.offering_primary_keys"}, ) try: mapped.append(SubscriptionOffering.from_offering(offering)) diff --git a/tests/test_reporting_subscription_beta3_rules.py b/tests/test_reporting_subscription_beta3_rules.py index 1186ef9cf..da85f6f30 100644 --- a/tests/test_reporting_subscription_beta3_rules.py +++ b/tests/test_reporting_subscription_beta3_rules.py @@ -247,21 +247,33 @@ async def plan() -> list: # -- seller refusals ------------------------------------------------------------- -def test_a_destination_offering_must_key_rows_by_account_id() -> None: - from adcp.reporting import ReliableReportingConfigurationError +def test_a_destination_offering_without_account_id_keys_is_warned(caplog) -> None: + """adcp#8619: a managed-delivery offering's rows are keyed by account_id. + + Warned at configuration rather than refused while existing row contracts + migrate; the warning names the offering. + """ + import logging delivered = { **_offering("off-files", "utc"), "method": {"pattern": "file_transfer", "transport": "s3"}, "reporting_profile": {"id": "delivery", "primary_keys": ["package_id"]}, } - with pytest.raises(ReliableReportingConfigurationError, match="primary_keys"): - ManagedSubscriptions._offerings([delivered], {}) - keyed = {**delivered, "reporting_profile": {"id": "delivery", "primary_keys": ["account_id"]}} - (offering,) = ManagedSubscriptions._offerings([keyed], {}) + with caplog.at_level(logging.WARNING, logger="adcp.reporting.subscriptions.managed"): + (offering,) = ManagedSubscriptions._offerings([delivered], {}) assert offering.method_pattern == "file_transfer" + assert any( + getattr(record, "event", None) == "reporting.subscription.offering_primary_keys" + and "off-files" in record.getMessage() + for record in caplog.records + ) + caplog.clear() + keyed = {**delivered, "reporting_profile": {"id": "delivery", "primary_keys": ["account_id"]}} + with caplog.at_level(logging.WARNING, logger="adcp.reporting.subscriptions.managed"): + ManagedSubscriptions._offerings([keyed], {}) # An API-delivered offering is not a managed-delivery offering. - (api,) = ManagedSubscriptions._offerings( + ManagedSubscriptions._offerings( [{**_offering("off-api", "utc"), "reporting_profile": {"id": "delivery"}}], {} ) - assert api.method_pattern is None + assert not caplog.records From 5925a3a6027182ce6d342fef8b1a422ebc7678f0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 13:51:48 +0000 Subject: [PATCH 3/3] feat(reporting): mirror the TypeScript SDK's beta.3 names and the wire regranted_at Align with adcontextprotocol/adcp-client#3559 and #3560: - resolve_reporting_subscription_package, the TypeScript resolveReportingSubscriptionPackageV1, is the sticky per-package resolution the buyer and the seller share, exported from adcp.reporting.advanced. - assert_reporting_task_granted, the TypeScript assertReportingTaskGrantedV1, refuses a task a resolved account's grant leaves out with SCOPE_INSUFFICIENT. - A host reports a re-grant inside the chain as authorization.regranted_at, as AuthorizedAccount.regranted_at or GrantObservation.regranted_at. The grant journal dates the re-grant from it when it falls between the loss and the observation. authorized_at keeps the chain start. - grant-cases.json is the TypeScript copy: the re-grant step names regranted_at, not the engine-only granted_at. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/advanced.py | 4 ++ src/adcp/reporting/service_subscriptions.py | 29 ++++++++++++ src/adcp/reporting/subscriptions/coverage.py | 45 ++++-------------- src/adcp/reporting/subscriptions/engine.py | 31 ++++++++++--- src/adcp/reporting/subscriptions/managed.py | 4 ++ .../reporting/subscriptions/resolution.py | 46 +++++++++++++++++++ .../reporting_subscriptions/grant-cases.json | 2 +- ...test_reporting_subscription_beta3_rules.py | 42 +++++++++++++++++ ...st_reporting_subscription_grant_journal.py | 34 ++++++++++++-- 9 files changed, 190 insertions(+), 47 deletions(-) diff --git a/src/adcp/reporting/advanced.py b/src/adcp/reporting/advanced.py index 00564508e..1deb2ca36 100644 --- a/src/adcp/reporting/advanced.py +++ b/src/adcp/reporting/advanced.py @@ -23,6 +23,7 @@ ProductionSubscriptionHooks, ReportingSubscriptionServiceOptions, SubscriptionPlanningFailure, + assert_reporting_task_granted, ) from adcp.reporting.subscriptions._clock import COVERAGE_INPUT_DETAILS, CoverageInputError from adcp.reporting.subscriptions.capabilities import ( @@ -91,10 +92,12 @@ SubscriptionOffering, SubscriptionResolution, SubscriptionSelector, + resolve_reporting_subscription_package, resolve_subscription_offerings, ) __all__ = [ + "assert_reporting_task_granted", # Engine "ENGINE_ERROR_CODES", "AccountConfigsEnder", @@ -144,6 +147,7 @@ "SubscriptionOffering", "SubscriptionResolution", "SubscriptionSelector", + "resolve_reporting_subscription_package", "resolve_subscription_offerings", # Capability builder "ReportingSubscriptionSupport", diff --git a/src/adcp/reporting/service_subscriptions.py b/src/adcp/reporting/service_subscriptions.py index 897686915..77ce67d0f 100644 --- a/src/adcp/reporting/service_subscriptions.py +++ b/src/adcp/reporting/service_subscriptions.py @@ -255,6 +255,34 @@ async def require_subscription_grant( ) +def assert_reporting_task_granted(account: Any, task: str) -> None: + """Refuse ``task`` on a resolved account whose grant leaves it out (adcp#8619). + + Coverage depends only on ``get_reporting_status``, so a grant without + ``sync_reporting_receipts`` or ``sync_reporting_status`` keeps the account + covered, and the seller refuses the missing task for that account with + ``SCOPE_INSUFFICIENT``, not as an unknown identifier. ``account`` is the + resolved account (model or mapping) with its ``authorization``; an absent + authorization, or one whose ``allowed_tasks`` restricts nothing, passes. + The TypeScript SDK's ``assertReportingTaskGrantedV1``. + """ + from adcp.reporting.ledger.store import LedgerConflictError + + if hasattr(account, "model_dump"): + account = account.model_dump(mode="json", exclude_none=True) + authorization: Any = account.get("authorization") if isinstance(account, Mapping) else None + if authorization is not None and hasattr(authorization, "model_dump"): + authorization = authorization.model_dump(mode="json", exclude_none=True) + allowed = authorization.get("allowed_tasks") if isinstance(authorization, Mapping) else None + if allowed is None or task in {str(getattr(item, "value", item)) for item in allowed}: + return + account_id = account.get("account_id") if isinstance(account, Mapping) else None + raise LedgerConflictError( + "SCOPE_INSUFFICIENT", + f"the caller's grant on account {account_id!r} does not include {task}", + ) + + async def with_subscription_coverage( response: dict[str, Any], options: ReportingSubscriptionServiceOptions, @@ -355,5 +383,6 @@ def check_managed_binding( "ReportingSubscriptionServiceOptions", "SubscriptionOptions", "SubscriptionPlanningFailure", + "assert_reporting_task_granted", "serves_subscriptions", ] diff --git a/src/adcp/reporting/subscriptions/coverage.py b/src/adcp/reporting/subscriptions/coverage.py index 121b88ccf..92db579fb 100644 --- a/src/adcp/reporting/subscriptions/coverage.py +++ b/src/adcp/reporting/subscriptions/coverage.py @@ -81,7 +81,7 @@ SubscriptionSelector, _text, _value, - resolve_subscription_offerings, + resolve_reporting_subscription_package, ) #: Exclusion reasons that remove periods. ``package_ids`` entries never do. @@ -1159,8 +1159,13 @@ def _regimes( first_resolved = max(first_resolved, buy.confirmed_at) found: dict[str, SubscriptionOffering] = {} for _package_id, offering_ids in buy.product_offering_ids(): - for item in _sticky_resolution( - selector, offerings, offering_ids, destination, first_resolved, start + for item in resolve_reporting_subscription_package( + selector, + offerings, + product_offering_ids=offering_ids, + destination=destination, + resolves_at=first_resolved, + at=start, ): found[item.offering_id] = item resolved = tuple( @@ -1206,40 +1211,6 @@ def _regimes( return regimes -def _sticky_resolution( - selector: SubscriptionSelector, - offerings: Sequence[SubscriptionOffering], - offering_ids: Sequence[str], - destination: Any, - first_resolved: datetime, - at: datetime, -) -> tuple[SubscriptionOffering, ...]: - """The offerings one package resolves to at ``at`` (adcp#8619). - - The package resolves once, at ``first_resolved``, among the offerings - available then; an offering added later never re-resolves it. Withdrawal of - a resolved offering is the one change: the package re-resolves among the - offerings available at ``withdrawn_at``. - """ - resolved_at = first_resolved - while True: - chosen = resolve_subscription_offerings( - selector, - offerings, - product_offering_ids=offering_ids, - destination=destination, - at=resolved_at, - ).offerings - withdrawals = [ - item.withdrawn_at - for item in chosen - if item.withdrawn_at is not None and resolved_at < item.withdrawn_at <= at - ] - if not withdrawals: - return chosen - resolved_at = min(withdrawals) - - def _regime_before_deactivation( regimes: Sequence[_Regime | None], window: SubscriptionVersionWindow, start: datetime ) -> tuple[datetime, _Regime] | None: diff --git a/src/adcp/reporting/subscriptions/engine.py b/src/adcp/reporting/subscriptions/engine.py index 68c55d5d2..e4ddf7024 100644 --- a/src/adcp/reporting/subscriptions/engine.py +++ b/src/adcp/reporting/subscriptions/engine.py @@ -123,6 +123,10 @@ class AuthorizedAccount: #: ``sync_reporting_receipts`` and ``sync_reporting_status``) rather than #: revoking; after revocation the account is unknown to every task. allowed_tasks: frozenset[str] | None = None + #: ``authorization.regranted_at`` (adcp#8618): the latest re-grant within + #: the current chain, when the host knows it. It dates a re-grant the + #: journal observes; ``authorized_at`` keeps the chain start. + regranted_at: datetime | None = None def __post_init__(self) -> None: require_zone(self.timezone, field="AuthorizedAccount.timezone") @@ -203,6 +207,8 @@ class GrantObservation: authorized_at: datetime | None = None timezone: str | None = None operator: str | None = None + #: The served ``authorization.regranted_at`` (adcp#8618), when shown. + regranted_at: datetime | None = None class AccountConfigsEnder(Protocol): @@ -910,14 +916,15 @@ async def observe_grants( if item.timezone is not None: require_zone(item.timezone, field="timezone") reported = None if item.authorized_at is None else utc_instant(item.authorized_at) - prepared.append((item, tasks, _covers(tasks), reported)) + regranted = None if item.regranted_at is None else utc_instant(item.regranted_at) + prepared.append((item, tasks, _covers(tasks), reported, regranted)) regressions: list[tuple[str, datetime]] = [] def change(state: _PrincipalState) -> dict[str, datetime | None]: regressions.clear() # the change may rerun on a write conflict served: dict[str, datetime | None] = {} - for item, tasks, covered, reported in prepared: + for item, tasks, covered, reported, regranted in prepared: account = state.accounts.get(item.account_id) if account is None: if not covered: @@ -926,7 +933,12 @@ def change(state: _PrincipalState) -> dict[str, datetime | None]: account = _Account(timezone=item.timezone, operator=item.operator) state.accounts[item.account_id] = account if self._observe( - account, covered=covered, reported=reported, at=instant, source="listed" + account, + covered=covered, + reported=reported, + at=instant, + source="listed", + regranted=regranted, ): assert reported is not None regressions.append((item.account_id, reported)) @@ -1108,6 +1120,7 @@ def _observe( reported: datetime | None, at: datetime, source: str, + regranted: datetime | None = None, ) -> bool: """Apply one observation of a grant to the journal; ``True`` if it regressed. @@ -1124,10 +1137,15 @@ def _observe( if not account.authorized: lost = account.grants[-1].unauthorized_at if account.grants else None # adcp#8585: authorized_at is the chain start. A later one is a new - # grant dated then; an unchanged one is a re-grant within - # retention, which the wire does not date, so it is stamped when + # grant dated then. An unchanged one is a re-grant within retention: + # the host's regranted_at (adcp#8618) dates it when it falls between + # the loss and this observation; otherwise it is stamped when # observed. - if reported is not None and (lost is None or reported > lost): + if regranted is not None: + regranted = utc_instant(regranted, field="regranted_at") + if regranted is not None and lost is not None and lost < regranted <= at: + granted = regranted + elif reported is not None and (lost is None or reported > lost): granted = min(reported, at) else: granted = at @@ -1246,6 +1264,7 @@ def change(principal: _PrincipalState) -> None: reported=item.authorized_at, at=instant, source="listed", + regranted=item.regranted_at, ): regressions.append((account_id, item.authorized_at)) account.set_timezone(item.timezone, instant) diff --git a/src/adcp/reporting/subscriptions/managed.py b/src/adcp/reporting/subscriptions/managed.py index ec8b1131e..ef88eb143 100644 --- a/src/adcp/reporting/subscriptions/managed.py +++ b/src/adcp/reporting/subscriptions/managed.py @@ -934,10 +934,12 @@ async def _platform_accounts( authorization = account.get("authorization") tasks = None authorized_at = now + regranted_at = None if isinstance(authorization, Mapping): if authorization.get("allowed_tasks") is not None: tasks = frozenset(str(item) for item in authorization["allowed_tasks"]) authorized_at = _instant(authorization.get("authorized_at")) or now + regranted_at = _instant(authorization.get("regranted_at")) accounts.append( AuthorizedAccount( account_id=str(account_id), @@ -945,6 +947,7 @@ async def _platform_accounts( authorized_at=authorized_at, operator=account.get("operator"), allowed_tasks=tasks, + regranted_at=regranted_at, ) ) pagination = response.get("pagination") or {} @@ -1065,6 +1068,7 @@ async def _project_page(self, principal: PrincipalRef, page: list[Any]) -> list[ _instant(authorization.get("authorized_at")), raw.get("timezone") or None, raw.get("operator"), + _instant(authorization.get("regranted_at")), ) ) regrants: dict[str, datetime | None] = {} diff --git a/src/adcp/reporting/subscriptions/resolution.py b/src/adcp/reporting/subscriptions/resolution.py index c78b83cfc..4069702e3 100644 --- a/src/adcp/reporting/subscriptions/resolution.py +++ b/src/adcp/reporting/subscriptions/resolution.py @@ -510,6 +510,51 @@ def resolve_subscription_offerings( ) +def resolve_reporting_subscription_package( + subscription: Any, + offerings: Sequence[SubscriptionOffering], + *, + product_offering_ids: Iterable[str], + resolves_at: datetime, + at: datetime, + destination: Any | None = None, +) -> tuple[SubscriptionOffering, ...]: + """Sticky per-package resolution (adcp#8619), shared by buyer and seller. + + The offerings one package resolves to at ``at``. The package resolves once, + at ``resolves_at`` (the later of the version's ``accepted_at`` and the + buy's ``confirmed_at``), among the offerings available then, and keeps that + resolution until a resolved offering is withdrawn: an offering added later + never re-resolves it. At a withdrawal the package re-resolves among the + offerings then available, which is a cutover. The TypeScript SDK's + ``resolveReportingSubscriptionPackageV1``. + """ + selector = ( + subscription + if isinstance(subscription, SubscriptionSelector) + else SubscriptionSelector.from_subscription(subscription) + ) + listed = tuple(product_offering_ids) + resolved_at = utc_instant(resolves_at, field="resolves_at") + instant = utc_instant(at, field="at") + while True: + chosen = resolve_subscription_offerings( + selector, + offerings, + product_offering_ids=listed, + destination=destination, + at=resolved_at, + ).offerings + withdrawals = [ + item.withdrawn_at + for item in chosen + if item.withdrawn_at is not None and resolved_at < item.withdrawn_at <= instant + ] + if not withdrawals: + return chosen + resolved_at = min(withdrawals) + + __all__ = [ "STANDARD_REPORTING_DIMENSIONS", "UTC_ZONE_NAMES", @@ -518,5 +563,6 @@ def resolve_subscription_offerings( "SubscriptionOffering", "SubscriptionResolution", "SubscriptionSelector", + "resolve_reporting_subscription_package", "resolve_subscription_offerings", ] diff --git a/tests/fixtures/reporting_subscriptions/grant-cases.json b/tests/fixtures/reporting_subscriptions/grant-cases.json index 1923bc9c4..96da532b6 100644 --- a/tests/fixtures/reporting_subscriptions/grant-cases.json +++ b/tests/fixtures/reporting_subscriptions/grant-cases.json @@ -150,7 +150,7 @@ "steps": [ { "at": "2026-09-01T18:00:00Z", "allowed_tasks": null }, { "at": "2026-09-04T12:00:00Z", "allowed_tasks": ["sync_reporting_receipts", "sync_reporting_status"] }, - { "at": "2026-09-06T12:00:00Z", "allowed_tasks": null, "granted_at": "2026-09-06T12:00:00Z" } + { "at": "2026-09-06T12:00:00Z", "allowed_tasks": null, "regranted_at": "2026-09-06T12:00:00Z" } ], "evaluate_at": "2026-09-13T00:00:00Z", "expected": { diff --git a/tests/test_reporting_subscription_beta3_rules.py b/tests/test_reporting_subscription_beta3_rules.py index da85f6f30..c2c2838ef 100644 --- a/tests/test_reporting_subscription_beta3_rules.py +++ b/tests/test_reporting_subscription_beta3_rules.py @@ -277,3 +277,45 @@ def test_a_destination_offering_without_account_id_keys_is_warned(caplog) -> Non [{**_offering("off-api", "utc"), "reporting_profile": {"id": "delivery"}}], {} ) assert not caplog.records + + +def test_assert_reporting_task_granted_refuses_an_unlisted_task() -> None: + from adcp.reporting.advanced import assert_reporting_task_granted + from adcp.reporting.ledger.store import LedgerConflictError + + partial = { + "account_id": "acct-1", + "authorization": {"allowed_tasks": ["get_reporting_status"]}, + } + with pytest.raises(LedgerConflictError) as refused: + assert_reporting_task_granted(partial, "sync_reporting_receipts") + assert refused.value.code == "SCOPE_INSUFFICIENT" + assert_reporting_task_granted(partial, "get_reporting_status") + assert_reporting_task_granted({"account_id": "acct-1"}, "sync_reporting_status") + assert_reporting_task_granted( + {"account_id": "acct-1", "authorization": {"read_only": False}}, "sync_reporting_status" + ) + + +def test_a_package_keeps_its_resolution_until_the_offering_is_withdrawn() -> None: + from adcp.reporting.advanced import resolve_reporting_subscription_package + + first = SubscriptionOffering.from_offering( + {**_offering("off-a", "utc"), "withdrawn_at": "2026-11-05T00:00:00Z"} + ) + later = SubscriptionOffering.from_offering( + {**_offering("off-b", "utc"), "available_from": "2026-11-03T00:00:00Z"} + ) + selector = _billing(1, "utc") + common = dict( + offerings=[first, later], + product_offering_ids=("off-a", "off-b"), + resolves_at=at("2026-11-01T00:00:00Z"), + ) + pick = lambda when: [ # noqa: E731 + item.offering_id + for item in resolve_reporting_subscription_package(selector, at=at(when), **common) + ] + # off-b appearing never re-resolves the package; off-a's withdrawal does. + assert pick("2026-11-04T00:00:00Z") == ["off-a"] + assert pick("2026-11-06T00:00:00Z") == ["off-b"] diff --git a/tests/test_reporting_subscription_grant_journal.py b/tests/test_reporting_subscription_grant_journal.py index df720d62e..82d892376 100644 --- a/tests/test_reporting_subscription_grant_journal.py +++ b/tests/test_reporting_subscription_grant_journal.py @@ -20,6 +20,7 @@ from adcp.exceptions import ADCPTaskError from adcp.reporting import PrincipalRef +from adcp.reporting.advanced import GrantObservation from adcp.reporting.subscriptions import ( AuthorizedAccount, ReportingSubscriptionEngine, @@ -151,13 +152,14 @@ async def test_grant_transitions_through_the_engine(case: dict[str, Any]) -> Non if tasks == "revoked": host.accounts.pop("account-a", None) else: + # The wire keeps authorized_at at the chain start and dates a + # re-grant inside it with regranted_at (adcp#8618). host.accounts["account-a"] = AuthorizedAccount( "account-a", timezone="UTC", - authorized_at=at(step.get("granted_at", "2026-08-01T00:00:00Z")) - if "granted_at" in step - else authorized_at, + authorized_at=authorized_at, allowed_tasks=None if tasks is None else frozenset(tasks), + regranted_at=at(step["regranted_at"]) if "regranted_at" in step else None, ) await engine.reconcile(IDENTITY, now=at(step["at"])) expected = case["expected"] @@ -355,3 +357,29 @@ async def test_an_authorization_without_allowed_tasks_is_observed_as_a_full_gran listed = await world.platform.list_accounts({}, _ctx()) assert listed["accounts"][0]["authorization"]["authorized_at"] == "2026-10-01T00:00:00Z" assert world.service._managed.engine.authorized_at_regressions == 1 + + +@pytest.mark.asyncio +async def test_the_hosts_regranted_at_dates_a_regrant_inside_the_chain() -> None: + # adcp#8618: the host keeps authorized_at at the chain start and reports the + # re-grant as regranted_at; the journal dates the re-grant from it, not from + # when it happened to observe it. + engine, host, _principals = await _engine() + start = at("2026-08-01T00:00:00Z") + host.accounts["account-a"] = AuthorizedAccount("account-a", timezone="UTC", authorized_at=start) + await engine.reconcile(IDENTITY, now=at("2026-09-01T00:00:00Z")) + host.accounts.pop("account-a") + await engine.reconcile(IDENTITY, now=at("2026-09-02T00:00:00Z")) + host.accounts["account-a"] = AuthorizedAccount( + "account-a", timezone="UTC", authorized_at=start, regranted_at=at("2026-09-03T06:00:00Z") + ) + await engine.reconcile(IDENTITY, now=at("2026-09-04T00:00:00Z")) + regrants: dict = {} + served = await engine.observe_grants( + IDENTITY, + [GrantObservation("account-a", None, start)], + at=at("2026-09-04T00:00:00Z"), + regrants=regrants, + ) + assert served["account-a"] == start + assert regrants["account-a"] == at("2026-09-03T06:00:00Z")