Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/reliable-reporting-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions docs/reporting-subscriptions-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
6 changes: 3 additions & 3 deletions docs/reporting-subscriptions-seller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`), 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.
Expand Down
6 changes: 6 additions & 0 deletions src/adcp/reporting/advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -45,6 +46,7 @@
SubscriptionException,
SubscriptionVersionWindow,
authorization_chain_start,
authorization_regranted_at,
expected_subscription_periods,
owed_subscription_periods,
subscription_covers_account,
Expand Down Expand Up @@ -90,10 +92,12 @@
SubscriptionOffering,
SubscriptionResolution,
SubscriptionSelector,
resolve_reporting_subscription_package,
resolve_subscription_offerings,
)

__all__ = [
"assert_reporting_task_granted",
# Engine
"ENGINE_ERROR_CODES",
"AccountConfigsEnder",
Expand Down Expand Up @@ -128,6 +132,7 @@
"SubscriptionException",
"SubscriptionVersionWindow",
"authorization_chain_start",
"authorization_regranted_at",
"expected_subscription_periods",
"owed_subscription_periods",
"subscription_covers_account",
Expand All @@ -142,6 +147,7 @@
"SubscriptionOffering",
"SubscriptionResolution",
"SubscriptionSelector",
"resolve_reporting_subscription_package",
"resolve_subscription_offerings",
# Capability builder
"ReportingSubscriptionSupport",
Expand Down
14 changes: 14 additions & 0 deletions src/adcp/reporting/ledger/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/reporting/ledger/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 41 additions & 3 deletions src/adcp/reporting/service_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -241,11 +244,45 @@ 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"
)


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,
Expand Down Expand Up @@ -346,5 +383,6 @@ def check_managed_binding(
"ReportingSubscriptionServiceOptions",
"SubscriptionOptions",
"SubscriptionPlanningFailure",
"assert_reporting_task_granted",
"serves_subscriptions",
]
2 changes: 2 additions & 0 deletions src/adcp/reporting/subscriptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
SubscriptionException,
SubscriptionVersionWindow,
authorization_chain_start,
authorization_regranted_at,
expected_subscription_periods,
owed_subscription_periods,
subscription_covers_account,
Expand Down Expand Up @@ -118,6 +119,7 @@
__all__ = [
"REPORTING_WIND_DOWN_TASKS",
"authorization_chain_start",
"authorization_regranted_at",
"wind_down_grant",
"COVERAGE_REASON_CODES",
"COVERAGE_RULE_ORDER",
Expand Down
15 changes: 13 additions & 2 deletions src/adcp/reporting/subscriptions/_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading