Skip to content

Commit 50dddb7

Browse files
bokelleyclaude
andcommitted
feat(reporting): adopt the AdCP 3.3.0-beta.4 coverage and controller rules
Per-package added_at (adcp#8718). SubscriptionBuyPackage carries the get_media_buys packages[].added_at, read by SubscriptionBuy.from_media_buy and kept by the managed path also under buy_offerings. Each package is owed from the later of the buy's confirmed_at and its added_at (SubscriptionBuy.package_confirmed_at), and resolves at the later of the version's accepted_at and that instant. A period of a resolved offering is owed when it ends after that instant for at least one package resolving the offering, so the period containing added_at is owed in full and adding a package is never a cutover; package_ids exceptions compose with it. explain reports the new before_package_added reason (rule confirmation), and judges a package_ids exception against the packages owed the period, as the reference model does. The test kit gains buys.add_package, which serves added_at. Reduced grants (adcp#8718, #8724). A grant reduced to allowed_tasks without get_reporting_status was already a loss of authority at the reduction (accounts_unauthorized, withheld periods, a re-grant when it is added back). The engine now keeps the reduced grant's tasks, so get_reporting_status refuses the still-visible account with SCOPE_INSUFFICIENT; only a revoked account is unknown (ACCOUNT_NOT_FOUND). SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY. check_subscription_offerings runs the spec's offering-checks.json (standard_dimensions, then the primary_keys and row-schema parts of the account_id rule, every failure in offering order). The managed service now refuses a managed-delivery offering whose primary_keys omit account_id with this code, where beta.3 only logged it. ReliableReportingConfigurationError gains context (offering_id, missing). Compliance controller (adcp#8717). reset_state is a scenario and a TestControllerStore hook; register_test_controller and the A2A server refuse a store that implements reporting_subscription_probe without it. Probe results are nested under simulated as SimulationSuccess requires. The reference seller serves reset_state (unpins its clock, restores the caller's default grant and the original timezone and discards what the probe recorded through ReportingSubscriptionEngine.discard_sandbox_changes) and returns SetAccountTimezoneResult from set_account_timezone (account_id, previous_timezone, timezone, changed_at when changed, timezone_cutovers[]). The vendored coverage vectors move to adcontextprotocol/adcp 8ee1bfa2e9: 138 cases, 18 more than before, all passing, including the new Pacific/Auckland, Pacific/Chatham, Asia/Kolkata and America/Mexico_City zones under the pinned tzdata 2026.6 (2026f). BREAKING CHANGE: a managed-delivery offering (one with method) whose reporting_profile.primary_keys omit account_id is refused with SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY instead of logged. After a reduction that drops get_reporting_status, get_reporting_status answers SCOPE_INSUFFICIENT instead of ACCOUNT_NOT_FOUND. A TestControllerStore that implements reporting_subscription_probe must implement reset_state, and probe results reach the wire under simulated. The reference controller's set_account_timezone result replaces cutovers with timezone_cutovers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ba09540 commit 50dddb7

28 files changed

Lines changed: 5303 additions & 738 deletions

‎docs/reporting-subscriptions-coverage.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ The frozen vocabulary comes from the spec's `reason-codes.json`, packaged with t
9797
| `outside_flight` | not owed | flight | Not owed: the period ends {end}, at or before {buy} starts at {start_time}. |
9898
| `after_stop` | not owed | flight | Not owed: the period starts {start}, at or after {buy} stopped at {stop_time}. |
9999
| `before_confirmation` | not owed | confirmation | Not owed: the period ends {end}, before {buy} was confirmed at {confirmed_at}. |
100+
| `before_package_added` | not owed | confirmation | Not owed: the period ends {end}, by {added_at}, before any package of {buy} resolving {offering} was added to it. |
100101
| `excluded:capacity` | not owed | exception | Not owed: account {account} is beyond the seller's subscription capacity from {from}. |
101102
| `excluded:overridden` | not owed | exception | Not owed: another of the caller's delivery configurations takes {account} for this feed from {from}. |
102103
| `excluded:action_required` | not owed | exception | Not owed: the seller serves account {account} only after a pending action completes; excluded from {from}. |
@@ -120,6 +121,14 @@ A period that ends at or before the buy's `confirmed_at` is not owed (`before_co
120121
>
121122
> Owed: mb_123 under daily_pkg for the period 2026-11-04T05:00Z to 2026-11-05T05:00Z, due at 2026-11-05T11:00Z.
122123
124+
### A package added later
125+
126+
A package added to a confirmed buy carries `added_at` on `get_media_buys` (3.3.0-beta.4), and `added_at` is that package's own `confirmed_at`. Each package is owed from the later of the buy's `confirmed_at` and its `added_at` (`confirmed_at` alone when `added_at` is absent: the package was part of the buy at confirmation). The buy owes a period of an offering when the period ends after that instant for at least one package resolving the offering. So the period containing `added_at` is owed in full, earlier periods are owed only through packages the buy already had, and adding a package is never a cutover. When every package resolving the offering was added after the period ended, the reason is `before_package_added`.
127+
128+
The package also resolves at the later of the version's `accepted_at` and its own `confirmed_at`, among the offerings available then. A `package_ids` exception removes a period when it names every package resolving the offering that is owed that period, so packages added and removed compose: a package keeps the buy covered from its `added_at` until an entry naming it takes it out.
129+
130+
`SubscriptionBuy.from_media_buy` reads `packages[].added_at` into `SubscriptionBuyPackage.added_at`, and `SubscriptionBuy.package_confirmed_at(package_id)` is the instant it is owed from.
131+
123132
### Cancellation mid-period
124133

125134
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.
@@ -150,7 +159,7 @@ When a cutover hands a billing feed to a schedule with no boundary at E, the str
150159

151160
### Sticky resolution
152161

153-
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.
162+
A package resolves once, when its (buy, version) pair first does: at the later of the version's `accepted_at` and the package's own `confirmed_at` (the buy's, or its `added_at` when later), 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.
154163

155164
### Reactivation
156165

@@ -160,4 +169,8 @@ Coverage resumes at the first boundary at or after the later of the reactivation
160169

161170
> Owed: mb_1 under off_e_pkg_acct_tz for the period 2026-11-01T04:00Z to 2026-11-02T05:00Z, due at 2026-11-02T11:00Z.
162171
163-
That New York day is 25 hours long. `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`).
172+
That New York day is 25 hours long. `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`). Under a local alignment a duration cannot mix date and time components (`P1DT12H`); since 3.3.0-beta.4 the schedule schemas reject it too, while `P1DT0H`, `P0DT12H` and any duration under `utc` stay valid.
173+
174+
### Time zones
175+
176+
Zones come from Python's `zoneinfo`, so periods follow the IANA tzdata the host resolves: the system database, or the `tzdata` package where there is none (Windows). The spec's vectors are derived with tzdata 2026f over the whole database, including southern-hemisphere DST (Pacific/Auckland), a 45-minute offset (Pacific/Chatham), half-hour offsets (Asia/Kolkata) and the 2022 rule change in America/Mexico_City. Their dates are chosen where releases 2026b through 2026f agree, so any of those reproduces them; `tests/test_reporting_subscription_parity.py` pins `tzdata==2026.6` (2026f) and ignores the host's database. Run buyer and seller on the same tzdata release, or a current one, so both derive the same boundaries.

‎docs/reporting-subscriptions-seller.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ The SDK reads these surfaces under each principal's identity. A principal sees e
2424
| Input | Read from | Must be present | Override |
2525
|---|---|---|---|
2626
| Authorized accounts | `list_accounts` (a decisioning platform's `accounts.list`) | `account_id`, `timezone` (IANA), `operator`, `authorization.allowed_tasks` | `SubscriptionOptions.accounts` |
27-
| 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` |
27+
| Visible buys | `get_media_buys` | `confirmed_at`, `start_time`, `end_time`, `cancellation.canceled_at`, `cancellation_effective_at`, `completed_at`, `flight_history`, `packages[].package_id`, and `packages[].added_at` on every package added after `confirmed_at` (3.3.0-beta.4) | `SubscriptionOptions.buys` |
2828
| Package offerings | `packages[].reporting_delivery_offering_ids` (fixed at confirmation; `[]` means none), else, only when the seller omits it, every registered offering | | `SubscriptionOptions.buy_offerings` |
29-
| 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 |
29+
| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering (else `SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS`); `available_from` / `withdrawn_at` when they apply; on every offering delivered to destinations (`method`), `account_id` in `reporting_profile.primary_keys` and a row schema that declares `account_id` as a required string (else `SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY`, refused at configuration since 3.3.0-beta.4) | none |
3030
| Report content | `account_context`, called with `configuration.subscription` set | | `SubscriptionOptions.configure` |
3131

3232
A decisioning `Account` carries `timezone=` and `operator=`. An account without a timezone is refused rather than read as UTC.
3333

34+
A host that supplies `SubscriptionOptions.buys` builds each `SubscriptionBuyPackage` with `added_at` for a package added after the buy's `confirmed_at`; one served through `get_media_buys` keeps it from the wire, also under `buy_offerings`. Without it the package reads as part of the buy at confirmation, which owes it periods it was never in.
35+
36+
`check_subscription_offerings(offerings, row_schemas)` runs the offering checks of the spec's `offering-checks.json` and returns every failure in offering order. Each failure's `.error()` is the `ReliableReportingConfigurationError` (`code`, `config_field`, and `context` with `offering_id` and `missing`: `primary_key` or `row_schema`). The service runs it on the registered offerings without row schemas; pass the row schemas you fetched to check those too.
37+
3438
### Capabilities and serving
3539

3640
The service fills in what it serves and leaves the rest to you:
@@ -45,14 +49,15 @@ The service fills in what it serves and leaves the rest to you:
4549

4650
The reference seller (`python -m adcp reference-seller reporting`) is a complete example.
4751

48-
## AdCP 3.3.0-beta.3 rules the engine applies
52+
## AdCP 3.3 rules the engine applies (3.3.0-beta.3 and beta.4)
4953

5054
- **Admission under `max_accounts`.** Candidates are ordered by `authorization.authorized_at`, then `account_id` in byte order, and an admitted account keeps its slot. A waiting candidate carries a `capacity` exception.
5155
- **Several feeds per purpose.** A caller may hold several pacing or analytics subscriptions for one account. Where two resolve the same offering for a package, the lowest `subscription_id` owes it and the other records an `overridden` exception naming the `package_ids`. Billing stays one per account at a scope level.
5256
- **Membership in place.** Membership edits (`account_ids`, `exclude_account_ids`) keep the version. A removed account stays listed, `excluded`, with an `excluded_by_buyer` entry from the cutover. An added account gets a closed entry covering the time before it joined.
5357
- **`destination_overrides`.** An account named by an override, by `account_ids` or `operator_domains`, resolves against the override's destination method. Its owed sets carry the override as the version's `destination`, so `bind_managed_generation` binds and checks the override's `path_template`.
5458
- **Bounded readback.** `prior_versions` lists the 16 newest inline, and the account lists 1,000, each with a total count. `get_principal` `reporting_subscription_page` pages the rest from the engine's state.
5559
- **Partial grants.** A grant that keeps the account covered refuses a task it does not list with `SCOPE_INSUFFICIENT`.
60+
- **Reduced grants (3.3.0-beta.4).** A grant reduced to `allowed_tasks` without `get_reporting_status` ends the caller's reporting authority exactly as a revocation does, at the reduction: the account is listed in `accounts_unauthorized` with that instant as `unauthorized_at`, nothing is delivered for periods ending after it, and owed periods not delivered by then are withheld. The account stays visible to the tasks the grant lists, so `get_reporting_status` refuses it with `SCOPE_INSUFFICIENT`, as for any task the grant omits; only a revoked account is unknown (`ACCOUNT_NOT_FOUND`). Adding `get_reporting_status` back is a re-grant for every rule: the chain, `regranted_at`, gaps, `backfill_on_regrant` and admission. A reduction that keeps `get_reporting_status` is not a loss of authority.
5661
- **Billing stubs and `regranted_at`.** A billing cutover owes a stub obligation (`stub: true`), and `list_accounts` serves `authorization.regranted_at`.
5762

5863
The reference seller, `adcp.testing.reporting.create_reference_reporting_seller`, is a complete example on this path. A ~100-line handler serves `list_accounts` and `get_media_buys`, and `ReliableReportingService(subscriptions=SubscriptionOptions(...))` adds the rest. Run it with `python -m adcp reference-seller reporting`; see [Testing](reporting-subscriptions-testing.md#the-reference-seller).
@@ -91,7 +96,7 @@ The SDK is the only source of `authorization.authorized_at`.
9196

9297
`authorized_at` is when the *current authorization chain* began, not when access was last granted. Never set it to "now" on a re-grant, and never move it earlier.
9398

94-
**Wind down, don't revoke.** Revocation means no access at all, receipts and statuses included. To end a relationship while the buyer finishes reconciling:
99+
**Wind down, don't revoke.** Revocation means no access at all, receipts and statuses included. A reduction that drops `get_reporting_status` ends reporting authority the same way, though the account stays visible to the tasks it keeps (`SCOPE_INSUFFICIENT`, not unknown). To end a relationship while the buyer finishes reconciling, keep `get_reporting_status`:
95100

96101
```python
97102
from adcp.server import wind_down_grant

‎docs/reporting-subscriptions-testing.md‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ async with reporting_test_kit(
2828
```
2929

3030
- **Clock.** `kit.clock.advance("P3D")` (days to seconds) and `advance_to(instant)` move the one virtual clock forward. At every period boundary crossed, the seller processes the move as production would: the boundary sweep, then planning.
31-
- **Grants, accounts and buys.** `kit.grants` (`revoke`, `regrant`, `reduce`, `wind_down`), `kit.accounts.set_timezone` and `kit.buys` (`add`, `cancel`) go through the controller's `reporting_subscription_probe` and `seed_media_buy`. That is the same scenario a storyboard drives against a remote seller.
31+
- **Grants, accounts and buys.** `kit.grants` (`revoke`, `regrant`, `reduce`, `wind_down`), `kit.accounts.set_timezone` and `kit.buys` (`add`, `add_package`, `cancel`) go through the controller's `reporting_subscription_probe` and `seed_media_buy`. That is the same scenario a storyboard drives against a remote seller. `add_package` adds a package to a confirmed buy now (or `at=`), so `get_media_buys` serves it with `added_at` (3.3.0-beta.4).
3232
- **Agreement.** `await kit.assert_agreement(principal)` checks that the seller's owed set equals what the buyer's `reconcile()` computes over the wire. It also checks that the `authorized_at` served on `list_accounts` is the one coverage used. On a difference, the `AgreementError` names each disputed period, the first rule at which the two `CoverageTrace`s differ, and both sentences.
33-
- **Your platform.** Pass `platform=` (your installed handler), `service=` (your `ReliableReportingService`) and `controller=` (a `TestControllerStore` serving `reporting_subscription_probe` and `seed_media_buy`). The kit then drives your seller through that controller.
33+
- **Your platform.** Pass `platform=` (your installed handler), `service=` (your `ReliableReportingService`) and `controller=` (a `TestControllerStore` serving `reporting_subscription_probe`, `reset_state` and `seed_media_buy`). The kit then drives your seller through that controller.
3434
- **Offerings.** `offerings.daily_analytics(...)` and `offerings.hourly_pacing(...)` build offering fixtures that publish `standard_dimensions`.
3535

3636
## The test kit
@@ -68,13 +68,19 @@ npx adcp storyboard run https://seller.example/mcp --storyboards reporting_subsc
6868
- Storage is process-local.
6969
- The virtual clock starts at `--start` (default: now) and moves only through `comply_test_controller`.
7070

71-
Its controller serves `seed_media_buy` and the `reporting_subscription_probe` scenario:
72-
- `revoke_grant`;
71+
Its controller serves `seed_media_buy`, `reset_state` and the `reporting_subscription_probe` scenario. Each probe result is `SimulationSuccess.simulated`, with `simulated_now` and `account_id`:
72+
- `revoke_grant`, which returns `unauthorized_at`;
7373
- `regrant`, which returns `chain`, `authorized_at` and `regranted_at`;
74-
- `reduce_grant`;
75-
- `set_account_timezone`, which returns `previous_timezone` and the cutovers;
74+
- `reduce_grant`, which returns the applied `allowed_tasks`. A list without `get_reporting_status` is a loss of authority at the reduction, and adding it back is a re-grant;
75+
- `set_account_timezone`, which returns `SetAccountTimezoneResult` (3.3.0-beta.4): `previous_timezone`, `timezone`, `changed_at` when the zone changed, and one `timezone_cutovers[]` entry per `account_timezone` offering, whose `effective_at` is the end of its last old-zone period that started before `changed_at`. Naming the current zone changes nothing: no `changed_at`, no cutovers;
7676
- `advance_time`, which processes every period boundary crossed.
7777

78+
`reset_state` (3.3.0-beta.4) unpins the clock, so `simulated_now` reads the wall clock until the next `advance_time`. It also keeps every account the caller's probe acted on with its full default grant, restarting at the reset, and its timezone from before the probe first changed it, with nothing the probe recorded left in `accounts_unauthorized[]`, `gaps[]` or `timezone_cutovers[]`. The reference seller runs one clock for every principal, so unpinning it is seen by every caller.
79+
80+
### Your controller
81+
82+
A `TestControllerStore` that implements `reporting_subscription_probe` must also implement `reset_state`: AdCP 3.3.0-beta.4 requires a seller listing the probe to list it, and `register_test_controller` (and the A2A server) refuse a store without it with `ConfigurationError`. A runner calls `reset_state` once before each storyboard. Return the probe's result fields; the dispatcher nests a result without `simulated` under it, as `SimulationSuccess` requires.
83+
7884
It passes the TypeScript `reporting_subscriptions` storyboard (15 steps, strict schema checks):
7985

8086
```

‎src/adcp/reporting/_errors.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Awaitable, Callable
5+
from collections.abc import Awaitable, Callable, Mapping
66
from typing import Literal, TypeVar
77

88
from adcp.exceptions import ADCPTaskError
@@ -66,6 +66,7 @@
6666
"REPORTING_STORAGE_NOT_DURABLE",
6767
"REPORTING_SCHEMA_BEHIND",
6868
"SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS",
69+
"SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY",
6970
"SUBSCRIPTION_ROSTER_UNAVAILABLE",
7071
}
7172
)
@@ -80,10 +81,12 @@ class ReliableReportingConfigurationError(ValueError):
8081
``code`` is set for the seller configuration codes of the cross-SDK
8182
vocabulary (``REPORTING_STORAGE_NOT_DURABLE``, ``REPORTING_SCHEMA_BEHIND``,
8283
``SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS``,
83-
``SUBSCRIPTION_ROSTER_UNAVAILABLE``); those carry ``recovery="correctable"``
84-
and a message whose last sentence is the fix; ``config_field`` names the
85-
configuration key to change. ``dispatched`` is always ``None``: a
86-
configuration check sends nothing.
84+
``SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY``, ``SUBSCRIPTION_ROSTER_UNAVAILABLE``);
85+
those carry ``recovery="correctable"`` and a message whose last sentence is
86+
the fix; ``config_field`` names the configuration key to change, and
87+
``context`` carries the code's ``error-codes.json`` context (for
88+
``SUBSCRIPTIONS_REQUIRE_ACCOUNT_ID_KEY``, ``offering_id`` and ``missing``).
89+
``dispatched`` is always ``None``: a configuration check sends nothing.
8790
"""
8891

8992
dispatched = None
@@ -95,6 +98,7 @@ def __init__(
9598
kind: Literal["misconfiguration", "auth_required", "invalid_request"] = "misconfiguration",
9699
code: str | None = None,
97100
config_field: str | None = None,
101+
context: Mapping[str, str] | None = None,
98102
) -> None:
99103
super().__init__(message)
100104
if code is not None and code not in SELLER_CONFIGURATION_CODES:
@@ -103,6 +107,8 @@ def __init__(
103107
self.code = code
104108
#: The configuration key to change (the TypeScript SDK's ``configField``).
105109
self.config_field = config_field
110+
#: The code's ``error-codes.json`` context, when it defines one.
111+
self.context: dict[str, str] = dict(context or {})
106112
self.recovery: Literal["correctable", "terminal"] | None = (
107113
"correctable" if code is not None else None
108114
)

0 commit comments

Comments
 (0)