diff --git a/.changeset/reporting-subscription-consumer.md b/.changeset/reporting-subscription-consumer.md new file mode 100644 index 000000000..d1569360e --- /dev/null +++ b/.changeset/reporting-subscription-consumer.md @@ -0,0 +1,18 @@ +--- +'@adcp/sdk': minor +--- + +Buyer runtime and durable storage for AdCP 3.3 principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, #3504). + +- `syncReportingSubscriptionsV1(client, subscriptions, options)` writes the complete `reporting_subscriptions` set through the guarded `sync_principal` lifecycle. + - It refuses, before writing, to deactivate an active subscription by omission unless `allowDeactivation: true`. Sending `active: false` is always allowed. + - It checks that every submitted subscription reads back at its version and activation state, and that every submitted configuration field is stored unchanged. +- `reportingSubscriptionVersionRecordsV1({ state, configurations? })` rebuilds the coverage function's version records from the readback (`accepted_at`, `prior_versions`, `deactivated_at`). Each version's configuration comes from the buyer's own records, or else from the readback: the current `configuration`, and `prior_versions[].configuration` from sellers on 3.3.0-beta.2 (adcp#8588). Pass the readback's `inactive_intervals` to `expectedSubscriptionPeriods` unchanged. +- `subscriptionExpectedReportingPeriodsV1(obligations, template)` turns owed obligations into the consumer runtime's `ExpectedReportingPeriod`s. The template must supply `coverage`; no default could match an obligation that reports packages or partial coverage. +- The reliable reporting consumer runtime accepts a per-account `subscriptionExpectations` hook. It reads the summary view's `subscription_coverage[]` before each reconciliation and reconciles the returned periods alongside configuration-derived ones. + - The hook's `now` is the summary's `ledger_as_of` minus `subscriptionExpectationGraceMs`, never the wall clock. + - Use it only against a seller that materializes subscription obligations in its ledger. +- `handleAuthenticatedPrincipalChanged` calls `onPrincipalChanged` (for example to re-read `get_principal` and `accounts_unauthorized` and replace the roster) and then reconciles every account of the consumer scope. + - It drops a redelivery with an already seen `idempotency_key`. + - Notifications that arrive during a run share one trailing run. +- `createReportingSubscriptionEngineStateStoreV1({ store })` persists the engine's sharded state on any `AdcpStateStore`: one CAS document per principal and one per (principal, account), listed per tenant. With `PostgresStateStore` it is durable and needs no migration beyond `ADCP_STATE_MIGRATION`. Subscription version history stays in the principal row and is never pruned, so `prior_versions` remain answerable for retained obligations. diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md new file mode 100644 index 000000000..c175464ae --- /dev/null +++ b/.changeset/reporting-subscription-coverage.md @@ -0,0 +1,13 @@ +--- +'@adcp/sdk': minor +--- + +Add the buyer-computable coverage rule for AdCP 3.3 principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, #3504). `expectedSubscriptionPeriods(input)`, exported from `@adcp/sdk/reporting/consumer` and `@adcp/sdk/reporting/ledger`, is a pure function of the buyer's own records: subscription versions with their `accepted_at`, visible confirmed buys and their earlier flight values, the account's grants and timezone, the seller's published offerings (matching reads each offering's `standard_dimensions` and `unmapped_dimensions`), the readback's `inactive_intervals`, and the published `subscription_coverage` exceptions, including per-offering `gaps[]` and `timezone_cutovers[]` with `previous_timezone`. It returns every owed (buy, offering, period), and `groupExpectedSubscriptionPeriodsV1` groups them into the obligations that must carry them. It implements the start rule of adcp#8593 (the first boundary at or after the later of `accepted_at` and `authorized_at`, and every period ending after the buy's `confirmed_at`, so the period in which a buy is confirmed is owed in full), flight overlap as of each period's resolution, unclipped periods, the single cutover rule for new versions, deactivation, resolution changes and account timezone changes, revocation and re-grant with and without `backfill_on_regrant`, and exceptions that only remove periods. + +Also exported: `createReportingPeriodCalendarV1`, the normative `period_generation` calendar for `utc`, `account_timezone`, `source_timezone` and `billing_cycle` schedules; `resolveReportingSubscriptionOfferingsV1` for `exact`, `finest` and `all` selector resolution with byte-order ties; `reportingSubscriptionScopeIncludesV1`; and `reportingConfigurationCapabilitiesV1`, which builds `configuration_tasks`, the deprecated `configuration_task` and the `subscriptions` block consistently. + +`installConfiguration` now throws a typed `ReportingConfigurationImmutableError` (`code: 'REPORTING_CONFIGURATION_IMMUTABLE'`, with `accountId`, `deliveryConfigId` and `deliveryConfigVersion`) when an existing generation is reinstalled with different content. The message is unchanged; use `isReportingConfigurationImmutableError` instead of matching it. + +The rules follow adcontextprotocol/adcp#8585. The fields it added after 3.3.0-beta.1 (`standard_dimensions`, `unmapped_dimensions`, `inactive_intervals`, `previous_timezone`, and per-offering `offering_id` on gaps and cutovers) are hand-modeled until 3.3.0-beta.2 ships. The cross-SDK parity table is `test/fixtures/reporting-subscriptions/coverage-cases.json`. + +Sub-daily periods under a local alignment restart at each local midnight on wall-clock multiples (adcontextprotocol/adcp#8588). A gap yields no boundary and a fold one per offset, and the duration must divide 24 hours; mixed date and time components are refused. Calendars gain `next()`. An empty flight overlaps nothing, and a cutover carries the outgoing stretch's own start, so two cutovers in one period never owe a period twice. A billing anchor inside a DST fold is interval zero itself. A wire `authorized_at` equal to the chain start continues the chain. The walk has an iteration budget. The SDK's schema validation accepts dimension values outside the published `enums/reporting-dimension.json` vocabulary, which is extensible. diff --git a/.changeset/reporting-subscription-delivery.md b/.changeset/reporting-subscription-delivery.md new file mode 100644 index 000000000..631d96c6a --- /dev/null +++ b/.changeset/reporting-subscription-delivery.md @@ -0,0 +1,18 @@ +--- +'@adcp/sdk': minor +'@adcp/reporting-storage': minor +--- + +Managed delivery for AdCP 3.3 subscription obligations (experimental `media_buy.reporting_subscriptions`, #3504). + +- `reportingSubscriptions.bindManagedGeneration` binds a managed subscription generation to its destination before the generation's first obligation is written. The planner calls it through `planSubscriptionObligations({ bindGeneration })`. A rejection writes nothing, and the next cycle tries again. A subscription that resolves a managed offering without a binding composition fails to plan. +- Every row of a subscription revision carries `account_id`. It is stamped before the revision is bound, and a row naming another account is refused. Account revisions are unchanged. +- `ReportingLedgerSubscriberV1.principal` scopes status subscribers: a subscription obligation's transitions reach only its owning principal. +- The engine gains `isAuthorized(principal, accountId)`, and `frozenOwedSets` versions carry the frozen `destination_method`. `withReportingSubscriptionCoverageV1` is exported for compositions. +- `@adcp/reporting-storage`: + - `createPostgresReliableReportingProductionService` delivers managed subscription obligations. Pass the principal runtime as `reportingSubscriptions.principal`; the engine must have `isAuthorized`. + - `createReportingSubscriptionManagedBindingV1` resolves the owner's current destination generation, authorizes it per account (`ensureDestinationAuthorization`), and installs a binding that freezes the version's `path_template`. + - The managed worker passes the expanded `delivery_path` to adapters. It re-checks the owner's account authority before delivering and before settling, and releases a lost claim with `holdMaterialization` without failing it. Reads are scoped with `resolveSubscriptionPrincipal`. + - `REPORTING_MANAGED_DELIVERY_MIGRATION` drops the three-column `adcp_reporting_managed_bindings` key, found by its columns, so bindings are keyed by their Core configuration. It is additive and idempotent. + - `reportingObjectWriteName` takes an optional delivery path that sits inside the generation prefix, and `isReportingObjectWriteName` recognizes it. The GCS adapter writes there, and status reads scope to the owner. + - Notification activity for a subscription obligation is fanned out only to its owning principal. diff --git a/.changeset/reporting-subscription-dx-advanced.md b/.changeset/reporting-subscription-dx-advanced.md new file mode 100644 index 000000000..e15f0f4b0 --- /dev/null +++ b/.changeset/reporting-subscription-dx-advanced.md @@ -0,0 +1,10 @@ +--- +'@adcp/sdk': minor +'@adcp/reporting-storage': minor +--- + +Reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): the low-level building blocks move to a new `@adcp/sdk/reporting/advanced` subpath, tagged `@experimental`. + +- `@adcp/sdk/reporting/advanced` exports the coverage engine (`createReportingSubscriptionEngineV1`) and its stores (`createInMemoryReportingSubscriptionEngineStoreV1`, `createReportingSubscriptionEngineStateStoreV1`), the coverage function (`expectedSubscriptionPeriods`, `groupExpectedSubscriptionPeriodsV1`, `reportingSubscriptionCoverageGrantedV1`), the period calendar, offering selection, the capability builder (`reportingConfigurationCapabilitiesV1`), the consumer bridge (`reportingSubscriptionVersionRecordsV1`, `subscriptionExpectedReportingPeriodsV1`), and the service's subscription hooks (`ReliableReportingSubscriptionsOptionsV1` and its input types, `withReportingSubscriptionCoverageV1`). +- `@adcp/sdk/reporting/service`, `/consumer` and `/ledger` no longer re-export them. Change the import path; the symbols are unchanged. The `reportingSubscriptions` option on `createReliableReportingService` is unchanged. +- `@adcp/reporting-storage` imports `withReportingSubscriptionCoverageV1` and `ReliableReportingSubscriptionBindingInputV1` from the new subpath, so it needs an `@adcp/sdk` that has it. diff --git a/.changeset/reporting-subscription-dx-errors.md b/.changeset/reporting-subscription-dx-errors.md new file mode 100644 index 000000000..beb847fd6 --- /dev/null +++ b/.changeset/reporting-subscription-dx-errors.md @@ -0,0 +1,10 @@ +--- +'@adcp/sdk': minor +--- + +Typed reporting-subscription errors (experimental `media_buy.reporting_subscriptions`, AdCP 3.3), using the shared error vocabulary (`error-codes.json` in the spec repository). + +- `ReportingSubscriptionError` (package root) carries `code`, `recovery` and the vector context fields (`wouldDeactivate`, `wouldRevoke`, `deliveryConfigId`, `accountId`, `nearest`, `readback`). The five refusal codes the SDK raises before sending always carry `dispatched: false`. Its codes are `SUBSCRIPTION_WOULD_DEACTIVATE`, `DESTINATION_WOULD_REVOKE`, `SUBSCRIPTION_ID_CLASH`, `SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED`, `SUBSCRIPTION_SELECTOR_UNMATCHED`, `SUBSCRIPTION_CONFLICT_EXHAUSTED` and `SUBSCRIPTION_READBACK_MISMATCH`. `syncReportingSubscriptionsV1` throws it for its deactivation guard (with `wouldDeactivate`), for conflict exhaustion and for a readback mismatch (with `readback`), where it used to throw `PrincipalLifecycleError`. +- `PrincipalLifecycleError` is now an `ADCPError`, with `code` (`PRINCIPAL_CONFLICT_EXHAUSTED`, `PRINCIPAL_TRANSPORT_FAILED`, `PRINCIPAL_SETUP_TIMEOUT`, ...), `recovery`, and `dispatched: false` when no `sync_principal` write was sent. `applied` is renamed `appliedResponse` so it can't be read as a boolean next to `dispatched`. `applied` stays as a deprecated alias, and the constructor still accepts it. +- `CoverageInputError` (`COVERAGE_INPUT_INCOMPLETE`, `COVERAGE_INPUT_INVALID`, `COVERAGE_BUDGET_EXCEEDED`, with a `detail` such as `missing_account_timezone` or `invalid_period_duration`) replaces the `TypeError` and `RangeError` the coverage function and the period calendar threw. +- The engine throws `ReportingSubscriptionEngineError` (`SUBSCRIPTION_STATE_CONFLICT`, `SUBSCRIPTION_STATE_CORRUPT`, `SUBSCRIPTION_ROSTER_INCOMPLETE`, `SUBSCRIPTION_HOST_FACT_INVALID`) at run time, and `PlatformConfigError` for configuration mistakes. `PlatformConfigError` gains `code` (`PLATFORM_CONFIG_INVALID` by default; `REPORTING_STORAGE_NOT_DURABLE` and `SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS` where they apply), `configField` and `recovery: 'correctable'`. diff --git a/.changeset/reporting-subscription-dx-revocation.md b/.changeset/reporting-subscription-dx-revocation.md new file mode 100644 index 000000000..d288773e8 --- /dev/null +++ b/.changeset/reporting-subscription-dx-revocation.md @@ -0,0 +1,8 @@ +--- +'@adcp/sdk': minor +--- + +Reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): wind down instead of revoking (adcp#8607). + +- `windDownGrant(current?)` and `REPORTING_WIND_DOWN_TASKS` (`@adcp/sdk/server`). The grant lists only `get_reporting_status`, `sync_reporting_receipts` and `sync_reporting_status`, with `read_only: false`. Passing the current authorization keeps its `authorized_at`, because a reduced grant continues the chain. Revocation now means no access, receipts and statuses included, so set `account.authorization = windDownGrant(account.authorization)` to end a relationship while the buyer finishes reconciling. Coverage continues while the grant lists `get_reporting_status`. +- New shared vectors, `test/fixtures/reporting-subscriptions/grant-cases.json`, cover the coverage grant rule and grant transitions through the engine: wind-down, a reduced grant without `get_reporting_status` (which reads as a revocation), dropping it from a wind-down grant, and restoring it inside retention. diff --git a/.changeset/reporting-subscription-dx-vectors.md b/.changeset/reporting-subscription-dx-vectors.md new file mode 100644 index 000000000..350b36ab5 --- /dev/null +++ b/.changeset/reporting-subscription-dx-vectors.md @@ -0,0 +1,8 @@ +--- +'@adcp/sdk': minor +--- + +Reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): the coverage function now runs the canonical spec vectors (adcp#8616), and two bugs they exposed are fixed. + +- An `excluded[]` entry with `offering_id` removes only that offering's periods. It used to be applied to every offering, so a per-offering capacity exclusion dropped periods it didn't cover. The coverage input type gains `ReportingSubscriptionExclusionEntryV1` (`offering_id?`), and the engine's `ReportingSubscriptionExclusionV1` carries it. +- A selector `period_duration` the schedule pattern refuses, such as `P1W`, now throws `CoverageInputError` (`COVERAGE_INPUT_INVALID`, `invalid_period_duration`). It used to compute an empty owed set. diff --git a/.changeset/reporting-subscription-ledger-service.md b/.changeset/reporting-subscription-ledger-service.md new file mode 100644 index 000000000..4038b8541 --- /dev/null +++ b/.changeset/reporting-subscription-ledger-service.md @@ -0,0 +1,11 @@ +--- +'@adcp/sdk': minor +--- + +`createReliableReportingService({ reportingSubscriptions })` now writes the subscription engine's obligations into the reporting ledger and executes them (experimental `media_buy.reporting_subscriptions`, #3504). + +- Every `runCycle` plans each listed principal's frozen owed sets from `engine.frozenOwedSets` before it plans account configurations. It uses the same trusted path as `installConfiguration`: the subscription version gives identity, feed purpose and required finality, the resolved offering gives report definition and schedule, and `resolveSource`, `resolveCurrency` and `resolveCoverage` give route, currency and authorized constituents. Each obligation freezes the constituents of exactly the media buys it owes. +- New required options when subscriptions are advertised: `listSubscriptionPrincipals()` (for example `engine.listPrincipals(tenantId)` across your tenants) and `resolveSubscriptionConfiguration(...)` (requested metrics and dimensions, source settings, and the finality policy or canonicalization a subscription needs). New optional ones: `resolveSubscriptionAccount(accountId)` and `onPlanningError(error, context)`. A subscription that cannot be planned never stops another one or the account's own configurations. Without `onPlanningError`, `runCycle` rejects with an `AggregateError` after the rest of the cycle has run. +- The service scopes `get_reporting_status`, `get_media_buy_delivery` and `sync_reporting_status` with `resolvePrincipal`. A principal sees the account's configurations plus only its own subscription generations. +- The engine gains `frozenOwedSets(principal, { accountId? })` and `listPrincipals(tenantId)`. +- Without `reportingSubscriptions` the service is byte-for-byte unchanged. A golden transcript captured from the service before this change covers it. diff --git a/.changeset/reporting-subscription-ledger.md b/.changeset/reporting-subscription-ledger.md new file mode 100644 index 000000000..f0b064ae8 --- /dev/null +++ b/.changeset/reporting-subscription-ledger.md @@ -0,0 +1,17 @@ +--- +'@adcp/sdk': minor +'@adcp/reporting-storage': minor +--- + +The reporting ledger now holds subscription obligations for AdCP 3.3 principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, #3504). + +- `producer.planSubscriptionObligations({ account_id, principal, subscriptionId, configurations, obligations, owedThrough })` writes one subscription's frozen owed set into the ledger. + - It installs one generation per (account, owning principal, subscription_id, subscription_version, resolved offering). The generation's `delivery_config_id` is the `subscription_id` and its `delivery_config_version` is the `subscription_version`. A changed generation throws `ReportingConfigurationImmutableError`. + - Each obligation carries its own period, `media_buy_ids` frozen at its `scope_resolved_at` (the period end) and constituents. Its `periodOrdinal` is the period start in whole seconds. Periods are never grid-filled. + - It then advances the mutable `owedSet.through` horizon on every generation the subscription owns on the account. +- Subscription generations carry an immutable `subscription` block (`tenantId`, `principalId`, `subscriptionId`, `offeringId`, `ownership: 'owed_set'`) and a mutable `owedSet`. Account generations keep exactly their previous ids and fingerprints. +- Completeness of an owed-set generation is its `owedSet.through` horizon, not ordinal counting. A gap is a period that is not owed, so it never raises a continuity error. A scope whose end is past the horizon reports `scope_closed: false` and keeps `coverage_complete: true`. +- Caller isolation: `get_reporting_status`, `get_media_buy_delivery` and `sync_reporting_status` take `resolveSubscriptionPrincipal`. A caller sees account generations plus only its own subscription generations and obligations, and nothing of another principal's. Without it no subscription generation is visible. Snapshot queries carry `subscription_principal`. +- Subscription obligations emit `offering_id` to callers served AdCP 3.3. `sync_reporting_status` accepts a status without `reporting_obligation_id` against a subscription generation only when it names an existing owed obligation. +- `ReportingLedgerStore` gains optional `recordOwedSetProgress` and `findObligationByOrdinal` methods. A store must implement `recordOwedSetProgress` to hold subscription generations. +- `@adcp/reporting-storage`: `REPORTING_LEDGER_MIGRATION` adds `adcp_reporting_configurations.subscription_key` (empty for account generations). It replaces the three-column generation constraint with a unique index on `(account_id, delivery_config_id, delivery_config_version, subscription_key)`. The migration is additive and idempotent, and existing rows keep their uniqueness. `PostgresReportingLedgerStore` implements the new methods and applies `subscription_principal` to every snapshot. diff --git a/.changeset/reporting-subscription-receipts.md b/.changeset/reporting-subscription-receipts.md new file mode 100644 index 000000000..18e823874 --- /dev/null +++ b/.changeset/reporting-subscription-receipts.md @@ -0,0 +1,14 @@ +--- +'@adcp/sdk': minor +'@adcp/reporting-storage': minor +--- + +Owner-scoped receipts and statuses, and grant-aware coverage, for AdCP 3.3 reporting subscriptions (experimental `media_buy.reporting_subscriptions`, #3504). + +- **Owner-only receipts and statuses.** A subscription obligation accepts `sync_reporting_receipts` and `sync_reporting_status` only from its owning principal. Account obligations stay open to every consumer of the account. `reportingSubscriptionReceiptAdmissibleV1` is the single rule. + - `createSyncReportingReceiptsHandler` takes `{ resolveSubscriptionPrincipal }` and passes an `admission` to stores that declare `receiptAdmission`. + - `PostgresReportingManagedDeliveryStore` enforces it, and a refused receipt reads exactly like an unknown reference. + - The production service wires it from `reportingSubscriptions.resolvePrincipal`. +- **No post-revocation window.** This follows the owner's decision, ahead of the 3.3.0-beta.3 spec text; beta.2 still describes the old window. Once authority for an account ends, every task treats the account as unknown, receipts and statuses included. A seller that wants a buyer to finish reconciling keeps a reduced grant instead, for example `allowed_tasks` limited to `get_reporting_status`, `sync_reporting_receipts` and `sync_reporting_status`. +- **Coverage follows the grant.** `ReportingSubscriptionAccountFactV1.allowed_tasks` carries the grant's `authorization.allowed_tasks`. The engine covers an account only while that list includes `get_reporting_status`; `reportingSubscriptionCoverageGrantedV1` holds the rule, and a grant with no task restriction counts as all tasks. Dropping `get_reporting_status` from the grant is a loss of authority for coverage at that instant, with an `accounts_unauthorized` entry. The same applies to an `authorizationChanged` push. +- **Subscriptions off.** Nothing changes. diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md new file mode 100644 index 000000000..8da4856ab --- /dev/null +++ b/.changeset/reporting-subscription-runtime.md @@ -0,0 +1,55 @@ +--- +'@adcp/sdk': minor +--- + +Serve AdCP 3.3 principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, #3504), opt-in. Without the new options every response is byte-for-byte unchanged. + +**Principal runtime.** `createPrincipalLifecycle({ reportingSubscriptions: { listDeliveryConfigIds, replaceAccountConfigs?, maxSubscriptions?, accountConfigurations? } })` accepts the `sync_principal` `reporting_subscriptions` section. It validates the cross-field rules of `core/reporting-subscription.json`: + +- scope shape and `exclude_account_ids` +- single-account `period_timezone` +- billing requiring official finality and `consumer_receipt`, and `consumer_receipt` requiring a destination +- a destination that exists after the same request, with `path_template` placeholders and `{account_id}` rules for multi-account and shared destinations +- duplicate ids, the `delivery_config_id` namespace clash, and same-level overlap + +Versioning follows the spec: a version changes only for semantic changes; each version records `accepted_at`, and earlier versions stay readable in `prior_versions`; `active` and `replace_account_configs` change in place. Omission or `active: false` deactivates as a cutover, and a reactivation is recorded. `replace_account_configs` is applied once per version through the host hook. `dry_run` and `expected_configuration_version` behave as for the other sections. The readback is `core/reporting-subscription-state.json`, including `action_required` when the destination needs action. `principal.changed` gains reason `reporting_subscription_changed`. The runtime exposes `capabilities` (the `adcp.principal` block), `listReportingSubscriptions`, `reportingSubscriptionIds` (for the reverse namespace check in `sync_accounts`) and `recordReportingSubscriptionCoverage`. + +**Coverage engine.** `createReportingSubscriptionEngineV1` (from `@adcp/sdk/reporting/service`) takes host hooks `listAuthorizedAccounts`, `listAccountBuys`, `resolveRevocationInstant`, `isPeriodDelivered` and, optionally, `listAccountDeliveryConfigs`, over a store sharded into one CAS document per principal and per account. From them it maintains: + +- grants and revocations +- `max_accounts` slots, kept in a deterministic order (served accounts keep their slots, then byte order) +- precedence: the caller's own account-level configuration, then its own `account_ids` subscription +- `offering_unsupported` (buy- and package-level), `excluded_by_buyer`, `capacity`, `overridden` and `action_required` exceptions opened and closed at cutover instants +- timezone cutovers, and re-grant gaps unless `backfill_on_regrant` + +It publishes `accounts_with_exceptions` and `accounts_unauthorized`, with `last_owed_period_end` and `withheld_period_count`. It serves `subscription_coverage[]`, `owedPeriods`/`owedObligations` (computed with the same `expectedSubscriptionPeriods` rule buyers run), and the receipt roster. There is no post-loss receipt window: once authority is lost the account is unknown to every task (adcontextprotocol/adcp#8607). + +**Service and capabilities.** `createReliableReportingService({ reportingSubscriptions: { engine, resolvePrincipal } })` advertises `configuration_tasks` and the `subscriptions` block and adds `subscription_coverage[]` to summary and periods views. `createAdcpServerFromPlatform` passes `capabilities.principal` through as `adcp.principal`, adds `protocol.principal` and `media_buy.reporting_subscriptions` to `experimental_features`, and refuses to construct when subscriptions are advertised without the `reporting_subscriptions` section. Callers pinned below 3.3 see neither the 3.3 registration fields nor that section. `createAdcpServer` accepts `capabilities.principal`. + +Conforms to adcontextprotocol/adcp#8585: + +- The readback lists `inactive_intervals`. +- The engine publishes per-offering `gaps[]` and `timezone_cutovers[]`, with `previous_timezone`, when offerings' boundaries differ. +- A host's `authorized_at` is the start of the authorization chain, so a re-grant inside retention is stamped when authority is observed again. +- `createReliableReportingService({ reportingSubscriptions })` requires `standard_dimensions` on every offering. + +Elapsed history is immutable: + +- Owed periods are frozen as they elapse and never recomputed. Pruning retained exceptions and obligations past `status_retention_days` never re-owes or drops an elapsed period. +- A product or package edit, or a withdrawn offering, applies from the E of its observation; periods of that buy starting earlier keep resolving from the buy as it was. +- Change instants come from source data: `granted_at` on a re-grant, `timezone_changed_at` on a zone change, `resolveRevocationInstant` for a revocation, and the overriding configuration's `active_until`. Reconcile time is used only where the change itself is first observed. + +Also in this release: + +- The runtime requires `listDeliveryConfigIds`, the namespace clash check. Pass `accountConfigurations: false` to opt out. +- It applies `replace_account_configs` only after the section commits, and retries the hook on the next sync if it failed. +- It rejects a destination transport change without a version change. +- It rejects more than 64 inactive intervals rather than truncating. +- It rejects shared `path_template`s that can expand to the same path. +- `prior_versions[]` carries each version's `configuration` (adcp#8588). +- `recordReportingSubscriptionCoverage` rejects lists over the readback's 1000-entry limit and does not bump `configuration_version`. `engine.reconcile` isolates a subscription that exceeds the limit: that subscription keeps its last published lists, the others still update, and the pass reports it in `readback_overflow` and through `onReadbackOverflow`. +- The receipt roster is scoped by tenant. + +A deactivated subscription reads `status: "inactive"` with `deactivated_at` in `subscription_coverage[]`, and is listed only while it still has retained obligations for the account (adcontextprotocol/adcp#8613, decided for beta.3). Callers served a release whose schema lacks that value see the account-level status as before, without `deactivated_at`. + +Responses served at 3.3.0-beta.1 omit the fields that release's closed schemas don't define (`standard_dimensions`, `unmapped_dimensions`, `inactive_intervals`, `previous_timezone`, per-offering `offering_id`). Each field is emitted only when the schema bundle the caller is served defines it, so a bundle that lacks it, or an unknown served version, never emits it. diff --git a/.changeset/reporting-subscriptions-storyboard.md b/.changeset/reporting-subscriptions-storyboard.md new file mode 100644 index 000000000..cbba511db --- /dev/null +++ b/.changeset/reporting-subscriptions-storyboard.md @@ -0,0 +1,5 @@ +--- +'@adcp/sdk': minor +--- + +Add a reporting-subscriptions conformance storyboard and a reference seller that passes it (experimental `media_buy.reporting_subscriptions`, AdCP 3.3). `examples/storyboards/reporting-subscriptions.yaml` checks capability discovery, a dry run, the applied lifecycle with readback and idempotent replay, rejections, versioning with `prior_versions`, `get_reporting_status` `subscription_coverage`, and deactivation by omission. `examples/reporting-subscriptions-seller/seller.mjs` composes the principal runtime, the coverage engine and the Reliable Reporting service behind `createAdcpServerFromPlatform`. Owed periods, grant changes and the `delivery_config_id` clash are not covered: the runner cannot move the seller's clock, change a grant or seed an account-level configuration. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aabe35c35..6310a2f3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,6 +815,7 @@ jobs: test/examples/durable-buyer-writes.test.js test/lib/reporting-consumer-postgres.test.js test/lib/reporting-webhook-activity-pg.test.js + test/lib/reporting-subscription-postgres.test.js # The Reliable Reporting storage package (`@adcp/reporting-storage`) holds the PostgreSQL ledger store, row # storage, managed delivery, notification activity and the BigQuery sink, with their tests. It has its own job diff --git a/.prettierignore b/.prettierignore index 3f09969b7..85038f493 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,3 +41,5 @@ test/fixtures/wba-profile/ # Local agent worktrees (Claude Code); not part of the repo tree. .claude/ +# Canonical reporting-subscription coverage vectors, byte-identical to adcp#8616 (see PINNED.json). +test/fixtures/reporting-subscriptions/canonical/ diff --git a/docs/guides/PRINCIPAL-LIFECYCLE.md b/docs/guides/PRINCIPAL-LIFECYCLE.md index a6c3a278b..8ba325f8e 100644 --- a/docs/guides/PRINCIPAL-LIFECYCLE.md +++ b/docs/guides/PRINCIPAL-LIFECYCLE.md @@ -72,6 +72,33 @@ the helper throws `PrincipalLifecycleError` with the original `taskResult` attached. Use its `submitted` or `deferred` continuation when present; do not start a second logical replacement with a new idempotency key. +Every `PrincipalLifecycleError` is an `ADCPError` with a stable `code` and a +`recovery` (`transient`, `correctable` or `terminal`): + +| `code` | When | `recovery` | +|---|---|---| +| `PRINCIPAL_TASK_INCOMPLETE` | A `get_principal` or `sync_principal` task did not complete (see `taskResult`) | `transient` | +| `PRINCIPAL_READ_FAILED` / `PRINCIPAL_WRITE_FAILED` | The seller returned a failed result (see `protocolErrors`) | the seller's, else `terminal` | +| `PRINCIPAL_CONFLICT_EXHAUSTED` | Every guarded attempt met `CONFLICT` | `transient` | +| `PRINCIPAL_TRANSPORT_FAILED` | The write threw; replay `attemptedRequest` exactly | `transient` | +| `PRINCIPAL_IDENTITY_CHANGED` | The principal changed, or its kind did not match `expectedPrincipalKind` | `terminal` | +| `PRINCIPAL_INVALID_RESPONSE` | The seller answered a write with a dry run, or applied nothing | `terminal` | +| `PRINCIPAL_INVALID_OPTIONS` | `createIdempotencyKey` reused a key | `correctable` | +| `PRINCIPAL_SETUP_TIMEOUT` | Destination setup did not finish in time (`PrincipalLifecycleTimeoutError`) | `transient` | +| `PRINCIPAL_SETUP_INTERRUPTED` | The configuration disappeared or changed while setup was pending | `transient` | + +`dispatched` is `false` when the helper refused before sending any +`sync_principal` write, so nothing changed at the seller; check it with +`isPreDispatchError(error)`. It is absent whenever a write may have reached the +seller. When the write was applied and a later step failed, the applied result +is on `appliedResponse`. The older `applied` property is a deprecated alias. + +`syncReportingSubscriptionsV1` throws `ReportingSubscriptionError` for its own +refusals: `SUBSCRIPTION_WOULD_DEACTIVATE` (with `wouldDeactivate` and +`dispatched: false`), `SUBSCRIPTION_CONFLICT_EXHAUSTED` +and `SUBSCRIPTION_READBACK_MISMATCH` (with `readback`). The codes and recovery +classes are the shared vocabulary the Python SDK uses. + ## Seller runtime `createPrincipalLifecycle()` is the server-side owner for `get_principal`, diff --git a/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 973044f3f..3ceedddbb 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -230,6 +230,149 @@ before deployment. It covers source replay, two-account isolation, configuration replay/frozen currency, lifecycle start/stop, and Core capability truthfulness. +### Principal-level reporting subscriptions (experimental, AdCP 3.3) + +With `reportingSubscriptions`, every `runCycle` first writes the subscription +engine's frozen owed sets into the ledger, then plans account configurations as +before, and the worker executes both. + +The coverage engine, its stores, the coverage function, the calendar, offering +selection, the capability builder and the type of this option are low-level +building blocks. They import from `@adcp/sdk/reporting/advanced`, where every +export is `@experimental`; `@adcp/sdk/reporting/service`, `/consumer`, `/ledger` +and the package root no longer re-export them. + +```ts +import { createReliableReportingService } from '@adcp/sdk/reporting/service'; +import { + createReportingSubscriptionEngineV1, + createReportingSubscriptionEngineStateStoreV1, +} from '@adcp/sdk/reporting/advanced'; + +const service = createReliableReportingService({ + // ...store, adapters, resolveSource, resolveCurrency, resolveCoverage as above + reportingSubscriptions: { + engine, // createReportingSubscriptionEngineV1(...) + resolvePrincipal: ctx => principalScopeFrom(ctx), // the principal runtime's mapping + listSubscriptionPrincipals: async () => + (await Promise.all(tenantIds.map(tenantId => engine.listPrincipals(tenantId)))).flat(), + // Seller policy for one (subscription version, resolved offering): the facts a + // sync_accounts mapping also supplies to installConfiguration. + resolveSubscriptionConfiguration: ({ subscription, offering }) => ({ + requestedMetrics: metricsFor(offering.report_definition_id), + requestedDimensions: dimensionsFor(offering.report_definition_id), + sourceSettings: settingsFor(offering.offering_id), + // finalityPolicy for an official subscription, canonicalization for billing + }), + }, +}); +``` + +- **One generation per (account, owning principal, `subscription_id`, + `subscription_version`, resolved `offering_id`).** Its `delivery_config_id` + is the `subscription_id` and its `delivery_config_version` the + `subscription_version`. Subscription ids are unique per principal, so two + principals can both own `analytics-daily` on one account. The generation + carries an immutable `subscription` block; account generations keep exactly + their previous ids and fingerprints. +- **Sparse, never grid-filled.** Each obligation is one owed period with its own + `media_buy_ids` frozen at its `scope_resolved_at` (the period end) and the + host-authorized constituents of exactly those buys from `resolveCoverage`. Its + `periodOrdinal` is the period start in whole seconds. The offering is + authoritative for timing: `expected_at` is the period end plus its + `delivery_sla`, and obligations carry `offering_id`. +- **Completeness is a horizon.** Only periods the engine has frozen are written, + and the generation's mutable `owedSet.through` advances after they are + durable. A gap is a period that is not owed, never a continuity error. A scope + ending after the horizon reports `scope_closed: false` and keeps + `coverage_complete: true`. +- **Caller isolation.** `get_reporting_status`, `get_media_buy_delivery` and + `sync_reporting_status` show a principal the account configurations plus only + its own subscription generations. `sync_reporting_status` accepts a status + without `reporting_obligation_id` against a subscription only for an existing + owed obligation. +- **Failures are isolated per subscription.** One subscription that cannot be + planned never stops another or the account's own configurations; it is passed + to `onPlanningError`, or `runCycle` rejects with an `AggregateError` after the + rest of the cycle has run. + +A custom `ReportingLedgerStore` holds subscription generations only when it +implements `recordOwedSetProgress` (and should implement +`findObligationByOrdinal`), keys generations by +`reportingLedgerSubscriptionKeyV1`, and applies `subscription_principal` to every +snapshot. For PostgreSQL, reapply `REPORTING_LEDGER_MIGRATION`: it adds +`adcp_reporting_configurations.subscription_key` (empty for every existing row) +and replaces the three-column generation constraint with a unique index on +`(account_id, delivery_config_id, delivery_config_version, subscription_key)`. + +**Managed delivery of subscriptions.** A subscription with a `destination` +resolves only managed offerings. Serve it with +`createPostgresReliableReportingProductionService` and pass the principal +runtime as `reportingSubscriptions.principal`. The engine must have +`isAuthorized`. Then: + +- Before a managed generation's first obligation is written, the service binds + it to the owner's destination. The version's `destination.destination_id` + names the destination, and the binding uses that destination's current + generation (its `destination_ref`). The destination must be delivery-eligible + and serve the offering's pattern and transport. +- One destination serves every covered account, so each account gets its own + authorization of that `destination_ref`. +- A destination that is not ready rejects the bind, and the obligations wait + for a later cycle instead of becoming undeliverable. +- Bindings are keyed by their Core configuration, so every (owner, offering) + generation of one version gets its own. Reapply + `REPORTING_MANAGED_DELIVERY_MIGRATION` to drop the three-column binding key. +- The binding freezes the version's `path_template`. Each delivery expands it + per obligation and passes the result to the adapter as `delivery_path`. + Object-store writes place it inside the collision-free generation prefix: + `adcp-reporting/{namespace_key}/{scope_key}/{delivery_path}/{plan_id}/{index}`. +- Every row of a subscription revision carries `account_id`. The producer stamps + it before the revision is bound, so the committed digest covers it, and it + refuses a row that names another account. +- The managed worker re-checks `engine.isAuthorized(owner, account)` before + delivering and again before settling. A lost grant releases the claim with + `holdMaterialization` without spending an attempt: the obligation stays + owed, and the engine reports the loss in `accounts_unauthorized`. +- Status subscribers with a `principal`, and notification activity, carry a + subscription obligation's transitions only to its owner. + +**Receipts, statuses and grants.** A subscription obligation accepts +`sync_reporting_receipts` and `sync_reporting_status` only from its owning +principal (`reportingSubscriptionReceiptAdmissibleV1`, the one rule). Account +obligations stay open to every consumer of the account. This applies on top of +the host's own account resolution and `allowed_tasks` checks. + +**To end a relationship, wind down; don't revoke.** Revocation means no access +(adcp#8607): there is no post-revocation window. Once authority for an account +ends, the account is unknown to every task, receipts and statuses included, and +the principal's subscriptions stop covering it, so final revisions and +adjustments for its buys never reach it. Revoke only for fraud or a compromise. +A seller that wants a buyer to finish reconciling, for example after an agency +change, replaces the grant with `windDownGrant()` from `@adcp/sdk/server`: + +```ts +import { windDownGrant, REPORTING_WIND_DOWN_TASKS } from '@adcp/sdk/server'; + +// allowed_tasks: get_reporting_status, sync_reporting_receipts, sync_reporting_status; read_only: false. +// Passing the current grant keeps its authorized_at: a reduced grant continues the chain. +account.authorization = windDownGrant(account.authorization); +``` + +Your account resolver must then refuse every task outside +`REPORTING_WIND_DOWN_TASKS` for that principal, as it does for any grant. + +The engine counts an account as covered only while its grant lists +`get_reporting_status` (`reportingSubscriptionCoverageGrantedV1`; a grant with +no task restriction counts as all tasks). Report the grant's +`authorization.allowed_tasks` on each `listAuthorizedAccounts` fact, or on +`authorizationChanged`. Dropping `get_reporting_status` ends coverage at that +instant, as a loss of authority with an `accounts_unauthorized` entry. + +The obligated-consumer roster stays host-owned, because it is keyed by consumer +id. Include a subscription obligation's owner in it while the owner's grant +lists the receipt task. + ## Advanced: assemble the primitives directly ```ts diff --git a/docs/guides/REPORTING-STABILITY.md b/docs/guides/REPORTING-STABILITY.md index 4079cecd1..5b76f550f 100644 --- a/docs/guides/REPORTING-STABILITY.md +++ b/docs/guides/REPORTING-STABILITY.md @@ -23,6 +23,8 @@ This follows how the AdCP protocol treats experimental surfaces (`x-status: expe **Storage-facing building blocks in `@adcp/sdk`.** A few exports exist so a store can be written against public API only, and they are covered by the same policy even where their TSDoc is silent: the schedule, instant and evidence helpers on `@adcp/sdk/reporting/ledger` (`compareReportingInstants`, `reportingPeriodDate`, `reportingPeriodSchedule`, `isFrozenCalendarRulesMismatch`, `assertReportingLedgerHistoryDoesNotOverlap`, the receipt and verification evidence guards, `isWellFormedUnicodeString`, `getReportingDeliveryOfferingValidatorV1`), and the scheduler primitives and `REPORTING_MANAGED_DELIVERY_COMPOSITION_V1` on `@adcp/sdk/reporting/service`. Application code should not depend on them. +**Everything exported by `@adcp/sdk/reporting/advanced`.** The low-level building blocks for principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3) live there: the coverage engine and its stores, the coverage function, the period calendar, offering selection, the capability builder, the consumer-runtime bridge and the Reliable Reporting service's subscription hooks. The subpath is tagged `@experimental` as a whole, and a breaking change to it ships as a minor of `@adcp/sdk`. The managed seller path and the buyer facade sit on top of it; reach for it only when you compose these pieces yourself. + ### Not experimental These follow normal semver. A breaking change to them ships in a major release of `@adcp/sdk`. diff --git a/examples/README.md b/examples/README.md index 27412ca05..231100c62 100644 --- a/examples/README.md +++ b/examples/README.md @@ -52,6 +52,12 @@ The [existing-app reliable reporting buyer](./reliable-reporting-buyer/README.md wires the public buyer entrypoint, PostgreSQL stores, verified webhook intake, buyer-retained expectations, adjustment policy, metrics, and shutdown. +The [reporting subscriptions reference seller](./reporting-subscriptions-seller/seller.mjs) +composes the principal runtime, the subscription coverage engine and the +Reliable Reporting service for principal-level `reporting_subscriptions` +(experimental). It passes +[`storyboards/reporting-subscriptions.yaml`](./storyboards/reporting-subscriptions.yaml). + ## Building an AdCP agent — fork-target reference adapters Pick the example whose AdCP role and specialism most closely matches what you're building, fork it, replace the `// SWAP:` markers, and follow the `FORK CHECKLIST` block for the unmarked but load-bearing constants. The `hello_*_adapter_*` examples are paired with the three-gate CI test (strict tsc / storyboard / upstream-traffic) where a matching mock server exists; examples without one are called out below with the narrower runtime coverage they currently have. `proxy-seller-snap/` is a lighter bridge-pattern fork target: it proves seed-bridge wiring and must be paired with your live-OAuth sandbox runner for upstream health. diff --git a/examples/reporting-subscriptions-seller/seller.mjs b/examples/reporting-subscriptions-seller/seller.mjs new file mode 100644 index 000000000..12556b249 --- /dev/null +++ b/examples/reporting-subscriptions-seller/seller.mjs @@ -0,0 +1,247 @@ +/** + * Reference seller for principal-level reporting subscriptions (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3). It passes + * `examples/storyboards/reporting-subscriptions.yaml`. + * + * The composition is the production one: a principal runtime created with + * `reportingSubscriptions`, the coverage engine, and the Reliable Reporting service, served by + * `createAdcpServerFromPlatform`. Only the state is in memory and the sample offering is the SDK's + * redacted reference offering. The seller serves principal-level subscriptions only + * (`configuration_tasks: ['sync_principal']`), accepts no reporting destinations, and makes no + * outbound calls. + * + * Pass a `ReportingLedgerStore` as `store` (`PostgresReportingLedgerStore` from + * `@adcp/reporting-storage` in production) and a durable `stateStore` (`PostgresStateStore`). + * The in-memory default state store is refused outside `NODE_ENV=test` or `development`. + */ +import { randomUUID } from 'node:crypto'; +import { + InMemoryStateStore, + createAdcpServerFromPlatform, + createPersistentNotificationRuntime, + createPrincipalLifecycle, + createPrincipalStateStore, + principalNotificationSubscriptionStore, +} from '@adcp/sdk/server'; +import { createReliableReportingService } from '@adcp/sdk/reporting/service'; +import { + createReportingSubscriptionEngineStateStoreV1, + createReportingSubscriptionEngineV1, +} from '@adcp/sdk/reporting/advanced'; +import { redactedReportingSourceOfferingV1, redactedReportingSourceRequestV1 } from '@adcp/sdk/reporting/source'; + +export const REFERENCE_TENANT = 'reference-seller'; +/** The one advertiser account every authenticated buyer is authorized on. */ +export const REFERENCE_ACCOUNT_ID = 'acct-reference'; + +/** The delivery offering the redacted source offering serves, with the dimensions subscriptions match on. */ +function deliveryOffering() { + const source = redactedReportingSourceOfferingV1; + return { + offering_id: source.offeringId, + feed_purpose: 'analytics', + report_definition_id: source.contract.report_definition_id, + report_definition_uri: source.contract.reportDefinitionUri, + report_definition_sha256: source.contract.reportDefinitionSha256, + reporting_profile: { + id: source.contract.reportingProfile, + version: source.contract.schemaVersion, + schema_uri: source.contract.schemaUri, + schema_sha256: source.contract.schemaSha256, + schema_dialect: source.contract.schemaDialect, + schema_ref_policy: source.contract.schemaRefPolicy, + grain: source.grain, + primary_keys: ['media_buy_id'], + }, + schedule: { + period_duration: 'P1D', + alignment: 'source_timezone', + period_timezone_policy: 'fixed', + period_timezone: 'UTC', + delivery_sla: 'PT0S', + }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + standard_dimensions: ['package'], + }; +} + +/** A dropping emitter: this seller has no outbound network path. */ +function noOutboundNotifications(store) { + return createPersistentNotificationRuntime({ + store: principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: false }) }, + validateDestination: async () => ({ allowed: false, reason: 'This reference seller makes no outbound calls' }), + authorizeDelivery: async () => ({ authorized: false }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 0, + delivered: false, + terminal: true, + errors: ['outbound delivery is disabled'], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); +} + +/** + * @param {object} options + * @param {import('@adcp/sdk/reporting/ledger').ReportingLedgerStore} options.store + * @param {import('@adcp/sdk/server').AdcpStateStore} [options.stateStore] Principal and engine state; + * any store other than `InMemoryStateStore` is declared durable. + * @param {() => Date} [options.now] + * @param {(protocol: object) => object} [options.wrapProtocol] Test seam: wrap the principal protocol handlers. + */ +export function createReportingSubscriptionsSeller({ + store, + stateStore = new InMemoryStateStore(), + now = () => new Date(), + wrapProtocol = protocol => protocol, +} = {}) { + if (!store) throw new TypeError('store is required: pass a ReportingLedgerStore'); + // Identity comes from authentication only; the request cannot name its own principal. + const principalOf = ctx => { + const clientId = ctx.authInfo?.clientId; + if (typeof clientId !== 'string' || clientId.length === 0) throw new Error('authenticated caller required'); + return { tenantId: REFERENCE_TENANT, principalId: clientId }; + }; + const durability = stateStore instanceof InMemoryStateStore ? 'process-local' : 'durable'; + const principalStore = createPrincipalStateStore({ store: stateStore, durability }); + const lifecycle = createPrincipalLifecycle({ + store: principalStore, + notifications: noOutboundNotifications(principalStore), + agentUrl: 'https://reference-seller.example/mcp', + resolvePrincipal: ctx => { + const { tenantId, principalId } = principalOf(ctx); + return { tenant_id: tenantId, principal_id: principalId, principal_kind: 'buyer_agent' }; + }, + issuePrincipalId: () => `principal-${randomUUID()}`, + issueConfigurationVersion: () => `cfg-${randomUUID()}`, + now, + // notification_configs is always a principal section, and the server pairs it with this task. + includeCompatibilityNotificationTask: true, + // Principal-level registrations only: no sync_accounts delivery configs exist to clash with. + reportingSubscriptions: { accountConfigurations: false }, + }); + + const offering = deliveryOffering(); + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [offering], + store: createReportingSubscriptionEngineStateStoreV1({ store: stateStore, durability }), + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + maxAccounts: 100, + now, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: REFERENCE_ACCOUNT_ID, authorized_at: '2026-01-01T00:00:00.000Z' }], + }), + listAccountBuys: () => [], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + + const sourceOffering = structuredClone(redactedReportingSourceOfferingV1); + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const request = redactedReportingSourceRequestV1(); + // The currency comes from resolveCurrency, not from per-generation source settings. + const sourceSettings = { ...request.sourceSettings }; + delete sourceSettings.currency; + const service = createReliableReportingService({ + store, + adapters: { + reference: { + sourceOffering, + deliveryOffering: offering, + // No upstream: every period is observed with zero rows. + fetchSlice: (slice, context) => ({ + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: context.sourceSettings.currency, + reporting_rows: [], + }), + }, + }, + contact: { name: 'Reporting operations', email: 'reporting@reference-seller.example' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + resolveSource: account => ({ + adapterId: 'reference', + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + resolveCoverage: () => ({ constituents: [] }), + reportingSubscriptions: { + engine, + principal: lifecycle, + resolvePrincipal: principalOf, + listSubscriptionPrincipals: () => engine.listPrincipals(REFERENCE_TENANT), + dimensions: ['package'], + accountConfigurations: false, + resolveSubscriptionConfiguration: () => ({ + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceSettings, + }), + }, + }); + + // Coverage follows configuration: reconcile the caller's subscriptions after every applied change. + const protocol = wrapProtocol({ + ...lifecycle.protocol, + async syncPrincipal(params, ctx) { + const response = await lifecycle.protocol.syncPrincipal(params, ctx); + if (response?.result?.kind === 'applied' && ctx.callerMutationScope) { + await engine.reconcile({ + tenantId: ctx.callerMutationScope.tenant_id, + principalId: ctx.callerMutationScope.principal_id, + }); + } + return response; + }, + }); + + const platform = service.install({ + capabilities: { + specialisms: [], + config: {}, + principal: lifecycle.capabilities, + // A seller exposing sync_principal advertises how buyers learn its capabilities changed. + capability_changes: { + capabilities_version: 'reference-1', + cache_ttl_seconds: 3600, + notifications: { supported: true, registration_task: 'sync_principal', event_types: ['capabilities.changed'] }, + }, + }, + accounts: { + resolution: 'explicit', + resolve: async ref => + ref?.account_id === REFERENCE_ACCOUNT_ID ? { id: REFERENCE_ACCOUNT_ID, ctx_metadata: {} } : null, + upsert: async () => [], + }, + }); + const createServer = () => + createAdcpServerFromPlatform(platform, { + name: 'reporting-subscriptions-reference-seller', + version: '1.0.0', + protocol, + validation: { requests: 'strict', responses: 'strict' }, + }); + return { createServer, lifecycle, engine, service }; +} diff --git a/examples/storyboards/reporting-subscriptions.yaml b/examples/storyboards/reporting-subscriptions.yaml new file mode 100644 index 000000000..2ee93668f --- /dev/null +++ b/examples/storyboards/reporting-subscriptions.yaml @@ -0,0 +1,607 @@ +id: reporting_subscriptions +version: '1.0.0' +title: 'Principal-level reporting subscriptions' +category: schema_validation +summary: 'Validates the experimental sync_principal reporting_subscriptions section: capability discovery, dry run, applied lifecycle with readback and idempotent replay, rejections, versioning with prior_versions, get_reporting_status subscription_coverage, and deactivation by omission.' +track: core +required_tools: + - get_adcp_capabilities + - sync_principal + - get_principal + - get_reporting_status + +narrative: | + A buyer registers reporting once for its whole relationship with a seller: + one `reporting_subscriptions` entry in `sync_principal` covers every account + the principal is authorized on, now and later. The seller versions each + subscription, keeps superseded versions readable through `prior_versions`, + deactivates a subscription the buyer omits instead of deleting it, and + reports per-account coverage exceptions in `get_reporting_status` + `subscription_coverage`. + + The subscription here names no destination (`reconciliation_mode: + delivery_only`), so no destination proof or outbound call is needed. The + seller under test authorizes the authenticated principal on the account + `acct-reference` (the SDK reference seller in + `examples/reporting-subscriptions-seller/seller.mjs` does), and the principal + starts with no reporting subscriptions. + + Not covered, because the runner cannot express it: + - Owed periods. Which periods a subscription owes on each account depends on + elapsed time and on media buys the runner cannot create, and the runner + cannot move the seller's clock or compare an owed set to a ledger. + - Grant changes. Revoking or reducing a principal's grant on an account, and + the `accounts_unauthorized` and `gaps` that follow, need a seller-side + change the runner cannot make. `comply_test_controller` scenarios are a + spec-defined enum, so an SDK-local storyboard cannot add one. + - The `delivery_config_id` namespace clash. It needs an account-level + `reporting_delivery_configs` entry made through `sync_accounts`, which a + seller that lists only `sync_principal` in `configuration_tasks` does not + serve, and which the runner cannot seed. + - Destination delivery, receipts and `replace_account_configs`. + +agent: + interaction_model: any + capabilities: + - 'protocol.principal' + - 'media_buy.reporting_subscriptions' + examples: + - 'The SDK reference seller in examples/reporting-subscriptions-seller' + +caller: + role: buyer_agent + example: 'Compliance test harness acting as a stable authenticated principal' + +prerequisites: + description: | + The agent under test advertises `sync_principal` with the + `reporting_subscriptions` section and the experimental + `media_buy.reporting_subscriptions` feature. The authenticated caller is + authorized on account `acct-reference` and has no reporting subscriptions. + controller_seeding: false + +phases: + - id: capability_declaration + title: 'Advertise principal-level reporting subscriptions' + narrative: | + The principal section, the reporting registration tasks, the + subscriptions block and the experimental feature id agree. + + steps: + - id: get_capabilities_declares_subscriptions + title: 'Read the subscription capability' + task: get_adcp_capabilities + schema_ref: 'protocol/get-adcp-capabilities-request.json' + response_schema_ref: 'protocol/get-adcp-capabilities-response.json' + stateful: false + sample_request: + context: + correlation_id: 'reporting_subscriptions--capabilities' + validations: + - check: response_schema + description: 'Response matches get-adcp-capabilities-response.json' + - check: field_contains + path: 'adcp.principal.supported_sections[*]' + value: 'reporting_subscriptions' + description: 'sync_principal accepts the reporting_subscriptions section' + - check: field_contains + path: 'media_buy.reporting_delivery.configuration_tasks[*]' + value: 'sync_principal' + description: 'Reporting registrations are accepted through sync_principal' + - check: field_value + path: 'media_buy.reporting_delivery.subscriptions.supported' + value: true + description: 'The subscriptions block is advertised' + - check: field_contains + path: 'experimental_features[*]' + value: 'media_buy.reporting_subscriptions' + description: 'The experimental subscriptions feature is declared' + - check: field_contains + path: 'experimental_features[*]' + value: 'protocol.principal' + description: 'The experimental principal feature is declared' + + - id: dry_run + title: 'Validate a subscription without persisting it' + depends_on: ['capability_declaration'] + narrative: | + dry_run validates the section and persists nothing. + + steps: + - id: dry_run_subscription + title: 'Dry-run one subscription' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: false + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_dry_run' + dry_run: true + configuration: + reporting_subscriptions: + - &subscription_v1 + subscription_id: 'analytics-daily' + subscription_version: 1 + active: true + scope: + all_authorized_accounts: true + selector: + feed_purpose: analytics + period_duration: P1D + required_finality: snapshot + reconciliation_mode: delivery_only + context: + correlation_id: 'reporting_subscriptions--dry_run' + validations: + - check: response_schema + description: 'Response matches sync-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'validated' + description: 'The subscription validates' + - check: field_value + path: 'result.dry_run' + value: true + description: 'Nothing was applied' + + - id: dry_run_persisted_nothing + title: 'Read back: the dry run left no subscription' + task: get_principal + schema_ref: 'protocol/get-principal-request.json' + response_schema_ref: 'protocol/get-principal-response.json' + stateful: true + sample_request: + context: + correlation_id: 'reporting_subscriptions--after_dry_run' + validations: + - check: response_schema + description: 'Response matches get-principal-response.json' + - check: field_absent + path: 'result.configuration.reporting_subscriptions[0]' + description: 'The dry run persisted no subscription' + + - id: applied_lifecycle + title: 'Apply, read back and replay a subscription' + depends_on: ['dry_run'] + narrative: | + The applied response, a get_principal read and an exact replay describe + the same subscription version, acceptance time and configuration + version. + + steps: + - id: apply_subscription + title: 'Apply one subscription' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + context_outputs: + - name: subscription_configuration_version + path: 'result.configuration_version' + - name: subscription_v1_accepted_at + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_apply' + configuration: + reporting_subscriptions: + - *subscription_v1 + context: + correlation_id: 'reporting_subscriptions--apply' + validations: + - check: response_schema + description: 'Response matches sync-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'applied' + description: 'The subscription is persisted' + - check: field_value + path: 'result.dry_run' + value: false + description: 'Applied responses assert dry_run false' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_id' + value: 'analytics-daily' + description: 'Readback echoes the subscription_id' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 1 + description: 'Readback carries the accepted version' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].state' + value: 'active' + description: 'A subscription without a destination is active at once' + - check: field_present + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + description: 'The seller records when it accepted this version' + - check: field_absent + path: 'result.configuration.reporting_subscriptions[0].prior_versions' + description: 'A first version has no prior versions' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].configuration.selector.feed_purpose' + value: 'analytics' + description: 'Readback carries the accepted configuration' + + - id: read_back_subscription + title: 'Read the subscription back' + task: get_principal + schema_ref: 'protocol/get-principal-request.json' + response_schema_ref: 'protocol/get-principal-response.json' + stateful: true + sample_request: + context: + correlation_id: 'reporting_subscriptions--read_back' + validations: + - check: response_schema + description: 'Response matches get-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'current' + description: 'The principal record is readable' + - check: field_equals_context + path: 'result.configuration_version' + context_key: 'subscription_configuration_version' + description: 'Reading does not advance the configuration version' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].state' + value: 'active' + description: 'The subscription reads back active' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 1 + description: 'The subscription reads back at version 1' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + context_key: 'subscription_v1_accepted_at' + description: 'accepted_at is stable' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].configuration.scope.all_authorized_accounts' + value: true + description: 'Readback carries the accepted scope' + + - id: replay_subscription + title: 'Replay the applied sync with its original key and body' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_apply' + configuration: + reporting_subscriptions: + - *subscription_v1 + context: + correlation_id: 'reporting_subscriptions--replay' + validations: + - check: response_schema + description: 'Response matches sync-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'applied' + description: 'The replay resolves to the applied outcome' + - check: field_equals_context + path: 'result.configuration_version' + context_key: 'subscription_configuration_version' + description: 'The replay does not advance the configuration version' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + context_key: 'subscription_v1_accepted_at' + description: 'The replay does not re-accept the subscription' + + - id: rejections + title: 'Reject invalid subscription sections' + depends_on: ['applied_lifecycle'] + narrative: | + An invalid section is refused whole and changes nothing. + + steps: + - id: reject_duplicate_subscription_id + title: 'Reject two entries with one subscription_id' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + expect_error: true + negative_path: payload_well_formed + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_duplicate' + configuration: + reporting_subscriptions: + - *subscription_v1 + - *subscription_v1 + context: + correlation_id: 'reporting_subscriptions--duplicate' + validations: + - check: field_value + path: 'result.kind' + value: 'failed' + description: 'The section is refused' + - check: field_value + path: 'result.errors[0].code' + value: 'INVALID_REQUEST' + description: 'A duplicate subscription_id is an invalid request' + + - id: reject_invalid_scope + title: 'Reject a scope that names both scope forms' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + stateful: true + expect_error: true + negative_path: schema_invalid + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_invalid_scope' + configuration: + reporting_subscriptions: + - subscription_id: 'analytics-daily' + subscription_version: 1 + active: true + scope: + all_authorized_accounts: true + account_ids: ['acct-reference'] + selector: + feed_purpose: analytics + period_duration: P1D + required_finality: snapshot + reconciliation_mode: delivery_only + context: + correlation_id: 'reporting_subscriptions--invalid_scope' + validations: + - check: error_code + allowed_values: ['VALIDATION_ERROR', 'INVALID_REQUEST'] + description: 'A scope must name exactly one of all_authorized_accounts or account_ids' + + - id: reject_reused_version + title: 'Reject changed content under the same subscription_version' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + expect_error: true + negative_path: payload_well_formed + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_reused_version' + configuration: + reporting_subscriptions: + - subscription_id: 'analytics-daily' + subscription_version: 1 + active: true + scope: + all_authorized_accounts: true + exclude_account_ids: ['acct-excluded'] + selector: + feed_purpose: analytics + period_duration: P1D + required_finality: snapshot + reconciliation_mode: delivery_only + context: + correlation_id: 'reporting_subscriptions--reused_version' + validations: + - check: field_value + path: 'result.kind' + value: 'failed' + description: 'Changed content needs a new subscription_version' + - check: field_value + path: 'result.errors[0].code' + value: 'INVALID_REQUEST' + description: 'Reusing a version for different content is an invalid request' + + - id: rejections_changed_nothing + title: 'Read back: the rejections changed nothing' + task: get_principal + schema_ref: 'protocol/get-principal-request.json' + response_schema_ref: 'protocol/get-principal-response.json' + stateful: true + sample_request: + context: + correlation_id: 'reporting_subscriptions--after_rejections' + validations: + - check: field_equals_context + path: 'result.configuration_version' + context_key: 'subscription_configuration_version' + description: 'No rejected request advanced the configuration version' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 1 + description: 'The subscription is still at version 1' + + - id: versioning + title: 'Supersede a version and keep the prior one readable' + depends_on: ['rejections'] + narrative: | + A changed subscription carries a new subscription_version. The readback + lists the superseded version in prior_versions with when it was + accepted and when it was superseded. + + steps: + - id: apply_version_2 + title: 'Apply version 2 with an account exclusion' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + context_outputs: + - name: subscription_v2_accepted_at + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_version_2' + configuration: + reporting_subscriptions: + - subscription_id: 'analytics-daily' + subscription_version: 2 + active: true + scope: + all_authorized_accounts: true + exclude_account_ids: ['acct-excluded'] + selector: + feed_purpose: analytics + period_duration: P1D + required_finality: snapshot + reconciliation_mode: delivery_only + context: + correlation_id: 'reporting_subscriptions--version_2' + validations: + - check: response_schema + description: 'Response matches sync-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'applied' + description: 'The new version is persisted' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 2 + description: 'Readback carries the new version' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].prior_versions[0].subscription_version' + value: 1 + description: 'The superseded version is listed' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].prior_versions[0].accepted_at' + context_key: 'subscription_v1_accepted_at' + description: 'The prior version keeps its acceptance time' + - check: field_present + path: 'result.configuration.reporting_subscriptions[0].prior_versions[0].superseded_at' + description: 'The prior version records when it was superseded' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].configuration.exclude_account_ids[0]' + value: 'acct-excluded' + description: 'Readback carries the new configuration' + + - id: read_back_version_2 + title: 'Read version 2 back' + task: get_principal + schema_ref: 'protocol/get-principal-request.json' + response_schema_ref: 'protocol/get-principal-response.json' + stateful: true + context_outputs: + - name: subscription_v2_configuration_version + path: 'result.configuration_version' + sample_request: + context: + correlation_id: 'reporting_subscriptions--read_version_2' + validations: + - check: response_schema + description: 'Response matches get-principal-response.json' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 2 + description: 'The subscription reads back at version 2' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].accepted_at' + context_key: 'subscription_v2_accepted_at' + description: 'Version 2 keeps its acceptance time' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].prior_versions[0].superseded_at' + context_key: 'subscription_v2_accepted_at' + description: 'Version 1 was superseded when version 2 was accepted' + + - id: coverage + title: 'Report subscription coverage per account' + depends_on: ['versioning'] + narrative: | + get_reporting_status lists one subscription_coverage entry for each of + the caller's subscriptions whose scope includes the account. The account + is in scope and has no exceptions. + + steps: + - id: get_reporting_status_coverage + title: 'Read the account summary' + task: get_reporting_status + schema_ref: 'media-buy/get-reporting-status-request.json' + response_schema_ref: 'media-buy/get-reporting-status-response.json' + stateful: true + sample_request: + account: + account_id: 'acct-reference' + view: summary + context: + correlation_id: 'reporting_subscriptions--coverage' + validations: + - check: response_schema + description: 'Response matches get-reporting-status-response.json' + - check: field_value + path: 'subscription_coverage[0].subscription_id' + value: 'analytics-daily' + description: 'The subscription covers the account' + - check: field_value + path: 'subscription_coverage[0].subscription_version' + value: 2 + description: 'Coverage follows the current version' + - check: field_value + path: 'subscription_coverage[0].status' + value: 'active' + description: 'The account has no coverage exception' + + - id: deactivation + title: 'Deactivate a subscription by omission' + depends_on: ['coverage'] + narrative: | + sync_principal replaces the whole section. A subscription the buyer + omits is deactivated, not deleted: it stays readable as inactive with + deactivated_at and its version history. + + steps: + - id: omit_subscription + title: 'Sync an empty reporting_subscriptions section' + task: sync_principal + schema_ref: 'protocol/sync-principal-request.json' + response_schema_ref: 'protocol/sync-principal-response.json' + stateful: true + context_outputs: + - name: subscription_deactivated_at + path: 'result.configuration.reporting_subscriptions[0].deactivated_at' + sample_request: + idempotency_key: '$generate:uuid_v4#subscriptions_omit' + configuration: + reporting_subscriptions: [] + context: + correlation_id: 'reporting_subscriptions--omit' + validations: + - check: response_schema + description: 'Response matches sync-principal-response.json' + - check: field_value + path: 'result.kind' + value: 'applied' + description: 'The omission is persisted' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_id' + value: 'analytics-daily' + description: 'The omitted subscription stays readable' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].state' + value: 'inactive' + description: 'Omission deactivates' + - check: field_present + path: 'result.configuration.reporting_subscriptions[0].deactivated_at' + description: 'The seller records when it deactivated' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].configuration.active' + value: false + description: 'The configuration reads back inactive' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].prior_versions[0].subscription_version' + value: 1 + description: 'Version history survives deactivation' + + - id: read_back_inactive + title: 'Read the inactive subscription back' + task: get_principal + schema_ref: 'protocol/get-principal-request.json' + response_schema_ref: 'protocol/get-principal-response.json' + stateful: true + sample_request: + context: + correlation_id: 'reporting_subscriptions--read_inactive' + validations: + - check: response_schema + description: 'Response matches get-principal-response.json' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].state' + value: 'inactive' + description: 'The subscription reads back inactive' + - check: field_equals_context + path: 'result.configuration.reporting_subscriptions[0].deactivated_at' + context_key: 'subscription_deactivated_at' + description: 'deactivated_at is stable' + - check: field_value + path: 'result.configuration.reporting_subscriptions[0].subscription_version' + value: 2 + description: 'The current version is unchanged by deactivation' diff --git a/package.json b/package.json index bace0775d..cf957af87 100644 --- a/package.json +++ b/package.json @@ -399,6 +399,16 @@ "default": "./dist/lib/reporting/service/index.js" } }, + "./reporting/advanced": { + "import": { + "types": "./dist/lib/reporting/advanced/index.d.mts", + "default": "./dist/lib/reporting/advanced/index.mjs" + }, + "require": { + "types": "./dist/lib/reporting/advanced/index.d.ts", + "default": "./dist/lib/reporting/advanced/index.js" + } + }, "./negotiation/verification": { "import": { "types": "./dist/lib/negotiation/verification.d.mts", @@ -532,6 +542,9 @@ "reporting/service": [ "dist/lib/reporting/service/index.d.ts" ], + "reporting/advanced": [ + "dist/lib/reporting/advanced/index.d.ts" + ], "enums": [ "dist/lib/enums.d.ts" ], @@ -590,8 +603,10 @@ "examples/README.md", "examples/CONTRIBUTING.md", "examples/storyboards/account-sync-contract.yaml", + "examples/storyboards/reporting-subscriptions.yaml", "examples/adcp.config.json", "examples/reliable-reporting-buyer/**/*", + "examples/reporting-subscriptions-seller/**/*", "examples/seller-3.2-starter.ts", "examples/supply-path-verification.ts", "examples/wholesale-feed-sync.ts", @@ -717,7 +732,8 @@ "format": "prettier --write \"./**/*.{css,html,js,ts,tsx,json}\"", "format:check": "prettier --check \"./**/*.{css,html,js,ts,tsx,json}\"", "review:codex": "bash scripts/codex-review.sh", - "test:reporting-storage": "npm test --workspace=@adcp/reporting-storage" + "test:reporting-storage": "npm test --workspace=@adcp/reporting-storage", + "sync:reporting-subscription-vectors": "node scripts/sync-reporting-subscription-vectors.mjs" }, "keywords": [ "adcp", diff --git a/packages/reporting-storage/src/gcs/adapter.ts b/packages/reporting-storage/src/gcs/adapter.ts index a3fa27729..b537f35c9 100644 --- a/packages/reporting-storage/src/gcs/adapter.ts +++ b/packages/reporting-storage/src/gcs/adapter.ts @@ -8,7 +8,7 @@ import { createReportingStatusHandler } from '@adcp/sdk/reporting/ledger'; import type { ReportingManagedDeliveryAdapterV1, ReportingManagedDeliveryStore } from '../managed/runtime'; import type { ReportingLedgerStore, ReportingManagedDeliveryBindingV1 } from '@adcp/sdk/reporting/ledger'; import { - reportingObjectWriteName, + isReportingObjectWriteName, reportingObjectWriteScopeKey, type ReportingObjectWriteAuthorityStoreV1, } from '../object-writes'; @@ -129,7 +129,13 @@ export async function createGcsReportingManagedDeliveryAdapterV1( }, { signal: context.signal, milliseconds } ); - const getStatus = createReportingStatusHandler(coreStore); + const getAccountStatus = createReportingStatusHandler(coreStore); + // A subscription obligation is visible only to its owning principal. + const getOwnedStatus = createReportingStatusHandler<{ + account: { id: string; account_id: string }; + signal: AbortSignal; + owner: { tenantId: string; principalId: string }; + }>(coreStore, { resolveSubscriptionPrincipal: context => context.owner }); async function readerOptions( binding: ReportingManagedDeliveryBindingV1, signal: AbortSignal @@ -167,16 +173,25 @@ export async function createGcsReportingManagedDeliveryAdapterV1( let cursor: string | undefined; for (let page = 0; page < 25; page++) { activeGcsSignal(signal); - const status = await getStatus( - { - account: { account_id: input.binding.account_id }, - view: 'periods', - delivery_config_ids: [input.binding.delivery_config_id], - period: input.revision.wireRevision.period, - pagination: { max_results: 100, ...(cursor ? { cursor } : {}) }, - }, - { account: { id: input.binding.account_id, account_id: input.binding.account_id }, signal } - ); + const request = { + account: { account_id: input.binding.account_id }, + view: 'periods' as const, + delivery_config_ids: [input.binding.delivery_config_id], + period: input.revision.wireRevision.period, + pagination: { max_results: 100, ...(cursor ? { cursor } : {}) }, + }; + const account = { id: input.binding.account_id, account_id: input.binding.account_id }; + const owner = input.obligation.subscription; + const status = owner + ? await getOwnedStatus( + request as never, + { + account, + signal, + owner: { tenantId: owner.tenantId, principalId: owner.principalId }, + } as never + ) + : await getAccountStatus(request as never, { account, signal }); activeGcsSignal(signal); const selected = status.periods?.find( value => value.reporting_obligation_id === input.obligation.reporting_obligation_id @@ -312,7 +327,10 @@ export async function createGcsReportingManagedDeliveryAdapterV1( ); if (rows.length + manifestBytes.length > input.maxBytes) throw new ReportingGcsFenceError('INVALID_INPUT'); const scope = scopeFor(binding); - const plan = await fence.register(scope, deliveryIdentity(input), [rows, manifestBytes], { signal }); + const plan = await fence.register(scope, deliveryIdentity(input), [rows, manifestBytes], { + signal, + ...(input.delivery_path !== undefined ? { path: input.delivery_path } : {}), + }); activeGcsSignal(signal); await fence.write(plan, 0, rows, { signal }); await fence.write(plan, 1, manifestBytes, { signal }); @@ -408,7 +426,11 @@ export async function createGcsReportingManagedDeliveryAdapterV1( const plan = await store.getObjectWritePlan(scope, planId, { signal }); activeGcsSignal(signal); if (!plan) throw new ReportingGcsFenceError('REVOKED'); - const location = `https://storage.googleapis.com/${plan.provider.bucket}/${reportingObjectWriteName(scope, plan.provider, planId, 1)}`; + // The manifest's recorded name, which carries the delivery path when there is one. + const manifestName = plan.objects[1]?.object_name ?? ''; + if (!isReportingObjectWriteName(scope, plan.provider, planId, 1, manifestName)) + throw new ReportingGcsFenceError('INVALID_INPUT'); + const location = `https://storage.googleapis.com/${plan.provider.bucket}/${manifestName}`; if ( plan.objects.length !== 2 || input.resource.resource_ref !== `gcs:${planId}` || diff --git a/packages/reporting-storage/src/gcs/fence.ts b/packages/reporting-storage/src/gcs/fence.ts index 0f36ba284..529b5a247 100644 --- a/packages/reporting-storage/src/gcs/fence.ts +++ b/packages/reporting-storage/src/gcs/fence.ts @@ -4,6 +4,7 @@ import { assertReportingObjectWriteScope, reportingObjectWritePlanFingerprint, reportingObjectWriteName, + isReportingObjectWriteName, reportingObjectWriteScopeKey, type ReportingObjectWriteScopeV1, type ReportingObjectWritePlanV1, @@ -28,7 +29,8 @@ export interface GcsReportingObjectFenceV1 { scope: ReportingObjectWriteScopeV1, deliveryId: string, bodies: readonly Uint8Array[], - context?: { signal?: AbortSignal } + /** `path` places the objects under a delivery path inside the scope prefix; see `reportingObjectWriteName`. */ + context?: { signal?: AbortSignal; path?: string } ): Promise; write( plan: ReportingObjectWritePlanV1, @@ -120,7 +122,8 @@ export function createGcsReportingObjectFenceV1(options: { scope: ReportingObjectWriteScopeV1, deliveryId: string, bodies: readonly Uint8Array[], - signal: AbortSignal + signal: AbortSignal, + path?: string ): Promise { assertScope(scope); if ( @@ -146,7 +149,13 @@ export function createGcsReportingObjectFenceV1(options: { plan_id: planId, objects: bodies.map((b, i) => ({ bucket: bucketName, - object_name: reportingObjectWriteName(scopeCopy, provider, planId, i), + object_name: (() => { + try { + return reportingObjectWriteName(scopeCopy, provider, planId, i, path); + } catch { + throw new ReportingGcsFenceError('INVALID_INPUT'); + } + })(), sha256: hash(b), size_bytes: b.byteLength, })), @@ -241,7 +250,7 @@ export function createGcsReportingObjectFenceV1(options: { active(signal); if ( object.bucket !== binding.bucket || - object.object_name !== reportingObjectWriteName(scope, binding, object.plan_id, object.object_index) + !isReportingObjectWriteName(scope, binding, object.plan_id, object.object_index, object.object_name) ) throw new ReportingGcsFenceError('STATE_UNAVAILABLE'); const file = bucket.file(object.object_name); @@ -309,8 +318,8 @@ export function createGcsReportingObjectFenceV1(options: { scope: ReportingObjectWriteScopeV1, deliveryId: string, bodies: readonly Uint8Array[], - context?: { signal?: AbortSignal } - ) => bounded(signal => register(scope, deliveryId, bodies, signal), context?.signal), + context?: { signal?: AbortSignal; path?: string } + ) => bounded(signal => register(scope, deliveryId, bodies, signal, context?.path), context?.signal), write: async ( plan: ReportingObjectWritePlanV1, index: number, diff --git a/packages/reporting-storage/src/managed/postgres.ts b/packages/reporting-storage/src/managed/postgres.ts index b94106f8d..d29e4965b 100644 --- a/packages/reporting-storage/src/managed/postgres.ts +++ b/packages/reporting-storage/src/managed/postgres.ts @@ -12,6 +12,7 @@ import { isReportingReceiptEvidence, isReportingRowStoreError, REPORTING_LEDGER_AUTHORITY, + reportingSubscriptionReceiptAdmissibleV1, } from '@adcp/sdk/reporting/ledger'; import type { ReportingPgPool } from '../postgres'; import type { @@ -22,6 +23,7 @@ import type { ReportingLedgerTransactionV1, ReportingManagedDeliveryBindingV1, ReportingLedgerStore, + ReportingLedgerSubscriptionV1, ReportingReceiptBatchEntryV1, ReportingReceiptBatchInputV1, } from '@adcp/sdk/reporting/ledger'; @@ -107,8 +109,7 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_managed_bindings ( data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, FOREIGN KEY (account_id, destination_ref, authorization_generation) - REFERENCES adcp_reporting_destination_authorizations(account_id, destination_ref, generation), - UNIQUE (account_id, delivery_config_id, delivery_config_version) + REFERENCES adcp_reporting_destination_authorizations(account_id, destination_ref, generation) ); CREATE TABLE IF NOT EXISTS adcp_reporting_materializations ( @@ -333,6 +334,36 @@ BEGIN END IF; END $pattern_transport$; + +-- A binding is keyed by its Core configuration (the primary key). Principal-level reporting +-- subscriptions (AdCP 3.3) install one Core configuration per owning principal and resolved +-- offering under one (account, delivery_config_id, delivery_config_version), so the published +-- three-column constraint would refuse their bindings. Core configurations stay unique per +-- (account, id, version, subscription_key), so every account binding keeps its uniqueness. The +-- constraint is found by its columns, not by a guessed name, and the block is idempotent. +DO $binding_configuration_key$ +DECLARE + bindings regclass := to_regclass('adcp_reporting_managed_bindings'); + legacy RECORD; +BEGIN + FOR legacy IN + SELECT constraint_row.conname + FROM pg_constraint constraint_row + WHERE constraint_row.conrelid = bindings + AND constraint_row.contype = 'u' + AND ( + SELECT array_agg(attribute.attname::text ORDER BY attribute.attname::text) + FROM unnest(constraint_row.conkey) AS key(attnum) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid AND attribute.attnum = key.attnum + ) = ARRAY['account_id', 'delivery_config_id', 'delivery_config_version']::text[] + LOOP + EXECUTE format('ALTER TABLE adcp_reporting_managed_bindings DROP CONSTRAINT %I', legacy.conname); + END LOOP; +END +$binding_configuration_key$; +CREATE INDEX IF NOT EXISTS adcp_reporting_managed_bindings_generation + ON adcp_reporting_managed_bindings (account_id, delivery_config_id, delivery_config_version); `.trim(); const GENERIC_RECEIPT_MESSAGE = 'Receipt does not match authorized current reporting evidence'; @@ -440,6 +471,8 @@ export interface PostgresReportingManagedDeliveryStoreOptions { export class PostgresReportingManagedDeliveryStore implements ReportingManagedDeliveryStore, ReportingObjectWriteStoreV1, ReportingObjectWriteAuthorityStoreV1 { + /** Enforces `ReportingReceiptBatchInputV1.admission` (experimental, AdCP 3.3). */ + readonly receiptAdmission = true as const; private readonly evidenceRetentionDays: number | undefined; private readonly statusRetentionDays: number | undefined; private readonly pendingRecoveryWindowSeconds: number | undefined; @@ -1143,6 +1176,85 @@ export class PostgresReportingManagedDeliveryStore }); } + /** + * The current authorization generation of a destination for one account, creating the next one + * when none is current. Used when a principal-level reporting subscription binds a shared + * destination to each account it covers: one destination, one authorization per account. + */ + async ensureDestinationAuthorization(input: { + account_id: string; + destination_ref: string; + pattern: ReportingDestinationAuthorizationV1['pattern']; + transport?: string; + }): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await this.query< + QueryRow & { generation: string; pattern: string | null; transport: string | null } + >( + `SELECT generation::text, pattern, transport FROM adcp_reporting_destination_authorizations + WHERE account_id = $1 AND destination_ref = $2 AND revoked_at IS NULL`, + [input.account_id, input.destination_ref] + ); + const row = current.rows[0]; + if (row) { + if ( + (row.pattern !== null && row.pattern !== input.pattern) || + (row.transport !== null && input.transport !== undefined && row.transport !== input.transport) + ) { + throw new Error('The current destination authorization serves another pattern or transport'); + } + return Number(row.generation); + } + const latest = await this.query( + `SELECT MAX(generation)::text AS generation FROM adcp_reporting_destination_authorizations + WHERE account_id = $1 AND destination_ref = $2`, + [input.account_id, input.destination_ref] + ); + const generation = Number(latest.rows[0]?.generation ?? 0) + 1; + try { + await this.authorizeDestination({ + account_id: input.account_id, + destination_ref: input.destination_ref, + generation, + authorized_at: new Date().toISOString(), + ...(input.pattern ? { pattern: input.pattern } : {}), + ...(input.transport !== undefined ? { transport: input.transport } : {}), + }); + return generation; + } catch (error) { + // A concurrent bind authorized it first; read it back. + if (attempt === 2) throw error; + } + } + throw new Error('Destination authorization could not be resolved'); + } + + /** + * Releases a claimed materialization without spending a delivery attempt and holds it out of + * selection for `backoff_milliseconds`. For a subscription whose owner has lost authority for + * the account: the delivery stops, and the obligation stays owed rather than failed. + */ + async holdMaterialization(input: { + lease: ReportingManagedDeliveryLeaseV1; + backoff_milliseconds: number; + }): Promise { + positiveInteger(input.backoff_milliseconds, 'backoff_milliseconds'); + const result = await this.query( + `UPDATE adcp_reporting_materializations + SET lease_owner = NULL, + lease_generation = GREATEST(lease_generation - 1, 0), + lease_expires_at = clock_timestamp() + ($4::bigint * INTERVAL '1 millisecond') + WHERE materialization_id = $1 AND lease_owner = $2 AND lease_generation = $3 AND status = 'pending'`, + [ + input.lease.materialization.reporting_materialization_id, + input.lease.owner, + input.lease.generation, + input.backoff_milliseconds, + ] + ); + return (result.rowCount ?? 0) === 1; + } + async revokeDestination(input: { account_id: string; destination_ref: string; @@ -1198,6 +1310,14 @@ export class PostgresReportingManagedDeliveryStore // caller's `semantic_fingerprint`, so changed binding content carrying a // copied-over old fingerprint was accepted as an idempotent replay and the // immutable binding silently meant something else than the stored row. + if ( + binding.path_template !== undefined && + (typeof binding.path_template !== 'string' || + binding.path_template.length > 512 || + !/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))[A-Za-z0-9_.=/{}-]+$/.test(binding.path_template)) + ) { + throw new Error('Managed binding path_template is not a relative path template'); + } const expectedFingerprint = managedBindingFingerprint(binding); if (binding.semantic_fingerprint !== expectedFingerprint) { throw new Error('Managed binding semantic fingerprint does not match its immutable content'); @@ -2733,6 +2853,17 @@ export class PostgresReportingManagedDeliveryStore )?.reporting_receipt_id; const evidence = await this.loadReceiptEvidence(client, batch.account_id, entry); if (!evidence) return failed(entry.receipt.reporting_receipt_id); + // A subscription obligation accepts only its owner. A refusal reads exactly like an unknown + // reference. + if ( + batch.admission && + !reportingSubscriptionReceiptAdmissibleV1({ + obligation: evidence.subscription ? { subscription: evidence.subscription } : {}, + admission: batch.admission, + }) + ) { + return failed(entry.receipt.reporting_receipt_id); + } const matches = entry.kind === 'revision' ? receiptEvidenceMatches(entry.receipt, evidence.materialization!) @@ -2811,11 +2942,19 @@ export class PostgresReportingManagedDeliveryStore subjectId: string; materialization?: ReportingMaterialization; adjustment?: ReportingLedgerAdjustmentV1; + /** The owning subscription of the obligation, when it is a subscription obligation. */ + subscription?: ReportingLedgerSubscriptionV1; } | null> { if (entry.kind === 'revision') { const receipt = entry.receipt; - const result = await client.query( - `SELECT materialization.data AS materialization + const result = await client.query< + QueryRow & { + materialization: ReportingMaterialization; + subscription: ReportingLedgerSubscriptionV1 | null; + } + >( + `SELECT materialization.data AS materialization, + obligation.data->'subscription' AS subscription FROM adcp_reporting_materializations materialization JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = materialization.configuration_id JOIN adcp_reporting_destination_authorizations authz @@ -2842,13 +2981,24 @@ export class PostgresReportingManagedDeliveryStore receipt.reporting_obligation_id, ] ); - return result.rows[0] - ? { subjectId: receipt.reporting_revision_id, materialization: result.rows[0].materialization } + const row = result.rows[0]; + return row + ? { + subjectId: receipt.reporting_revision_id, + materialization: row.materialization, + ...(row.subscription ? { subscription: row.subscription } : {}), + } : null; } const receipt = entry.receipt; - const result = await client.query( - `SELECT adjustment.data AS adjustment + const result = await client.query< + QueryRow & { + adjustment: ReportingLedgerAdjustmentV1; + subscription: ReportingLedgerSubscriptionV1 | null; + } + >( + `SELECT adjustment.data AS adjustment, + obligation.data->'subscription' AS subscription FROM adcp_reporting_adjustments adjustment JOIN adcp_reporting_revisions revision ON revision.revision_id = adjustment.adjusts_revision_id JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id @@ -2865,8 +3015,13 @@ export class PostgresReportingManagedDeliveryStore AND binding.data->>'reconciliation_mode' = 'consumer_receipt'`, [accountId, receipt.reporting_adjustment_id, receipt.adjusts_reporting_revision_id] ); - return result.rows[0] - ? { subjectId: receipt.reporting_adjustment_id, adjustment: result.rows[0].adjustment } + const row = result.rows[0]; + return row + ? { + subjectId: receipt.reporting_adjustment_id, + adjustment: row.adjustment, + ...(row.subscription ? { subscription: row.subscription } : {}), + } : null; } diff --git a/packages/reporting-storage/src/managed/runtime.ts b/packages/reporting-storage/src/managed/runtime.ts index 83e9f249b..372e3eee4 100644 --- a/packages/reporting-storage/src/managed/runtime.ts +++ b/packages/reporting-storage/src/managed/runtime.ts @@ -11,6 +11,8 @@ import type { } from '@adcp/sdk/types'; import { ReportingDeliveryCapabilitiesSchema, ReportingMaterializationSchema } from '@adcp/sdk/schemas'; import { canonicalize } from '@adcp/sdk'; +import { expandReportingSubscriptionPathTemplate } from '@adcp/sdk/server'; +import type { ReliableReportingSubscriptionBindingInputV1 } from '@adcp/sdk/reporting/advanced'; import { isReportingAdjustmentReceiptEvidence, isReportingReceiptEvidence, @@ -19,6 +21,7 @@ import { createReportingStatusHandler, createSyncReportingReceiptsHandler, getReportingDeliveryOfferingValidatorV1, + reportingManagedDeliveryBindingV1, type ReportingReceiptSyncStoreV1, type SyncReportingReceiptsParamsV1, } from '@adcp/sdk/reporting/ledger'; @@ -29,12 +32,15 @@ import type { ReportingLedgerObligationV1, ReportingLedgerRevisionV1, ReportingLedgerStore, + ReportingLedgerSubscriptionPrincipalV1, ReportingManagedDeliveryBindingV1, + ReportingSubscriptionPrincipalResolverV1, } from '@adcp/sdk/reporting/ledger'; const DEFAULT_LEASE_MILLISECONDS = 65_000; const DEFAULT_DELIVERY_DEADLINE_MILLISECONDS = 60_000; const MINIMUM_SETTLEMENT_GRACE_MILLISECONDS = 5_000; +const DEFAULT_AUTHORITY_HOLD_MILLISECONDS = 60_000; const DEFAULT_RESOURCE_MAX_BYTES = 64 * 1024 * 1024; /** * Upper bound on an adapter's startup probe, so a hung provider cannot hang startup. @@ -285,6 +291,25 @@ export interface ReportingManagedDeliveryStore extends ReportingReceiptSyncStore resource_ref: string; }): Promise<{ materialization: ReportingMaterialization; binding: ReportingManagedDeliveryBindingV1 } | null>; isAuthorizationCurrent(input: { account_id: string; destination_ref: string; generation: number }): Promise; + /** + * Releases a claimed materialization without spending a delivery attempt and keeps it out of + * selection for `backoff_milliseconds`. Required by a worker that re-checks subscription + * authority: a lost grant stops the delivery while the obligation stays owed. + */ + holdMaterialization?(input: { + lease: ReportingManagedDeliveryLeaseV1; + backoff_milliseconds: number; + }): Promise; + /** + * The current authorization generation of a destination for one account, creating the next one + * when none is current. Required to bind managed subscription generations. + */ + ensureDestinationAuthorization?(input: { + account_id: string; + destination_ref: string; + pattern: ReportingDestinationPatternV1; + transport?: string; + }): Promise; // `syncReceiptBatch` comes from `ReportingReceiptSyncStoreV1`, the one method the SDK's // `sync_reporting_receipts` handler needs. } @@ -304,6 +329,13 @@ export interface ReportingManagedDeliveryAdapterV1 { obligation: ReportingLedgerObligationV1; revision: ReportingLedgerRevisionV1; maxBytes: number; + /** + * The binding's `path_template` expanded for this obligation: a relative path under the + * destination location, each placeholder value one percent-encoded segment. Present only + * for a subscription binding with a template. An object-store adapter places the delivery + * under it; see `reportingObjectWriteName`. + */ + delivery_path?: string; }>, context: { signal: AbortSignal } ): Promise<{ status: 'available' | 'delivered'; resource: ReportingResource; verification: ReportingVerification }>; @@ -384,6 +416,11 @@ export const UNROUTED_REPORTING_ROUTE_KEY_V1 = '~unrouted'; * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ export interface ReportingManagedDeliveryWorkerResultV1 extends Partial { + /** + * Subscription materializations released because the owning principal is no longer + * authorized for the account. Present only when the worker re-checks subscription authority. + */ + heldForAuthority?: number; planned: number; claimed: number; delivered: number; @@ -431,8 +468,30 @@ export interface ReportingManagedDeliveryWorkerOptionsV1 { account_id: string, signal: AbortSignal | undefined ) => Promise; + /** + * Re-checks, before delivering and again before settling, that the principal owning a + * subscription obligation is still authorized for its account (experimental, AdCP 3.3). A + * `false` answer releases the materialization with `holdMaterialization` and does not fail it: + * the obligation stays owed, and the engine reports the loss in `accounts_unauthorized`. + * Account obligations are never checked. + */ + authorizeSubscriptionDelivery?: ReportingSubscriptionDeliveryAuthorityV1; + /** How long a materialization held for lost authority stays out of selection. Defaults to 60 seconds. */ + authorityHoldMilliseconds?: number; } +/** + * Whether the owner of a subscription obligation may still receive this account's data. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionDeliveryAuthorityV1 = (input: { + principal: ReportingLedgerSubscriptionPrincipalV1; + account_id: string; + obligation: ReportingLedgerObligationV1; + binding: ReportingManagedDeliveryBindingV1; +}) => boolean | Promise; + /** * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ @@ -451,6 +510,10 @@ export interface CreateReportingManagedDeliveryRuntimeOptionsV1< authorizationRevocationSeconds: number; resolveConsumerId?: (context: TContext) => string | Promise; consumerMismatchEscalation?: ReportingStatusConsumerScopeOptionsV1['consumerMismatchEscalation']; + /** Scopes subscription generations in status and revision reads to the caller's principal. */ + resolveSubscriptionPrincipal?: ReportingSubscriptionPrincipalResolverV1; + /** Re-checks subscription authority at every delivery; see the worker option of the same name. */ + authorizeSubscriptionDelivery?: ReportingSubscriptionDeliveryAuthorityV1; } /** @@ -546,21 +609,37 @@ export async function createReportingManagedDeliveryRuntime< throw new Error('Reconciled Billing offerings require the pinned canonicalization contract'); } } + if (options.authorizeSubscriptionDelivery && typeof options.store.holdMaterialization !== 'function') { + throw new Error('Re-checking subscription authority at delivery requires a store with holdMaterialization'); + } + const subscriptionScope = options.resolveSubscriptionPrincipal + ? { resolveSubscriptionPrincipal: options.resolveSubscriptionPrincipal } + : undefined; const statusOptions = options.resolveConsumerId ? { resolveConsumerId: options.resolveConsumerId, ...(options.consumerMismatchEscalation ? { consumerMismatchEscalation: options.consumerMismatchEscalation } : {}), + ...(subscriptionScope ?? {}), } - : undefined; + : subscriptionScope; const getReportingStatus = createReportingStatusHandler(options.coreStore, statusOptions); - const getMediaBuyDelivery = createReportingDeliveryHandler( - options.coreStore, - options.legacyRevisionReader ? { legacyRevisionReader: options.legacyRevisionReader } : {} - ); + const getMediaBuyDelivery = createReportingDeliveryHandler(options.coreStore, { + ...(options.legacyRevisionReader ? { legacyRevisionReader: options.legacyRevisionReader } : {}), + ...(options.resolveSubscriptionPrincipal + ? { + resolveSubscriptionPrincipal: context => + options.resolveSubscriptionPrincipal!(context as unknown as TContext), + } + : {}), + }); const syncReportingReceipts = reconciledBilling - ? createSyncReportingReceiptsHandler(options.store, options.resolveConsumerId!) + ? options.resolveSubscriptionPrincipal + ? createSyncReportingReceiptsHandler(options.store, options.resolveConsumerId!, undefined, { + resolveSubscriptionPrincipal: options.resolveSubscriptionPrincipal, + }) + : createSyncReportingReceiptsHandler(options.store, options.resolveConsumerId!) : undefined; const reportingDeliveryCapabilities: ReportingDeliveryCapabilities = { supported: true, @@ -650,6 +729,9 @@ export async function createReportingManagedDeliveryRuntime< }, } : {}), + ...(options.authorizeSubscriptionDelivery + ? { authorizeSubscriptionDelivery: options.authorizeSubscriptionDelivery } + : {}), ...workerOptions, minimumResourceRetentionDays: Math.max( options.resourceRetentionDays, @@ -693,6 +775,25 @@ export async function runManagedDeliveryWorker( if (options.authorizationRevocationSeconds !== undefined) { nonnegativeInteger(options.authorizationRevocationSeconds, 'authorizationRevocationSeconds'); } + const authorityHoldMilliseconds = options.authorityHoldMilliseconds ?? DEFAULT_AUTHORITY_HOLD_MILLISECONDS; + positiveInteger(authorityHoldMilliseconds, 'authorityHoldMilliseconds'); + if (options.authorizeSubscriptionDelivery && typeof store.holdMaterialization !== 'function') { + throw new Error('Re-checking subscription authority at delivery requires a store with holdMaterialization'); + } + // A subscription delivery proceeds only while its owner is authorized for the account + // (`core/reporting-subscription.json`: destination proof never establishes account authority). + const ownerAuthorized = async (lease: ReportingManagedDeliveryLeaseV1): Promise => { + const owner = lease.obligation.subscription; + if (!owner || !options.authorizeSubscriptionDelivery) return true; + return ( + (await options.authorizeSubscriptionDelivery({ + principal: { tenantId: owner.tenantId, principalId: owner.principalId }, + account_id: lease.obligation.account.account_id, + obligation: structuredClone(lease.obligation), + binding: structuredClone(lease.binding), + })) === true + ); + }; const revocationWindow = options.authorizationRevocationSeconds === undefined ? undefined : options.authorizationRevocationSeconds * 1_000; // A failed cleanup keeps its lease as the retry delay, so the lease is also @@ -743,6 +844,11 @@ export async function runManagedDeliveryWorker( } : undefined; if (routed) Object.assign(counts, routed); + if (options.authorizeSubscriptionDelivery) counts.heldForAuthority = 0; + const holdForAuthority = async (lease: ReportingManagedDeliveryLeaseV1) => { + await store.holdMaterialization!({ lease, backoff_milliseconds: authorityHoldMilliseconds }); + counts.heldForAuthority = (counts.heldForAuthority ?? 0) + 1; + }; const countRoute = ( bucket: keyof ReportingManagedDeliveryRouteCountsV1, target: Parameters>[0] @@ -836,6 +942,10 @@ export async function runManagedDeliveryWorker( if (!lease) break; counts.claimed += 1; try { + if (!(await ownerAuthorized(lease))) { + await holdForAuthority(lease); + continue; + } if (!Array.isArray(lease.revision.rows) && options.hydrateRevision) { const hydrated = await options.hydrateRevision( lease.revision, @@ -861,6 +971,9 @@ export async function runManagedDeliveryWorker( obligation: lease.obligation, revision: lease.revision, maxBytes: resourceMaxBytes, + ...(lease.binding.path_template !== undefined + ? { delivery_path: reportingSubscriptionDeliveryPathV1(lease.binding, lease.obligation) } + : {}), }, { signal } ), @@ -869,6 +982,11 @@ export async function runManagedDeliveryWorker( options.signal ); assertMaterializationOutcome(lease, outcome, now().toISOString(), minimumResourceRetentionDays); + // Authority may have been lost while the bytes were in flight: never record such a delivery. + if (!(await ownerAuthorized(lease))) { + await holdForAuthority(lease); + continue; + } if ( await store.settleMaterialization({ lease, @@ -899,6 +1017,141 @@ export async function runManagedDeliveryWorker( return counts; } +/** + * A subscription binding's `path_template` expanded for one obligation: `{account_id}`, + * `{subscription_id}`, `{offering_id}` and `{period_start}` each fill one percent-encoded segment. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function reportingSubscriptionDeliveryPathV1( + binding: Pick, + obligation: Pick +): string { + if (binding.path_template === undefined) throw new TypeError('Managed binding has no path_template'); + if (obligation.account.account_id !== binding.account_id) { + throw new TypeError('Obligation and binding name different accounts'); + } + return expandReportingSubscriptionPathTemplate(binding.path_template, { + account_id: binding.account_id, + subscription_id: obligation.delivery_config_id, + offering_id: obligation.offeringId, + period_start: obligation.period.start, + }); +} + +/** + * Binds one managed subscription generation to the subscription's destination, for + * `createReliableReportingService({ reportingSubscriptions: { bindManagedGeneration } })`. + * + * The destination is resolved from the owner: the subscription version names + * `destination.destination_id`, the principal's current generation of that destination supplies + * the `destination_ref` (`core/reporting-subscription.json`: "The seller binds the current + * generation"), and it must be delivery-eligible and serve the offering's pattern and transport + * (the version froze both). One destination serves every account the subscription covers, so + * the account gets its own authorization of that `destination_ref`, created on first use. The + * binding freezes the version's `path_template`. A destination that is not ready rejects the + * bind, so the generation's obligations wait for the next cycle instead of becoming + * undeliverable. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function createReportingSubscriptionManagedBindingV1(options: { + store: Pick; + principal: { + listReportingSubscriptions(scope: ReportingLedgerSubscriptionPrincipalV1): Promise<{ + states: ReadonlyArray<{ + subscription_id: string; + destination_ref?: string; + configuration: { destination?: { destination_id: string } }; + }>; + }>; + resolveReportingDestination( + scope: ReportingLedgerSubscriptionPrincipalV1, + destinationRef: string + ): Promise<{ + destinationId: string; + destinationRef: string; + configuration: unknown; + deliveryEligible: boolean; + } | null>; + }; + resourceRetentionDays: number; + /** The delivery adapter's verification profiles; `canonical_digest` is required for consumer receipts. */ + verificationProfiles: readonly ReportingManagedDeliveryBindingV1['verification_profile'][]; +}): (input: ReliableReportingSubscriptionBindingInputV1) => Promise { + positiveInteger(options.resourceRetentionDays, 'resourceRetentionDays'); + const ensure = options.store.ensureDestinationAuthorization; + if (typeof ensure !== 'function') { + throw new Error('Binding subscription generations requires a store with ensureDestinationAuthorization'); + } + return async input => { + const { principal, configuration, subscription, offering } = input; + const method = offering.method as { pattern?: string; transport?: string } | undefined; + const destination = subscription.destination; + if (!method?.pattern || !method.transport || !destination) { + throw new TypeError('A managed subscription generation needs a destination and a managed offering method'); + } + if ( + input.destinationMethod && + (input.destinationMethod.pattern !== method.pattern || input.destinationMethod.transport !== method.transport) + ) { + throw new Error('The resolved offering method differs from the destination method the version froze'); + } + const owner = configuration.subscription; + if ( + !owner || + owner.tenantId !== principal.tenantId || + owner.principalId !== principal.principalId || + owner.subscriptionId !== subscription.subscription_id + ) { + throw new Error('The generation is not owned by this subscription'); + } + const { states } = await options.principal.listReportingSubscriptions(principal); + const state = states.find(value => value.subscription_id === subscription.subscription_id); + if (!state?.destination_ref || state.configuration.destination?.destination_id !== destination.destination_id) { + throw new Error('The subscription destination has no current generation to bind'); + } + const resolved = await options.principal.resolveReportingDestination(principal, state.destination_ref); + const resolvedMethod = (resolved?.configuration ?? {}) as { pattern?: unknown; transport?: unknown }; + if (!resolved || resolved.destinationId !== destination.destination_id || !resolved.deliveryEligible) { + throw new Error('The subscription destination is not ready for delivery'); + } + if (resolvedMethod.pattern !== method.pattern || resolvedMethod.transport !== method.transport) { + throw new Error('The subscription destination serves another pattern or transport than its offering'); + } + const reconciliationMode = + offering.reconciliation_mode === 'consumer_receipt' ? 'consumer_receipt' : 'delivery_only'; + const verificationProfile = + reconciliationMode === 'consumer_receipt' ? 'canonical_digest' : options.verificationProfiles[0]; + if (!verificationProfile || !options.verificationProfiles.includes(verificationProfile)) { + throw new Error('The delivery adapter cannot verify this subscription generation'); + } + const authorizationGeneration = await ensure.call(options.store, { + account_id: configuration.account.account_id, + destination_ref: resolved.destinationRef, + pattern: method.pattern as ReportingDestinationPatternV1, + transport: method.transport, + }); + await options.store.installBinding( + reportingManagedDeliveryBindingV1({ + configurationId: configuration.configurationId, + account_id: configuration.account.account_id, + delivery_config_id: configuration.delivery_config_id, + delivery_config_version: configuration.delivery_config_version, + destination_ref: resolved.destinationRef, + authorization_generation: authorizationGeneration, + feed_purpose: configuration.feedPurpose, + method: method.pattern as ReportingManagedDeliveryBindingV1['method'], + transport: method.transport, + verification_profile: verificationProfile, + reconciliation_mode: reconciliationMode, + resource_retention_days: options.resourceRetentionDays, + ...(destination.path_template !== undefined ? { path_template: destination.path_template } : {}), + }) + ); + }; +} + /** * Runs the adapter's own startup probe. A router rejects with the ids of the * routes that failed; they are named here, and only the ids, because the diff --git a/packages/reporting-storage/src/notifications/notification-activity.ts b/packages/reporting-storage/src/notifications/notification-activity.ts index 6a8c59017..8071b8dc0 100644 --- a/packages/reporting-storage/src/notifications/notification-activity.ts +++ b/packages/reporting-storage/src/notifications/notification-activity.ts @@ -44,6 +44,19 @@ interface ReportingAccountActivityBaseV1 { accountId: string; reporting_obligation_id: string; occurredAt: string; + /** + * The principal owning a subscription obligation (experimental, AdCP 3.3). Its notification + * reaches only that principal's subscribers on the account. Absent for account obligations. + */ + ownerPrincipalId?: string; +} + +/** Scope a subscription obligation's activity to its owning principal. */ +function withOwner( + activity: T, + obligation: Readonly> +): T { + return obligation.subscription ? { ...activity, ownerPrincipalId: obligation.subscription.principalId } : activity; } /** @@ -943,7 +956,10 @@ export function createPostgresReportingNotificationActivityRuntime( if (input.transition.reporting_obligation_id !== input.obligation.reporting_obligation_id) { throw new Error('Reporting transition and authoritative obligation identities disagree'); } - const activity = buildActivity(namespace, tenantId, input.transition, input.obligation); + const activity = withOwner( + buildActivity(namespace, tenantId, input.transition, input.obligation), + input.obligation + ); const notificationRequired = input.transition.previousHealth !== input.transition.health; await persistActivity(transaction, { eventId: input.transition.transitionId, @@ -961,7 +977,7 @@ export function createPostgresReportingNotificationActivityRuntime( throw new Error('Reporting ledger record and authoritative obligation identities disagree'); } const occurredAt = await databaseNow(transaction); - const activity = buildLedgerChangedActivity(namespace, tenantId, occurredAt, input); + const activity = withOwner(buildLedgerChangedActivity(namespace, tenantId, occurredAt, input), input.obligation); await persistActivity(transaction, { eventId, obligation: input.obligation, @@ -982,7 +998,7 @@ export function createPostgresReportingNotificationActivityRuntime( throw new Error('Reporting delivery-ready activity requires a successful materialization'); } const occurredAt = await databaseNow(transaction); - const activity = buildDeliveryReadyActivity(namespace, tenantId, occurredAt, input); + const activity = withOwner(buildDeliveryReadyActivity(namespace, tenantId, occurredAt, input), input.obligation); await persistActivity(transaction, { eventId, obligation: input.obligation, @@ -1148,6 +1164,7 @@ export function createPostgresReportingNotificationActivityRuntime( anchor: 'account', tenantId: claim.tenantId, accountId: claim.accountId, + ...(claim.activity.ownerPrincipalId ? { principalId: claim.activity.ownerPrincipalId } : {}), payload: notificationPayload(claim.activity), // Commit the recipient set under this lease before anything leaves // the process. Each recipient is revisable until its own durable diff --git a/packages/reporting-storage/src/object-writes.ts b/packages/reporting-storage/src/object-writes.ts index d0ee07767..859de681e 100644 --- a/packages/reporting-storage/src/object-writes.ts +++ b/packages/reporting-storage/src/object-writes.ts @@ -97,13 +97,68 @@ export function reportingObjectWriteScopeKey(scope: ReportingObjectWriteScopeV1) /** * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +/** + * Object name of one write. `adcp-reporting/{namespace_key}/{scope_key}/` is the collision-free, + * account- and generation-scoped prefix every reader and cleanup is fenced to, and + * `{plan_id}/{index}` keeps each logical delivery distinct across retries. A subscription + * delivery's expanded `path_template` (`delivery_path`) goes between them: + * `adcp-reporting/{namespace_key}/{scope_key}/{delivery_path}/{plan_id}/{index}`, so the template + * names where the data sits inside the generation's prefix without ever escaping it or making + * two deliveries collide. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ export function reportingObjectWriteName( scope: ReportingObjectWriteScopeV1, binding: ReportingObjectWriteBindingV1, planId: string, - index: number + index: number, + path?: string ): string { - return `adcp-reporting/${binding.namespace_key}/${reportingObjectWriteScopeKey(scope)}/${planId}/${index}`; + const prefix = `adcp-reporting/${binding.namespace_key}/${reportingObjectWriteScopeKey(scope)}/`; + if (path === undefined) return `${prefix}${planId}/${index}`; + assertReportingObjectWritePath(path); + return `${prefix}${path}/${planId}/${index}`; +} + +/** + * Whether `name` is the object name of write `index` of plan `planId` in this scope, with or + * without a delivery path. Cleanup and readers use it instead of recomputing a name. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function isReportingObjectWriteName( + scope: ReportingObjectWriteScopeV1, + binding: ReportingObjectWriteBindingV1, + planId: string, + index: number, + name: string +): boolean { + if (typeof name !== 'string') return false; + if (name === reportingObjectWriteName(scope, binding, planId, index)) return true; + const prefix = `adcp-reporting/${binding.namespace_key}/${reportingObjectWriteScopeKey(scope)}/`; + const suffix = `/${planId}/${index}`; + if (!name.startsWith(prefix) || !name.endsWith(suffix) || name.length <= prefix.length + suffix.length) return false; + try { + assertReportingObjectWritePath(name.slice(prefix.length, name.length - suffix.length)); + return true; + } catch { + return false; + } +} + +/** A relative path of non-empty segments, none `.` or `..`, from the expanded-template alphabet. */ +function assertReportingObjectWritePath(path: string): void { + if ( + typeof path !== 'string' || + path.length < 1 || + path.length > 1024 || + path + .split('/') + .some(segment => !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%]+$/.test(segment) || segment === '.' || segment === '..') + ) { + throw new TypeError('Reporting object-write path must be relative segments without . or ..'); + } } /** diff --git a/packages/reporting-storage/src/postgres.ts b/packages/reporting-storage/src/postgres.ts index ce44675d0..3a4be0b9e 100644 --- a/packages/reporting-storage/src/postgres.ts +++ b/packages/reporting-storage/src/postgres.ts @@ -9,6 +9,10 @@ import { assertReportingLedgerHistoryDoesNotOverlap, reportingLedgerEffectivePeriod, reportingLedgerScopeClosed, + reportingLedgerCoverageClosedV1, + reportingLedgerSubscriptionKeyV1, + mergeReportingLedgerOwedSetV1, + isReportingLedgerOwedSetV1, assertReportingConsumerMismatchEscalation, projectReportingConsumerStatusMismatchV1, projectReportingObligationHealthV1, @@ -46,6 +50,7 @@ import type { ReportingLedgerIssueV1, ReportingLedgerLeaseV1, ReportingLedgerObligationV1, + ReportingLedgerOwedSetProgressV1, ReportingLedgerPageV1, ReportingLedgerRevisionV1, ReportingLedgerRevisionSnapshotV1, @@ -115,12 +120,55 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_configurations ( semantic_fingerprint TEXT NOT NULL, data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, - UNIQUE (account_id, delivery_config_id, delivery_config_version) + subscription_key TEXT NOT NULL DEFAULT '' ); -- History ownership is independent of the immutable generation and real install time. ALTER TABLE adcp_reporting_configurations ADD COLUMN IF NOT EXISTS history_start TIMESTAMPTZ; +-- Principal-level reporting subscriptions (AdCP 3.3) install one generation per owning principal +-- and resolved offering under the same (account, delivery_config_id, delivery_config_version): +-- subscription_key digests the owner and offering, and stays empty for every account generation, +-- so existing rows keep exactly their uniqueness. The column is added under a catalog guard +-- because ADD COLUMN IF NOT EXISTS takes ACCESS EXCLUSIVE even when the column exists. The +-- widened unique index is built before the published three-column constraint is dropped, and that +-- constraint is found by its columns rather than by a guessed name. +DO $subscription_key$ +DECLARE + configurations regclass := to_regclass('adcp_reporting_configurations'); +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = configurations AND attname = 'subscription_key' AND NOT attisdropped + ) THEN + ALTER TABLE adcp_reporting_configurations ADD COLUMN subscription_key TEXT NOT NULL DEFAULT ''; + END IF; +END +$subscription_key$; +CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_configurations_generation + ON adcp_reporting_configurations (account_id, delivery_config_id, delivery_config_version, subscription_key); +DO $subscription_unique$ +DECLARE + configurations regclass := to_regclass('adcp_reporting_configurations'); + legacy RECORD; +BEGIN + FOR legacy IN + SELECT constraint_row.conname + FROM pg_constraint constraint_row + WHERE constraint_row.conrelid = configurations + AND constraint_row.contype = 'u' + AND ( + SELECT array_agg(attribute.attname::text ORDER BY attribute.attname::text) + FROM unnest(constraint_row.conkey) AS key(attnum) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid AND attribute.attnum = key.attnum + ) = ARRAY['account_id', 'delivery_config_id', 'delivery_config_version']::text[] + LOOP + EXECUTE format('ALTER TABLE adcp_reporting_configurations DROP CONSTRAINT %I', legacy.conname); + END LOOP; +END +$subscription_unique$; + CREATE TABLE IF NOT EXISTS adcp_reporting_obligations ( obligation_id TEXT PRIMARY KEY, configuration_id TEXT NOT NULL REFERENCES adcp_reporting_configurations(configuration_id), @@ -822,17 +870,25 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [ `adcp-reporting-config:${configuration.account.account_id}:${configuration.delivery_config_id}`, ]); - const newer = await client.query( - `SELECT MAX(delivery_config_version)::integer AS highest - FROM adcp_reporting_configurations WHERE account_id = $1 AND delivery_config_id = $2`, - [configuration.account.account_id, configuration.delivery_config_id] - ); - if ((newer.rows[0]?.highest ?? -1) > configuration.delivery_config_version) { - throw new Error('Reporting configuration version cannot regress'); + const subscriptionKey = reportingLedgerSubscriptionKeyV1(configuration); + // Version order is a property of an account configuration's lineage. A subscription + // installs one generation per resolved offering as its periods freeze, so an older + // version's offering can legitimately arrive after a newer one. + if (subscriptionKey === '') { + const newer = await client.query( + `SELECT MAX(delivery_config_version)::integer AS highest + FROM adcp_reporting_configurations + WHERE account_id = $1 AND delivery_config_id = $2 AND subscription_key = ''`, + [configuration.account.account_id, configuration.delivery_config_id] + ); + if ((newer.rows[0]?.highest ?? -1) > configuration.delivery_config_version) { + throw new Error('Reporting configuration version cannot regress'); + } } if (configuration.historyStart !== undefined) { const generations = await client.query>( - 'SELECT data FROM adcp_reporting_configurations WHERE account_id = $1 AND delivery_config_id = $2', + `SELECT data FROM adcp_reporting_configurations + WHERE account_id = $1 AND delivery_config_id = $2 AND subscription_key = ''`, [configuration.account.account_id, configuration.delivery_config_id] ); assertReportingLedgerHistoryDoesNotOverlap( @@ -843,8 +899,8 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { const inserted = await client.query>( `INSERT INTO adcp_reporting_configurations (configuration_id, account_id, delivery_config_id, delivery_config_version, - semantic_fingerprint, data, created_at, history_start) - VALUES ($1, $2, $3, $4, $5, $6::jsonb, clock_timestamp(), $7) + semantic_fingerprint, data, created_at, history_start, subscription_key) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, clock_timestamp(), $7, $8) ON CONFLICT DO NOTHING RETURNING data`, [ configuration.configurationId, @@ -854,6 +910,7 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { configuration.semanticFingerprint, JSON.stringify(configuration), configuration.historyStart ?? null, + subscriptionKey, ] ); const value = @@ -861,11 +918,13 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { ( await client.query>( `SELECT data FROM adcp_reporting_configurations - WHERE account_id = $1 AND delivery_config_id = $2 AND delivery_config_version = $3`, + WHERE account_id = $1 AND delivery_config_id = $2 AND delivery_config_version = $3 + AND subscription_key = $4`, [ configuration.account.account_id, configuration.delivery_config_id, configuration.delivery_config_version, + subscriptionKey, ] ) ).rows[0]?.data; @@ -894,7 +953,8 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { async client => { // The account fence also serializes generation installation and history extension. const result = await client.query>( - 'SELECT data FROM adcp_reporting_configurations WHERE account_id = $1 AND delivery_config_id = $2', + `SELECT data FROM adcp_reporting_configurations + WHERE account_id = $1 AND delivery_config_id = $2 AND subscription_key = ''`, [target.account.account_id, target.delivery_config_id] ); const configurations = result.rows.map(row => row.data); @@ -930,6 +990,47 @@ export class PostgresReportingLedgerStore implements ReportingLedgerStore { ); } + async recordOwedSetProgress(input: ReportingLedgerOwedSetProgressV1): Promise { + const { configurationId, account_id, ...progress } = input; + return this.transaction( + async client => { + const found = await client.query>( + `SELECT data FROM adcp_reporting_configurations + WHERE configuration_id = $1 AND account_id = $2 AND subscription_key <> '' + FOR UPDATE`, + [configurationId, account_id] + ); + const stored = found.rows[0]?.data; + if (!stored || !isReportingLedgerOwedSetV1(stored)) { + throw new Error('Unknown subscription reporting configuration'); + } + const owedSet = mergeReportingLedgerOwedSetV1(stored.owedSet, progress); + if (JSON.stringify(owedSet) === JSON.stringify(stored.owedSet ?? null)) return clone(stored); + const value = { ...stored, owedSet }; + // Mutable progress only: the fingerprint and every immutable field are untouched. + await client.query( + `UPDATE adcp_reporting_configurations SET data = jsonb_set(data, '{owedSet}', $2::jsonb, true) + WHERE configuration_id = $1`, + [configurationId, JSON.stringify(owedSet)] + ); + return clone(value); + }, + { preBeginAdvisoryLock: accountLock(account_id) } + ); + } + + async findObligationByOrdinal(input: { + account_id: string; + configurationId: string; + periodOrdinal: number; + }): Promise { + return this.one( + `SELECT data FROM adcp_reporting_obligations + WHERE account_id = $1 AND configuration_id = $2 AND period_ordinal = $3`, + [input.account_id, input.configurationId, input.periodOrdinal] + ); + } + async listConfigurations(account_id?: string): Promise { const result = await this.query>( `SELECT data FROM adcp_reporting_configurations @@ -3903,6 +4004,8 @@ ${managedDueArm} ) throw new Error('Reporting ledger snapshot exceeds the item limit'); } const frozenCalendarProofRequired = configurations.some(configuration => { + // Owed-set generations never resolve a period grid. + if (isReportingLedgerOwedSetV1(configuration)) return false; try { reportingPeriodSchedule(configuration); return false; @@ -3927,6 +4030,7 @@ ${managedDueArm} ) // prove the handoff exclusively from frozen obligations. period: { start: configurations + .filter(configuration => !isReportingLedgerOwedSetV1(configuration)) .map(configuration => configuration.historyStart ?? configuration.installedAt) .sort(compareReportingInstants)[0], end: ledgerAsOf, @@ -4076,7 +4180,7 @@ ${managedDueArm} ) value, obligationRevisions, ledgerAsOf, - reportingLedgerScopeClosed(query, ledgerAsOf, ledgerCoverage.complete) + reportingLedgerScopeClosed(query, ledgerAsOf, reportingLedgerCoverageClosedV1(ledgerCoverage)) ); const sellerHealth = projectReportingUnreadableRevisionHealthV1( value, @@ -4316,19 +4420,28 @@ ${managedDueArm} ) ): Promise { const period = reportingLedgerEffectivePeriod(query, ledgerAsOf); const result = await client.query>( + // EXISTS rather than a join: one subscription version owns one generation per resolved + // offering, so several visible generations can share a status's (id, version). `SELECT status.data FROM adcp_reporting_consumer_statuses status - JOIN adcp_reporting_configurations configuration - ON configuration.account_id = status.account_id - AND configuration.delivery_config_id = status.data->>'delivery_config_id' - AND configuration.delivery_config_version = (status.data->>'delivery_config_version')::integer WHERE status.account_id = $1 AND status.consumer_id = $2 AND status.recorded_at <= $3 AND ($4::timestamptz IS NULL OR status.recorded_at > $4) AND ($5::text[] IS NULL OR status.data->>'delivery_config_id' = ANY($5)) AND ($10::text IS NOT NULL OR (status.data->'period'->>'end')::timestamptz > $6) AND ($10::text IS NOT NULL OR (status.data->'period'->>'start')::timestamptz < $7) - AND ($8::text[] IS NULL OR configuration.data->>'feedPurpose' = ANY($8)) - AND ($9::text[] IS NULL OR configuration.data->'mediaBuyIds' ?| $9::text[]) + AND EXISTS ( + SELECT 1 FROM adcp_reporting_configurations configuration + WHERE configuration.account_id = status.account_id + AND configuration.delivery_config_id = status.data->>'delivery_config_id' + AND configuration.delivery_config_version = (status.data->>'delivery_config_version')::integer + AND (configuration.subscription_key = '' + OR (configuration.data->'subscription'->>'tenantId' = $13 + AND configuration.data->'subscription'->>'principalId' = $14)) + AND ($8::text[] IS NULL OR configuration.data->>'feedPurpose' = ANY($8)) + AND ($9::text[] IS NULL OR (CASE WHEN configuration.subscription_key = '' + THEN configuration.data->'mediaBuyIds' + ELSE COALESCE(configuration.data->'owedSet'->'mediaBuyIds', '[]'::jsonb) END) ?| $9::text[]) + ) AND ($10::text IS NULL OR status.data->>'reporting_revision_id' = $10) AND (NOT $11::boolean OR status.is_current) ORDER BY status.recorded_at, status.consumer_status_id @@ -4346,6 +4459,8 @@ ${managedDueArm} ) query.reporting_revision_id ?? null, currentOnly, limit, + query.subscription_principal?.tenantId ?? null, + query.subscription_principal?.principalId ?? null, ] ); return result.rows.map(row => clone(row.data)); @@ -4418,6 +4533,8 @@ ${managedDueArm} ) WHERE exact_revision.obligation_id = adcp_reporting_obligations.obligation_id AND exact_revision.revision_id = $9 AND exact_revision.recorded_at <= $2 )) + AND (NOT (data ? 'subscription') + OR (data->'subscription'->>'tenantId' = $12 AND data->'subscription'->>'principalId' = $13)) ORDER BY period_start, obligation_id LIMIT $11`, [ @@ -4432,6 +4549,8 @@ ${managedDueArm} ) query.reporting_revision_id ?? null, query.consumer_id ?? null, MAX_SNAPSHOT_ITEMS + 1, + query.subscription_principal?.tenantId ?? null, + query.subscription_principal?.principalId ?? null, ] ); return result.rows.map(row => row.data); @@ -4446,9 +4565,18 @@ ${managedDueArm} ) `SELECT data FROM adcp_reporting_configurations WHERE account_id = $1 AND created_at <= $2 AND ($3::text[] IS NULL OR delivery_config_id = ANY($3)) - ORDER BY delivery_config_id, delivery_config_version + AND (subscription_key = '' + OR (data->'subscription'->>'tenantId' = $5 AND data->'subscription'->>'principalId' = $6)) + ORDER BY delivery_config_id, delivery_config_version, subscription_key LIMIT $4`, - [query.account_id, ledgerAsOf, query.delivery_config_ids ?? null, MAX_SNAPSHOT_ITEMS + 1] + [ + query.account_id, + ledgerAsOf, + query.delivery_config_ids ?? null, + MAX_SNAPSHOT_ITEMS + 1, + query.subscription_principal?.tenantId ?? null, + query.subscription_principal?.principalId ?? null, + ] ); return result.rows.map(row => row.data); } @@ -6065,6 +6193,14 @@ function checkpointScopeFingerprint(query: ReportingLedgerSnapshotQueryV1): stri return digest({ account_id: query.account_id, ...(query.consumer_id ? { consumer_id: query.consumer_id } : {}), + ...(query.subscription_principal + ? { + subscription_principal: { + tenantId: query.subscription_principal.tenantId, + principalId: query.subscription_principal.principalId, + }, + } + : {}), ...(query.media_buy_ids ? { media_buy_ids: sorted(query.media_buy_ids) } : {}), ...(query.delivery_config_ids ? { delivery_config_ids: sorted(query.delivery_config_ids) } : {}), ...(query.feed_purposes ? { feed_purposes: sorted(query.feed_purposes) } : {}), diff --git a/packages/reporting-storage/src/service/index.ts b/packages/reporting-storage/src/service/index.ts index 21bc54845..ef1813cf2 100644 --- a/packages/reporting-storage/src/service/index.ts +++ b/packages/reporting-storage/src/service/index.ts @@ -22,6 +22,7 @@ import { type ReliableReportingSchedulerOptionsV1, type ReliableReportingServiceV1, } from '@adcp/sdk/reporting/service'; +import { withReportingSubscriptionCoverageV1 } from '@adcp/sdk/reporting/advanced'; import { composeNotificationDeliveryAttemptCheckpoints, composeWebhookAttemptResultObservers, @@ -61,6 +62,7 @@ import { PostgresReportingManagedDeliveryStore, REPORTING_MANAGED_DELIVERY_MIGRATION, createReportingManagedDeliveryRuntime, + createReportingSubscriptionManagedBindingV1, type PostgresReportingManagedDeliveryStoreOptions, type ReportingManagedDeliveryAdapterV1, type ReportingManagedDeliveryRuntimeV1, @@ -320,8 +322,37 @@ export async function createPostgresReliableReportingProductionService { + const { + standard_dimensions: _standard, + unmapped_dimensions: _unmapped, + ...rest + } = offering as typeof offering & { standard_dimensions?: unknown; unmapped_dimensions?: unknown }; + void _standard; + void _unmapped; + return rest; + }) as typeof core.capabilities.offerings, automatedRecoveryWindowSeconds: options.automatedRecoveryWindowSeconds, statusRetentionDays: options.statusRetentionDays, resourceRetentionDays: options.managedDelivery.resourceRetentionDays, authorizationRevocationSeconds: options.managedDelivery.authorizationRevocationSeconds, ...(options.resolveConsumerId ? { resolveConsumerId: options.resolveConsumerId } : {}), ...(options.consumerMismatchEscalation ? { consumerMismatchEscalation: options.consumerMismatchEscalation } : {}), + ...(subscriptions + ? { + resolveSubscriptionPrincipal: async (context: RequestContext>) => { + const principal = await subscriptions.resolvePrincipal(context); + return { tenantId: principal.tenantId, principalId: principal.principalId }; + }, + authorizeSubscriptionDelivery: ({ principal, account_id }) => + subscriptions.engine.isAuthorized!(principal, account_id), + } + : {}), }); const capabilities = deepFreeze( ReportingDeliveryCapabilitiesSchema.parse({ @@ -369,7 +421,14 @@ export async function createPostgresReliableReportingProductionService = { capabilities, - getReportingStatus: (request, context) => managed.getReportingStatus(request, context as never), + getReportingStatus: subscriptions + ? async (request, context) => + withReportingSubscriptionCoverageV1( + await managed.getReportingStatus(request, context as never), + context as never, + subscriptions + ) + : (request, context) => managed.getReportingStatus(request, context as never), getMediaBuyDelivery: (request, context) => managed.getMediaBuyDelivery(request, context as never), ...(core.platform.syncReportingStatus ? { diff --git a/packages/reporting-storage/test/helpers/service.js b/packages/reporting-storage/test/helpers/service.js index b247f5c80..304e99019 100644 --- a/packages/reporting-storage/test/helpers/service.js +++ b/packages/reporting-storage/test/helpers/service.js @@ -1,11 +1,13 @@ 'use strict'; /** * The pre-split `@adcp/sdk/reporting/service` surface as one namespace: the Core service from `@adcp/sdk` - * plus the production composition and the maintenance helpers from this package. + * plus the production composition and the maintenance helpers from this package, and the reporting-subscription + * building blocks that moved to `@adcp/sdk/reporting/advanced`. */ const storage = require('@adcp/reporting-storage'); module.exports = { + ...require('@adcp/sdk/reporting/advanced'), ...require('@adcp/sdk/reporting/service'), ...require('@adcp/reporting-storage/service'), reportingLedgerMaintenance: storage.reportingLedgerMaintenance, diff --git a/packages/reporting-storage/test/reporting-subscription-delivery-pg.test.js b/packages/reporting-storage/test/reporting-subscription-delivery-pg.test.js new file mode 100644 index 000000000..6fc072cf6 --- /dev/null +++ b/packages/reporting-storage/test/reporting-subscription-delivery-pg.test.js @@ -0,0 +1,475 @@ +/** + * Managed delivery of subscription obligations on PostgreSQL (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3), through the production service: the engine owes + * periods, the Core cycle binds each (owner, offering) generation to the owner's destination, the + * producer executes, and the managed worker delivers with the expanded path_template and + * account_id on every row. A lost grant stops delivery between claim and settle. Also the + * additive binding-key migration on a published schema. + */ +const assert = require('node:assert/strict'); +const { generateKeyPairSync } = require('node:crypto'); +const { after, before, describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; +const DATABASE_URL = process.env.REPORTING_LEDGER_PG_URL; +const DAY = 86_400_000; +const ACCOUNT = 'subscription-delivery-account'; +const TENANT = 'tenant-1'; +const A = { tenantId: TENANT, principalId: 'principal-a' }; +const B = { tenantId: TENANT, principalId: 'principal-b' }; +const TEMPLATE = '{account_id}/{offering_id}/{period_start}'; + +/** The published managed binding layout, before bindings were keyed by configuration alone. */ +const PUBLISHED_BINDINGS = ` +CREATE TABLE adcp_reporting_destination_authorizations ( + account_id TEXT NOT NULL, destination_ref TEXT NOT NULL, generation BIGINT NOT NULL, + authorized_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, cleanup_completed_at TIMESTAMPTZ, + cleanup_lease_owner TEXT, cleanup_lease_generation BIGINT NOT NULL DEFAULT 0, + cleanup_lease_expires_at TIMESTAMPTZ, data JSONB NOT NULL, + PRIMARY KEY (account_id, destination_ref, generation)); +CREATE TABLE adcp_reporting_managed_bindings ( + configuration_id TEXT PRIMARY KEY REFERENCES adcp_reporting_configurations(configuration_id), + account_id TEXT NOT NULL, delivery_config_id TEXT NOT NULL, delivery_config_version INTEGER NOT NULL, + destination_ref TEXT NOT NULL, authorization_generation BIGINT NOT NULL, + semantic_fingerprint TEXT NOT NULL, data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY (account_id, destination_ref, authorization_generation) + REFERENCES adcp_reporting_destination_authorizations(account_id, destination_ref, generation), + UNIQUE (account_id, delivery_config_id, delivery_config_version));`; + +function principalLifecycle(sdkServer, clock) { + const store = sdkServer.createPrincipalStateStore({ store: new sdkServer.InMemoryStateStore() }); + const notifications = sdkServer.createPersistentNotificationRuntime({ + store: sdkServer.principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: true }) }, + validateDestination: async () => ({ allowed: true }), + authorizeDelivery: async () => ({ authorized: true }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 1, + delivered: true, + terminal: true, + errors: [], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); + let version = 0; + let ref = 0; + return sdkServer.createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + issuePrincipalId: scope => `record-${scope.tenantId}-${scope.principalId}`, + issueConfigurationVersion: () => `cfg-${++version}`, + issueDestinationRef: () => `dest-ref-${++ref}`, + now: () => clock.now, + prepareReportingDestination: ({ destination }) => ({ + state: 'validating', + configuration: structuredClone(destination), + }), + reportingSubscriptions: { listDeliveryConfigIds: () => [] }, + }); +} + +describe('PostgreSQL managed subscription delivery', { skip: !DATABASE_URL && 'PostgreSQL URL not set' }, () => { + const schema = `adcp_reporting_subscription_delivery_${process.pid}`; + const published = `adcp_reporting_subscription_binding_${process.pid}`; + let bootstrap; + let pool; + let publishedPool; + let service; + let engine; + let host; + const deliveries = []; + const origin = Math.floor(Date.now() / DAY) * DAY - 10 * DAY; + const day = n => new Date(origin + n * DAY).toISOString(); + const offeringIds = ['subscription-daily-a', 'subscription-daily-b']; + + before(async () => { + const { Pool } = require('pg'); + const reporting = require('./helpers/service.js'); + const source = require('@adcp/sdk/reporting/source'); + const sdkServer = require('@adcp/sdk/server'); + bootstrap = new Pool({ connectionString: DATABASE_URL }); + await bootstrap.query(`CREATE SCHEMA "${schema}"`); + await bootstrap.query(`CREATE SCHEMA "${published}"`); + pool = new Pool({ connectionString: DATABASE_URL, options: `-c search_path="${schema}"` }); + publishedPool = new Pool({ connectionString: DATABASE_URL, options: `-c search_path="${published}"` }); + + const clock = { now: new Date(day(0.5)) }; + const lifecycle = principalLifecycle(sdkServer, clock); + const request = source.redactedReportingSourceRequestV1(); + const adapters = {}; + const deliveryOfferings = []; + for (const offeringId of offeringIds) { + const sourceOffering = structuredClone(source.redactedReportingSourceOfferingV1); + sourceOffering.offeringId = offeringId; + sourceOffering.publicationNamespace = `reporting-source:${offeringId}`; + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const deliveryOffering = { + offering_id: offeringId, + feed_purpose: 'analytics', + report_definition_id: sourceOffering.contract.report_definition_id, + report_definition_uri: sourceOffering.contract.reportDefinitionUri, + report_definition_sha256: sourceOffering.contract.reportDefinitionSha256, + reporting_profile: { + id: sourceOffering.contract.reportingProfile, + version: sourceOffering.contract.schemaVersion, + schema_uri: sourceOffering.contract.schemaUri, + schema_sha256: sourceOffering.contract.schemaSha256, + schema_dialect: sourceOffering.contract.schemaDialect, + schema_ref_policy: sourceOffering.contract.schemaRefPolicy, + grain: sourceOffering.grain, + primary_keys: ['media_buy_id'], + }, + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT0S' }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + method: { + pattern: 'file_transfer', + transport: 's3', + orchestration: 'producer_managed', + destination_modes: ['existing'], + provider: { domain: 'storage.example' }, + format: 'jsonl', + }, + standard_dimensions: [], + }; + deliveryOfferings.push(deliveryOffering); + adapters[offeringId] = { + sourceOffering, + deliveryOffering, + fetchSlice: slice => ({ + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: 'USD', + reporting_rows: slice.media_buy_ids.map(mediaBuyId => ({ media_buy_id: mediaBuyId, impressions: 3 })), + }), + }; + } + + // Both principals subscribe with the same subscription_id, each to its own destination, + // resolving both offerings (match all). + let key = 0; + for (const [principal, destinationId] of [ + [A, 'dest-a'], + [B, 'dest-b'], + ]) { + const scope = { + tenant_id: principal.tenantId, + principal_id: principal.principalId, + principal_kind: 'buyer_agent', + }; + const synced = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: `subscription-delivery-pg-${String(++key).padStart(4, '0')}`, + configuration: { + reporting_destinations: [ + { + pattern: 'file_transfer', + destination_id: destinationId, + active: true, + provider: { domain: 'storage.example' }, + transport: 's3', + location: `s3://bucket/${destinationId}`, + accepted_formats: ['jsonl'], + accepted_verification_profiles: ['manifest_checksums'], + }, + ], + reporting_subscriptions: [ + { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { + feed_purpose: 'analytics', + period_duration: 'P1D', + required_finality: 'snapshot', + match: 'all', + }, + reconciliation_mode: 'delivery_only', + destination: { destination_id: destinationId, path_template: TEMPLATE }, + }, + ], + }, + }, + { callerMutationScope: scope } + ); + assert.equal(synced.result.kind, 'applied', JSON.stringify(synced)); + const destination = synced.result.configuration.reporting_destinations[0]; + await lifecycle.transitionDestination(principal, { + destinationId, + destinationRef: destination.destination_ref, + state: 'ready', + }); + } + + host = { authorized: new Set(['principal-a', 'principal-b']), onDeliver: undefined }; + engine = reporting.createReportingSubscriptionEngineV1(engineOptions()); + function engineOptions() { + return { + principal: lifecycle, + offerings: deliveryOfferings, + statusRetentionDays: 90, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: principal => ({ + accounts: host.authorized.has(principal.principalId) + ? [{ account_id: ACCOUNT, authorized_at: new Date(origin - 30 * DAY).toISOString() }] + : [], + }), + listAccountBuys: principal => [ + { + media_buy_id: `mb-${principal.principalId}`, + confirmed_at: day(1.5), + start_time: day(3), + end_time: day(4), + packages: [{ package_id: 'pkg', reporting_delivery_offering_ids: offeringIds }], + }, + ], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }; + } + + const [template] = request.coverage.constituents; + const constituent = mediaBuyId => ({ + ...structuredClone(template), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(template.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const { privateKey } = generateKeyPairSync('ed25519'); + const signerKey = { + keyid: 'reporting-subscription-delivery-test', + alg: 'ed25519', + privateKey: { + ...privateKey.export({ format: 'jwk' }), + kid: 'reporting-subscription-delivery-test', + alg: 'ed25519', + adcp_use: 'request-signing', + key_ops: ['sign'], + }, + }; + service = await reporting.createPostgresReliableReportingProductionService({ + db: pool, + namespace: 'reporting-subscription-delivery-test', + publisherScope: 'reporting-subscription-delivery-test', + acknowledgeIsolatedDatabase: true, + adapters, + contact: { name: 'Reporting operations', email: 'reporting@example.com' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: 90, + resolveSource: (account, configuration) => ({ + adapterId: configuration.offeringId, + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + resolveCoverage: () => ({ constituents: ['mb-principal-a', 'mb-principal-b'].map(constituent) }), + reportingSubscriptions: { + engine, + principal: lifecycle, + resolvePrincipal: context => context.principalScope, + listSubscriptionPrincipals: () => [A, B], + resolveSubscriptionConfiguration: () => ({ + requestedMetrics: ['impressions'], + requestedDimensions: request.requestedDimensions, + sourceSettings, + }), + }, + notifications: { + subscriptions: { acknowledgeIsolatedDatabase: true }, + webhooks: { signerKey, fetch: async () => ({ status: 204, headers: { get: () => undefined } }) }, + proofAdapter: { prove: async () => ({ proved: true }) }, + authorizeDelivery: async () => ({ authorized: true }), + validateDestination: async () => ({ allowed: true }), + }, + activity: { tenantScopeForAccount: accountId => `tenant:${accountId}` }, + resolveWebhookActivityScope: () => ({ tenantId: `tenant:${ACCOUNT}`, principalId: 'principal-a' }), + managedDelivery: { + resourceRetentionDays: 90, + authorizationRevocationSeconds: 60, + store: { evidenceRetentionDays: 90 }, + adapter: { + verificationProfiles: ['manifest_checksums'], + revocationFencesDeliveryGenerations: true, + deliver: async input => { + deliveries.push(structuredClone(input)); + await host.onDeliver?.(input); + const now = Date.now(); + return { + status: 'delivered', + resource: { + resource_ref: `resource-${input.materialization.reporting_materialization_id}`, + kind: 'manifest', + location: `reports/${input.delivery_path}/manifest.json`, + manifest_version: '1.0', + manifest_sha256: 'f'.repeat(64), + immutability: 'immutable_location', + expires_at: new Date(now + 120 * DAY).toISOString(), + }, + verification: { + verified_at: new Date(now).toISOString(), + verification_path: 'destination', + verification_profile: 'manifest_checksums', + row_count: input.revision.binding.rowCount, + control_totals: input.revision.wireRevision.control_totals, + physical_checksums: [{ object_ref: 'rows.jsonl', algorithm: 'sha256', value: '7'.repeat(64) }], + }, + }; + }, + read: async () => new Uint8Array(), + revoke: async () => {}, + }, + }, + applyMigrations: async migrations => { + for (const migration of migrations) await pool.query(migration); + }, + }); + clock.now = new Date(day(6)); + await engine.reconcile(A); + await engine.reconcile(B); + }); + + after(async () => { + await service?.stop(); + await pool?.end(); + await publishedPool?.end(); + if (bootstrap) { + await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await bootstrap.query(`DROP SCHEMA IF EXISTS "${published}" CASCADE`); + await bootstrap.end(); + } + }); + + test('delivers each owner’s offerings to its own destination with the expanded path and account_id', async () => { + // A loses its grant while its first delivery is in flight: nothing of A is recorded as delivered. + let revoked = false; + host.onDeliver = async input => { + if (revoked || input.obligation.subscription.principalId !== 'principal-a') return; + revoked = true; + host.authorized.delete('principal-a'); + await engine.authorizationChanged(A, ACCOUNT, { authorized: false, at: new Date().toISOString() }); + }; + const result = await service.runCycle({ accountId: ACCOUNT, maxWorkerIterations: 25 }); + assert.equal(result.core.planned, 4, JSON.stringify(result.core)); + assert.equal(result.core.revisionsCommitted, 4, JSON.stringify(result.core)); + const bindings = await pool.query( + `SELECT configuration_id, destination_ref, data FROM adcp_reporting_managed_bindings WHERE account_id = $1`, + [ACCOUNT] + ); + assert.equal(bindings.rows.length, 4, 'one binding per (owner, offering) under one (account, id, version)'); + const configurations = await pool.query( + `SELECT configuration_id, data FROM adcp_reporting_configurations WHERE account_id = $1`, + [ACCOUNT] + ); + const ownerOf = new Map(configurations.rows.map(row => [row.configuration_id, row.data.subscription.principalId])); + const refs = new Map(); + for (const row of bindings.rows) { + assert.equal(row.data.path_template, TEMPLATE); + assert.equal(row.data.delivery_config_id, 'analytics-daily'); + const owner = ownerOf.get(row.configuration_id); + refs.set(owner, new Set([...(refs.get(owner) ?? []), row.destination_ref])); + } + assert.equal(refs.get('principal-a').size, 1); + assert.equal(refs.get('principal-b').size, 1); + assert.notDeepEqual([...refs.get('principal-a')], [...refs.get('principal-b')]); + + // B's two offerings were delivered, each under its own expanded path, rows stamped with account_id. + const forB = deliveries.filter(value => value.obligation.subscription.principalId === 'principal-b'); + assert.deepEqual(forB.map(value => value.obligation.offeringId).sort(), offeringIds); + for (const delivery of forB) { + assert.equal(delivery.binding.destination_ref, [...refs.get('principal-b')][0]); + assert.equal( + delivery.delivery_path, + `${ACCOUNT}/${delivery.obligation.offeringId}/${delivery.obligation.period.start}` + ); + assert.ok(delivery.revision.rows.length > 0); + for (const row of delivery.revision.rows) assert.equal(row.account_id, ACCOUNT); + } + // A was claimed once, lost authority before settling, and was held, not failed. + assert.equal(result.managed.heldForAuthority >= 1, true, JSON.stringify(result.managed)); + const materializations = await pool.query( + `SELECT configuration_id, status FROM adcp_reporting_materializations WHERE account_id = $1`, + [ACCOUNT] + ); + for (const row of materializations.rows) { + if (ownerOf.get(row.configuration_id) === 'principal-a') assert.equal(row.status, 'pending'); + else assert.equal(row.status, 'delivered'); + } + assert.equal( + deliveries.filter(value => value.obligation.subscription.principalId === 'principal-a').length, + 1, + 'after the loss no further delivery of A is attempted' + ); + // A is reported unauthorized; its obligations stay owed. + const obligations = await pool.query(`SELECT data FROM adcp_reporting_obligations WHERE account_id = $1`, [ + ACCOUNT, + ]); + assert.equal(obligations.rows.length, 4); + }); + + test('the binding migration drops the published three-column key on an existing schema', async () => { + const ledger = require('./helpers/ledger.js'); + await publishedPool.query(ledger.REPORTING_LEDGER_MIGRATION); + await publishedPool.query(PUBLISHED_BINDINGS); + const configuration = { configurationId: 'legacy-config', account: { account_id: ACCOUNT } }; + await publishedPool.query( + `INSERT INTO adcp_reporting_configurations + (configuration_id, account_id, delivery_config_id, delivery_config_version, semantic_fingerprint, data, created_at) + VALUES ('legacy-config', $1, 'legacy', 1, 'sha256:legacy', $2::jsonb, clock_timestamp())`, + [ACCOUNT, JSON.stringify(configuration)] + ); + await publishedPool.query( + `INSERT INTO adcp_reporting_destination_authorizations (account_id, destination_ref, generation, authorized_at, data) + VALUES ($1, 'legacy-dest', 1, clock_timestamp(), '{}'::jsonb)`, + [ACCOUNT] + ); + await publishedPool.query( + `INSERT INTO adcp_reporting_managed_bindings VALUES ('legacy-config', $1, 'legacy', 1, 'legacy-dest', 1, 'sha256:b', '{"legacy":true}'::jsonb, clock_timestamp())`, + [ACCOUNT] + ); + const unique = async () => + ( + await publishedPool.query( + `SELECT conname FROM pg_constraint + WHERE conrelid = 'adcp_reporting_managed_bindings'::regclass AND contype = 'u'` + ) + ).rows.length; + assert.equal(await unique(), 1); + await publishedPool.query(ledger.REPORTING_MANAGED_DELIVERY_MIGRATION); + await publishedPool.query(ledger.REPORTING_MANAGED_DELIVERY_MIGRATION); + assert.equal(await unique(), 0, 'the published three-column key is found by its columns and dropped'); + const kept = await publishedPool.query('SELECT configuration_id, data FROM adcp_reporting_managed_bindings'); + assert.deepEqual( + kept.rows.map(row => [row.configuration_id, row.data]), + [['legacy-config', { legacy: true }]] + ); + // The configuration is still the binding's key. + await assert.rejects( + publishedPool.query( + `INSERT INTO adcp_reporting_managed_bindings VALUES ('legacy-config', $1, 'legacy', 1, 'legacy-dest', 1, 'sha256:c', '{}'::jsonb, clock_timestamp())`, + [ACCOUNT] + ), + /duplicate key value/ + ); + }); +}); diff --git a/packages/reporting-storage/test/reporting-subscription-ledger-pg.test.js b/packages/reporting-storage/test/reporting-subscription-ledger-pg.test.js new file mode 100644 index 000000000..6006d32bd --- /dev/null +++ b/packages/reporting-storage/test/reporting-subscription-ledger-pg.test.js @@ -0,0 +1,326 @@ +/** + * Subscription generations on PostgreSQL (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): + * the additive `subscription_key` migration on a fresh and on a published schema, the widened + * generation key, owed-set progress, and caller isolation between principals that share a + * subscription_id on one account. + */ +const assert = require('node:assert/strict'); +const { after, before, describe, test } = require('node:test'); + +const DATABASE_URL = process.env.REPORTING_LEDGER_PG_URL; +const DAY = 86_400_000; +const ACCOUNT = 'subscription-pg-account'; +const PRINCIPAL_A = { tenantId: 'tenant-1', principalId: 'principal-a' }; +const PRINCIPAL_B = { tenantId: 'tenant-1', principalId: 'principal-b' }; + +/** The published configuration layout, before subscription generations. */ +const PUBLISHED_CONFIGURATIONS = `CREATE TABLE adcp_reporting_configurations ( + configuration_id TEXT PRIMARY KEY, account_id TEXT NOT NULL, delivery_config_id TEXT NOT NULL, + delivery_config_version INTEGER NOT NULL, semantic_fingerprint TEXT NOT NULL, + data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, + UNIQUE (account_id, delivery_config_id, delivery_config_version))`; + +async function generationConstraints(pool) { + const unique = await pool.query( + `SELECT ( + SELECT array_agg(attribute.attname::text ORDER BY attribute.attname::text) + FROM unnest(constraint_row.conkey) AS key(attnum) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid AND attribute.attnum = key.attnum + ) AS columns + FROM pg_constraint constraint_row + WHERE constraint_row.conrelid = 'adcp_reporting_configurations'::regclass AND constraint_row.contype = 'u'` + ); + const index = await pool.query( + `SELECT indexdef FROM pg_indexes + WHERE tablename = 'adcp_reporting_configurations' AND indexname = 'adcp_reporting_configurations_generation' + AND schemaname = current_schema()` + ); + const column = await pool.query( + `SELECT column_default, is_nullable FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'adcp_reporting_configurations' + AND column_name = 'subscription_key'` + ); + return { + uniqueConstraints: unique.rows.map(row => [...row.columns].join(',')), + generationIndex: index.rows[0]?.indexdef ?? null, + subscriptionKey: column.rows[0] ?? null, + }; +} + +describe('PostgreSQL subscription generations', { skip: !DATABASE_URL && 'PostgreSQL URL not set' }, () => { + const fresh = `adcp_reporting_subscription_fresh_${process.pid}`; + const published = `adcp_reporting_subscription_published_${process.pid}`; + let bootstrap; + let freshPool; + let publishedPool; + let ledger; + let sourceApi; + + before(async () => { + const { Pool } = require('pg'); + ledger = require('./helpers/ledger.js'); + sourceApi = require('@adcp/sdk/reporting/source'); + bootstrap = new Pool({ connectionString: DATABASE_URL }); + await bootstrap.query(`CREATE SCHEMA "${fresh}"`); + await bootstrap.query(`CREATE SCHEMA "${published}"`); + freshPool = new Pool({ connectionString: DATABASE_URL, options: `-c search_path="${fresh}"` }); + publishedPool = new Pool({ connectionString: DATABASE_URL, options: `-c search_path="${published}"` }); + }); + + after(async () => { + await freshPool?.end(); + await publishedPool?.end(); + if (bootstrap) { + await bootstrap.query(`DROP SCHEMA IF EXISTS "${fresh}" CASCADE`); + await bootstrap.query(`DROP SCHEMA IF EXISTS "${published}" CASCADE`); + await bootstrap.end(); + } + }); + + test('a fresh schema keys generations by subscription_key and reruns idempotently', async () => { + await freshPool.query(ledger.REPORTING_LEDGER_MIGRATION); + await freshPool.query(ledger.REPORTING_LEDGER_MIGRATION); + const state = await generationConstraints(freshPool); + assert.deepEqual(state.uniqueConstraints, []); + assert.match( + state.generationIndex, + /UNIQUE INDEX adcp_reporting_configurations_generation .*\(account_id, delivery_config_id, delivery_config_version, subscription_key\)/ + ); + assert.equal(state.subscriptionKey.is_nullable, 'NO'); + assert.match(state.subscriptionKey.column_default, /''::text/); + }); + + test('an existing schema keeps its rows and gains the widened key', async () => { + await publishedPool.query(PUBLISHED_CONFIGURATIONS); + const legacy = { + configurationId: 'legacy-generation', + account: { account_id: ACCOUNT }, + delivery_config_id: 'legacy', + delivery_config_version: 1, + semanticFingerprint: 'sha256:legacy', + }; + await publishedPool.query( + `INSERT INTO adcp_reporting_configurations VALUES ($1, $2, $3, $4, $5, $6::jsonb, clock_timestamp())`, + ['legacy-generation', ACCOUNT, 'legacy', 1, legacy.semanticFingerprint, JSON.stringify(legacy)] + ); + assert.deepEqual((await generationConstraints(publishedPool)).uniqueConstraints, [ + 'account_id,delivery_config_id,delivery_config_version', + ]); + await publishedPool.query(ledger.REPORTING_LEDGER_MIGRATION); + await publishedPool.query(ledger.REPORTING_LEDGER_MIGRATION); + const state = await generationConstraints(publishedPool); + assert.deepEqual(state.uniqueConstraints, [], 'the published three-column constraint is dropped by its columns'); + assert.ok(state.generationIndex); + const rows = await publishedPool.query( + 'SELECT configuration_id, subscription_key, data FROM adcp_reporting_configurations' + ); + assert.deepEqual( + rows.rows.map(row => [row.configuration_id, row.subscription_key]), + [['legacy-generation', '']] + ); + assert.deepEqual(rows.rows[0].data, legacy); + // An account generation is still unique per (account, id, version). + await assert.rejects( + publishedPool.query( + `INSERT INTO adcp_reporting_configurations + (configuration_id, account_id, delivery_config_id, delivery_config_version, semantic_fingerprint, data, created_at) + VALUES ('legacy-duplicate', $1, 'legacy', 1, 'sha256:other', '{}'::jsonb, clock_timestamp())`, + [ACCOUNT] + ), + /duplicate key value/ + ); + const store = new ledger.PostgresReportingLedgerStore(publishedPool, { acknowledgeIsolatedDatabase: true }); + assert.deepEqual(await store.listConfigurations(ACCOUNT), [legacy]); + }); + + test('two principals own the same subscription_id beside an account generation', async () => { + const store = new ledger.PostgresReportingLedgerStore(freshPool, { acknowledgeIsolatedDatabase: true }); + const offering = sourceApi.redactedReportingSourceOfferingV1; + const request = sourceApi.redactedReportingSourceRequestV1(); + const producer = ledger.createReportingProducer({ + store, + source: sourceApi.createInlineReportingSourceExecutor(() => [], offering), + offerings: [offering], + contact: { name: 'Reporting operations' }, + }); + const origin = Math.floor(Date.now() / DAY) * DAY - 10 * DAY; + const day = n => new Date(origin + n * DAY).toISOString(); + const base = request.coverage.constituents[0]; + const constituent = mediaBuyId => ({ + ...structuredClone(base), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(base.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }); + const generation = principal => ({ + account: { account_id: ACCOUNT }, + sourceScope: request.sourceScope, + delivery_config_id: request.delivery_config_id, + delivery_config_version: 1, + offeringId: offering.offeringId, + report_definition_id: request.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'snapshot', + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceTimezone: 'UTC', + schedule: { + anchor: '1970-01-01T00:00:00.000Z', + periodMilliseconds: DAY, + deliverySlaMilliseconds: 3_600_000, + recoveryWindowMilliseconds: DAY, + periodDuration: 'P1D', + alignment: 'utc', + deliverySlaDuration: 'PT1H', + }, + sourceSettings: request.sourceSettings, + contract: request.contract, + subscription: { + ...principal, + subscriptionId: request.delivery_config_id, + offeringId: offering.offeringId, + ownership: 'owed_set', + }, + }); + const plan = (principal, starts, mediaBuyId, through) => ({ + account_id: ACCOUNT, + principal, + subscriptionId: request.delivery_config_id, + configurations: [generation(principal)], + obligations: starts.map(start => ({ + delivery_config_version: 1, + offeringId: offering.offeringId, + period: { start: day(start), end: day(start + 1) }, + mediaBuyIds: [mediaBuyId], + constituents: [constituent(mediaBuyId)], + })), + owedThrough: day(through), + now: day(through), + }); + + // An account generation with the very same (account, id, version). + const account = await producer.installConfiguration({ + account: { account_id: ACCOUNT }, + sourceScope: request.sourceScope, + delivery_config_id: request.delivery_config_id, + delivery_config_version: 1, + offeringId: offering.offeringId, + report_definition_id: request.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'snapshot', + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + constituents: request.coverage.constituents, + mediaBuyIds: request.coverage.mediaBuyIds, + sourceTimezone: 'UTC', + schedule: { + anchor: new Date(origin).toISOString(), + periodMilliseconds: DAY, + deliverySlaMilliseconds: 0, + recoveryWindowMilliseconds: DAY, + }, + sourceSettings: request.sourceSettings, + contract: request.contract, + }); + const a = await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [1, 4], 'mb-a', 6)); + const b = await producer.planSubscriptionObligations(plan(PRINCIPAL_B, [2], 'mb-b', 6)); + // Replays are idempotent against the widened key. + assert.deepEqual((await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [1, 4], 'mb-a', 6))).created, []); + assert.equal(a.created.length, 2); + assert.equal(b.created.length, 1); + const keys = await freshPool.query( + `SELECT configuration_id, subscription_key FROM adcp_reporting_configurations + WHERE account_id = $1 ORDER BY subscription_key`, + [ACCOUNT] + ); + assert.equal(keys.rows.length, 3); + assert.deepEqual(Object.fromEntries(keys.rows.map(row => [row.configuration_id, row.subscription_key])), { + [account.configurationId]: '', + [a.configurations[0].configurationId]: ledger.reportingLedgerSubscriptionKeyV1(a.configurations[0]), + [b.configurations[0].configurationId]: ledger.reportingLedgerSubscriptionKeyV1(b.configurations[0]), + }); + // A changed generation is immutable. + const changed = plan(PRINCIPAL_A, [1], 'mb-a', 6); + changed.configurations[0].requestedMetrics = ['impressions']; + await assert.rejects( + producer.planSubscriptionObligations(changed), + error => error.code === 'REPORTING_CONFIGURATION_IMMUTABLE' + ); + + // Owed-set progress is mutable, monotonic and outside the fingerprint. + const [stored] = (await store.listConfigurations(ACCOUNT)).filter( + value => value.configurationId === a.configurations[0].configurationId + ); + assert.deepEqual(stored.owedSet, { through: day(6), from: day(1), until: day(5), mediaBuyIds: ['mb-a'] }); + assert.equal(stored.semanticFingerprint, a.configurations[0].semanticFingerprint); + const regressed = await store.recordOwedSetProgress({ + configurationId: stored.configurationId, + account_id: ACCOUNT, + through: day(2), + }); + assert.equal(regressed.owedSet.through, day(6)); + await assert.rejects( + store.recordOwedSetProgress({ configurationId: account.configurationId, account_id: ACCOUNT, through: day(6) }), + error => /Unknown subscription reporting configuration/.test(error.cause?.message ?? error.message) + ); + const found = await store.findObligationByOrdinal({ + account_id: ACCOUNT, + configurationId: stored.configurationId, + periodOrdinal: Math.floor(Date.parse(day(4)) / 1_000), + }); + assert.equal(found.period.start, day(4)); + + // Each principal sees account generations plus only its own subscription generation. + const snapshot = principal => + store.createSnapshot({ + account_id: ACCOUNT, + view: 'periods', + ...(principal ? { subscription_principal: principal } : {}), + period: { start: day(0), end: day(6) }, + }); + const forA = await snapshot(PRINCIPAL_A); + const forB = await snapshot(PRINCIPAL_B); + const anonymous = await snapshot(undefined); + const ids = value => value.configurations.map(item => item.configurationId).sort(); + assert.deepEqual(ids(forA), [account.configurationId, a.configurations[0].configurationId].sort()); + assert.deepEqual(ids(forB), [account.configurationId, b.configurations[0].configurationId].sort()); + assert.deepEqual(ids(anonymous), [account.configurationId]); + const subscriptionPeriods = value => + value.obligations + .filter(item => item.subscription) + .map(item => [item.subscription.principalId, item.period.start]); + assert.deepEqual(subscriptionPeriods(forA), [ + ['principal-a', day(1)], + ['principal-a', day(4)], + ]); + assert.deepEqual(subscriptionPeriods(forB), [['principal-b', day(2)]]); + assert.deepEqual(subscriptionPeriods(anonymous), []); + + // The status handler reads through the same filter. + const handler = ledger.createReportingStatusHandler(store, { + resolveSubscriptionPrincipal: context => context.principal, + }); + const read = principal => + handler( + { + account: { account_id: ACCOUNT }, + view: 'periods', + period: { start: day(0), end: day(6) }, + pagination: { max_results: 100 }, + }, + { account: { account_id: ACCOUNT }, principal } + ); + const periodsA = await read(PRINCIPAL_A); + assert.equal(periodsA.status, 'completed', JSON.stringify(periodsA)); + assert.deepEqual( + periodsA.periods.filter(value => value.offering_id).map(value => value.media_buy_ids), + [['mb-a'], ['mb-a']] + ); + const periodsB = await read(PRINCIPAL_B); + assert.deepEqual( + periodsB.periods.filter(value => value.offering_id).map(value => value.media_buy_ids), + [['mb-b']] + ); + }); +}); diff --git a/packages/reporting-storage/test/reporting-subscription-receipts-pg.test.js b/packages/reporting-storage/test/reporting-subscription-receipts-pg.test.js new file mode 100644 index 000000000..51f269e95 --- /dev/null +++ b/packages/reporting-storage/test/reporting-subscription-receipts-pg.test.js @@ -0,0 +1,319 @@ +/** + * Receipts for subscription obligations on PostgreSQL (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3): only the owning principal may file one, and a + * refused receipt reads exactly like an unknown reference. Account obligations stay open to every + * consumer of the account. Who reaches the handler at all is the host's account resolution and + * grant (`allowed_tasks`); there is no post-revocation window. + */ +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { after, before, describe, test } = require('node:test'); + +const DATABASE_URL = process.env.REPORTING_LEDGER_PG_URL; +const A = { tenantId: 'tenant-1', principalId: 'principal-a' }; +const B = { tenantId: 'tenant-1', principalId: 'principal-b' }; + +describe('PostgreSQL subscription receipts', { skip: !DATABASE_URL && 'PostgreSQL URL not set' }, () => { + const schema = `adcp_reporting_subscription_receipts_${process.pid}`; + let bootstrap; + let pool; + let ledger; + let core; + let managed; + let canonicalize; + + before(async () => { + const { Pool } = require('pg'); + ledger = require('./helpers/ledger.js'); + canonicalize = require('@adcp/sdk').canonicalize; + bootstrap = new Pool({ connectionString: DATABASE_URL }); + await bootstrap.query(`CREATE SCHEMA "${schema}"`); + pool = new Pool({ connectionString: DATABASE_URL, options: `-c search_path="${schema}"` }); + await pool.query(ledger.REPORTING_LEDGER_MIGRATION); + await pool.query(ledger.REPORTING_MANAGED_DELIVERY_MIGRATION); + core = new ledger.PostgresReportingLedgerStore(pool, { acknowledgeIsolatedDatabase: true, managedDelivery: true }); + managed = new ledger.PostgresReportingManagedDeliveryStore(pool); + }); + + after(async () => { + await pool?.end(); + if (bootstrap) { + await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await bootstrap.end(); + } + }); + + /** One delivered consumer-receipt revision, owned by `owner` (or an account obligation). */ + async function seed(suffix, owner) { + const nowMs = Date.now(); + const now = new Date(nowMs).toISOString(); + const period = { + start: new Date(nowMs - 10_800_000).toISOString(), + end: new Date(nowMs - 9_000_000).toISOString(), + }; + const accountId = `account-receipts-${suffix}`; + const subscription = owner + ? { ...owner, subscriptionId: `${suffix}-files`, offeringId: `${suffix}-files-v1`, ownership: 'owed_set' } + : undefined; + const configuration = { + configurationId: `configuration-receipts-${suffix}`, + account: { account_id: accountId }, + sourceScope: { warehouse: 'fixture' }, + delivery_config_id: `${suffix}-files`, + delivery_config_version: 1, + offeringId: `${suffix}-files-v1`, + report_definition_id: 'analytics-v1', + feedPurpose: 'analytics', + requiredFinality: 'official', + canonicalization: { + id: 'analytics-rows-v1', + uri: 'https://schemas.fixture.example/canonicalization.json', + sha256: 'c'.repeat(64), + primaryKeys: ['media_buy_id'], + }, + requestedMetrics: ['impressions'], + requestedDimensions: ['media_buy_id'], + constituents: [], + mediaBuyIds: owner ? [] : ['buy-3'], + sourceTimezone: 'UTC', + schedule: { + anchor: period.start, + periodMilliseconds: 1_800_000, + deliverySlaMilliseconds: 0, + recoveryWindowMilliseconds: 60_000, + }, + sourceSettings: {}, + contract: { reportingProfile: 'analytics-v1' }, + installedAt: period.start, + semanticFingerprint: `configuration-receipts-${suffix}-fingerprint`, + ...(subscription ? { subscription } : {}), + }; + await core.putConfiguration(configuration); + await managed.authorizeDestination({ + account_id: accountId, + destination_ref: `destination-${suffix}`, + generation: 1, + authorized_at: now, + }); + await managed.installBinding( + ledger.reportingManagedDeliveryBindingV1({ + configurationId: configuration.configurationId, + account_id: accountId, + delivery_config_id: configuration.delivery_config_id, + delivery_config_version: 1, + destination_ref: `destination-${suffix}`, + authorization_generation: 1, + feed_purpose: 'analytics', + method: 'file_transfer', + transport: 'fixture_object_store', + verification_profile: 'canonical_digest', + reconciliation_mode: 'consumer_receipt', + resource_retention_days: 30, + created_at: now, + }) + ); + const obligation = { + reporting_obligation_id: `obligation-receipts-${suffix}`, + configurationId: configuration.configurationId, + account: configuration.account, + sourceScope: configuration.sourceScope, + delivery_config_id: configuration.delivery_config_id, + delivery_config_version: 1, + offeringId: configuration.offeringId, + report_definition_id: configuration.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'official', + periodOrdinal: 0, + period: { ...period, sourceTimezone: 'UTC' }, + schedule: configuration.schedule, + scopeResolvedAt: period.end, + coverage: { + status: 'full', + evaluatedAt: period.end, + mediaBuyIds: ['buy-3'], + fullyCoveredMediaBuyIds: ['buy-3'], + partiallyCoveredMediaBuyIds: [], + unsupportedMediaBuyIds: [], + unknownMediaBuyIds: [], + }, + requestedMetrics: ['impressions'], + requestedDimensions: ['media_buy_id'], + constituents: [], + mediaBuyIds: ['buy-3'], + sourceSettings: {}, + contract: configuration.contract, + expectedAt: period.end, + recoveryDeadlineAt: new Date(Date.parse(period.end) + 60_000).toISOString(), + publicationOffsets: [], + nextAttemptAt: period.end, + attemptCount: 0, + state: 'pending', + semanticFingerprint: `obligation-receipts-${suffix}-fingerprint`, + createdAt: now, + ...(subscription ? { subscription } : {}), + }; + await core.putObligation(obligation); + const lease = await core.claimObligation({ + owner: `${suffix}-worker`, + now, + leaseMilliseconds: 600_000, + account_id: accountId, + }); + const rows = [{ media_buy_id: 'buy-3', impressions: 4 }]; + const controlTotals = [{ name: 'impressions', value: '4', value_type: 'integer', unit: 'impressions' }]; + const revisionId = `revision-receipts-${suffix}`; + const bytes = Buffer.from( + canonicalize({ + reporting_revision_id: revisionId, + row_count: 1, + control_totals: controlTotals, + reporting_rows: rows, + }) + ); + const sha = createHash('sha256').update(bytes).digest('hex'); + const digest = { + algorithm: 'sha256', + value: '6'.repeat(64), + canonicalization_id: 'analytics-rows-v1', + canonicalization_uri: 'https://schemas.fixture.example/canonicalization.json', + canonicalization_sha256: 'c'.repeat(64), + }; + const revision = { + reporting_revision_id: revisionId, + reporting_obligation_id: obligation.reporting_obligation_id, + revisionNumber: 1, + finality: 'official', + kind: 'official', + manifest: { level: 'basic', objectRef: 'manifest', sha256: 'a'.repeat(64), byteCount: 1 }, + sourcePublicationId: `publication-receipts-${suffix}`, + binding: { algorithm: 'rfc8785_jcs_v1', sha256: sha, byteCount: bytes.byteLength, rowCount: 1 }, + rows, + observedAt: now, + dataThrough: period.end, + sourceReadCutoffAt: now, + createdAt: now, + wireRevision: { + reporting_revision_id: revisionId, + revision_content_sha256: sha, + report_definition_id: 'analytics-v1', + report_definition_uri: 'https://schemas.fixture.example/report-definition.json', + report_definition_sha256: '9'.repeat(64), + reporting_profile: 'analytics-v1', + schema_version: '1.0', + schema_uri: 'https://schemas.fixture.example/reporting-profile.json', + schema_sha256: '8'.repeat(64), + schema_dialect: 'https://json-schema.org/draft/2020-12/schema', + schema_ref_policy: 'local_fragment_only', + account_id: accountId, + media_buy_ids: ['buy-3'], + coverage: { + status: 'full', + evaluated_at: now, + media_buy_ids: ['buy-3'], + fully_covered_media_buy_ids: ['buy-3'], + partially_covered_media_buy_ids: [], + unsupported_media_buy_ids: [], + unknown_media_buy_ids: [], + package_ids: [], + covered_package_ids: [], + unsupported_package_ids: [], + unknown_package_ids: [], + limitations: [], + }, + period: { ...period, source_timezone: 'UTC' }, + finality: 'official', + finality_basis: 'contractual_cutoff', + finality_policy_id: 'contractual-cutoff-v1', + finalized_at: now, + observed_at: now, + data_through: period.end, + data_through_precision: 'exact', + row_count: 1, + control_totals: controlTotals, + canonical_content_digest: digest, + created_at: now, + }, + }; + await core.commitRevision(revision, lease); + await managed.planMaterializations({ account_id: accountId }); + await ledger.runManagedDeliveryWorker( + managed, + { + verificationProfiles: ['canonical_digest'], + revocationFencesDeliveryGenerations: true, + deliver: async () => ({ + status: 'available', + resource: { + resource_ref: `resource-${suffix}`, + kind: 'manifest', + location: `reports/${revisionId}/manifest.json`, + manifest_version: '1.0', + manifest_sha256: 'f'.repeat(64), + immutability: 'immutable_location', + expires_at: new Date(Date.now() + 31 * 86_400_000).toISOString(), + }, + verification: { + verified_at: new Date().toISOString(), + verification_path: 'representative_consumer', + verification_profile: 'canonical_digest', + row_count: 1, + control_totals: controlTotals, + physical_checksums: [{ object_ref: 'rows.json', algorithm: 'sha256', value: '7'.repeat(64) }], + canonical_content_digest: digest, + }, + }), + read: async () => Buffer.from(''), + revoke: async () => {}, + }, + { maxIterations: 2, account_id: accountId } + ); + const materialization = ( + await pool.query( + `SELECT data FROM adcp_reporting_materializations WHERE obligation_id = $1 AND status = 'available'`, + [obligation.reporting_obligation_id] + ) + ).rows[0].data; + return { accountId, now, obligation, revision, materialization, digest }; + } + + function receipt(fixture, id) { + return { + reporting_receipt_id: id, + reporting_obligation_id: fixture.obligation.reporting_obligation_id, + reporting_revision_id: fixture.revision.reporting_revision_id, + reporting_materialization_id: fixture.materialization.reporting_materialization_id, + status: 'accepted', + verification_profile: 'canonical_digest', + observed_row_count: 1, + observed_control_totals: fixture.revision.wireRevision.control_totals, + observed_canonical_content_digest: fixture.revision.wireRevision.canonical_content_digest, + observed_manifest_sha256: fixture.materialization.resource.manifest_sha256, + observed_at: new Date().toISOString(), + }; + } + + const handler = ledger => + ledger.createSyncReportingReceiptsHandler(managed, context => context.principal.principalId, undefined, { + resolveSubscriptionPrincipal: context => context.principal, + }); + const submit = async (fixture, principal, account, id) => + ( + await handler(ledger)( + { idempotency_key: `receipt-batch-${id}`, receipts: [receipt(fixture, id)] }, + { account, principal } + ) + ).results[0]; + const account = fixture => ({ id: fixture.accountId }); + test('only the owning principal may file a receipt for a subscription obligation', async () => { + const owned = await seed('owned', A); + const foreign = await submit(owned, B, account(owned), 'receipt-owned-foreign-1'); + assert.equal(foreign.result, 'failed', JSON.stringify(foreign)); + assert.equal(foreign.errors[0].code, 'INVALID_REQUEST', 'a foreign owner reads like an unknown reference'); + const own = await submit(owned, A, account(owned), 'receipt-owned-owner-01'); + assert.equal(own.result, 'recorded', JSON.stringify(own)); + // An account obligation stays open to every consumer of the account. + const shared = await seed('shared', undefined); + const anyone = await submit(shared, B, account(shared), 'receipt-shared-anyone1'); + assert.equal(anyone.result, 'recorded', JSON.stringify(anyone)); + }); +}); diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 7bcd01fe9..a26508bcf 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -137,12 +137,28 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 26,314,876 packed bytes, 144,462,916 unpacked bytes and 8,411 files. Allow // about 45 KB of compressor variance; the unpacked, file-count, bundled-schema // and declaration budgets are unchanged and still pass. +// AdCP 3.3 reporting subscriptions (#3504) add the subscription calendar, +// selection, coverage, capability and engine modules plus the principal +// subscription runtime. Measured 26,423,860 packed bytes after merging main; +// allow about 40 KB above that for compressor variance. +// The buyer runtime, engine store and expectations (#3504) measured 26,470,249 +// packed bytes after merging main's 3.3.0-beta.2 adoption; allow about 40 KB. +// Writing subscription obligations into the reporting ledger (#3504) extends the +// ledger producer, coverage, handlers and status ingest, and then the service, +// engine and managed delivery, and adds no module file. Allow about 55 KB above +// the runtime's budget for them. Other budgets stay fixed. +// AdCP 3.3 reporting subscription coverage (#3504) adds the calendar, +// selection, coverage and capability modules and the parity table. Measured +// 26,370,041 packed bytes; allow about 40 KB above that for compressor variance. // AdCP 3.3.0-beta.2 (#3541) and the creative-delivery legacy-ref // reconciliation (#3510) landed together. CI measured 26,363,856 packed bytes // after #3510 and 26,369,089 after #3541 on main, about 9 KB over the beta.1 // cap. Allow about 50 KB of compressor variance; the unpacked, file-count, // bundled-schema and declaration budgets are unchanged and still pass. -const MAX_PACKED_TARBALL_BYTES = 26_420_000; +// The reporting-subscription DX slices (#3504: the advanced subpath, wind-down grant, typed errors with +// their TSDoc, and the canonical-vector fixes) plus main through #3510 measured 26,520,756 packed bytes; +// allow about 40 KB above that for compressor variance. Other budgets stay fixed. +const MAX_PACKED_TARBALL_BYTES = 26_560_000; const MAX_UNPACKED_PACKAGE_BYTES = 144 * 1024 * 1024; // AdCP 3.2.0-beta.10 adds the account-change schema family. The seller reporting // ledger adds ten public module artifacts. AdCP 3.2.0-rc.2 replaced the preview @@ -307,7 +323,14 @@ const MAX_PACKED_FILE_COUNT = 4 + // Property-list reference probe (one dual-format runtime/declaration module set). 5 + // Creative build probe: one dual-format runtime/declaration module (+4) and one guide (+1). 8 + // Discovery currency and required-metric invariants (two dual-format runtime/declaration sets). - 1840; // SDK 17: 3.3 schemas/compliance, per-tool type entrypoints and migration/release notes. + 1840 + // SDK 17: 3.3 schemas/compliance, per-tool type entrypoints and migration/release notes. + 20 + // Reporting subscription calendar, selection, coverage, capabilities and index (five dual-format sets). + 4 + // Reporting subscription hand-modeled wire types until 3.3.0-beta.2 (one dual-format set). + 8 + // Reporting subscription engine and principal subscription runtime (two dual-format sets). + 4 + // Reporting subscription release gate (one dual-format set). + 12 + // Reporting subscription engine store, buyer expectations and client helper (three dual-format sets). + 2 + // Reporting subscriptions example storyboard and reference seller. + 8; // `reporting/advanced` subpath and the service's subscription-hook module (two dual-format sets). const MAX_BUNDLED_SCHEMA_BYTES = 2000 * 1024; const MAX_CJS_SCHEMA_DECLARATION_BYTES = 45 * 1024 * 1024; const MAX_ESM_SCHEMA_FACADE_BYTES = 1024; diff --git a/scripts/check-reporting-consumer-package.mjs b/scripts/check-reporting-consumer-package.mjs index f99a4e507..c2f7ef184 100644 --- a/scripts/check-reporting-consumer-package.mjs +++ b/scripts/check-reporting-consumer-package.mjs @@ -42,6 +42,9 @@ try { assert.equal(typeof buyer[name], 'function', name); } assert.equal(buyer.createReliableReportingServiceV1, undefined); + // Reporting-subscription coverage is a low-level building block on the advanced subpath only. + assert.equal(buyer.expectedSubscriptionPeriods, undefined); + assert.equal(typeof require('@adcp/sdk/reporting/advanced').expectedSubscriptionPeriods, 'function'); ` ); writeFileSync( @@ -53,6 +56,9 @@ try { assert.equal(typeof buyer[name], 'function', name); } assert.equal(buyer.createReliableReportingServiceV1, undefined); + assert.equal(buyer.expectedSubscriptionPeriods, undefined); + const advanced = await import('@adcp/sdk/reporting/advanced'); + assert.equal(typeof advanced.expectedSubscriptionPeriods, 'function'); ` ); for (const file of ['consumer.cjs', 'consumer.mjs']) { diff --git a/scripts/sync-reporting-subscription-vectors.mjs b/scripts/sync-reporting-subscription-vectors.mjs new file mode 100644 index 000000000..b6036d012 --- /dev/null +++ b/scripts/sync-reporting-subscription-vectors.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Refresh the vendored canonical reporting-subscription vectors + * (test/fixtures/reporting-subscriptions/canonical/) from the spec repository at one commit, byte for byte, + * and rewrite PINNED.json with the commit and each file's sha256. + * + * npm run sync:reporting-subscription-vectors -- --commit # GitHub raw + * npm run sync:reporting-subscription-vectors -- --commit --from # local spec checkout (git show) + */ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPOSITORY = 'adcontextprotocol/adcp'; +const UPSTREAM = 'static/compliance/source/test-vectors/reporting-subscriptions'; +const FILES = ['case.schema.json', 'error-codes.json', 'reason-codes.json', 'vectors.json']; +const target = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'test', + 'fixtures', + 'reporting-subscriptions', + 'canonical' +); + +function argument(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const commit = argument('--commit'); +const from = argument('--from'); +if (!commit || !/^[0-9a-f]{7,40}$/.test(commit)) { + console.error('Usage: sync-reporting-subscription-vectors --commit [--from ]'); + process.exit(2); +} + +async function read(file) { + if (from) { + return execFileSync('git', ['-C', from, 'show', `${commit}:${UPSTREAM}/${file}`], { maxBuffer: 64 * 1024 * 1024 }); + } + const url = `https://raw.githubusercontent.com/${REPOSITORY}/${commit}/${UPSTREAM}/${file}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +const fullCommit = from ? execFileSync('git', ['-C', from, 'rev-parse', commit], { encoding: 'utf8' }).trim() : commit; +const pinPath = join(target, 'PINNED.json'); +const pin = JSON.parse(readFileSync(pinPath, 'utf8')); +const sha256 = {}; +for (const file of FILES) { + const bytes = await read(file); + writeFileSync(join(target, file), bytes); + sha256[file] = createHash('sha256').update(bytes).digest('hex'); +} +writeFileSync(pinPath, `${JSON.stringify({ ...pin, sha: fullCommit, sha256 }, null, 2)}\n`); +console.log(`Synced ${FILES.length} files from ${REPOSITORY}@${fullCommit}.`); diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 3db385fce..ce6f478d4 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1272,3 +1272,222 @@ export class PreparedFixturesDeniedError extends ADCPError { this.details = { refusal_code: refusalCode }; } } + +// ====== Reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3) ====== +// Codes and recovery classes follow the shared vocabulary in the spec repository +// (`static/compliance/source/test-vectors/reporting-subscriptions/error-codes.json`), which the Python SDK uses too. + +/** How to recover from an SDK error: the same vocabulary as {@link ClientPreflightAdcpErrorRecovery}. */ +export type AdcpErrorRecovery = ClientPreflightAdcpErrorRecovery; + +/** + * Codes a buyer-side reporting-subscription refusal or failure carries. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export type ReportingSubscriptionErrorCode = + | 'SUBSCRIPTION_WOULD_DEACTIVATE' + | 'DESTINATION_WOULD_REVOKE' + | 'SUBSCRIPTION_ID_CLASH' + | 'SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED' + | 'SUBSCRIPTION_SELECTOR_UNMATCHED' + | 'SUBSCRIPTION_CONFLICT_EXHAUSTED' + | 'SUBSCRIPTION_READBACK_MISMATCH'; + +/** Codes the SDK raises only before sending anything, so they always carry `dispatched: false`. */ +const REPORTING_SUBSCRIPTION_PRE_DISPATCH: ReadonlySet = new Set([ + 'SUBSCRIPTION_WOULD_DEACTIVATE', + 'DESTINATION_WOULD_REVOKE', + 'SUBSCRIPTION_ID_CLASH', + 'SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED', + 'SUBSCRIPTION_SELECTOR_UNMATCHED', +]); + +const REPORTING_SUBSCRIPTION_RECOVERY: Readonly> = { + SUBSCRIPTION_WOULD_DEACTIVATE: 'correctable', + DESTINATION_WOULD_REVOKE: 'correctable', + SUBSCRIPTION_ID_CLASH: 'correctable', + SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED: 'correctable', + SUBSCRIPTION_SELECTOR_UNMATCHED: 'correctable', + SUBSCRIPTION_CONFLICT_EXHAUSTED: 'transient', + SUBSCRIPTION_READBACK_MISMATCH: 'terminal', +}; + +/** Options for {@link ReportingSubscriptionError}. */ +export interface ReportingSubscriptionErrorOptions { + cause?: unknown; + /** `SUBSCRIPTION_WOULD_DEACTIVATE`: the `subscription_id` values the write would deactivate. */ + wouldDeactivate?: readonly string[]; + /** `DESTINATION_WOULD_REVOKE`: the `destination_id` values the write would revoke. */ + wouldRevoke?: readonly string[]; + /** `SUBSCRIPTION_ID_CLASH`: the clashing `delivery_config_id`. */ + deliveryConfigId?: string; + /** `SUBSCRIPTION_ID_CLASH`: the account that uses it. */ + accountId?: string; + /** `SUBSCRIPTION_SELECTOR_UNMATCHED`: offerings that fail on the fewest attributes, each with the attributes it fails. */ + nearest?: readonly unknown[]; + /** `SUBSCRIPTION_READBACK_MISMATCH`: the seller's readback of the subscription. */ + readback?: unknown; + /** The applied `sync_principal` result, when the write was applied and a later check failed. */ + appliedResponse?: unknown; + /** The last coherent `get_principal` readback, when one was observed. */ + current?: unknown; +} + +/** + * A buyer-side reporting-subscription refusal or failure, with a stable `code` and `recovery`. + * The message's last sentence says how to fix it; it is display text, never parse it. + * + * `dispatched` is `false` for the codes the SDK raises only before sending a `sync_principal` + * write (`SUBSCRIPTION_WOULD_DEACTIVATE`, `DESTINATION_WOULD_REVOKE`, `SUBSCRIPTION_ID_CLASH`, + * `SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED`, `SUBSCRIPTION_SELECTOR_UNMATCHED`), so the refused write + * changed nothing at the seller ({@link isPreDispatchError}). It is absent for the others, where the + * write may have reached the seller. + * + * Wire errors a seller returns (`INVALID_REQUEST`, `UNSUPPORTED_FEATURE`, `CONFLICT`) are not + * redefined here. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export class ReportingSubscriptionError extends ADCPError { + readonly code: ReportingSubscriptionErrorCode; + readonly recovery: AdcpErrorRecovery; + /** `false` when the write was refused before it was sent; absent otherwise. */ + declare readonly dispatched?: false; + declare readonly wouldDeactivate?: readonly string[]; + declare readonly wouldRevoke?: readonly string[]; + declare readonly deliveryConfigId?: string; + declare readonly accountId?: string; + declare readonly nearest?: readonly unknown[]; + declare readonly readback?: unknown; + declare readonly appliedResponse?: unknown; + declare readonly current?: unknown; + + constructor(code: ReportingSubscriptionErrorCode, message: string, options: ReportingSubscriptionErrorOptions = {}) { + super(message); + this.code = code; + this.recovery = REPORTING_SUBSCRIPTION_RECOVERY[code]; + if (REPORTING_SUBSCRIPTION_PRE_DISPATCH.has(code)) { + Object.defineProperty(this, 'dispatched', { value: false, enumerable: true }); + } + if (options.cause !== undefined) Object.defineProperty(this, 'cause', { value: options.cause, configurable: true }); + if (options.deliveryConfigId !== undefined) this.deliveryConfigId = options.deliveryConfigId; + if (options.accountId !== undefined) this.accountId = options.accountId; + if (options.nearest !== undefined) this.nearest = Object.freeze([...options.nearest]); + if (options.wouldDeactivate !== undefined) this.wouldDeactivate = Object.freeze([...options.wouldDeactivate]); + if (options.wouldRevoke !== undefined) this.wouldRevoke = Object.freeze([...options.wouldRevoke]); + if (options.readback !== undefined) this.readback = options.readback; + if (options.appliedResponse !== undefined) this.appliedResponse = options.appliedResponse; + if (options.current !== undefined) this.current = options.current; + this.details = { + code, + ...(this.wouldDeactivate ? { would_deactivate: this.wouldDeactivate } : {}), + ...(this.wouldRevoke ? { would_revoke: this.wouldRevoke } : {}), + ...(this.deliveryConfigId !== undefined ? { delivery_config_id: this.deliveryConfigId } : {}), + ...(this.accountId !== undefined ? { account_id: this.accountId } : {}), + ...(this.nearest ? { nearest: this.nearest } : {}), + }; + } +} + +/** + * Codes for a coverage computation that cannot run on its input. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export type CoverageInputErrorCode = + | 'COVERAGE_INPUT_INCOMPLETE' + | 'COVERAGE_INPUT_INVALID' + | 'COVERAGE_BUDGET_EXCEEDED'; + +/** + * The specific cause of a {@link CoverageInputError}: `missing_*` details go with + * `COVERAGE_INPUT_INCOMPLETE`, `invalid_*` details with `COVERAGE_INPUT_INVALID`. + * `COVERAGE_BUDGET_EXCEEDED` has none. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export type CoverageInputErrorDetail = + | 'missing_account_timezone' + | 'missing_source_timezone' + | 'missing_previous_timezone' + | 'missing_standard_dimensions' + | 'missing_status_retention_days' + | 'missing_version_configuration' + | 'missing_destination_method' + | 'invalid_timezone' + | 'invalid_period_duration' + | 'invalid_instant' + | 'invalid_input'; + +/** + * The coverage rule cannot be evaluated on this input: a value it needs is missing + * (`COVERAGE_INPUT_INCOMPLETE`), a value it reads is malformed (`COVERAGE_INPUT_INVALID`), or the + * result would exceed the caller's budget (`COVERAGE_BUDGET_EXCEEDED`). `detail` names the cause. + * Coverage is a local computation, so the error never carries `dispatched`. The SDK fails closed: it + * never returns a partial owed set. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export class CoverageInputError extends ADCPError { + readonly code: CoverageInputErrorCode; + readonly recovery = 'correctable' as const; + declare readonly detail?: CoverageInputErrorDetail; + + constructor(code: CoverageInputErrorCode, message: string, detail?: CoverageInputErrorDetail) { + super(message); + this.code = code; + if (detail !== undefined) this.detail = detail; + this.details = { code, ...(detail !== undefined ? { detail } : {}) }; + } +} + +/** + * Codes for a seller-side reporting-subscription engine failure at run time. + * + * - `SUBSCRIPTION_STATE_CONFLICT`: concurrent reconciles kept winning the engine store's + * compare-and-set; retry. + * - `SUBSCRIPTION_STATE_CORRUPT`: the engine store returned a malformed document, a revision of the + * wrong type, or a repeating cursor. + * - `SUBSCRIPTION_ROSTER_INCOMPLETE`: `listAuthorizedAccounts` repeated a cursor or did not finish + * within the page limit, so the engine refused a partial roster. + * - `SUBSCRIPTION_HOST_FACT_INVALID`: a host hook returned a fact the engine cannot use, such as a + * re-grant without `granted_at`, a timezone change without `timezone_changed_at`, or an instant + * that is not RFC 3339. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export type ReportingSubscriptionEngineErrorCode = + | 'SUBSCRIPTION_STATE_CONFLICT' + | 'SUBSCRIPTION_STATE_CORRUPT' + | 'SUBSCRIPTION_ROSTER_INCOMPLETE' + | 'SUBSCRIPTION_HOST_FACT_INVALID'; + +const REPORTING_SUBSCRIPTION_ENGINE_RECOVERY: Readonly< + Record +> = { + SUBSCRIPTION_STATE_CONFLICT: 'transient', + SUBSCRIPTION_STATE_CORRUPT: 'terminal', + SUBSCRIPTION_ROSTER_INCOMPLETE: 'correctable', + SUBSCRIPTION_HOST_FACT_INVALID: 'correctable', +}; + +/** + * A seller-side reporting-subscription engine failure at run time, with a stable `code` and + * `recovery`. Configuration mistakes caught when the engine is created throw `PlatformConfigError` + * instead, and coverage-rule input problems throw {@link CoverageInputError}. + * + * @experimental Reporting subscriptions (AdCP 3.3). + */ +export class ReportingSubscriptionEngineError extends ADCPError { + readonly code: ReportingSubscriptionEngineErrorCode; + readonly recovery: AdcpErrorRecovery; + + constructor(code: ReportingSubscriptionEngineErrorCode, message: string, options: { cause?: unknown } = {}) { + super(message); + this.code = code; + this.recovery = REPORTING_SUBSCRIPTION_ENGINE_RECOVERY[code]; + if (options.cause !== undefined) Object.defineProperty(this, 'cause', { value: options.cause, configurable: true }); + this.details = { code }; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 076a4b785..7caabdcdb 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -768,6 +768,9 @@ export { AgentRateLimitedError, ActionNotAllowedError, PreparedFixturesDeniedError, + ReportingSubscriptionError, + CoverageInputError, + ReportingSubscriptionEngineError, adcpErrorToTypedError, getClientPreflightAdcpError, isADCPError, @@ -789,6 +792,12 @@ export type { ActionNotAllowedAttemptedAction, ActionNotAllowedAvailableAction, ActionNotAllowedRecovery, + AdcpErrorRecovery, + ReportingSubscriptionErrorCode, + ReportingSubscriptionErrorOptions, + CoverageInputErrorCode, + CoverageInputErrorDetail, + ReportingSubscriptionEngineErrorCode, } from './errors'; // ====== MEDIA BUY ACTIONS (AdCP 3.1 / RFC #4480) ====== diff --git a/src/lib/principal/index.ts b/src/lib/principal/index.ts index 660fe96b6..2764ef57a 100644 --- a/src/lib/principal/index.ts +++ b/src/lib/principal/index.ts @@ -3,6 +3,13 @@ export { PrincipalLifecycleError, PrincipalLifecycleTimeoutError, type PrincipalLifecycleClient, + type PrincipalLifecycleErrorCode, + type PrincipalLifecycleErrorOptions, type PrincipalLifecycleOptions, type PrincipalLifecycleResult, } from './lifecycle'; +export { + syncReportingSubscriptionsV1, + type SyncReportingSubscriptionsOptionsV1, + type SyncReportingSubscriptionsResultV1, +} from './reporting-subscriptions'; diff --git a/src/lib/principal/lifecycle.ts b/src/lib/principal/lifecycle.ts index 89520ff8c..1ade72649 100644 --- a/src/lib/principal/lifecycle.ts +++ b/src/lib/principal/lifecycle.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { InputHandler, TaskOptions, TaskResult } from '../core/ConversationTypes'; -import { TaskTimeoutError } from '../errors'; +import { ADCPError, TaskTimeoutError, isPreDispatchError, type AdcpErrorRecovery } from '../errors'; import type { GetPrincipalRequest, GetPrincipalResponse, @@ -56,31 +56,103 @@ export interface PrincipalLifecycleResult { declarations: CurrentPrincipal['configuration']['declarations']; } -export class PrincipalLifecycleError extends Error { +/** + * Codes a {@link PrincipalLifecycleError} carries. + * + * - `PRINCIPAL_TASK_INCOMPLETE`: a `get_principal` or `sync_principal` task did not complete (it + * failed, or is submitted or deferred; see `taskResult`). + * - `PRINCIPAL_READ_FAILED`: `get_principal` returned a failed result (see `protocolErrors`). + * - `PRINCIPAL_WRITE_FAILED`: `sync_principal` returned a failed result other than `CONFLICT`. + * - `PRINCIPAL_CONFLICT_EXHAUSTED`: every guarded replacement attempt met `CONFLICT`. + * - `PRINCIPAL_TRANSPORT_FAILED`: the `sync_principal` call threw; replay `attemptedRequest` exactly. + * - `PRINCIPAL_IDENTITY_CHANGED`: the authenticated principal changed, its kind did not match + * `expectedPrincipalKind`, or its continuity could not be verified for a retry. + * - `PRINCIPAL_INVALID_RESPONSE`: the seller answered a write with a dry-run result, or applied nothing. + * - `PRINCIPAL_INVALID_OPTIONS`: a lifecycle option misbehaved (`createIdempotencyKey` reused a key). + * - `PRINCIPAL_SETUP_TIMEOUT`: destination setup did not finish in time ({@link PrincipalLifecycleTimeoutError}). + * - `PRINCIPAL_SETUP_INTERRUPTED`: the configuration disappeared or changed while setup was pending. + * - `PRINCIPAL_LIFECYCLE_FAILED`: the default for an error constructed without a code. + */ +export type PrincipalLifecycleErrorCode = + | 'PRINCIPAL_TASK_INCOMPLETE' + | 'PRINCIPAL_READ_FAILED' + | 'PRINCIPAL_WRITE_FAILED' + | 'PRINCIPAL_CONFLICT_EXHAUSTED' + | 'PRINCIPAL_TRANSPORT_FAILED' + | 'PRINCIPAL_IDENTITY_CHANGED' + | 'PRINCIPAL_INVALID_RESPONSE' + | 'PRINCIPAL_INVALID_OPTIONS' + | 'PRINCIPAL_SETUP_TIMEOUT' + | 'PRINCIPAL_SETUP_INTERRUPTED' + | 'PRINCIPAL_LIFECYCLE_FAILED'; + +const PRINCIPAL_LIFECYCLE_RECOVERY: Readonly> = { + PRINCIPAL_TASK_INCOMPLETE: 'transient', + PRINCIPAL_READ_FAILED: 'terminal', + PRINCIPAL_WRITE_FAILED: 'terminal', + PRINCIPAL_CONFLICT_EXHAUSTED: 'transient', + PRINCIPAL_TRANSPORT_FAILED: 'transient', + PRINCIPAL_IDENTITY_CHANGED: 'terminal', + PRINCIPAL_INVALID_RESPONSE: 'terminal', + PRINCIPAL_INVALID_OPTIONS: 'correctable', + PRINCIPAL_SETUP_TIMEOUT: 'transient', + PRINCIPAL_SETUP_INTERRUPTED: 'transient', + PRINCIPAL_LIFECYCLE_FAILED: 'terminal', +}; + +/** Options for {@link PrincipalLifecycleError}. */ +export interface PrincipalLifecycleErrorOptions { + /** Defaults to `PRINCIPAL_LIFECYCLE_FAILED`. */ + code?: PrincipalLifecycleErrorCode; + /** Defaults to the seller's first protocol error `recovery`, else the code's. */ + recovery?: AdcpErrorRecovery; + /** + * Pass `false` only when no bytes of the `sync_principal` write left the process. See + * {@link isPreDispatchError}. + */ + dispatched?: false; + cause?: unknown; + taskResult?: TaskResult; + protocolErrors?: readonly unknown[]; + appliedResponse?: AppliedPrincipal; + /** @deprecated Use `appliedResponse`. */ + applied?: AppliedPrincipal; + current?: CurrentPrincipal; + attemptedRequest?: MutatingRequestInput; +} + +const RECOVERIES: ReadonlySet = new Set(['transient', 'correctable', 'terminal']); + +function protocolRecovery(protocolErrors: readonly unknown[] | undefined): AdcpErrorRecovery | undefined { + const first = protocolErrors?.[0]; + const recovery = isRecord(first) ? first.recovery : undefined; + return typeof recovery === 'string' && RECOVERIES.has(recovery) ? (recovery as AdcpErrorRecovery) : undefined; +} + +export class PrincipalLifecycleError extends ADCPError { + readonly code: PrincipalLifecycleErrorCode; + readonly recovery: AdcpErrorRecovery; + /** `false` when the lifecycle refused before sending any `sync_principal` write; absent otherwise. */ + declare readonly dispatched?: false; /** Original task result, including a resumable submitted/deferred continuation when one exists. */ - readonly taskResult?: TaskResult; + declare readonly taskResult?: TaskResult; /** Structured tool-level issues returned by the seller. */ readonly protocolErrors?: readonly unknown[]; - /** Successfully applied mutation, when a later observation step failed. */ - readonly applied?: AppliedPrincipal; + /** The applied `sync_principal` result, when the write was applied and a later observation step failed. */ + readonly appliedResponse?: AppliedPrincipal; /** Last coherent readback observed after the mutation, when available. */ readonly current?: CurrentPrincipal; /** Exact helper-built write body for byte-equivalent lost-response replay. Kept non-enumerable because it may contain secrets. */ - readonly attemptedRequest?: MutatingRequestInput; + declare readonly attemptedRequest?: MutatingRequestInput; - constructor( - message: string, - options: { - cause?: unknown; - taskResult?: TaskResult; - protocolErrors?: readonly unknown[]; - applied?: AppliedPrincipal; - current?: CurrentPrincipal; - attemptedRequest?: MutatingRequestInput; - } = {} - ) { - super(message, options.cause === undefined ? undefined : { cause: options.cause }); + constructor(message: string, options: PrincipalLifecycleErrorOptions = {}) { + super(message); this.name = 'PrincipalLifecycleError'; + this.code = options.code ?? 'PRINCIPAL_LIFECYCLE_FAILED'; + this.recovery = + options.recovery ?? protocolRecovery(options.protocolErrors) ?? PRINCIPAL_LIFECYCLE_RECOVERY[this.code]; + if (options.dispatched === false) Object.defineProperty(this, 'dispatched', { value: false, enumerable: true }); + if (options.cause !== undefined) Object.defineProperty(this, 'cause', { value: options.cause, configurable: true }); Object.defineProperty(this, 'taskResult', { value: options.taskResult, enumerable: false, @@ -88,7 +160,7 @@ export class PrincipalLifecycleError extends Error { configurable: false, }); this.protocolErrors = options.protocolErrors; - this.applied = options.applied; + this.appliedResponse = options.appliedResponse ?? options.applied; this.current = options.current; Object.defineProperty(this, 'attemptedRequest', { value: options.attemptedRequest, @@ -97,14 +169,24 @@ export class PrincipalLifecycleError extends Error { configurable: false, }); } + + /** + * The applied `sync_principal` result. + * + * @deprecated Use {@link PrincipalLifecycleError.appliedResponse}. Renamed so it is not read as a + * boolean next to `dispatched`; this alias will be removed in a later release. + */ + get applied(): AppliedPrincipal | undefined { + return this.appliedResponse; + } } export class PrincipalLifecycleTimeoutError extends PrincipalLifecycleError { constructor( message = 'Timed out waiting for principal destination setup.', - options: { applied?: AppliedPrincipal; current?: CurrentPrincipal } = {} + options: Pick = {} ) { - super(message, options); + super(message, { ...options, code: 'PRINCIPAL_SETUP_TIMEOUT' }); this.name = 'PrincipalLifecycleTimeoutError'; } } @@ -144,6 +226,7 @@ function isFailedGetPrincipalResponse(value: unknown): value is GetPrincipalResp function completedData( result: TaskResult, operation: string, + written: boolean, acceptFailedData?: (value: unknown) => value is T, attemptedRequest?: MutatingRequestInput ): T { @@ -151,8 +234,13 @@ function completedData( if (!result.success && result.status === 'failed' && acceptFailedData?.(result.data)) { return result.data; } + const cause = result.success ? undefined : (result.errorInstance ?? new Error(result.error)); + // A local refusal of this task is pre-dispatch for the lifecycle only while no earlier write was sent. + const refusedLocally = !written && isPreDispatchError(cause); throw new PrincipalLifecycleError(`${operation} did not complete successfully (status: ${result.status}).`, { - cause: result.success ? undefined : (result.errorInstance ?? new Error(result.error)), + code: 'PRINCIPAL_TASK_INCOMPLETE', + ...(refusedLocally ? { dispatched: false as const, recovery: 'correctable' as const } : {}), + cause, taskResult: result, attemptedRequest, }); @@ -162,24 +250,27 @@ function conflictResponse(response: SyncPrincipalResponse): boolean { return response.result.kind === 'failed' && response.result.errors.some(error => error.code === 'CONFLICT'); } +const IDENTITY_CHANGED = { code: 'PRINCIPAL_IDENTITY_CHANGED' } as const; + function assertSamePrincipal( previous: GetPrincipalResponse['result'], refreshed: GetPrincipalResponse['result'] ): void { if (previous.kind === 'unconfigured') { throw new PrincipalLifecycleError( - 'Cannot retry a principal conflict because the initial read did not expose an identity continuity fence.' + 'Cannot retry a principal conflict because the initial read did not expose an identity continuity fence.', + IDENTITY_CHANGED ); } if (previous.kind !== 'current' && previous.kind !== 'recognized') { - throw new PrincipalLifecycleError('Cannot retry a failed principal read.'); + throw new PrincipalLifecycleError('Cannot retry a failed principal read.', IDENTITY_CHANGED); } if ( (refreshed.kind !== 'current' && refreshed.kind !== 'recognized') || refreshed.principal_id !== previous.principal_id || refreshed.principal_kind !== previous.principal_kind ) { - throw new PrincipalLifecycleError('Authenticated principal changed during guarded replacement.'); + throw new PrincipalLifecycleError('Authenticated principal changed during guarded replacement.', IDENTITY_CHANGED); } } @@ -188,13 +279,17 @@ function assertAppliedToSamePrincipal(previous: GetPrincipalResponse['result'], (previous.kind === 'current' || previous.kind === 'recognized') && (applied.principal_id !== previous.principal_id || applied.principal_kind !== previous.principal_kind) ) { - throw new PrincipalLifecycleError('Authenticated principal changed during guarded replacement.'); + throw new PrincipalLifecycleError('Authenticated principal changed during guarded replacement.', { + ...IDENTITY_CHANGED, + appliedResponse: applied, + }); } } function assertExpectedPrincipalKind( principal: GetPrincipalResponse['result'] | AppliedPrincipal, - expectedPrincipalKind?: PrincipalKind + expectedPrincipalKind: PrincipalKind | undefined, + written: boolean ): void { if ( expectedPrincipalKind !== undefined && @@ -202,7 +297,8 @@ function assertExpectedPrincipalKind( principal.principal_kind !== expectedPrincipalKind ) { throw new PrincipalLifecycleError( - `Authenticated principal kind did not match the caller's ${expectedPrincipalKind} assertion.` + `Authenticated principal kind did not match the caller's ${expectedPrincipalKind} assertion.`, + { ...IDENTITY_CHANGED, ...(written ? {} : { dispatched: false as const }) } ); } } @@ -279,15 +375,18 @@ function destinationOutcome( async function readCurrent( client: PrincipalLifecycleClient, options: PrincipalLifecycleOptions, + written: boolean, remainingMs?: number ): Promise { const response = completedData( await client.getPrincipal({}, options.inputHandler, taskOptions(options, remainingMs)), 'get_principal', + written, isFailedGetPrincipalResponse ); if (response.result.kind === 'failed') { throw new PrincipalLifecycleError('get_principal returned a failed result.', { + code: 'PRINCIPAL_READ_FAILED', protocolErrors: response.result.errors, }); } @@ -306,6 +405,21 @@ export async function syncPrincipalLifecycle( client: PrincipalLifecycleClient, configuration: PrincipalConfiguration, options: PrincipalLifecycleOptions = {} +): Promise { + return guardedSyncPrincipalLifecycle(client, configuration, options); +} + +/** + * {@link syncPrincipalLifecycle} with a check of each configuration read before the guarded + * write that replaces it, including the fresh read after a conflict. Section helpers use it to + * refuse a replacement the caller did not intend against the configuration actually replaced. + * @internal + */ +export async function guardedSyncPrincipalLifecycle( + client: PrincipalLifecycleClient, + configuration: PrincipalConfiguration, + options: PrincipalLifecycleOptions = {}, + assertReplaceable?: (prior: GetPrincipalResponse['result'], context: { written: boolean }) => void ): Promise { const maxAttempts = options.maxAttempts ?? 3; const setupTimeoutMs = options.setupTimeoutMs ?? 60_000; @@ -328,8 +442,11 @@ export async function syncPrincipalLifecycle( .filter(destination => destination.active) .map(destination => destination.destination_id) ); - let prior = await readCurrent(client, options); - assertExpectedPrincipalKind(prior, options.expectedPrincipalKind); + // Whether any sync_principal write may have left the process. Until then a refusal is pre-dispatch. + let written = false; + let prior = await readCurrent(client, options, written); + assertExpectedPrincipalKind(prior, options.expectedPrincipalKind, written); + assertReplaceable?.(prior, { written }); let applied: AppliedPrincipal | undefined; const attemptedIdempotencyKeys = new Set(); @@ -339,7 +456,9 @@ export async function syncPrincipalLifecycle( throw new TypeError('createIdempotencyKey must return a valid AdCP idempotency key.'); } if (attemptedIdempotencyKeys.has(idempotencyKey)) { - throw new PrincipalLifecycleError('createIdempotencyKey must return a fresh key for each conflict retry.'); + throw new PrincipalLifecycleError('createIdempotencyKey must return a fresh key for each conflict retry.', { + code: 'PRINCIPAL_INVALID_OPTIONS', + }); } attemptedIdempotencyKeys.add(idempotencyKey); const request: MutatingRequestInput = { @@ -353,39 +472,56 @@ export async function syncPrincipalLifecycle( : {}), }; let taskResult: TaskResult; + const writtenBefore = written; + written = true; try { taskResult = await client.syncPrincipal(request, options.inputHandler, taskOptions(options)); } catch (error) { + const refusedLocally = !writtenBefore && isPreDispatchError(error); throw new PrincipalLifecycleError('sync_principal transport failed; replay attemptedRequest exactly.', { + code: 'PRINCIPAL_TRANSPORT_FAILED', + ...(refusedLocally ? { dispatched: false as const, recovery: 'correctable' as const } : {}), cause: error, attemptedRequest: request, }); } - const response = completedData(taskResult, 'sync_principal', isFailedSyncPrincipalResponse, request); + const response = completedData(taskResult, 'sync_principal', writtenBefore, isFailedSyncPrincipalResponse, request); if (response.result.kind === 'applied') { assertAppliedToSamePrincipal(prior, response.result); - assertExpectedPrincipalKind(response.result, options.expectedPrincipalKind); + assertExpectedPrincipalKind(response.result, options.expectedPrincipalKind, written); applied = response.result; break; } if (response.result.kind === 'validated') { - throw new PrincipalLifecycleError('sync_principal unexpectedly returned a dry-run result.'); + throw new PrincipalLifecycleError('sync_principal unexpectedly returned a dry-run result.', { + code: 'PRINCIPAL_INVALID_RESPONSE', + }); } if (!conflictResponse(response) || attempt === maxAttempts) { throw new PrincipalLifecycleError( conflictResponse(response) ? `Principal configuration changed during all ${maxAttempts} guarded replacement attempts.` : 'sync_principal returned a failed result.', - { taskResult, protocolErrors: response.result.errors, attemptedRequest: request } + { + code: conflictResponse(response) ? 'PRINCIPAL_CONFLICT_EXHAUSTED' : 'PRINCIPAL_WRITE_FAILED', + taskResult, + protocolErrors: response.result.errors, + attemptedRequest: request, + } ); } - const refreshed = await readCurrent(client, options); + const refreshed = await readCurrent(client, options, written); assertSamePrincipal(prior, refreshed); - assertExpectedPrincipalKind(refreshed, options.expectedPrincipalKind); + assertExpectedPrincipalKind(refreshed, options.expectedPrincipalKind, written); + assertReplaceable?.(refreshed, { written }); prior = refreshed; } - if (!applied) throw new PrincipalLifecycleError('Principal configuration was not applied.'); + if (!applied) { + throw new PrincipalLifecycleError('Principal configuration was not applied.', { + code: 'PRINCIPAL_INVALID_RESPONSE', + }); + } let current: CurrentPrincipal = { kind: 'current', principal_id: applied.principal_id, @@ -398,12 +534,14 @@ export async function syncPrincipalLifecycle( const deadline = Date.now() + setupTimeoutMs; while (outcome === 'pending') { const remaining = deadline - Date.now(); - if (remaining <= 0) throw new PrincipalLifecycleTimeoutError(undefined, { applied, current }); + if (remaining <= 0) throw new PrincipalLifecycleTimeoutError(undefined, { appliedResponse: applied, current }); const delay = Math.min(pollIntervalMs, remaining); await wait(delay, options.signal); - if (delay === remaining) throw new PrincipalLifecycleTimeoutError(undefined, { applied, current }); + if (delay === remaining) + throw new PrincipalLifecycleTimeoutError(undefined, { appliedResponse: applied, current }); const remainingAfterWait = deadline - Date.now(); - if (remainingAfterWait <= 0) throw new PrincipalLifecycleTimeoutError(undefined, { applied, current }); + if (remainingAfterWait <= 0) + throw new PrincipalLifecycleTimeoutError(undefined, { appliedResponse: applied, current }); const configuredTaskTimeout = options.taskOptions?.timeout; const lifecycleBoundsRead = configuredTaskTimeout === undefined || @@ -411,17 +549,19 @@ export async function syncPrincipalLifecycle( configuredTaskTimeout >= remainingAfterWait; let readback: GetPrincipalResponse['result']; try { - readback = await readCurrent(client, options, remainingAfterWait); + readback = await readCurrent(client, options, written, remainingAfterWait); } catch (error) { if (error instanceof TaskTimeoutError && lifecycleBoundsRead) { - throw new PrincipalLifecycleTimeoutError(undefined, { applied, current }); + throw new PrincipalLifecycleTimeoutError(undefined, { appliedResponse: applied, current }); } if (error instanceof PrincipalLifecycleError) { throw new PrincipalLifecycleError(error.message, { + code: error.code, + recovery: error.recovery, cause: error, taskResult: error.taskResult, protocolErrors: error.protocolErrors, - applied, + appliedResponse: applied, current, }); } @@ -429,7 +569,8 @@ export async function syncPrincipalLifecycle( } if (readback.kind !== 'current') { throw new PrincipalLifecycleError('Principal configuration disappeared while destination setup was pending.', { - applied, + code: 'PRINCIPAL_SETUP_INTERRUPTED', + appliedResponse: applied, current, }); } @@ -439,7 +580,8 @@ export async function syncPrincipalLifecycle( readback.configuration_version !== applied.configuration_version ) { throw new PrincipalLifecycleError('Principal configuration changed while destination setup was pending.', { - applied, + code: 'PRINCIPAL_SETUP_INTERRUPTED', + appliedResponse: applied, current, }); } diff --git a/src/lib/principal/reporting-subscriptions.ts b/src/lib/principal/reporting-subscriptions.ts new file mode 100644 index 000000000..b468684be --- /dev/null +++ b/src/lib/principal/reporting-subscriptions.ts @@ -0,0 +1,141 @@ +/** + * Buyer helper for AdCP 3.3 principal-level reporting subscriptions (experimental + * `media_buy.reporting_subscriptions`): write the `reporting_subscriptions` section through + * the guarded `sync_principal` lifecycle and read the subscriptions back. + */ +import { ReportingSubscriptionError } from '../errors'; +import type { ReportingSubscription, ReportingSubscriptionState } from '../types/core.generated'; +import { + PrincipalLifecycleError, + guardedSyncPrincipalLifecycle, + type PrincipalLifecycleClient, + type PrincipalLifecycleOptions, + type PrincipalLifecycleResult, +} from './lifecycle'; + +export interface SyncReportingSubscriptionsResultV1 extends PrincipalLifecycleResult { + /** Readback for every subscription, including inactive ones whose obligations are retained. */ + subscriptions: ReportingSubscriptionState[]; +} + +export interface SyncReportingSubscriptionsOptionsV1 extends PrincipalLifecycleOptions { + /** + * Allow the write to deactivate a currently active subscription by omitting it. Defaults to + * false: deactivation is a cutover that cannot be undone retroactively, so a filtered list + * is refused before anything is written. Sending a subscription with `active: false` is an + * explicit deactivation and is always allowed. + */ + allowDeactivation?: boolean; +} + +/** + * Replace the caller's complete reporting subscription set. + * + * Throws {@link ReportingSubscriptionError}: `SUBSCRIPTION_WOULD_DEACTIVATE` (with `dispatched: false`), `SUBSCRIPTION_CONFLICT_EXHAUSTED`, or `SUBSCRIPTION_READBACK_MISMATCH`. + * Other lifecycle failures throw `PrincipalLifecycleError`. + * Uses the same guarded + * replacement as `syncPrincipalLifecycle` (expected_configuration_version, fresh idempotency + * keys on conflict). Omitting a currently active subscription deactivates it, so that is + * refused unless `allowDeactivation` is set; the check runs against every configuration read + * the write replaces, including the fresh read after a conflict. After the write, every + * submitted subscription must read back at the submitted version, with the submitted active + * state and with every submitted configuration field unchanged. + * + * Keep each submitted configuration by version. A seller on 3.3.0-beta.2 or later also + * returns it in `prior_versions[].configuration` (adcp#8588). + */ +export async function syncReportingSubscriptionsV1( + client: PrincipalLifecycleClient, + subscriptions: readonly ReportingSubscription[], + options: SyncReportingSubscriptionsOptionsV1 = {} +): Promise { + const { allowDeactivation = false, ...lifecycleOptions } = options; + const submittedIds = new Set(subscriptions.map(subscription => subscription.subscription_id)); + let result: PrincipalLifecycleResult; + try { + result = await guardedSyncPrincipalLifecycle( + client, + { reporting_subscriptions: structuredClone([...subscriptions]) } as never, + lifecycleOptions, + prior => { + if (allowDeactivation || prior.kind !== 'current') return; + const current = (prior.configuration as { reporting_subscriptions?: ReportingSubscriptionState[] }) + .reporting_subscriptions; + const omitted = (current ?? []) + .filter(state => state.state !== 'inactive' && !submittedIds.has(state.subscription_id)) + .map(state => state.subscription_id); + if (omitted.length > 0) { + throw new ReportingSubscriptionError( + 'SUBSCRIPTION_WOULD_DEACTIVATE', + `The write would deactivate active subscriptions it omits (${omitted.map(id => JSON.stringify(id)).join(', ')}), because reporting_subscriptions is the complete set. Include them, send them with active: false, or pass allowDeactivation: true.`, + // A refused write is never sent. An earlier attempt that met CONFLICT applied nothing. + { wouldDeactivate: omitted } + ); + } + } + ); + } catch (error) { + if (error instanceof PrincipalLifecycleError && error.code === 'PRINCIPAL_CONFLICT_EXHAUSTED') { + throw new ReportingSubscriptionError( + 'SUBSCRIPTION_CONFLICT_EXHAUSTED', + `${error.message} Another writer is updating this principal; retry later.`, + { cause: error } + ); + } + throw error; + } + const states = (result.current.configuration.reporting_subscriptions ?? []) as ReportingSubscriptionState[]; + for (const subscription of subscriptions) { + const state = states.find(item => item.subscription_id === subscription.subscription_id); + const fail = (message: string) => + new ReportingSubscriptionError( + 'SUBSCRIPTION_READBACK_MISMATCH', + `${message}. The seller altered a submitted field; contact the seller.`, + { readback: state, appliedResponse: result.applied, current: result.current } + ); + if (!state || state.subscription_version !== subscription.subscription_version) { + throw fail( + `Seller readback does not carry subscription ${JSON.stringify(subscription.subscription_id)} at version ${subscription.subscription_version}` + ); + } + if ((subscription.active === false) !== (state.state === 'inactive')) { + throw fail( + `Seller readback for subscription ${JSON.stringify(subscription.subscription_id)} does not reflect active: ${subscription.active}` + ); + } + const { active: _active, ...submitted } = subscription; + const { active: _readActive, ...stored } = (state.configuration ?? {}) as Partial; + const differing = differingPath(submitted, stored, ''); + if (differing !== undefined) { + throw fail( + `Seller readback for subscription ${JSON.stringify(subscription.subscription_id)} stores a different configuration at ${differing || '/'}` + ); + } + } + return { ...result, subscriptions: structuredClone(states) }; +} + +/** + * The first path at which `stored` lacks or changes a value the buyer sent. Fields the seller + * adds (defaults it fills) are ignored; arrays must match element for element. + */ +function differingPath(sent: unknown, stored: unknown, path: string): string | undefined { + if (Array.isArray(sent)) { + if (!Array.isArray(stored) || stored.length !== sent.length) return path; + for (let index = 0; index < sent.length; index += 1) { + const differing = differingPath(sent[index], stored[index], `${path}/${index}`); + if (differing !== undefined) return differing; + } + return undefined; + } + if (sent !== null && typeof sent === 'object') { + if (stored === null || typeof stored !== 'object' || Array.isArray(stored)) return path; + for (const [key, value] of Object.entries(sent)) { + if (value === undefined) continue; + const differing = differingPath(value, (stored as Record)[key], `${path}/${key}`); + if (differing !== undefined) return differing; + } + return undefined; + } + return Object.is(sent, stored) ? undefined : path; +} diff --git a/src/lib/reporting/advanced/index.ts b/src/lib/reporting/advanced/index.ts new file mode 100644 index 000000000..f1227db5e --- /dev/null +++ b/src/lib/reporting/advanced/index.ts @@ -0,0 +1,70 @@ +/** + * Low-level building blocks for principal-level reporting subscriptions (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3): the coverage engine and its stores, the coverage + * function, the period calendar, offering selection, the capability builder, the bridge onto the + * consumer runtime, and the Reliable Reporting service's subscription hooks. + * + * This is the third rung of the reporting-subscription ladder: the managed seller path and the buyer + * facade sit on top of these pieces. Import from here when you compose the engine yourself, run your + * own ledger, or compute coverage yourself. + * + * Every export is experimental: breaking changes may ship in minor releases of `@adcp/sdk`. + * + * Stability policy: https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + * + * @experimental + * @packageDocumentation + */ + +// The calendar, selection, coverage function, capability builder and hand-modelled wire types. +export * from '../subscriptions'; + +// Coverage onto the consumer runtime's expected periods (buyer). +export { + reportingSubscriptionVersionRecordsV1, + subscriptionExpectedReportingPeriodsV1, + type SubscriptionExpectationTemplateV1, +} from '../subscriptions/expectations'; + +// The coverage engine (seller) and its stores. +export { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, + reportingSubscriptionCoverageGrantedV1, + type CreateReportingSubscriptionEngineOptionsV1, + type ReportingSubscriptionAccountConfigV1, + type ReportingSubscriptionAccountFactV1, + type ReportingSubscriptionCoverageEntryV1, + type ReportingSubscriptionEngineHooksV1, + type ReportingSubscriptionEngineStateV1, + type ReportingSubscriptionEngineStoreV1, + type ReportingSubscriptionEngineV1, + type ReportingSubscriptionExclusionReasonV1, + type ReportingSubscriptionExclusionV1, + type ReportingSubscriptionFrozenOwedSetV1, + type ReportingSubscriptionReadbackOverflowV1, + type ReportingSubscriptionReconcileResultV1, +} from '../subscriptions/engine'; +export { + DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION, + createReportingSubscriptionEngineStateStoreV1, + type CreateReportingSubscriptionEngineStateStoreOptionsV1, +} from '../subscriptions/engine-state-store'; + +// The Reliable Reporting service's subscription planner hooks and its coverage hook. +export { + withReportingSubscriptionCoverageV1, + type ReliableReportingSubscriptionBindingInputV1, + type ReliableReportingSubscriptionGenerationContextV1, + type ReliableReportingSubscriptionPolicyV1, + type ReliableReportingSubscriptionsOptionsV1, +} from '../service/subscriptions'; + +// Typed errors the coverage function and the engine throw (also exported from the package root). +export { + CoverageInputError, + ReportingSubscriptionEngineError, + type CoverageInputErrorCode, + type CoverageInputErrorDetail, + type ReportingSubscriptionEngineErrorCode, +} from '../../errors'; diff --git a/src/lib/reporting/consumer-runtime.ts b/src/lib/reporting/consumer-runtime.ts index ee3421470..ac1683b63 100644 --- a/src/lib/reporting/consumer-runtime.ts +++ b/src/lib/reporting/consumer-runtime.ts @@ -1,8 +1,10 @@ import type { GetReportingStatusRequest } from '../types'; import type { + PrincipalChangedWebhook, ReportingDeliveryReadyWebhook, ReportingLedgerChangedWebhook, ReportingStatusChangedWebhook, + ReportingSubscriptionCoverage, } from '../types/core.generated'; import type { ReportingChangesCheckpointStoreV1, @@ -21,9 +23,11 @@ import { type ReportingCheckpointStore, type ReportingReconciliationClient, type ReportingReconciliationResult, + type ExpectedReportingPeriod, } from './reconciliation'; const DEFAULT_POLL_INTERVAL_MS = 60_000; +const PRINCIPAL_CHANGED_DEDUPE_WINDOW = 1_024; const DEFAULT_LEASE_MS = 30_000; const DEFAULT_MAX_CONCURRENCY = 4; @@ -45,6 +49,31 @@ export interface ReliableReportingConsumerAccountV1 { accountId: string; /** Full-snapshot reconciliation inputs. Durable stores are injected by this runtime. */ reconciliation: Omit, DurableOptionKeys>; + /** + * Subscription-derived expectations (experimental `media_buy.reporting_subscriptions`). + * Before each reconciliation the runtime reads the summary view's + * `subscription_coverage[]` and passes it here; the returned periods are reconciled + * alongside `reconciliation.expectedPeriods`. Compute them with + * `expectedSubscriptionPeriods` and `subscriptionExpectedReportingPeriodsV1`, passing + * `now` so the expectations match the snapshot being reconciled. + * + * Use this only against a seller that materializes subscription obligations in its + * reporting ledger. Against one that does not, every owed subscription period is reported + * as a missing expected period. + */ + subscriptionExpectations?: (input: { + subscriptionCoverage: readonly ReportingSubscriptionCoverage[]; + /** The summary's `ledger_as_of` minus `subscriptionExpectationGraceMs`, never the wall clock. */ + now: Date; + /** The summary's `ledger_as_of`. */ + ledgerAsOf: string; + signal: AbortSignal; + }) => readonly ExpectedReportingPeriod[] | Promise; + /** + * How long after a period ends the seller may take to create its obligation. Periods ending + * within this of `ledger_as_of` are not expected yet. Defaults to 0. + */ + subscriptionExpectationGraceMs?: number; } export interface ReliableReportingConsumerPersistenceV1 { @@ -66,6 +95,11 @@ export interface CreateReliableReportingConsumerOptionsV1 maxConcurrentAccounts?: number; runOnStart?: boolean; onResult?: (result: ReliableReportingConsumerRunResultV1) => void | Promise; + /** Called for an authenticated `principal.changed` before the scope's accounts are reconciled. */ + onPrincipalChanged?: (input: { + consumerScope: string; + notification: PrincipalChangedWebhook; + }) => void | Promise; /** Errors from background work and hooks. The third argument identifies the durable partition. */ onError?: ( error: unknown, @@ -78,6 +112,7 @@ export type ReliableReportingConsumerRunReasonV1 = | 'startup' | 'poll' | 'manual' + | 'principal.changed' | ReportingNotificationV1['notification_type']; export interface ReliableReportingConsumerErrorContextV1 { @@ -114,6 +149,18 @@ export interface ReliableReportingConsumerV1 { /** Required when the same account ID is configured under multiple authenticated sellers/principals. */ consumerScope?: string ): Promise; + /** + * Handle an authenticated, signature-verified `principal.changed` webhook: call + * `onPrincipalChanged` (for example to re-read `get_principal`, record + * `accounts_unauthorized` and replace the roster), then reconcile every account of that + * consumer scope. A retry with an already seen `idempotency_key` returns an empty list, and + * notifications arriving while the scope is being reconciled share one trailing run. Returns + * null for any other payload. + */ + handleAuthenticatedPrincipalChanged( + payload: unknown, + authentication: { consumerScope: string } + ): Promise; /** Call only after authenticating and verifying the webhook signature. */ handleAuthenticatedNotification( payload: unknown, @@ -526,8 +573,14 @@ export function createReliableReportingConsumerV1( } } + const subscriptionExpected = account.subscriptionExpectations + ? await subscriptionExpectedPeriods(account, runSignal) + : undefined; const reconciliation = await reconcileReporting({ ...account.reconciliation, + ...(subscriptionExpected + ? { expectedPeriods: [...(account.reconciliation.expectedPeriods ?? []), ...subscriptionExpected] } + : {}), client: abortableClient(account.reconciliation.client, runSignal, account.accountId), request: withoutChangesAfter(account.reconciliation.request), checkpointStore: leaseGuardedCheckpointStore(options.persistence.checkpointStore, assertLeaseCurrent), @@ -625,6 +678,101 @@ export function createReliableReportingConsumerV1( return work; }; + // principal.changed: a delivery retry carries the same idempotency_key and is dropped, and + // a notification that arrives while a scope is being reconciled joins one trailing run. + const principalChangedSeen = new Map(); + const principalChangedQueue = new Map< + string, + { + running: Promise; + queued?: Promise; + latest?: PrincipalChangedWebhook; + } + >(); + + const reconcilePrincipalChanged = async ( + consumerScope: string, + notification: PrincipalChangedWebhook + ): Promise => { + await options.onPrincipalChanged?.({ consumerScope, notification }); + const scoped = [...accounts.values()].filter(account => account.consumerScope === consumerScope); + const results: ReliableReportingConsumerRunResultV1[] = []; + for (const account of scoped) { + try { + results.push(await runSelectedAccount(account, 'principal.changed')); + } catch (error) { + await reportError(error, { + consumerScope: account.consumerScope, + accountId: account.accountId, + reason: 'principal.changed', + }); + } + } + return results; + }; + + const schedulePrincipalChanged = ( + consumerScope: string, + notification: PrincipalChangedWebhook + ): Promise => { + const entry = principalChangedQueue.get(consumerScope); + if (entry) { + entry.latest = notification; + entry.queued ??= entry.running + .catch(() => undefined) + .then(() => { + principalChangedQueue.delete(consumerScope); + return schedulePrincipalChanged(consumerScope, entry.latest!); + }); + return entry.queued; + } + const running = reconcilePrincipalChanged(consumerScope, notification).finally(() => { + const current = principalChangedQueue.get(consumerScope); + if (current?.running === running && !current.queued) principalChangedQueue.delete(consumerScope); + }); + principalChangedQueue.set(consumerScope, { running }); + return running; + }; + + /** Read the summary view's subscription_coverage and ask the account for its expectations. */ + const subscriptionExpectedPeriods = async ( + account: ReliableReportingConsumerAccountV1, + signal: AbortSignal + ): Promise => { + const response = (await abortableClient( + account.reconciliation.client, + signal, + account.accountId + ).getReportingStatus( + { ...withoutChangesAfter(account.reconciliation.request), view: 'summary' } as GetReportingStatusRequest, + { signal } + )) as unknown as { + status?: unknown; + account_id?: unknown; + ledger_as_of?: unknown; + subscription_coverage?: unknown; + }; + const ledgerAsOf = typeof response.ledger_as_of === 'string' ? Date.parse(response.ledger_as_of) : NaN; + if (response.status !== 'completed' || response.account_id !== account.accountId || !Number.isFinite(ledgerAsOf)) { + throw consumerError('INCOMPLETE_SUMMARY', 'get_reporting_status returned an incomplete subscription summary'); + } + const graceMs = account.subscriptionExpectationGraceMs ?? 0; + if (!Number.isSafeInteger(graceMs) || graceMs < 0) { + throw new TypeError('subscriptionExpectationGraceMs must be a non-negative integer'); + } + const coverage = Array.isArray(response.subscription_coverage) + ? (response.subscription_coverage as ReportingSubscriptionCoverage[]) + : []; + const expected = await account.subscriptionExpectations!({ + subscriptionCoverage: coverage, + now: new Date(ledgerAsOf - graceMs), + ledgerAsOf: response.ledger_as_of as string, + signal, + }); + if (!Array.isArray(expected)) throw new TypeError('subscriptionExpectations must return an array'); + return [...expected]; + }; + const runAccount = ( accountId: string, reason: ReliableReportingConsumerRunReasonV1 = 'manual', @@ -676,6 +824,26 @@ export function createReliableReportingConsumerV1( replaceAccounts(configured); }, runAccount, + async handleAuthenticatedPrincipalChanged(payload, authentication) { + const notification = principalChangedNotification(payload); + if (!notification) return null; + if (!authentication) throw new TypeError('consumerScope is required for a principal.changed notification'); + boundedString(authentication.consumerScope, 'consumerScope', 4_096); + const scope = authentication.consumerScope; + const seen = principalChangedSeen.get(scope) ?? []; + if (seen.includes(notification.idempotency_key)) return []; + seen.push(notification.idempotency_key); + if (seen.length > PRINCIPAL_CHANGED_DEDUPE_WINDOW) seen.shift(); + principalChangedSeen.set(scope, seen); + try { + return await schedulePrincipalChanged(scope, notification); + } catch (error) { + // A failed delivery must be retryable under the same key. + const index = seen.indexOf(notification.idempotency_key); + if (index >= 0) seen.splice(index, 1); + throw error; + } + }, async handleAuthenticatedNotification(payload, authentication) { const notification = reportingNotification(payload); if (!notification) return null; @@ -912,6 +1080,19 @@ function reportingNotification(value: unknown): ReportingNotificationV1 | null { return value as ReportingNotificationV1; } +function principalChangedNotification(value: unknown): PrincipalChangedWebhook | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as { notification_type?: unknown; idempotency_key?: unknown; reason?: unknown }; + if (candidate.notification_type !== 'principal.changed') return null; + try { + boundedString(candidate.idempotency_key, 'notification.idempotency_key', 255); + boundedString(candidate.reason, 'notification.reason', 128); + } catch { + throw consumerError('INVALID_NOTIFICATION', 'principal.changed notification has invalid identity fields'); + } + return value as PrincipalChangedWebhook; +} + function runtimeAccountKey(consumerScope: string, accountId: string): string { return canonicalJsonSha256({ consumerScope, accountId }); } diff --git a/src/lib/reporting/ledger/coverage.ts b/src/lib/reporting/ledger/coverage.ts index 19ef43584..10a8af8a0 100644 --- a/src/lib/reporting/ledger/coverage.ts +++ b/src/lib/reporting/ledger/coverage.ts @@ -1,4 +1,15 @@ -import type { ReportingLedgerConfigurationV1, ReportingLedgerSnapshotQueryV1 } from './types'; +import { createHash } from 'node:crypto'; + +import { canonicalJsonV1 } from '../source'; +import type { + ReportingSubscriptionReceiptAdmissionV1, + ReportingLedgerConfigurationV1, + ReportingLedgerOwedSetProgressV1, + ReportingLedgerOwedSetV1, + ReportingLedgerSnapshotQueryV1, + ReportingLedgerSubscriptionPrincipalV1, + ReportingLedgerSubscriptionV1, +} from './types'; import { frozenCalendarObligationsCoverOwnership, isFrozenCalendarRulesMismatch, @@ -22,13 +33,124 @@ export function reportingLedgerScopeClosed( ); } +/** + * Whether evaluated coverage lets the scope close: every grid generation is complete and every + * owed-set generation's horizon has reached the scope end. `coverage_complete` stays + * `complete` alone, because an owed set that is still being frozen is not missing history. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingLedgerCoverageClosedV1(coverage: { complete: boolean; owedSetOpen?: boolean }): boolean { + return coverage.complete && coverage.owedSetOpen !== true; +} + +/** + * Whether a generation owns an owed set (a principal-level reporting subscription) rather than a + * contiguous period grid. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function isReportingLedgerOwedSetV1(configuration: { + subscription?: ReportingLedgerSubscriptionV1 | undefined; +}): boolean { + return configuration.subscription?.ownership === 'owed_set'; +} + +/** + * The uniqueness discriminator a store keys generations by beside `(account_id, + * delivery_config_id, delivery_config_version)`: empty for an account generation, and a digest + * of the owning principal and the resolved offering for a subscription generation. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingLedgerSubscriptionKeyV1(configuration: { + subscription?: ReportingLedgerSubscriptionV1 | undefined; +}): string { + const subscription = configuration.subscription; + if (!subscription) return ''; + return createHash('sha256') + .update( + canonicalJsonV1(['subscription-key-v1', subscription.tenantId, subscription.principalId, subscription.offeringId]) + ) + .digest('hex'); +} + +/** + * Whether a generation or obligation is visible to the authenticated principal: account + * generations always are, a subscription generation only to its owner, and nothing of a + * subscription without a principal. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingLedgerSubscriptionVisibleV1( + value: { subscription?: ReportingLedgerSubscriptionV1 | undefined }, + principal: ReportingLedgerSubscriptionPrincipalV1 | undefined +): boolean { + const subscription = value.subscription; + if (!subscription) return true; + return ( + principal !== undefined && + subscription.tenantId === principal.tenantId && + subscription.principalId === principal.principalId + ); +} + +/** + * `periodOrdinal` of an owed-set obligation: the period start in whole seconds since the epoch. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingLedgerOwedSetOrdinalV1(periodStart: string): number { + const start = Date.parse(periodStart); + if (!Number.isFinite(start)) throw new TypeError('Owed-set period start must be an RFC 3339 instant'); + return Math.floor(start / 1_000); +} + +/** + * Monotonic merge of owed-set progress: `through` and `until` only move later, `from` only + * earlier, and `mediaBuyIds` is a sorted union. Stores apply it under their account fence. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function mergeReportingLedgerOwedSetV1( + current: ReportingLedgerOwedSetV1 | undefined, + progress: Omit +): ReportingLedgerOwedSetV1 { + const later = (left: string | undefined, right: string | undefined) => + left === undefined ? right : right === undefined ? left : Date.parse(right) > Date.parse(left) ? right : left; + const earlier = (left: string | undefined, right: string | undefined) => + left === undefined ? right : right === undefined ? left : Date.parse(right) < Date.parse(left) ? right : left; + for (const value of [progress.through, progress.from, progress.until]) { + if (value !== undefined && !Number.isFinite(Date.parse(value))) { + throw new TypeError('Owed-set progress instants must be RFC 3339 instants'); + } + } + const canonical = (value: string | undefined) => (value === undefined ? undefined : new Date(value).toISOString()); + const through = later(current?.through, canonical(progress.through)); + const from = earlier(current?.from, canonical(progress.from)); + const until = later(current?.until, canonical(progress.until)); + return { + ...(through !== undefined ? { through } : {}), + ...(from !== undefined ? { from } : {}), + ...(until !== undefined ? { until } : {}), + mediaBuyIds: [...new Set([...(current?.mediaBuyIds ?? []), ...(progress.mediaBuyIds ?? [])])].sort(), + }; +} + +/** + * The media buys a generation answers for in scope matching. An owed-set generation answers for + * the union of its written obligations' frozen `media_buy_ids`, never its own (empty) + * install-time `mediaBuyIds`. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingLedgerConfigurationMediaBuyIdsV1(configuration: ReportingLedgerConfigurationV1): string[] { + return isReportingLedgerOwedSetV1(configuration) + ? (configuration.owedSet?.mediaBuyIds ?? []) + : configuration.mediaBuyIds; +} + export function reportingLedgerConfigurationMatchesScope( query: ReportingLedgerSnapshotQueryV1, configuration: ReportingLedgerConfigurationV1 ): boolean { return ( (!query.feed_purposes || query.feed_purposes.includes(configuration.feedPurpose)) && - (!query.media_buy_ids || configuration.mediaBuyIds.some(value => query.media_buy_ids!.includes(value))) + (!query.media_buy_ids || + reportingLedgerConfigurationMediaBuyIdsV1(configuration).some(value => query.media_buy_ids!.includes(value))) ); } @@ -36,10 +158,14 @@ export function reportingLedgerSuccessor( configuration: ReportingLedgerConfigurationV1, configurations: readonly ReportingLedgerConfigurationV1[] ): ReportingLedgerConfigurationV1 | undefined { + // An owed-set generation owns exactly its written obligations: nothing succeeds it, and it + // never succeeds an account generation. + if (isReportingLedgerOwedSetV1(configuration)) return undefined; const installed = Date.parse(configuration.installedAt); return configurations .filter(value => { if ( + isReportingLedgerOwedSetV1(value) || value.account.account_id !== configuration.account.account_id || value.delivery_config_id !== configuration.delivery_config_id ) { @@ -67,6 +193,12 @@ export function relevantReportingLedgerConfigurations( const start = Date.parse(periodStart); const end = Date.parse(periodEnd); return configurations.filter(configuration => { + if (isReportingLedgerOwedSetV1(configuration)) { + // Relevant where its written obligations are, which is all it owns. + const owedSet = configuration.owedSet; + if (owedSet?.from === undefined || owedSet.until === undefined) return false; + return Date.parse(owedSet.from) < end && Date.parse(owedSet.until) > start; + } const installed = Date.parse(configuration.historyStart ?? configuration.installedAt); const successor = reportingLedgerSuccessor(configuration, configurations); const superseded = Math.min( @@ -97,7 +229,7 @@ export function evaluateReportingLedgerCoverageV1( period?: { start: string; end: string }; }>, ledgerAsOf: string -): { complete: boolean; retainedFrom: string } { +): { complete: boolean; retainedFrom: string; owedSetOpen?: true } { const period = reportingLedgerEffectivePeriod(query, ledgerAsOf); if (configurations.length === 0) return { complete: true, retainedFrom: period.start }; const stored = new Map>(); @@ -107,8 +239,27 @@ export function evaluateReportingLedgerCoverageV1( stored.set(obligation.configurationId, ordinals); } let complete = true; + let owedSetOpen = false; let retainedFrom: string | undefined; for (const configuration of configurations.filter(value => reportingLedgerConfigurationMatchesScope(query, value))) { + if (isReportingLedgerOwedSetV1(configuration)) { + // The obligations present are the owed set by construction, so a gap is a period that is + // not owed, never a continuity failure. Completeness is the planner's horizon: until + // `owedSet.through` reaches the scope end, an owed period may still enter the scope. + const through = configuration.owedSet?.through; + if (through === undefined || Date.parse(through) < Date.parse(period.end)) owedSetOpen = true; + for (const obligation of obligations) { + if (obligation.configurationId !== configuration.configurationId || !obligation.period) continue; + if ( + Date.parse(obligation.period.start) < Date.parse(period.end) && + Date.parse(obligation.period.end) > Date.parse(period.start) && + (!retainedFrom || Date.parse(obligation.period.start) < Date.parse(retainedFrom)) + ) { + retainedFrom = obligation.period.start; + } + } + continue; + } const installedAt = Date.parse(configuration.historyStart ?? configuration.installedAt); const successor = reportingLedgerSuccessor(configuration, configurations); const ownershipEnd = Math.min( @@ -169,7 +320,7 @@ export function evaluateReportingLedgerCoverageV1( } if (!complete) break; } - return { complete, retainedFrom: retainedFrom ?? period.start }; + return { complete, retainedFrom: retainedFrom ?? period.start, ...(owedSetOpen ? { owedSetOpen: true } : {}) }; } /** @@ -180,7 +331,8 @@ export function assertReportingLedgerHistoryDoesNotOverlap( configuration: ReportingLedgerConfigurationV1, configurations: readonly ReportingLedgerConfigurationV1[] ): void { - if (configuration.historyStart === undefined) return; + if (configuration.historyStart === undefined || isReportingLedgerOwedSetV1(configuration)) return; + configurations = configurations.filter(value => !isReportingLedgerOwedSetV1(value)); const all = [ ...configurations.filter(value => value.configurationId !== configuration.configurationId), configuration, @@ -222,3 +374,32 @@ export function assertReportingLedgerHistoryDoesNotOverlap( } } } + +/** + * The single admission rule for receipts and consumer statuses against subscription + * obligations (experimental, AdCP 3.3). Change it here and nowhere else. + * + * - Without an admission, nothing is scoped: subscriptions are off. + * - An account obligation accepts any consumer of the account; a subscription obligation only + * its owning principal. + * + * Access means access: there is no post-revocation window. Once a principal loses authority for + * an account, the host's account resolution refuses every task on it, `sync_reporting_receipts` + * and `sync_reporting_status` included, so this rule never sees such a caller. A seller that + * wants a buyer to finish reconciling keeps a reduced grant instead of revoking (for example + * `allowed_tasks` limited to `get_reporting_status`, `sync_reporting_receipts` and + * `sync_reporting_status`), and this rule then applies as usual. + * @experimental Storage-facing building block; see docs/guides/REPORTING-STABILITY.md. + */ +export function reportingSubscriptionReceiptAdmissibleV1(input: { + obligation: { subscription?: ReportingLedgerSubscriptionV1 | undefined }; + admission?: ReportingSubscriptionReceiptAdmissionV1; +}): boolean { + const { admission, obligation } = input; + if (!admission) return true; + const owner = obligation.subscription; + return ( + owner === undefined || + (owner.tenantId === admission.principal.tenantId && owner.principalId === admission.principal.principalId) + ); +} diff --git a/src/lib/reporting/ledger/handler.ts b/src/lib/reporting/ledger/handler.ts index d2af9f2c3..01ffe8352 100644 --- a/src/lib/reporting/ledger/handler.ts +++ b/src/lib/reporting/ledger/handler.ts @@ -7,12 +7,17 @@ import { getSchemaDocumentByRef } from '../../validation/schema-loader'; import type { GetReportingStatusResponse } from '../../types'; import type { ReportingAdjustmentReceipt, ReportingMaterialization, ReportingReceipt } from '../../types'; import { canonicalJsonV1 } from '../source'; +import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; import { evaluateReportingLedgerCoverageV1, + isReportingLedgerOwedSetV1, relevantReportingLedgerConfigurations, reportingLedgerConfigurationMatchesScope, + reportingLedgerConfigurationMediaBuyIdsV1, + reportingLedgerCoverageClosedV1, reportingLedgerEffectivePeriod, reportingLedgerScopeClosed, + reportingLedgerSubscriptionVisibleV1, reportingLedgerSuccessor, } from './coverage'; import { @@ -40,6 +45,7 @@ import type { ReportingLedgerRevisionSnapshotV1, ReportingLedgerSnapshotQueryV1, ReportingLedgerStore, + ReportingLedgerSubscriptionPrincipalV1, ReportingManagedDeliveryBindingV1, CreateReportingDeliveryHandlerOptionsV1, ReportingDeliveryHandlerV1, @@ -57,6 +63,50 @@ export interface ReportingStatusConsumerScopeOptionsV1 { * (or honoring one the document doesn't advertise) is worse than silence. */ consumerMismatchEscalation?: ReportingConsumerMismatchEscalationV1; + /** + * The authenticated principal whose subscription generations this caller may see + * (experimental, AdCP 3.3). Without it no subscription generation or obligation is visible. + * Derive it from authenticated transport only, as the principal runtime does. + */ + resolveSubscriptionPrincipal?: ReportingSubscriptionPrincipalResolverV1; +} + +/** + * Status reader without consumer-attributed readback that still scopes subscription generations + * to the authenticated principal. + */ +export interface ReportingStatusSubscriptionScopeOptionsV1 { + resolveConsumerId?: undefined; + consumerMismatchEscalation?: undefined; + resolveSubscriptionPrincipal: ReportingSubscriptionPrincipalResolverV1; +} + +/** + * Resolves the authenticated principal whose subscription generations a read may see. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionPrincipalResolverV1 = ( + context: TContext +) => ReportingLedgerSubscriptionPrincipalV1 | Promise; + +/** Resolve and shape-check the caller's subscription principal. */ +export async function resolveReportingSubscriptionPrincipalV1( + resolver: ReportingSubscriptionPrincipalResolverV1 | undefined, + context: TContext +): Promise { + if (!resolver) return undefined; + const principal = await resolver(context); + if ( + !principal || + typeof principal.tenantId !== 'string' || + typeof principal.principalId !== 'string' || + principal.tenantId.length === 0 || + principal.principalId.length === 0 + ) { + throw new TypeError('resolveSubscriptionPrincipal must return a tenantId and principalId'); + } + return { tenantId: principal.tenantId, principalId: principal.principalId }; } // The no-consumer-scope reader needs only a resolved account; transport state is unused. @@ -68,11 +118,11 @@ export function createReportingStatusHandler( ) => ReturnType; export function createReportingStatusHandler( store: ReportingLedgerStore, - options?: ReportingStatusConsumerScopeOptionsV1 + options?: ReportingStatusConsumerScopeOptionsV1 | ReportingStatusSubscriptionScopeOptionsV1 ): ReportingStatusHandlerV1; export function createReportingStatusHandler( store: ReportingLedgerStore, - options?: ReportingStatusConsumerScopeOptionsV1 + options?: ReportingStatusConsumerScopeOptionsV1 | ReportingStatusSubscriptionScopeOptionsV1 ): ReportingStatusHandlerV1 { // Fail at wiring time, not on the first escalated read. Inherit the store's // setting when the handler was not given one, and refuse a disagreement @@ -103,8 +153,18 @@ export function createReportingStatusHandler( : undefined; if (!resolvedAccountId) throw new Error('get_reporting_status requires a resolved account'); const accountId = resolvedAccountId; - const consumerId = options ? await options.resolveConsumerId(context as TContext) : undefined; - if (options && !consumerId) throw new Error('get_reporting_status requires an authenticated consumer'); + const consumerId = options?.resolveConsumerId ? await options.resolveConsumerId(context as TContext) : undefined; + if (options?.resolveConsumerId && !consumerId) { + throw new Error('get_reporting_status requires an authenticated consumer'); + } + const subscriptionPrincipal = await resolveReportingSubscriptionPrincipalV1( + options?.resolveSubscriptionPrincipal, + context as TContext + ); + // `offering_id` on an obligation is a 3.3 field; earlier response shapes are closed. + const servedAdcpVersion = (context as { servedAdcpVersion?: unknown }).servedAdcpVersion; + const emitsOfferingId = + typeof servedAdcpVersion !== 'string' || isAdcpVersionAtLeast(servedAdcpVersion, '3.3.0-beta.1'); const view = raw.view; if (view !== 'summary' && view !== 'periods' && view !== 'revision') { throw new Error('Unsupported reporting status view'); @@ -120,6 +180,7 @@ export function createReportingStatusHandler( const query: ReportingLedgerSnapshotQueryV1 = { account_id: accountId, ...(consumerId ? { consumer_id: consumerId } : {}), + ...(subscriptionPrincipal ? { subscription_principal: subscriptionPrincipal } : {}), view, ...copyArray(raw, 'media_buy_ids'), ...copyArray(raw, 'delivery_config_ids'), @@ -184,6 +245,17 @@ export function createReportingStatusHandler( }; } if (canonicalJsonV1(page.snapshot.query) !== canonicalJsonV1(query)) return lookupUnavailable(view); + // Defense in depth for caller isolation: a store that returned another principal's + // subscription generation or obligation has broken `subscription_principal`. + if ( + page.snapshot.configurations.some( + value => !reportingLedgerSubscriptionVisibleV1(value, subscriptionPrincipal) + ) || + page.snapshot.obligations.some(value => !reportingLedgerSubscriptionVisibleV1(value, subscriptionPrincipal)) || + page.obligations.some(value => !reportingLedgerSubscriptionVisibleV1(value, subscriptionPrincipal)) + ) { + throw new Error('Reporting ledger snapshot exposed a subscription generation the caller does not own'); + } if ( query.delivery_config_ids && query.delivery_config_ids.some( @@ -197,7 +269,9 @@ export function createReportingStatusHandler( query.media_buy_ids && query.media_buy_ids.some( mediaBuyId => - !page.snapshot.configurations.some(configuration => configuration.mediaBuyIds.includes(mediaBuyId)) + !page.snapshot.configurations.some(configuration => + reportingLedgerConfigurationMediaBuyIdsV1(configuration).includes(mediaBuyId) + ) ) ) { return lookupUnavailable(view); @@ -216,7 +290,7 @@ export function createReportingStatusHandler( obligation, revisions, page.snapshot.ledgerAsOf, - reportingLedgerScopeClosed(query, page.snapshot.ledgerAsOf, ledgerCoverage.complete) + reportingLedgerScopeClosed(query, page.snapshot.ledgerAsOf, reportingLedgerCoverageClosedV1(ledgerCoverage)) ); const persistedIssues = page.snapshot.issues.filter( issue => @@ -482,7 +556,8 @@ export function createReportingStatusHandler( value.projection, value.currentConsumerStatus, consumerId ? value.consumerStatusProjection.length : undefined, - value.managed + value.managed, + emitsOfferingId ) ), revisions: page.revisions @@ -520,7 +595,11 @@ export function createReportingStatusHandler( const issues = selected.flatMap(value => value.projection.issues); if (!ledgerCoverage.complete) issues.push(historyUnavailableIssue(query, page.snapshot.ledgerAsOf)); const summaryHealth = aggregateReportingHealthV1(healthValues, { - closed: reportingLedgerScopeClosed(query, page.snapshot.ledgerAsOf, ledgerCoverage.complete), + closed: reportingLedgerScopeClosed( + query, + page.snapshot.ledgerAsOf, + reportingLedgerCoverageClosedV1(ledgerCoverage) + ), coverageComplete: ledgerCoverage.complete, }); return { @@ -692,6 +771,17 @@ export function createReportingDeliveryHandler( if (!revision) return SDK_REVISION_NOT_OWNED; const obligation = await store.getObligation(revision.reporting_obligation_id); if (!obligation || obligation.account.account_id !== accountId) return SDK_REVISION_NOT_OWNED; + // Another principal's subscription revision reads exactly like an unknown one, and is never + // offered to the legacy reader: the SDK owns this id. + if ( + obligation.subscription && + !reportingLedgerSubscriptionVisibleV1( + obligation, + await resolveReportingSubscriptionPrincipalV1(options.resolveSubscriptionPrincipal, context) + ) + ) { + throw reportingRevisionNotFound(); + } const totalCount = revision.binding.rowCount; let offset = 0; if (typeof pagination.cursor === 'string') { @@ -817,13 +907,17 @@ function wireObligation( projection: ReturnType, currentConsumerStatus?: ReportingLedgerConsumerStatementV1, consumerStatusCount?: number, - managed?: ReturnType + managed?: ReturnType, + emitsOfferingId = false ) { return { reporting_obligation_id: obligation.reporting_obligation_id, delivery_config_id: obligation.delivery_config_id, delivery_config_version: obligation.delivery_config_version, report_definition_id: obligation.report_definition_id, + // AdCP 3.3: a subscription obligation names the offering it resolved to, which is + // authoritative for its timing. Account obligations keep their pre-3.3 shape. + ...(emitsOfferingId && obligation.subscription ? { offering_id: obligation.offeringId } : {}), feed_purpose: obligation.feedPurpose, reporting_profile: obligation.contract.reportingProfile, account_id: obligation.account.account_id, @@ -1316,6 +1410,9 @@ function nextActivePeriodStart( configurations: ReportingLedgerConfigurationV1[], ledgerAsOf: string ): string[] { + // An owed set has no schedule grid to project: which future periods are owed depends on + // buys the ledger does not see. Only its written obligations count. + if (isReportingLedgerOwedSetV1(configuration)) return []; const asOf = Date.parse(ledgerAsOf); const installed = Date.parse(configuration.historyStart ?? configuration.installedAt); const ownershipEnd = reportingLedgerConfigurationOwnershipEnd(configuration, configurations); @@ -1337,6 +1434,7 @@ function nextObligationDue( query: ReportingLedgerSnapshotQueryV1, ledgerAsOf: string ): string[] { + if (isReportingLedgerOwedSetV1(configuration)) return []; const schedule = reportingPeriodSchedule(configuration); const installed = Date.parse(configuration.historyStart ?? configuration.installedAt); const ownershipEnd = reportingLedgerConfigurationOwnershipEnd(configuration, configurations); @@ -1399,7 +1497,7 @@ function publicScope( query: ReportingLedgerSnapshotQueryV1, configurations: ReportingLedgerConfigurationV1[], ledgerAsOf: string, - coverage: { complete: boolean; retainedFrom: string } + coverage: { complete: boolean; retainedFrom: string; owedSetOpen?: true } ) { const { start: periodStart, end: periodEnd } = reportingLedgerEffectivePeriod(query, ledgerAsOf); configurations = scopedConfigurations(query, configurations, ledgerAsOf); @@ -1418,7 +1516,7 @@ function publicScope( return { period_start: periodStart, period_end: periodEnd, - scope_closed: Date.parse(periodEnd) <= Date.parse(ledgerAsOf) && coverage.complete, + scope_closed: Date.parse(periodEnd) <= Date.parse(ledgerAsOf) && reportingLedgerCoverageClosedV1(coverage), ...(query.media_buy_ids ? { media_buy_ids: [...query.media_buy_ids].sort() } : {}), all_accessible_media_buys: query.media_buy_ids === undefined, delivery_config_generations: generations, diff --git a/src/lib/reporting/ledger/lifecycle.ts b/src/lib/reporting/ledger/lifecycle.ts index 6e612663c..9a8b74ae5 100644 --- a/src/lib/reporting/ledger/lifecycle.ts +++ b/src/lib/reporting/ledger/lifecycle.ts @@ -13,6 +13,7 @@ import type { ReportingLedgerStatusTransitionV1, ReportingLedgerStore, ReportingLedgerSubscriberV1, + ReportingLedgerObligationV1, ReportingObservedFinalityV1, } from './types'; @@ -258,7 +259,7 @@ export async function reconcileReportingStatusLifecycleV1( await input.store.markTransitionNotified(pending.transitionId, ledgerAsOf); continue; } - if (await notifyTransition(pending, obligation.account.account_id, input.subscribers)) { + if (await notifyTransition(pending, obligation, input.subscribers)) { await input.store.markTransitionNotified(pending.transitionId, ledgerAsOf); } } @@ -345,7 +346,7 @@ export async function reconcileReportingStatusLifecycleV1( await input.store.markTransitionNotified(transition.transitionId, ledgerAsOf); return { ...transition, notifiedAt: ledgerAsOf }; } - const notified = await notifyTransition(transition, obligation.account.account_id, input.subscribers); + const notified = await notifyTransition(transition, obligation, input.subscribers); if (notified) await input.store.markTransitionNotified(transition.transitionId, ledgerAsOf); return notified ? { ...transition, notifiedAt: ledgerAsOf } : transition; } @@ -653,10 +654,17 @@ export async function reconcileReportingStatusDeadlinesV1(input: { async function notifyTransition( transition: ReportingLedgerStatusTransitionV1, - accountId: string, + obligation: Pick, configured: readonly ReportingLedgerSubscriberV1[] | undefined ): Promise { - const subscribers = (configured ?? []).filter(value => value.account_id === accountId); + const owner = obligation.subscription; + // A subscription obligation belongs to its owning principal: another principal's subscriber on + // the same account never learns of it. + const subscribers = (configured ?? []).filter( + value => + value.account_id === obligation.account.account_id && + (!owner || (value.principal?.tenantId === owner.tenantId && value.principal?.principalId === owner.principalId)) + ); if (subscribers.length === 0) return true; if (subscribers.length > 64) throw new Error('Reporting status subscriber fanout exceeds 64'); const results = await Promise.allSettled( diff --git a/src/lib/reporting/ledger/producer.ts b/src/lib/reporting/ledger/producer.ts index fa943d7ee..53b1fdc7e 100644 --- a/src/lib/reporting/ledger/producer.ts +++ b/src/lib/reporting/ledger/producer.ts @@ -18,7 +18,12 @@ import { type ReportingSourceOfferingV1, type ReportingSourceSliceRequestV1, } from '../source'; -import { assertReportingLedgerHistoryDoesNotOverlap, reportingLedgerSuccessor } from './coverage'; +import { + assertReportingLedgerHistoryDoesNotOverlap, + isReportingLedgerOwedSetV1, + reportingLedgerOwedSetOrdinalV1, + reportingLedgerSuccessor, +} from './coverage'; import { assertReportingCalendarDayPeriod, assertReportingCalendarDaySource, @@ -36,7 +41,10 @@ import { retryReportingStatusNotificationsV1, } from './lifecycle'; import { listAdjustmentMetadataFromStore, listRevisionMetadataFromStore } from './revision-metadata'; +import { ReportingConfigurationImmutableError, isReportingLedgerErrorNamed } from './types'; import type { + ReportingSubscriptionConfigurationInputV1, + ReportingSubscriptionLedgerObligationInputV1, CreateReportingProducerOptionsV1, ReportingLedgerAdjustmentMetadataV1, ReportingLedgerAdjustmentV1, @@ -75,6 +83,9 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV if (Object.hasOwn(input, 'historyStart') || Object.hasOwn(input, 'historyStartOrdinal')) { throw new TypeError('Reporting history must be supplied through trusted install options'); } + if (Object.hasOwn(input, 'subscription') || Object.hasOwn(input, 'owedSet')) { + throw new TypeError('Subscription generations are installed through planSubscriptionObligations'); + } const normalizedInput = { ...structuredClone(input), schedule: { @@ -90,7 +101,7 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV // offering up first made reinstalling one throw "Unknown reporting // source offering" without ever reading the ledger. const existing = (await options.store.listConfigurations(normalizedInput.account.account_id)).filter( - value => value.delivery_config_id === normalizedInput.delivery_config_id + value => value.delivery_config_id === normalizedInput.delivery_config_id && !isReportingLedgerOwedSetV1(value) ); const semantic = { ...normalizedInput }; const semanticFingerprint = prefixedDigest(semantic); @@ -110,7 +121,11 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV } } if (!replayFingerprints.includes(replay.semanticFingerprint)) { - throw new Error('Reporting configuration generation is immutable'); + throw new ReportingConfigurationImmutableError({ + accountId: normalizedInput.account.account_id, + deliveryConfigId: normalizedInput.delivery_config_id, + deliveryConfigVersion: normalizedInput.delivery_config_version, + }); } if (installOptions.historyStart !== undefined) { const requested = historyInstant(installOptions.historyStart); @@ -197,6 +212,9 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV value => value.configurationId === input.configurationId ); if (!configuration) throw new Error('Unknown reporting configuration'); + if (isReportingLedgerOwedSetV1(configuration)) { + throw new TypeError('Subscription generations own their owed set and have no history to extend'); + } if (configuration.historyStart !== undefined) { const requested = historyInstant(input.historyStart); if (requested === Date.parse(configuration.historyStart)) return configuration; @@ -224,12 +242,16 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV const historyBySource = new Map(); const created: ReportingLedgerObligationV1[] = []; let attempted = 0; - const configurations = (await options.store.listConfigurations(planOptions.account_id)).sort( - (left, right) => - instant(left.installedAt, 'installedAt') - instant(right.installedAt, 'installedAt') || - left.delivery_config_version - right.delivery_config_version || - left.configurationId.localeCompare(right.configurationId) - ); + // Owed-set (subscription) generations own exactly the obligations written into them by + // planSubscriptionObligations and are never grid-filled here. + const configurations = (await options.store.listConfigurations(planOptions.account_id)) + .filter(value => !isReportingLedgerOwedSetV1(value)) + .sort( + (left, right) => + instant(left.installedAt, 'installedAt') - instant(right.installedAt, 'installedAt') || + left.delivery_config_version - right.delivery_config_version || + left.configurationId.localeCompare(right.configurationId) + ); const obligationsByAccount = new Map(); const retiredByAccount = new Map(); // Complete the live pass across all configurations before filling history budgets. @@ -344,6 +366,168 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV return created; }, + async planSubscriptionObligations(plan) { + const store = options.store; + if (typeof store.recordOwedSetProgress !== 'function') { + throw new TypeError('Reporting ledger store does not support subscription generations'); + } + const nowIso = plan.now ?? new Date().toISOString(); + const nowMs = instant(nowIso, 'now'); + const owedThroughMs = instant(plan.owedThrough, 'owedThrough'); + if (owedThroughMs > nowMs) throw new RangeError('owedThrough must not be in the future'); + const owedThrough = new Date(owedThroughMs).toISOString(); + boundedId(plan.account_id, 'account_id'); + boundedId(plan.subscriptionId, 'subscriptionId'); + boundedId(plan.principal?.tenantId, 'principal.tenantId'); + boundedId(plan.principal?.principalId, 'principal.principalId'); + if (!Array.isArray(plan.configurations) || !Array.isArray(plan.obligations)) { + throw new TypeError('planSubscriptionObligations requires configurations and obligations arrays'); + } + const ownedBySubscription = (value: ReportingLedgerConfigurationV1) => + isReportingLedgerOwedSetV1(value) && + value.account.account_id === plan.account_id && + value.subscription!.tenantId === plan.principal.tenantId && + value.subscription!.principalId === plan.principal.principalId && + value.subscription!.subscriptionId === plan.subscriptionId; + const existing = (await store.listConfigurations(plan.account_id)).filter(ownedBySubscription); + // Install each (version, offering) generation once, oldest version first. + const inputs = [...plan.configurations].sort( + (left, right) => + left.delivery_config_version - right.delivery_config_version || + (left.offeringId < right.offeringId ? -1 : left.offeringId > right.offeringId ? 1 : 0) + ); + const generations = new Map(); + for (const input of inputs) { + const configuration = subscriptionConfiguration(input, plan, nowIso); + const key = `${configuration.delivery_config_version}\0${configuration.offeringId}`; + if (generations.has(key)) { + throw new TypeError('planSubscriptionObligations names one (version, offering) generation twice'); + } + const stored = existing.find(value => value.configurationId === configuration.configurationId); + if (stored) { + if (stored.semanticFingerprint !== configuration.semanticFingerprint) { + throw new ReportingConfigurationImmutableError({ + accountId: plan.account_id, + deliveryConfigId: configuration.delivery_config_id, + deliveryConfigVersion: configuration.delivery_config_version, + }); + } + generations.set(key, stored); + continue; + } + // Only a new generation needs a live offering, as in installConfiguration. + validateConfigurationAgainstOffering( + configuration, + requiredOffering(offeringById, configuration.offeringId), + 'owed_set' + ); + const written = (await store.putConfiguration(configuration)).value; + if ( + written.configurationId !== configuration.configurationId || + written.semanticFingerprint !== configuration.semanticFingerprint + ) { + throw new ReportingConfigurationImmutableError({ + accountId: plan.account_id, + deliveryConfigId: configuration.delivery_config_id, + deliveryConfigVersion: configuration.delivery_config_version, + }); + } + generations.set(key, written); + } + const prepared: Array<{ + configuration: ReportingLedgerConfigurationV1; + obligation: ReportingLedgerObligationV1; + }> = []; + const seen = new Set(); + for (const input of plan.obligations) { + const configuration = generations.get(`${input?.delivery_config_version}\0${input?.offeringId}`); + if (!configuration) { + throw new TypeError('Every subscription obligation must name a generation in configurations'); + } + const obligation = planOwedSetObligation( + configuration, + input, + requiredOffering(offeringById, configuration.offeringId), + owedThroughMs, + nowIso + ); + const identity = `${obligation.configurationId}\0${obligation.periodOrdinal}`; + if (seen.has(identity)) throw new TypeError('planSubscriptionObligations names one period twice'); + seen.add(identity); + prepared.push({ configuration, obligation }); + } + prepared.sort( + (left, right) => + Date.parse(left.obligation.period.start) - Date.parse(right.obligation.period.start) || + (left.obligation.configurationId < right.obligation.configurationId ? -1 : 1) + ); + const retired = new Set( + ((await store.listRetiredObligationOrdinals?.(plan.account_id)) ?? []).map( + value => `${value.configurationId}\0${value.periodOrdinal}` + ) + ); + // A managed binding must exist before a generation's first obligation: bind every generation + // about to receive one. A generation that has never been widened has no obligation yet. + if (plan.bindGeneration) { + for (const configuration of generations.values()) { + if (configuration.owedSet?.from !== undefined) continue; + if (!prepared.some(value => value.configuration.configurationId === configuration.configurationId)) continue; + await plan.bindGeneration(structuredClone(configuration)); + } + } + // Widen each generation's scope summary before its obligations land, so a read never sees + // an obligation its generation does not answer for. Completeness (`through`) moves only + // after every obligation is durable. + for (const configuration of generations.values()) { + const owned = prepared.filter(value => value.configuration.configurationId === configuration.configurationId); + if (owned.length === 0) continue; + await store.recordOwedSetProgress({ + configurationId: configuration.configurationId, + account_id: plan.account_id, + from: owned[0]!.obligation.period.start, + until: owned + .map(value => value.obligation.period.end) + .sort((left, right) => Date.parse(left) - Date.parse(right)) + .at(-1)!, + mediaBuyIds: [...new Set(owned.flatMap(value => value.obligation.mediaBuyIds))].sort(), + }); + } + const created: ReportingLedgerObligationV1[] = []; + for (const { obligation } of prepared) { + if (retired.has(`${obligation.configurationId}\0${obligation.periodOrdinal}`)) continue; + let written: { inserted: boolean; value: ReportingLedgerObligationV1 }; + try { + written = await store.putObligation(obligation); + } catch (error) { + // Retention retired the period after the engine froze it; it is never re-created. + if (isReportingLedgerErrorNamed(error, 'ReportingLedgerPeriodRetiredError')) continue; + throw error; + } + if (!written.inserted) continue; + created.push(written.value); + await reconcileReportingStatusLifecycleV1({ + store, + reporting_obligation_id: written.value.reporting_obligation_id, + // A fallback clock, never a pinned cutoff; see planObligations. + now: () => new Date(nowIso), + subscribers: options.subscribers, + }); + } + const owned = new Map(); + for (const value of [...existing, ...generations.values()]) owned.set(value.configurationId, value); + const configurations: ReportingLedgerConfigurationV1[] = []; + for (const configuration of owned.values()) { + configurations.push( + await store.recordOwedSetProgress({ + configurationId: configuration.configurationId, + account_id: plan.account_id, + through: owedThrough, + }) + ); + } + return { configurations, created }; + }, + async runWorker(workerOptions = {}) { const now = workerOptions.now ?? (() => new Date()); const maxIterations = workerOptions.maxIterations ?? 25; @@ -549,7 +733,10 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV signal: executionSignal, }); assertPublicationProgress(adjustments.at(-1) ?? previous.at(-1), manifest); - const rows = await readManifestRows(options.source, obligation, manifest, executionSignal, settlementGrace); + const rows = withSubscriptionAccountId( + obligation, + await readManifestRows(options.source, obligation, manifest, executionSignal, settlementGrace) + ); validateManifestFinalityForObligation(obligation, manifest); if (previous.some(value => value.finality === 'official')) { const official = previous.find(value => value.finality === 'official')!; @@ -810,6 +997,215 @@ function planObligation( }; } +/** Identity, fingerprint and validation of one subscription generation. */ +function subscriptionConfiguration( + input: ReportingSubscriptionConfigurationInputV1, + plan: { account_id: string; principal: { tenantId: string; principalId: string }; subscriptionId: string }, + installedAt: string +): ReportingLedgerConfigurationV1 { + if (!input || typeof input !== 'object') throw new TypeError('Subscription configuration input must be an object'); + for (const field of [ + 'configurationId', + 'installedAt', + 'semanticFingerprint', + 'calendarRules', + 'historyStart', + 'historyStartOrdinal', + 'owedSet', + 'constituents', + 'mediaBuyIds', + ]) { + if (Object.hasOwn(input, field)) { + throw new TypeError(`Subscription configuration input must not supply ${field}`); + } + } + const subscription = input.subscription; + if ( + !subscription || + subscription.ownership !== 'owed_set' || + subscription.tenantId !== plan.principal.tenantId || + subscription.principalId !== plan.principal.principalId || + subscription.subscriptionId !== plan.subscriptionId || + subscription.subscriptionId !== input.delivery_config_id || + subscription.offeringId !== input.offeringId || + input.account?.account_id !== plan.account_id + ) { + throw new TypeError( + 'Subscription configuration must name the planned account, owner and subscription, with ' + + 'delivery_config_id equal to subscription_id and offeringId equal to the resolved offering' + ); + } + if (input.supersededAt !== undefined) { + throw new TypeError('Subscription generations own an owed set and are never superseded by time'); + } + const normalized: Omit = { + ...structuredClone(input), + subscription: { + tenantId: subscription.tenantId, + principalId: subscription.principalId, + subscriptionId: subscription.subscriptionId, + offeringId: subscription.offeringId, + ownership: 'owed_set', + }, + schedule: { + ...structuredClone(input.schedule), + anchor: new Date(instant(input.schedule.anchor, 'schedule.anchor')).toISOString(), + }, + // Each obligation freezes its own denominator at its scope_resolved_at; the generation + // carries none, so nothing can read a stale install-time scope from it. + constituents: [], + mediaBuyIds: [], + }; + return { + ...normalized, + // Owner and offering join the identity: subscription ids are unique per principal, and one + // version owns one generation per resolved offering. Account generations keep their digest. + configurationId: `rcfg_${digest([ + normalized.account.account_id, + normalized.delivery_config_id, + normalized.delivery_config_version, + 'subscription-v1', + subscription.tenantId, + subscription.principalId, + subscription.offeringId, + ]).slice(0, 32)}`, + installedAt, + semanticFingerprint: prefixedDigest(normalized), + }; +} + +/** One owed obligation with its own period, frozen media buys and constituents. */ +function planOwedSetObligation( + configuration: ReportingLedgerConfigurationV1, + input: ReportingSubscriptionLedgerObligationInputV1, + offering: ReportingSourceOfferingV1, + owedThroughMs: number, + createdAt: string +): ReportingLedgerObligationV1 { + const start = instant(input.period?.start, 'period.start'); + const end = instant(input.period?.end, 'period.end'); + if (end <= start) throw new RangeError('Subscription obligation period must be a non-empty interval'); + if (start % 1_000 !== 0 || end % 1_000 !== 0) { + throw new RangeError('Subscription obligation periods must fall on whole seconds'); + } + if (end > owedThroughMs) throw new RangeError('Subscription obligations must end at or before owedThrough'); + if ( + !Array.isArray(input.mediaBuyIds) || + input.mediaBuyIds.length === 0 || + input.mediaBuyIds.some(value => typeof value !== 'string' || value.length === 0) + ) { + throw new TypeError('Subscription obligations freeze at least one media buy'); + } + const mediaBuyIds = [...new Set(input.mediaBuyIds)].sort(); + if (mediaBuyIds.length !== input.mediaBuyIds.length) { + throw new TypeError('Subscription obligation media buys must be unique'); + } + const constituents = structuredClone(input.constituents); + if (!Array.isArray(constituents) || constituents.length === 0) { + throw new TypeError('Subscription obligations require their authorized constituents'); + } + if ( + !sameMembers(mediaBuyIds, [...new Set(constituents.map(value => value.mediaBuyId).filter(Boolean) as string[])]) + ) { + throw new Error('Subscription obligation media buys must equal its constituent denominator'); + } + const listedProducts = listedReportingSourceProductIdsV1(offering.applicability); + if (listedProducts !== undefined && constituents.some(value => !listedProducts.includes(value.productId))) { + throw new Error('Reporting constituent product is outside its offering applicability'); + } + if (constituents.some(value => !offering.applicability.constituentKinds.includes(value.constituentKind))) { + throw new Error('Reporting constituent kind is outside its offering applicability'); + } + const periodStart = new Date(start).toISOString(); + const periodEnd = new Date(end).toISOString(); + const periodOrdinal = reportingLedgerOwedSetOrdinalV1(periodStart); + // `reporting-schedule.json`: expected_at is period.end + delivery_sla, and the offering is + // authoritative for timing. + const expectedOffset = configuration.schedule.deliverySlaMilliseconds; + const expectedAt = end + expectedOffset; + const recoveryDeadlineAt = expectedAt + configuration.schedule.recoveryWindowMilliseconds; + const semantic = { + configurationId: configuration.configurationId, + periodOrdinal, + period: { start: periodStart, end: periodEnd }, + constituents, + mediaBuyIds, + coverageRequirement: 'full', + }; + return { + reporting_obligation_id: `robl_${digest(semantic).slice(0, 32)}`, + configurationId: configuration.configurationId, + account: structuredClone(configuration.account), + sourceScope: structuredClone(configuration.sourceScope), + delivery_config_id: configuration.delivery_config_id, + delivery_config_version: configuration.delivery_config_version, + offeringId: configuration.offeringId, + report_definition_id: configuration.report_definition_id, + feedPurpose: configuration.feedPurpose, + requiredFinality: configuration.requiredFinality, + ...(configuration.finalityPolicy ? { finalityPolicy: structuredClone(configuration.finalityPolicy) } : {}), + ...(configuration.canonicalization ? { canonicalization: structuredClone(configuration.canonicalization) } : {}), + periodOrdinal, + period: { start: periodStart, end: periodEnd, sourceTimezone: configuration.sourceTimezone }, + schedule: structuredClone(configuration.schedule), + // Each obligation freezes media_buy_ids at its own scope_resolved_at, its period end. + scopeResolvedAt: periodEnd, + coverage: { + status: 'full', + evaluatedAt: periodEnd, + mediaBuyIds: [...mediaBuyIds], + fullyCoveredMediaBuyIds: [...mediaBuyIds], + partiallyCoveredMediaBuyIds: [], + unsupportedMediaBuyIds: [], + unknownMediaBuyIds: [], + }, + requestedMetrics: [...configuration.requestedMetrics], + requestedDimensions: [...configuration.requestedDimensions], + constituents, + mediaBuyIds, + sourceSettings: structuredClone(configuration.sourceSettings), + contract: structuredClone(configuration.contract), + expectedAt: new Date(expectedAt).toISOString(), + recoveryDeadlineAt: new Date(recoveryDeadlineAt).toISOString(), + publicationOffsets: [...new Set(configuration.schedule.restatementMilliseconds ?? [])] + .filter(value => Number.isSafeInteger(value) && value > expectedOffset) + .sort((left, right) => left - right), + nextAttemptAt: new Date(expectedAt).toISOString(), + attemptCount: 0, + state: 'pending', + semanticFingerprint: prefixedDigest(semantic), + createdAt, + subscription: structuredClone(configuration.subscription!), + }; +} + +/** + * `core/reporting-subscription.json`: every delivered row of a subscription carries `account_id`, + * because one destination may receive several accounts. It is stamped before the revision is + * bound, so the committed digest covers it; a row naming another account is refused. Account + * obligations' rows are returned unchanged. + */ +function withSubscriptionAccountId( + obligation: ReportingLedgerObligationV1, + rows: Record[] +): Record[] { + if (!obligation.subscription) return rows; + const accountId = obligation.account.account_id; + return rows.map(row => { + if (row.account_id === undefined) return { account_id: accountId, ...row }; + if (row.account_id !== accountId) { + throw new ReportingSourceConfigurationError('Reporting source row names a different account_id'); + } + return row; + }); +} + +function boundedId(value: unknown, field: string): void { + if (typeof value !== 'string' || value.length === 0 || value.length > 255) { + throw new TypeError(`${field} must be a bounded non-empty string`); + } +} + function sourceRequest( obligation: ReportingLedgerObligationV1, offering: ReportingSourceOfferingV1, @@ -1591,7 +1987,8 @@ function assertScheduleIdentityMatchesBoundaries( function validateConfigurationAgainstOffering( configuration: Omit, - offering: ReportingSourceOfferingV1 + offering: ReportingSourceOfferingV1, + ownership: 'grid' | 'owed_set' = 'grid' ): void { // Refuse the reserved capability before any other validation so the buyer // gets UNSUPPORTED_FEATURE rather than an incidental complaint about a @@ -1614,17 +2011,31 @@ function validateConfigurationAgainstOffering( if (offering.publicationClass === 'AUTHORITATIVE' && configuration.requiredFinality !== 'official') { throw new Error('Authoritative source offerings require official ledger finality'); } - positiveInteger(configuration.schedule.periodMilliseconds, 'periodMilliseconds'); - if (configuration.schedule.periodMilliseconds % 1_000 !== 0) { - throw new Error('Reporting period must be representable as whole ISO 8601 seconds'); - } - const minimumWindow = reportingIsoDurationMillisecondsV1(offering.windowing.minimumWindow); - const maximumWindow = reportingIsoDurationMillisecondsV1(offering.windowing.maximumWindow); - if ( - configuration.schedule.periodMilliseconds < minimumWindow || - configuration.schedule.periodMilliseconds > maximumWindow - ) { - throw new Error('Reporting period is outside its offering window bounds'); + if (ownership === 'owed_set') { + // An owed set takes every period from the engine, so its schedule is the offering's + // published identity (echoed on the wire), never a grid this ledger generates. Calendar + // periods (P1M, account_timezone, billing_cycle) are representable here. + nonnegativeInteger(configuration.schedule.periodMilliseconds, 'periodMilliseconds'); + if ( + typeof configuration.schedule.periodDuration !== 'string' || + configuration.schedule.alignment === undefined || + !['utc', 'account_timezone', 'source_timezone', 'billing_cycle'].includes(configuration.schedule.alignment) + ) { + throw new Error('Subscription generations require the offering schedule identity'); + } + } else { + positiveInteger(configuration.schedule.periodMilliseconds, 'periodMilliseconds'); + if (configuration.schedule.periodMilliseconds % 1_000 !== 0) { + throw new Error('Reporting period must be representable as whole ISO 8601 seconds'); + } + const minimumWindow = reportingIsoDurationMillisecondsV1(offering.windowing.minimumWindow); + const maximumWindow = reportingIsoDurationMillisecondsV1(offering.windowing.maximumWindow); + if ( + configuration.schedule.periodMilliseconds < minimumWindow || + configuration.schedule.periodMilliseconds > maximumWindow + ) { + throw new Error('Reporting period is outside its offering window bounds'); + } } nonnegativeInteger(configuration.schedule.deliverySlaMilliseconds, 'deliverySlaMilliseconds'); if (configuration.schedule.deliverySlaMilliseconds % 1_000 !== 0) { @@ -1640,13 +2051,24 @@ function validateConfigurationAgainstOffering( for (const offset of configuration.schedule.restatementMilliseconds ?? []) { nonnegativeInteger(offset, 'restatementMilliseconds'); } - assertScheduleIdentityMatchesBoundaries(configuration.schedule, configuration.sourceTimezone); - if (isReportingCalendarDay(configuration.schedule, configuration.sourceTimezone)) { - assertReportingCalendarDaySource(configuration.schedule, configuration.sourceTimezone, offering, Date.now()); - } - const anchor = instant(configuration.schedule.anchor, 'schedule.anchor'); - if (configuration.supersededAt && instant(configuration.supersededAt, 'supersededAt') <= anchor) { - throw new Error('Reporting configuration supersession must follow its schedule anchor'); + if (ownership === 'owed_set') { + if ( + configuration.schedule.deliverySlaDuration !== undefined && + identityDurationMilliseconds(configuration.schedule.deliverySlaDuration) !== + configuration.schedule.deliverySlaMilliseconds + ) { + throw new Error('Reporting deliverySlaDuration does not describe the offset its obligations expect'); + } + instant(configuration.schedule.anchor, 'schedule.anchor'); + } else { + assertScheduleIdentityMatchesBoundaries(configuration.schedule, configuration.sourceTimezone); + if (isReportingCalendarDay(configuration.schedule, configuration.sourceTimezone)) { + assertReportingCalendarDaySource(configuration.schedule, configuration.sourceTimezone, offering, Date.now()); + } + const anchor = instant(configuration.schedule.anchor, 'schedule.anchor'); + if (configuration.supersededAt && instant(configuration.supersededAt, 'supersededAt') <= anchor) { + throw new Error('Reporting configuration supersession must follow its schedule anchor'); + } } if (configuration.requiredFinality === 'official') { if (!configuration.finalityPolicy || !/^[A-Za-z0-9_.:-]{1,255}$/.test(configuration.finalityPolicy.policyId)) { diff --git a/src/lib/reporting/ledger/receipts.ts b/src/lib/reporting/ledger/receipts.ts index 819a4ac5a..eea524fe4 100644 --- a/src/lib/reporting/ledger/receipts.ts +++ b/src/lib/reporting/ledger/receipts.ts @@ -5,7 +5,11 @@ import { canonicalize } from '../../utils/jcs'; import { isReportingAdjustmentReceiptEvidence, isReportingReceiptEvidence } from '../evidence'; import type { AdcpToolMap } from '../../server/create-adcp-server'; import { AdcpError } from '../../server/decisioning/async-outcome'; -import type { ReportingManagedDeliveryBindingV1 } from './types'; +import type { + ReportingLedgerSubscriptionPrincipalV1, + ReportingManagedDeliveryBindingV1, + ReportingSubscriptionReceiptAdmissionV1, +} from './types'; /** RC3 `maxItems` on `receipts` and on `adjustment_receipts`, applied separately. */ const MAX_RECEIPTS_PER_ARRAY = 100; @@ -31,6 +35,13 @@ export interface ReportingReceiptBatchInputV1 { request_fingerprint: string; entries: ReportingReceiptBatchEntryV1[]; received_at: string; + /** + * Who may file against which obligation (experimental, AdCP 3.3). A store that sets + * `receiptAdmission` applies `reportingSubscriptionReceiptAdmissibleV1` to every entry after + * resolving its obligation and revision, and answers a refused entry exactly like an unknown + * reference. Absent when subscriptions are off. + */ + admission?: ReportingSubscriptionReceiptAdmissionV1; } /** The `sync_reporting_receipts` request as the server framework hands it to a handler. */ @@ -45,16 +56,37 @@ export type SyncReportingReceiptsParamsV1 = AdcpToolMap['sync_reporting_receipts */ export interface ReportingReceiptSyncStoreV1 { syncReceiptBatch(input: ReportingReceiptBatchInputV1): Promise; + /** True when the store enforces `ReportingReceiptBatchInputV1.admission`. */ + readonly receiptAdmission?: true; } -/** Protocol handler for `sync_reporting_receipts`. Works against any `ReportingReceiptSyncStoreV1`. */ +/** + * Protocol handler for `sync_reporting_receipts`. Works against any `ReportingReceiptSyncStoreV1`. + * + * With `resolveSubscriptionPrincipal` (experimental, AdCP 3.3), a subscription obligation accepts + * receipts only from its owning principal; see `reportingSubscriptionReceiptAdmissibleV1`. The + * host's account resolution and `allowed_tasks` still decide who reaches the handler at all. + */ export function createSyncReportingReceiptsHandler( store: ReportingReceiptSyncStoreV1, resolveConsumerId: (context: TContext) => string | Promise, - now: () => Date = () => new Date() + now: () => Date = () => new Date(), + options: { + resolveSubscriptionPrincipal?: ( + context: TContext + ) => ReportingLedgerSubscriptionPrincipalV1 | Promise; + } = {} ) { + if (options.resolveSubscriptionPrincipal && store.receiptAdmission !== true) { + throw new TypeError('Scoping receipts to subscription owners requires a store that enforces receipt admission'); + } return async (request: SyncReportingReceiptsParamsV1, context: TContext): Promise => { const account_id = resolvedAccountId(context.account); + let admission: ReportingSubscriptionReceiptAdmissionV1 | undefined; + if (options.resolveSubscriptionPrincipal) { + const resolved = await options.resolveSubscriptionPrincipal(context); + admission = { principal: { tenantId: resolved.tenantId, principalId: resolved.principalId } }; + } const consumer_id = await resolveConsumerId(context); if (!consumer_id || consumer_id.length > 255) { throw new TypeError('resolveConsumerId must return a durable authenticated principal of at most 255 characters'); @@ -182,6 +214,7 @@ export function createSyncReportingReceiptsHandler { resolveConsumerId(context: TContext): string | Promise; now?: () => Date; clockSkewMilliseconds?: number; + /** + * The authenticated principal whose subscription generations this caller may report on + * (experimental, AdCP 3.3). Without it, statuses naming a subscription generation are + * ineligible, exactly like an unknown generation. + */ + resolveSubscriptionPrincipal?: ReportingSubscriptionPrincipalResolverV1; } type ReportingStatusValidationReason = @@ -254,8 +269,14 @@ export function createSyncReportingStatusHandler( ) { throw new RangeError('clockSkewMilliseconds must be a safe integer between 0 and 3600000'); } + const subscriptionPrincipal = await resolveReportingSubscriptionPrincipalV1( + options.resolveSubscriptionPrincipal, + context + ); + // Another principal's subscription generations are invisible here, exactly as in reads. const configurations = (await store.listConfigurations(accountId)).filter( - value => value?.account?.account_id === accountId + value => + value?.account?.account_id === accountId && reportingLedgerSubscriptionVisibleV1(value, subscriptionPrincipal) ); try { const entries: ReportingConsumerStatusBatchEntryV1[] = []; @@ -284,7 +305,16 @@ export function createSyncReportingStatusHandler( let validationError: string | undefined; let validationField: string | undefined; try { - await validateStatus(store, configurations, accountId, consumerId, status, now, clockSkewMilliseconds); + await validateStatus( + store, + configurations, + accountId, + consumerId, + status, + now, + clockSkewMilliseconds, + subscriptionPrincipal + ); } catch (error) { if (!(error instanceof ReportingStatusValidationError)) throw error; const diagnostic = reportingStatusValidationDiagnostic(error.reason, index); @@ -330,25 +360,31 @@ async function validateStatus( consumerId: string, status: ReportingConsumerStatusV1, now: Date, - clockSkewMilliseconds = 5 * 60_000 + clockSkewMilliseconds = 5 * 60_000, + subscriptionPrincipal?: ReportingLedgerSubscriptionPrincipalV1 ): Promise { if (compareReportingInstantToOffset(status.status_as_of, now.toISOString(), clockSkewMilliseconds) > 0) { throw new ReportingStatusValidationError('future status'); } - const configuration = configurations.find( + // One account generation per (id, version), but a subscription version may own one + // generation per resolved offering, so several can share the chain identity. + const candidates = configurations.filter( value => value.delivery_config_id === status.delivery_config_id && value.delivery_config_version === status.delivery_config_version && - value.report_definition_id === status.report_definition_id + value.report_definition_id === status.report_definition_id && + value.account.account_id === accountId ); - if (!configuration || configuration.account.account_id !== accountId) { - throw new ReportingStatusValidationError('ineligible period'); - } + if (candidates.length === 0) throw new ReportingStatusValidationError('ineligible period'); + let configuration: ReportingLedgerConfigurationV1 | undefined = candidates[0]; let obligation: ReportingLedgerObligationV1 | null = null; if (status.reporting_obligation_id) { obligation = await store.getObligation(status.reporting_obligation_id, accountId); + configuration = candidates.find(value => value.configurationId === obligation?.configurationId) ?? configuration; if ( !obligation || + !configuration || + !reportingLedgerSubscriptionVisibleV1(obligation, subscriptionPrincipal) || obligation.configurationId !== configuration.configurationId || obligation.account.account_id !== accountId || obligation.delivery_config_id !== status.delivery_config_id || @@ -359,8 +395,19 @@ async function validateStatus( obligation.period.sourceTimezone !== status.period.source_timezone ) throw new ReportingStatusValidationError('obligation mismatch'); - } else if (!isExactPeriod(configuration, configurations, status.period)) { - throw new ReportingStatusValidationError('ineligible period'); + } else { + configuration = undefined; + for (const candidate of candidates) { + if ( + isReportingLedgerOwedSetV1(candidate) + ? await isOwedPeriod(store, candidate, accountId, status.period) + : isExactPeriod(candidate, configurations, status.period) + ) { + configuration = candidate; + break; + } + } + if (!configuration) throw new ReportingStatusValidationError('ineligible period'); } // Consumer absence is legal only at or after the protocol expected_at, // which is period.end + delivery_sla for every finality. @@ -450,6 +497,8 @@ async function validateStatus( if ( page.snapshot.query.account_id !== accountId || page.snapshot.query.consumer_id !== consumerId || + page.snapshot.query.subscription_principal?.tenantId !== subscriptionPrincipal?.tenantId || + page.snapshot.query.subscription_principal?.principalId !== subscriptionPrincipal?.principalId || page.snapshot.ledgerAsOf !== status.seller_ledger_as_of || !configurationInScope || !periodInScope || @@ -461,6 +510,37 @@ async function validateStatus( } } +/** + * An owed-set generation owns only the obligations written into it, so a period is eligible + * exactly when it names an existing owed obligation, never a slot of a grid. + */ +async function isOwedPeriod( + store: ReportingConsumerStatusLedgerStore, + configuration: ReportingLedgerConfigurationV1, + accountId: string, + period: ReportingConsumerStatusV1['period'] +): Promise { + if (period.source_timezone !== configuration.sourceTimezone) return false; + const periodOrdinal = reportingLedgerOwedSetOrdinalV1(period.start); + const lookup = { account_id: accountId, configurationId: configuration.configurationId, periodOrdinal }; + const listObligations = (store as Partial>).listObligations; + const obligation = store.findObligationByOrdinal + ? await store.findObligationByOrdinal(lookup) + : listObligations + ? ((await listObligations.call(store, accountId)).find( + value => value.configurationId === configuration.configurationId && value.periodOrdinal === periodOrdinal + ) ?? null) + : null; + return ( + obligation !== null && + obligation.account.account_id === accountId && + obligation.configurationId === configuration.configurationId && + compareReportingInstants(obligation.period.start, period.start) === 0 && + compareReportingInstants(obligation.period.end, period.end) === 0 && + obligation.period.sourceTimezone === period.source_timezone + ); +} + function isExactPeriod( configuration: ReportingLedgerConfigurationV1, configurations: ReportingLedgerConfigurationV1[], diff --git a/src/lib/reporting/ledger/types.ts b/src/lib/reporting/ledger/types.ts index e8b33162d..b7f797f47 100644 --- a/src/lib/reporting/ledger/types.ts +++ b/src/lib/reporting/ledger/types.ts @@ -140,6 +140,110 @@ export interface ReportingLedgerConfigurationV1 { historyStartOrdinal?: number; supersededAt?: string; semanticFingerprint: string; + /** + * Present only on a generation materialized from a principal-level reporting subscription + * (experimental, AdCP 3.3). Immutable and part of the semantic fingerprint. See + * {@link ReportingLedgerSubscriptionV1}. + */ + subscription?: ReportingLedgerSubscriptionV1; + /** + * Mutable owed-set progress of a subscription generation. Never part of the semantic + * fingerprint, the same way `historyStart` is not. See {@link ReportingLedgerOwedSetV1}. + */ + owedSet?: ReportingLedgerOwedSetV1; +} + +/** + * Owner and identity of a ledger generation materialized from a principal-level reporting + * subscription (experimental `media_buy.reporting_subscriptions`, AdCP 3.3). + * + * One generation exists per (account, owning principal, `subscription_id`, + * `subscription_version`, resolved `offering_id`). Its `delivery_config_id` is the + * `subscription_id` and its `delivery_config_version` is the `subscription_version`, as + * `get-reporting-status-response.json` `subscription_coverage` requires. Subscription ids are + * unique per principal, not per account, so the owner is part of the identity: two principals + * may both own `analytics-daily` on one account. + * + * `ownership: 'owed_set'` means the generation owns exactly the obligations written into it + * (the seller's frozen owed set), never a contiguous grid of periods. Its obligations carry + * their own period, frozen `media_buy_ids` and constituents, and `periodOrdinal` is + * `floor(period_start_ms / 1000)`. Periods of one (version, offering) never overlap, even + * across a timezone cutover, because the cutover rule in `core/reporting-subscription.json` + * hands periods from the outgoing calendar to the incoming one at the end of the last + * outgoing period, so the start second is a unique key. + * + * Only the owner can see such a generation and its obligations: a snapshot or status read + * names the caller's principal in `subscription_principal`, and stores return account + * generations plus that principal's subscription generations only. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingLedgerSubscriptionV1 { + tenantId: string; + principalId: string; + /** Equals the generation's `delivery_config_id`. */ + subscriptionId: string; + /** Equals the generation's `offeringId`. */ + offeringId: string; + ownership: 'owed_set'; +} + +/** + * Mutable completeness horizon of an owed-set generation. + * + * The planner advances it only after every owed obligation ending at or before `through` is + * durable, so a snapshot is complete for the generation up to `through` by construction and a + * missing period is simply not owed. `from`, `until` and `mediaBuyIds` summarize the written + * obligations (earliest start, latest end, union of frozen `media_buy_ids`) for scope matching; + * they only ever widen. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingLedgerOwedSetV1 { + /** Every owed obligation that ends at or before this instant is written. Absent until the first advance. */ + through?: string; + from?: string; + until?: string; + mediaBuyIds: string[]; +} + +/** + * One owed-set progress update for {@link ReportingLedgerStore.recordOwedSetProgress}. Stores merge + * it monotonically: `through` and `until` only move later, `from` only earlier, and `mediaBuyIds` + * is a union. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingLedgerOwedSetProgressV1 { + configurationId: string; + account_id: string; + through?: string; + from?: string; + until?: string; + mediaBuyIds?: string[]; +} + +/** + * The authenticated principal a subscription-aware read is scoped to. Never derived from request + * content. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingLedgerSubscriptionPrincipalV1 { + tenantId: string; + principalId: string; +} + +/** + * Who may file a receipt or status against an obligation (experimental, AdCP 3.3): the + * authenticated principal. Subscription obligations accept only their owner; account obligations + * accept any consumer of the account, as before. It applies on top of the host's ordinary account + * resolution and `allowed_tasks` checks, never instead of them. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionReceiptAdmissionV1 { + principal: ReportingLedgerSubscriptionPrincipalV1; } export interface ReportingLedgerObligationV1 { @@ -179,6 +283,8 @@ export interface ReportingLedgerObligationV1 { state: 'pending' | 'terminal'; semanticFingerprint: string; createdAt: string; + /** Copied from a subscription generation, so ownership is checkable from the obligation alone. */ + subscription?: ReportingLedgerSubscriptionV1; } export interface ReportingRevisionBindingV1 { @@ -562,6 +668,12 @@ export interface ReportingLedgerNotificationActivityPortV1): void | Promise; } @@ -569,6 +681,13 @@ export interface ReportingLedgerSnapshotQueryV1 { account_id: string; /** Authenticated consumer scope used only for caller-attributed status readback. */ consumer_id?: string; + /** + * Authenticated principal whose subscription generations this read may see. Stores return + * account generations (no `subscription`) plus subscription generations and obligations this + * principal owns, and nothing of another principal's. Absent means no subscription generation + * is visible. + */ + subscription_principal?: ReportingLedgerSubscriptionPrincipalV1; view: 'summary' | 'periods' | 'revision'; media_buy_ids?: string[]; delivery_config_ids?: string[]; @@ -661,6 +780,12 @@ export interface ReportingManagedDeliveryBindingV1 { verification_profile: NonNullable['verification_profile']; reconciliation_mode: 'delivery_only' | 'consumer_receipt'; resource_retention_days: number; + /** + * A subscription's `destination.path_template` (experimental, AdCP 3.3), frozen with the + * binding. Each delivery expands it per obligation into a relative path under the destination. + * Absent on account bindings, whose fingerprint it therefore never changes. + */ + path_template?: string; created_at: string; semantic_fingerprint: string; } @@ -838,6 +963,38 @@ export class ReportingLedgerContinuityError extends Error { } } +/** + * `installConfiguration` was asked to install an existing `(delivery_config_id, + * delivery_config_version)` generation with different semantic content. A generation is + * immutable: install a higher `delivery_config_version` instead. + * + * The message is unchanged from earlier releases, so hosts that matched on it keep working; + * match on `code` (or `instanceof`, or `name` across SDK copies) instead. + */ +export class ReportingConfigurationImmutableError extends Error { + readonly code = 'REPORTING_CONFIGURATION_IMMUTABLE' as const; + readonly accountId: string; + readonly deliveryConfigId: string; + readonly deliveryConfigVersion: number; + constructor(details: { accountId: string; deliveryConfigId: string; deliveryConfigVersion: number }) { + super('Reporting configuration generation is immutable'); + this.name = 'ReportingConfigurationImmutableError'; + this.accountId = details.accountId; + this.deliveryConfigId = details.deliveryConfigId; + this.deliveryConfigVersion = details.deliveryConfigVersion; + } +} + +/** Whether an error is a {@link ReportingConfigurationImmutableError}, including one from another SDK copy. */ +export function isReportingConfigurationImmutableError(error: unknown): error is ReportingConfigurationImmutableError { + return ( + error instanceof ReportingConfigurationImmutableError || + (error instanceof Error && + error.name === 'ReportingConfigurationImmutableError' && + (error as { code?: unknown }).code === 'REPORTING_CONFIGURATION_IMMUTABLE') + ); +} + export class ReportingLedgerSnapshotUnavailableError extends Error { constructor() { super('Reporting ledger snapshot is unavailable'); @@ -946,10 +1103,28 @@ export interface ReportingLedgerStore { historyStart: string; historyStartOrdinal: number; }): Promise; + /** + * Merge owed-set progress into a subscription generation's mutable `owedSet`, under the same + * account fence as generation writes, and return the stored generation. Refuse an unknown + * configuration, another account's, and a generation without `subscription`. Never changes the + * fingerprint or any immutable field. + * + * Optional: a store that implements it also accepts subscription generations (keyed by + * account, `delivery_config_id`, `delivery_config_version` and + * {@link reportingLedgerSubscriptionKeyV1}) and honors `subscription_principal` on every + * snapshot. The producer refuses to plan subscription obligations into a store without it. + */ + recordOwedSetProgress?(input: ReportingLedgerOwedSetProgressV1): Promise; putObligation( obligation: ReportingLedgerObligationV1 ): Promise<{ inserted: boolean; value: ReportingLedgerObligationV1 }>; getObligation(reporting_obligation_id: string, account_id?: string): Promise; + /** Optional point lookup by `(configurationId, periodOrdinal)`, scoped to the account. */ + findObligationByOrdinal?(input: { + account_id: string; + configurationId: string; + periodOrdinal: number; + }): Promise; listObligations(account_id?: string): Promise; /** * Optional: periods whose ledger records were retired by retention. The @@ -1147,6 +1322,17 @@ export interface ReportingLedgerStore { export interface ReportingConsumerStatusLedgerStore { listConfigurations(account_id: string): Promise; getObligation(reporting_obligation_id: string, account_id: string): Promise; + /** + * Point lookup of an obligation by `(configurationId, periodOrdinal)`. A status without + * `reporting_obligation_id` against an owed-set (subscription) generation is accepted only when + * it names an existing owed obligation, never a bare grid slot; without this (or + * `listObligations`) such a status is rejected. + */ + findObligationByOrdinal?(input: { + account_id: string; + configurationId: string; + periodOrdinal: number; + }): Promise; /** Loads revision identity and binding for ingest validation without materializing rows. */ getRevisionMetadata( reporting_revision_id: string, @@ -1189,6 +1375,72 @@ export interface ReportingProducerContactV1 { url?: string; } +/** + * One subscription generation to install: everything a configuration carries except the + * identity, install metadata, mutable owed-set progress and the per-obligation denominator. + * `subscription` is required, and `delivery_config_id`/`offeringId` must equal its + * `subscriptionId`/`offeringId`. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionConfigurationInputV1 = Omit< + ReportingLedgerConfigurationV1, + | 'configurationId' + | 'installedAt' + | 'semanticFingerprint' + | 'calendarRules' + | 'historyStart' + | 'historyStartOrdinal' + | 'owedSet' + | 'constituents' + | 'mediaBuyIds' + | 'subscription' +> & { subscription: ReportingLedgerSubscriptionV1 }; + +/** + * One owed obligation of a subscription, as the engine froze it. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionLedgerObligationInputV1 { + /** The `subscription_version` that owes it. */ + delivery_config_version: number; + offeringId: string; + period: { start: string; end: string }; + /** Frozen at the period end (`scope_resolved_at`). Must equal the constituents' media buys. */ + mediaBuyIds: string[]; + constituents: ReportingSourceSliceRequestV1['coverage']['constituents']; +} + +/** + * Input of {@link ReportingProducerV1.planSubscriptionObligations}: one subscription of one + * principal on one account. + * + * @experimental Reliable Reporting storage surface: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionLedgerPlanV1 { + account_id: string; + /** The principal that owns the subscription. */ + principal: ReportingLedgerSubscriptionPrincipalV1; + subscriptionId: string; + /** One per (version, offering) the obligations name, each naming this account, owner and subscription. */ + configurations: ReportingSubscriptionConfigurationInputV1[]; + obligations: ReportingSubscriptionLedgerObligationInputV1[]; + /** + * The engine's frozen horizon: every owed period of this subscription on the account that ends + * at or before it is in `obligations` or already written. Advanced on every generation the + * subscription owns on the account, after the obligations are durable. + */ + owedThrough: string; + now?: string; + /** + * Called for each generation about to receive its first obligations, after it is installed and + * before any obligation is written: a managed binding must exist before the first obligation. + * A rejection writes nothing for the plan, and the hook runs again on the next plan. + */ + bindGeneration?(configuration: ReportingLedgerConfigurationV1): Promise; +} + export interface ReportingProducerV1 { installConfiguration( input: Omit< @@ -1199,6 +1451,8 @@ export interface ReportingProducerV1 { | 'calendarRules' | 'historyStart' | 'historyStartOrdinal' + | 'subscription' + | 'owedSet' >, options?: { historyStart?: string } ): Promise; @@ -1211,6 +1465,19 @@ export interface ReportingProducerV1 { now?: string, options?: { account_id?: string; maxObligations?: number; maxHistoryObligationsPerSource?: number } ): Promise; + /** + * Write one subscription's frozen owed set into the ledger (experimental, AdCP 3.3). + * + * Installs each (version, offering) generation once, idempotently and immutably (a changed + * input for an existing generation throws `ReportingConfigurationImmutableError`), writes every + * obligation with its own period, frozen `media_buy_ids` and constituents, and then advances + * `owedSet.through` to `owedThrough` on every generation this subscription owns on the account. + * Obligations are never grid-filled. Every obligation must end at or before `owedThrough`, and + * `owedThrough` must not be in the future. Requires a store with `recordOwedSetProgress`. + */ + planSubscriptionObligations( + input: ReportingSubscriptionLedgerPlanV1 + ): Promise<{ configurations: ReportingLedgerConfigurationV1[]; created: ReportingLedgerObligationV1[] }>; runWorker(options?: { signal?: AbortSignal; now?: () => Date; @@ -1277,6 +1544,14 @@ export interface CreateReportingDeliveryHandlerOptionsV1 { * propagates unchanged. */ legacyRevisionReader?: ReportingLegacyRevisionReaderV1; + /** + * The authenticated principal whose subscription revisions this caller may read + * (experimental, AdCP 3.3). A revision of another principal's subscription obligation, or of + * any subscription obligation when this is absent, is `REFERENCE_NOT_FOUND`. + */ + resolveSubscriptionPrincipal?: ( + context: HandlerContext + ) => ReportingLedgerSubscriptionPrincipalV1 | Promise; } export interface CreateReportingProducerOptionsV1 { diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 8ec467bc8..e4eb24390 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -18,6 +18,7 @@ import type { RequestContext } from '../../server/decisioning/context'; import type { DecisioningPlatform } from '../../server/decisioning/platform'; import type { ReliableReportingPlatform } from '../../server/decisioning/specialisms/reporting'; import { scanArgsForCredentials } from '../../server/credential-policy'; +import { PlatformConfigError } from '../../server/decisioning/runtime/validate-platform'; import { canonicalize } from '../../utils/jcs'; import type { ReportingDeliveryCapabilities, ReportingDeliveryOffering } from '../../types/tools.generated'; import { ReportingDeliveryOfferingSchema } from '../../types/schemas.generated'; @@ -38,6 +39,8 @@ import { type ReportingProducerContactV1, type ReportingProducerV1, type ReportingSourceWithReaderV1, + type ReportingSubscriptionConfigurationInputV1, + type ReportingSubscriptionLedgerObligationInputV1, } from '../ledger'; import { reportReportingSchedulerError, @@ -64,6 +67,12 @@ import { type ReportingSourceOfferingV1, } from '../source'; +import type { ReportingSubscriptionFrozenOwedSetV1 } from '../subscriptions/engine'; +import type { PrincipalStoreScope } from '../../server/principal/types'; +import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; +import type { ReportingDeliveryOfferingWithDimensionsV1 } from '../subscriptions/wire'; +import { type ReliableReportingSubscriptionsOptionsV1, withReportingSubscriptionCoverageV1 } from './subscriptions'; + const ADAPTER_SCOPE_KEY = '_adcp_reporting_adapter'; /** The intentionally small provider boundary: one bounded slice fetch plus immutable metadata. */ @@ -117,6 +126,8 @@ type LedgerInstallInput = Omit< | 'calendarRules' | 'historyStart' | 'historyStartOrdinal' + | 'subscription' + | 'owedSet' | 'account' | 'sourceScope' | 'sourceTimezone' @@ -272,6 +283,16 @@ export interface CreateReliableReportingServiceOptionsV1; } /** @@ -523,10 +544,76 @@ export function createReliableReportingService => maintenance!.run({ signal, ...(report ? { report } : {}) }); + const subscriptionOptions = options.reportingSubscriptions; + if (subscriptionOptions !== undefined) { + if ( + typeof subscriptionOptions.engine?.subscriptionCoverage !== 'function' || + typeof subscriptionOptions.engine.frozenOwedSets !== 'function' + ) { + throw new PlatformConfigError( + 'reportingSubscriptions requires a reporting subscription engine. Pass engine: createReportingSubscriptionEngineV1(...) from @adcp/sdk/reporting/advanced.', + { configField: 'reportingSubscriptions.engine' } + ); + } + if (typeof subscriptionOptions.resolvePrincipal !== 'function') { + throw new PlatformConfigError( + "reportingSubscriptions requires resolvePrincipal. Pass the principal runtime's authenticated mapping.", + { configField: 'reportingSubscriptions.resolvePrincipal' } + ); + } + // A seller that advertises subscriptions owes their obligations: the service writes them into + // the ledger every cycle, so it must know whose to write. + if (typeof subscriptionOptions.listSubscriptionPrincipals !== 'function') { + throw new PlatformConfigError( + 'reportingSubscriptions requires listSubscriptionPrincipals. Return engine.listPrincipals(tenantId) across your tenants.', + { configField: 'reportingSubscriptions.listSubscriptionPrincipals' } + ); + } + if (typeof subscriptionOptions.resolveSubscriptionConfiguration !== 'function') { + throw new PlatformConfigError( + 'reportingSubscriptions requires resolveSubscriptionConfiguration. Return the metrics, dimensions and source settings for each subscription generation.', + { configField: 'reportingSubscriptions.resolveSubscriptionConfiguration' } + ); + } + // Sellers advertising subscriptions MUST publish standard_dimensions on every offering + // (adcontextprotocol/adcp#8585), so buyers resolve match from the wire as the engine does. + for (const offering of deliveryOfferings as ReportingDeliveryOfferingWithDimensionsV1[]) { + if (!Array.isArray(offering.standard_dimensions)) { + throw new PlatformConfigError( + `Reporting offering ${JSON.stringify(offering.offering_id)} must publish standard_dimensions when ` + + 'reporting subscriptions are advertised. Publish standard_dimensions (empty for totals-only) on the offering.', + { code: 'SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS', configField: 'adapters' } + ); + } + } + } + // Without subscriptions this is exactly the pre-3.3 shape. + const registration = subscriptionOptions + ? reportingConfigurationCapabilitiesV1({ + subscriptions: { + ...(subscriptionOptions.engine.settings.maxAccounts !== undefined + ? { maxAccounts: subscriptionOptions.engine.settings.maxAccounts } + : {}), + backfillOnRegrant: subscriptionOptions.engine.settings.backfillOnRegrant, + ...(subscriptionOptions.dimensions ? { dimensions: subscriptionOptions.dimensions } : {}), + }, + ...(subscriptionOptions.accountConfigurations !== undefined + ? { accountConfigurations: subscriptionOptions.accountConfigurations } + : {}), + }) + : ({ configuration_task: 'sync_accounts' } as ReturnType); + if (subscriptionOptions && subscriptionOptions.engine.settings.statusRetentionDays !== options.statusRetentionDays) { + throw new PlatformConfigError( + 'The reporting subscription engine must use the statusRetentionDays this service advertises. Pass the same statusRetentionDays to both.', + { configField: 'statusRetentionDays' } + ); + } + const servesAccountConfigurations = registration.configuration_task === 'sync_accounts'; + const capabilities = deepFreeze({ supported: true as const, reliable_reporting_version: '1.0' as const, - configuration_task: 'sync_accounts' as const, + ...registration, status_task: 'get_reporting_status' as const, ...(hasConsumerStatus ? { consumer_status_task: 'sync_reporting_status' as const } : {}), revision_content_task: 'get_media_buy_delivery' as const, @@ -538,27 +625,46 @@ export function createReliableReportingService { + const principal = await subscriptionOptions.resolvePrincipal(context as RequestContext>); + return { tenantId: principal.tenantId, principalId: principal.principalId }; + }, + } + : undefined; const getReportingStatus = options.resolveConsumerId ? createReportingStatusHandler(options.store, { resolveConsumerId: options.resolveConsumerId, ...(effectiveConsumerMismatchEscalation ? { consumerMismatchEscalation: effectiveConsumerMismatchEscalation } : {}), + ...(subscriptionScope ?? {}), }) - : createReportingStatusHandler(options.store); - const getMediaBuyDelivery = createReportingDeliveryHandler( - options.store, - options.legacyRevisionReader ? { legacyRevisionReader: options.legacyRevisionReader } : {} - ); + : subscriptionScope + ? createReportingStatusHandler(options.store, subscriptionScope) + : createReportingStatusHandler(options.store); + const getMediaBuyDelivery = createReportingDeliveryHandler(options.store, { + ...(options.legacyRevisionReader ? { legacyRevisionReader: options.legacyRevisionReader } : {}), + ...(subscriptionScope ?? {}), + }); const syncReportingStatus = options.resolveConsumerId ? createSyncReportingStatusHandler(options.store as unknown as ReportingConsumerStatusLedgerStore, { resolveConsumerId: options.resolveConsumerId, + ...(subscriptionScope ?? {}), }) : undefined; const reportingPlatform: ReliableReportingPlatform = { capabilities, - getReportingStatus: (request, context) => getReportingStatus(request, context as never), + getReportingStatus: subscriptionOptions + ? async (request, context) => { + const response = await getReportingStatus(request, context as never); + return withReportingSubscriptionCoverageV1(response, context as never, subscriptionOptions); + } + : (request, context) => getReportingStatus(request, context as never), getMediaBuyDelivery: (request, context) => getMediaBuyDelivery(request, context as never), ...(syncReportingStatus && { syncReportingStatus: (request, context) => syncReportingStatus(request, context), @@ -572,6 +678,336 @@ export function createReliableReportingService | undefined; + /** + * The trusted install path shared by account configurations and subscription generations: + * offering and delivery-offering checks, the host's source route, currency and authorized + * coverage, and the frozen schedule identity. An owed-set (subscription) generation takes every + * period from the engine, so it skips the grid schedule checks and never reuses a keyless + * predecessor scope. + */ + async function prepareGeneration( + configuration: ReliableReportingConfigurationInputV1, + account: Pick, 'id' | 'ctx_metadata'>, + ownership: 'account' | 'owed_set' + ) { + assertNoUntrustedLineageFields(configuration as unknown as Record); + if (Object.prototype.hasOwnProperty.call(configuration.sourceSettings, 'currency')) { + throw new TypeError('Reporting configuration must not supply trusted lineage field sourceSettings.currency'); + } + const frozenInput = structuredClone(configuration); + const offering = offerings.get(configuration.offeringId); + if (!offering) throw new TypeError('Reporting configuration selected an unknown offering'); + const deliveryOffering = deliveryOfferings.find(value => value.offering_id === offering.offeringId)!; + validateConfigurationAgainstDeliveryOffering(frozenInput, deliveryOffering, options.automatedRecoveryWindowSeconds); + const [route, currency, coverage] = await Promise.all([ + options.resolveSource(account, frozenInput), + options.resolveCurrency(account, frozenInput), + options.resolveCoverage(account, frozenInput), + ]); + if (!route || typeof route !== 'object' || !route.sourceScope || typeof route.sourceScope !== 'object') { + throw new TypeError('resolveSource must return an adapter, sourceScope, and sourceTimezone'); + } + const source = sources.get(route.adapterId); + const adapterOffering = adapterOfferings.get(route.adapterId); + if (!source || !adapterOffering) throw new TypeError('resolveSource selected an unknown reporting adapter'); + // The adapter's own validated offering must be the selected one — not + // merely an offering its executor happens to expose. + if (adapterOffering.offeringId !== offering.offeringId) { + throw new TypeError('Resolved reporting adapter does not provide the selected offering'); + } + if (canonicalize(adapterOffering.contract) !== canonicalize(offering.contract)) { + throw new TypeError('Resolved reporting adapter contract differs from the selected offering'); + } + const normalizedCurrency = trustedCurrency(currency); + const normalizedTimezone = trustedTimezone(route.sourceTimezone); + if (configuration.expectedCurrency !== undefined && configuration.expectedCurrency !== normalizedCurrency) { + throw new TypeError('Trusted reporting currency conflicts with the configuration currency assertion'); + } + if ( + configuration.expectedSourceTimezone !== undefined && + configuration.expectedSourceTimezone !== normalizedTimezone + ) { + throw new TypeError('Trusted reporting timezone conflicts with the configuration timezone assertion'); + } + if (ownership === 'account') { + assertSupportedScheduleSemantics(frozenInput.schedule, deliveryOffering, normalizedTimezone, offering); + } + const { constituents, mediaBuyIds } = trustedCoverage(coverage, frozenInput.requestedMetrics); + if ( + configuration.expectedMediaBuyIds !== undefined && + !sameIdMembers(configuration.expectedMediaBuyIds, mediaBuyIds) + ) { + throw new TypeError('Trusted reporting coverage conflicts with the configuration media-buy assertion'); + } + if (Object.prototype.hasOwnProperty.call(route.sourceScope, ADAPTER_SCOPE_KEY)) { + throw new TypeError('Reporting sourceScope contains an SDK-reserved key'); + } + const credentialPaths = scanArgsForCredentials(route.sourceScope); + if (credentialPaths.length > 0 || containsContextMetadata(route.sourceScope)) { + throw new TypeError('Reporting sourceScope must contain non-secret routing identifiers only'); + } + const routedScope = ReportingSourceScopeV1Schema.parse({ + ...structuredClone(route.sourceScope), + [ADAPTER_SCOPE_KEY]: route.adapterId, + }); + const predecessor = + ownership === 'account' ? await predecessorGeneration(options.store, account.id, frozenInput) : undefined; + const sourceScope = keylessPredecessorScope(predecessor, route.sourceScope, sources.size) ?? routedScope; + // A generation installed before the identity was stored keeps its shape, + // or replaying it would change the semantic fingerprint. + const scheduleIdentity = + predecessor && predecessor.schedule.alignment === undefined + ? {} + : { + periodDuration: deliveryOffering.schedule.period_duration, + deliverySlaDuration: deliveryOffering.schedule.delivery_sla, + alignment: deliveryOffering.schedule.alignment, + ...(deliveryOffering.schedule.alignment === 'utc' ? {} : { periodTimezone: normalizedTimezone }), + }; + const { + expectedCurrency: _currency, + expectedSourceTimezone: _timezone, + expectedMediaBuyIds: _mediaBuyIds, + sourceSettings, + ...ledgerInput + } = frozenInput; + void _currency; + void _timezone; + void _mediaBuyIds; + return { + input: { + ...ledgerInput, + schedule: { ...ledgerInput.schedule, ...scheduleIdentity }, + account: { account_id: account.id }, + sourceScope, + sourceTimezone: normalizedTimezone, + sourceSettings: { ...sourceSettings, currency: normalizedCurrency }, + contract: structuredClone(offering.contract), + constituents, + mediaBuyIds, + }, + requestedMetrics: frozenInput.requestedMetrics, + }; + } + + /** + * Write every frozen subscription owed set the engine holds (for the cycle's account) into the + * ledger. Failures are isolated per subscription and returned, never thrown here. + */ + async function planSubscriptionCycle( + subscriptions: ReliableReportingSubscriptionsOptionsV1, + accountId: string | undefined, + now: Date, + signal: AbortSignal | undefined + ): Promise<{ + planned: number; + failures: Array<{ + error: unknown; + context: { principal: PrincipalStoreScope; accountId?: string; subscriptionId?: string }; + }>; + }> { + const principals = await subscriptions.listSubscriptionPrincipals(); + if (!Array.isArray(principals)) throw new TypeError('listSubscriptionPrincipals must return an array'); + let planned = 0; + const failures: Awaited>['failures'] = []; + for (const principal of principals) { + signal?.throwIfAborted(); + const scope = { tenantId: principal.tenantId, principalId: principal.principalId }; + let sets: ReportingSubscriptionFrozenOwedSetV1[]; + try { + sets = await subscriptions.engine.frozenOwedSets(scope, accountId !== undefined ? { accountId } : {}); + } catch (error) { + failures.push({ error, context: { principal: scope, ...(accountId !== undefined ? { accountId } : {}) } }); + continue; + } + for (const set of sets) { + if (accountId !== undefined && set.account_id !== accountId) continue; + signal?.throwIfAborted(); + try { + planned += await planFrozenOwedSet(subscriptions, scope, set, now); + } catch (error) { + failures.push({ + error, + context: { principal: scope, accountId: set.account_id, subscriptionId: set.subscription_id }, + }); + } + } + } + return { planned, failures }; + } + + async function planFrozenOwedSet( + subscriptions: ReliableReportingSubscriptionsOptionsV1, + principal: PrincipalStoreScope, + set: ReportingSubscriptionFrozenOwedSetV1, + now: Date + ): Promise { + // Only periods the engine froze, and never past this cycle's clock. + const owedThroughMs = Math.min(Date.parse(set.through), now.getTime()); + if (!Number.isFinite(owedThroughMs)) throw new TypeError('Frozen owed set has no valid horizon'); + const account = subscriptions.resolveSubscriptionAccount + ? await subscriptions.resolveSubscriptionAccount(set.account_id) + : ({ id: set.account_id, ctx_metadata: {} } as Pick, 'id' | 'ctx_metadata'>); + if (account?.id !== set.account_id) { + throw new TypeError('resolveSubscriptionAccount must return the account it was asked for'); + } + const generations = new Map>>(); + const obligations: ReportingSubscriptionLedgerObligationInputV1[] = []; + for (const owed of set.obligations) { + if (Date.parse(owed.period_end) > owedThroughMs) continue; + const key = `${owed.subscription_version}\0${owed.offering_id}`; + let generation = generations.get(key); + if (!generation) { + const version = set.versions.find(value => value.subscription_version === owed.subscription_version); + if (!version) { + throw new TypeError(`Subscription version ${owed.subscription_version} owes periods but is not retained`); + } + generation = await subscriptionGeneration(subscriptions, principal, account, version, owed.offering_id); + generations.set(key, generation); + } + if (owed.report_definition_id !== generation.input.report_definition_id) { + throw new TypeError('Subscription obligation names a report definition its offering does not publish'); + } + const owedBuys = new Set(owed.media_buy_ids); + // Each obligation freezes its own denominator: the host-authorized constituents of exactly + // the media buys owed for that period. + const constituents = generation.pool.filter(value => value.mediaBuyId && owedBuys.has(value.mediaBuyId)); + const covered = new Set(constituents.map(value => value.mediaBuyId)); + if (owed.media_buy_ids.some(mediaBuyId => !covered.has(mediaBuyId))) { + throw new TypeError('resolveCoverage did not authorize every media buy the subscription owes'); + } + if ( + constituents.length * generation.input.requestedMetrics.length > + SOURCE_BATCH_MANIFEST_MAX_METRIC_AVAILABILITY_V1 + ) { + throw new TypeError('Subscription obligation exceeds the per-slice metric availability bound'); + } + obligations.push({ + delivery_config_version: owed.subscription_version, + offeringId: owed.offering_id, + period: { start: owed.period_start, end: owed.period_end }, + mediaBuyIds: [...owed.media_buy_ids], + constituents: structuredClone(constituents), + }); + } + const result = await producer.planSubscriptionObligations({ + account_id: set.account_id, + principal, + subscriptionId: set.subscription_id, + configurations: [...generations.values()].map(value => value.input), + bindGeneration: async configuration => { + const generation = [...generations.values()].find( + value => + value.input.delivery_config_version === configuration.delivery_config_version && + value.input.offeringId === configuration.offeringId + ); + if (!generation?.managedOffering) return; + await subscriptions.bindManagedGeneration!({ + principal, + configuration, + subscription: structuredClone(generation.version.configuration), + ...(generation.version.destination_method + ? { destinationMethod: structuredClone(generation.version.destination_method) } + : {}), + offering: structuredClone(generation.managedOffering), + }); + }, + obligations, + owedThrough: new Date(owedThroughMs).toISOString(), + now: now.toISOString(), + }); + return result.created.length; + } + + /** One (version, offering) generation through the same trusted path as installConfiguration. */ + async function subscriptionGeneration( + subscriptions: ReliableReportingSubscriptionsOptionsV1, + principal: PrincipalStoreScope, + account: Pick, 'id' | 'ctx_metadata'>, + version: ReportingSubscriptionFrozenOwedSetV1['versions'][number], + offeringId: string + ): Promise<{ + input: ReportingSubscriptionConfigurationInputV1; + pool: ReportingLedgerConfigurationV1['constituents']; + /** The resolved offering when it is managed, so the generation needs a managed binding. */ + managedOffering?: ReportingDeliveryOffering; + version: ReportingSubscriptionFrozenOwedSetV1['versions'][number]; + }> { + const deliveryOffering = deliveryOfferings.find(value => value.offering_id === offeringId); + const sourceOffering = offerings.get(offeringId); + if (!deliveryOffering || !sourceOffering) { + throw new TypeError( + `Subscription resolved offering ${JSON.stringify(offeringId)}, which this service does not serve` + ); + } + // A managed offering (one with a delivery method) is delivered to the subscription's + // destination, which only a composition that owns managed delivery can bind. Selection + // matches the method to the version's destination, so the destination is always present. + const managed = deliveryOffering.method !== undefined; + if (managed && typeof subscriptions.bindManagedGeneration !== 'function') { + throw new TypeError( + 'A managed subscription offering needs a composition that binds managed delivery, such as ' + + 'createPostgresReliableReportingProductionService from @adcp/reporting-storage/service' + ); + } + if (!managed && deliveryOffering.reconciliation_mode !== 'delivery_only') { + throw new TypeError('A consumer-receipt subscription offering must be managed'); + } + const subscription = version.configuration; + const policy = await subscriptions.resolveSubscriptionConfiguration({ + principal, + account, + subscription: structuredClone(subscription), + offering: structuredClone(deliveryOffering), + sourceOffering: structuredClone(sourceOffering), + }); + if (!policy || !Array.isArray(policy.requestedMetrics) || !Array.isArray(policy.requestedDimensions)) { + throw new TypeError( + 'resolveSubscriptionConfiguration must return requested metrics, dimensions and source settings' + ); + } + const configuration: ReliableReportingConfigurationInputV1 = { + delivery_config_id: subscription.subscription_id, + delivery_config_version: version.subscription_version, + offeringId, + report_definition_id: deliveryOffering.report_definition_id, + feedPurpose: subscription.selector.feed_purpose, + requiredFinality: subscription.selector + .required_finality as ReliableReportingConfigurationInputV1['requiredFinality'], + ...(policy.finalityPolicy ? { finalityPolicy: structuredClone(policy.finalityPolicy) } : {}), + ...(policy.canonicalization ? { canonicalization: structuredClone(policy.canonicalization) } : {}), + requestedMetrics: [...policy.requestedMetrics], + requestedDimensions: [...policy.requestedDimensions], + // Descriptive only: an owed set takes every period from the engine. The offering is + // authoritative for timing, so expected_at is period end plus its delivery_sla. + schedule: { + anchor: '1970-01-01T00:00:00.000Z', + periodMilliseconds: reportingIsoDurationMillisecondsV1(deliveryOffering.schedule.period_duration), + deliverySlaMilliseconds: reportingIsoDurationMillisecondsV1(deliveryOffering.schedule.delivery_sla), + recoveryWindowMilliseconds: options.automatedRecoveryWindowSeconds * 1_000, + }, + sourceSettings: structuredClone(policy.sourceSettings), + }; + const { input } = await prepareGeneration(configuration, account, 'owed_set'); + const { constituents, mediaBuyIds: _mediaBuyIds, ...generation } = input; + void _mediaBuyIds; + return { + input: { + ...generation, + subscription: { + tenantId: principal.tenantId, + principalId: principal.principalId, + subscriptionId: subscription.subscription_id, + offeringId, + ownership: 'owed_set', + }, + }, + pool: constituents, + ...(managed ? { managedOffering: structuredClone(deliveryOffering) } : {}), + version, + }; + } + const service: ReliableReportingServiceV1 = { setup: Object.freeze({ component: 'reliable-reporting-core', @@ -587,105 +1023,10 @@ export function createReliableReportingService); - if (Object.prototype.hasOwnProperty.call(configuration.sourceSettings, 'currency')) { - throw new TypeError('Reporting configuration must not supply trusted lineage field sourceSettings.currency'); - } - const frozenInput = structuredClone(configuration); - const offering = offerings.get(configuration.offeringId); - if (!offering) throw new TypeError('Reporting configuration selected an unknown offering'); - const deliveryOffering = deliveryOfferings.find(value => value.offering_id === offering.offeringId)!; - validateConfigurationAgainstDeliveryOffering( - frozenInput, - deliveryOffering, - options.automatedRecoveryWindowSeconds - ); - const [route, currency, coverage] = await Promise.all([ - options.resolveSource(context.account, frozenInput), - options.resolveCurrency(context.account, frozenInput), - options.resolveCoverage(context.account, frozenInput), - ]); - if (!route || typeof route !== 'object' || !route.sourceScope || typeof route.sourceScope !== 'object') { - throw new TypeError('resolveSource must return an adapter, sourceScope, and sourceTimezone'); - } - const source = sources.get(route.adapterId); - const adapterOffering = adapterOfferings.get(route.adapterId); - if (!source || !adapterOffering) throw new TypeError('resolveSource selected an unknown reporting adapter'); - // The adapter's own validated offering must be the selected one — not - // merely an offering its executor happens to expose. - if (adapterOffering.offeringId !== offering.offeringId) { - throw new TypeError('Resolved reporting adapter does not provide the selected offering'); - } - if (canonicalize(adapterOffering.contract) !== canonicalize(offering.contract)) { - throw new TypeError('Resolved reporting adapter contract differs from the selected offering'); - } - const normalizedCurrency = trustedCurrency(currency); - const normalizedTimezone = trustedTimezone(route.sourceTimezone); - if (configuration.expectedCurrency !== undefined && configuration.expectedCurrency !== normalizedCurrency) { - throw new TypeError('Trusted reporting currency conflicts with the configuration currency assertion'); - } - if ( - configuration.expectedSourceTimezone !== undefined && - configuration.expectedSourceTimezone !== normalizedTimezone - ) { - throw new TypeError('Trusted reporting timezone conflicts with the configuration timezone assertion'); - } - assertSupportedScheduleSemantics(frozenInput.schedule, deliveryOffering, normalizedTimezone, offering); - const { constituents, mediaBuyIds } = trustedCoverage(coverage, frozenInput.requestedMetrics); - if ( - configuration.expectedMediaBuyIds !== undefined && - !sameIdMembers(configuration.expectedMediaBuyIds, mediaBuyIds) - ) { - throw new TypeError('Trusted reporting coverage conflicts with the configuration media-buy assertion'); - } - if (Object.prototype.hasOwnProperty.call(route.sourceScope, ADAPTER_SCOPE_KEY)) { - throw new TypeError('Reporting sourceScope contains an SDK-reserved key'); - } - const credentialPaths = scanArgsForCredentials(route.sourceScope); - if (credentialPaths.length > 0 || containsContextMetadata(route.sourceScope)) { - throw new TypeError('Reporting sourceScope must contain non-secret routing identifiers only'); - } - const routedScope = ReportingSourceScopeV1Schema.parse({ - ...structuredClone(route.sourceScope), - [ADAPTER_SCOPE_KEY]: route.adapterId, + const { input } = await prepareGeneration(configuration, context.account, 'account'); + return producer.installConfiguration(input, { + ...(context.historyStart !== undefined ? { historyStart: context.historyStart } : {}), }); - const predecessor = await predecessorGeneration(options.store, context.account.id, frozenInput); - const sourceScope = keylessPredecessorScope(predecessor, route.sourceScope, sources.size) ?? routedScope; - // A generation installed before the identity was stored keeps its shape, - // or replaying it would change the semantic fingerprint. - const scheduleIdentity = - predecessor && predecessor.schedule.alignment === undefined - ? {} - : { - periodDuration: deliveryOffering.schedule.period_duration, - deliverySlaDuration: deliveryOffering.schedule.delivery_sla, - alignment: deliveryOffering.schedule.alignment, - ...(deliveryOffering.schedule.alignment === 'utc' ? {} : { periodTimezone: normalizedTimezone }), - }; - const { - expectedCurrency: _currency, - expectedSourceTimezone: _timezone, - expectedMediaBuyIds: _mediaBuyIds, - sourceSettings, - ...ledgerInput - } = frozenInput; - void _currency; - void _timezone; - void _mediaBuyIds; - return producer.installConfiguration( - { - ...ledgerInput, - schedule: { ...ledgerInput.schedule, ...scheduleIdentity }, - account: { account_id: context.account.id }, - sourceScope, - sourceTimezone: normalizedTimezone, - sourceSettings: { ...sourceSettings, currency: normalizedCurrency }, - contract: structuredClone(offering.contract), - constituents, - mediaBuyIds, - }, - { ...(context.historyStart !== undefined ? { historyStart: context.historyStart } : {}) } - ); }, extendConfigurationHistory(input) { @@ -694,7 +1035,7 @@ export function createReliableReportingService>(platform: TPlatform) { - if (typeof platform.accounts.upsert !== 'function') { + if (servesAccountConfigurations && typeof platform.accounts.upsert !== 'function') { throw new TypeError( 'Reliable reporting requires accounts.upsert so the advertised sync_accounts configuration task is installed' ); @@ -717,6 +1058,10 @@ export function createReliableReportingService failure.error), + `Reporting subscription obligations could not be written for ${subscriptionPlanning.failures.length} subscription(s)` + ); + } + } + return { planned: planned.length + (subscriptionPlanning?.planned ?? 0), ...result }; }, async runMaintenance(maintenanceOptions) { @@ -957,6 +1314,7 @@ async function predecessorGeneration( ): Promise { return (await store.listConfigurations(accountId)).find( value => + value.subscription === undefined && value.delivery_config_id === configuration.delivery_config_id && value.delivery_config_version === configuration.delivery_config_version ); @@ -1524,6 +1882,8 @@ function assertNoUntrustedLineageFields(configuration: Record): 'applicability', 'products', 'productIds', + 'subscription', + 'owedSet', ]) { if (Object.prototype.hasOwnProperty.call(configuration, field)) { throw new TypeError(`Reporting configuration must not supply trusted lineage field ${field}`); @@ -1561,5 +1921,6 @@ function deepFreeze(value: T): T { return value; } +export { ReportingConfigurationImmutableError, isReportingConfigurationImmutableError } from '../ledger'; export * from './conformance'; export * from './scheduler-support'; diff --git a/src/lib/reporting/service/subscriptions.ts b/src/lib/reporting/service/subscriptions.ts new file mode 100644 index 000000000..2ad1fc25a --- /dev/null +++ b/src/lib/reporting/service/subscriptions.ts @@ -0,0 +1,158 @@ +/** + * The Reliable Reporting service's principal-level reporting-subscription options (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3) and the `get_reporting_status` coverage hook. + * + * Internal to the service: these are published from `@adcp/sdk/reporting/advanced`, not from + * `@adcp/sdk/reporting/service`. + */ +import type { Account } from '../../server/decisioning/account'; +import type { RequestContext } from '../../server/decisioning/context'; +import type { ReliableReportingPlatform } from '../../server/decisioning/specialisms/reporting'; +import type { PrincipalLifecycleRuntimeSubscriptions, PrincipalStoreScope } from '../../server/principal/types'; +import type { PrincipalLifecycleRuntime } from '../../server/principal/runtime'; +import { + reportingSubscriptionCoverageForReleaseV1, + reportingSubscriptionReleaseFieldsV1, +} from '../../server/reporting-subscription-release'; +import type { ReportingDeliveryOffering } from '../../types/tools.generated'; +import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; +import type { ReportingLedgerConfigurationV1 } from '../ledger'; +import type { ReportingSourceOfferingV1 } from '../source'; +import type { ReportingSubscriptionEngineV1, ReportingSubscriptionFrozenOwedSetV1 } from '../subscriptions/engine'; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReliableReportingSubscriptionsOptionsV1> { + engine: Pick & + Partial>; + /** + * The principal runtime. A composition that delivers managed subscription obligations (the + * production service in `@adcp/reporting-storage/service`) resolves each subscription's + * destination through it. + */ + principal?: Pick & + Pick; + /** + * The same authenticated mapping the principal runtime uses. Never derive it from request content. + * It also scopes `get_reporting_status`, `get_media_buy_delivery` and `sync_reporting_status`: a + * caller sees account configurations plus only its own subscription generations. + */ + resolvePrincipal(context: RequestContext>): PrincipalStoreScope | Promise; + /** + * Every principal whose subscription obligations `runCycle` writes into the ledger, for example + * `engine.listPrincipals(tenantId)` across your tenants. Each cycle plans the engine's frozen + * owed set of each of them (on the cycle's account) before it plans account configurations. + */ + listSubscriptionPrincipals(): readonly PrincipalStoreScope[] | Promise; + /** + * The account `resolveSource`, `resolveCurrency` and `resolveCoverage` receive for a subscription + * generation. Defaults to `{ id: accountId, ctx_metadata: {} }`. + */ + resolveSubscriptionAccount?( + accountId: string + ): Pick, 'id' | 'ctx_metadata'> | Promise, 'id' | 'ctx_metadata'>>; + /** + * Seller policy for one subscription generation: the facts a host also supplies when it maps a + * `sync_accounts` configuration onto `installConfiguration`. The service takes identity, feed + * purpose, required finality, report definition and schedule from the subscription version and the + * resolved offering, and the source route, currency and authorized coverage from + * `resolveSource`, `resolveCurrency` and `resolveCoverage`, as for an account configuration. + */ + resolveSubscriptionConfiguration( + input: ReliableReportingSubscriptionGenerationContextV1 + ): ReliableReportingSubscriptionPolicyV1 | Promise; + /** + * Called for each subscription whose obligations a cycle could not write. One subscription never + * stops the others or the account's own configurations. Without it, `runCycle` rejects with an + * `AggregateError` of these failures after the rest of the cycle has run. + */ + onPlanningError?( + error: unknown, + context: { principal: PrincipalStoreScope; accountId?: string; subscriptionId?: string } + ): void | Promise; + /** + * Binds a managed subscription generation to its destination before its first obligation is + * written. Supplied by a composition that owns managed delivery, such as + * `createPostgresReliableReportingProductionService`; a subscription that resolves a managed + * offering without it fails to plan. A rejection writes nothing, and the next cycle tries again. + */ + bindManagedGeneration?(input: ReliableReportingSubscriptionBindingInputV1): Promise; + /** Standard dimensions the seller maps its report definitions onto (`subscriptions.dimensions`). */ + dimensions?: readonly string[]; + /** Keep serving `sync_accounts` `reporting_delivery_configs`. Defaults to true. */ + accountConfigurations?: boolean; +} + +/** + * One managed subscription generation to bind to its destination. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReliableReportingSubscriptionBindingInputV1 { + /** The owning principal. */ + principal: PrincipalStoreScope; + /** The installed generation, with its `subscription` block. */ + configuration: ReportingLedgerConfigurationV1; + /** The subscription version's configuration, naming `destination.destination_id` and `path_template`. */ + subscription: ReportingSubscriptionFrozenOwedSetV1['versions'][number]['configuration']; + /** The destination's pattern and transport, frozen when the version was accepted. */ + destinationMethod?: NonNullable; + /** The resolved managed offering. */ + offering: ReportingDeliveryOffering; +} + +/** + * What {@link ReliableReportingSubscriptionsOptionsV1.resolveSubscriptionConfiguration} is asked about. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReliableReportingSubscriptionGenerationContextV1> { + principal: PrincipalStoreScope; + account: Pick, 'id' | 'ctx_metadata'>; + /** The subscription version's configuration. */ + subscription: ReportingSubscriptionFrozenOwedSetV1['versions'][number]['configuration']; + offering: ReportingDeliveryOffering; + sourceOffering: ReportingSourceOfferingV1; +} + +/** + * Seller policy for one subscription generation. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReliableReportingSubscriptionPolicyV1 { + requestedMetrics: string[]; + requestedDimensions: string[]; + sourceSettings: Omit; + finalityPolicy?: ReportingLedgerConfigurationV1['finalityPolicy']; + canonicalization?: ReportingLedgerConfigurationV1['canonicalization']; +} + +/** + * Add `subscription_coverage[]` to a completed summary or periods response. The revision + * view never carries it, and an account the caller cannot read has no entries. Exported for + * compositions that serve `get_reporting_status` from their own handler, such as the production + * service in `@adcp/reporting-storage/service`. + * + * @experimental Storage-facing building block: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export async function withReportingSubscriptionCoverageV1( + response: Awaited['getReportingStatus']>>, + context: RequestContext>, + subscriptions: ReliableReportingSubscriptionsOptionsV1 +) { + const body = response as unknown as Record; + if (body.status !== 'completed' || (body.view !== 'summary' && body.view !== 'periods')) return response; + // subscription_coverage is a 3.3 field; earlier response shapes are closed. + const served = (context as { servedAdcpVersion?: string }).servedAdcpVersion; + if (served !== undefined && !isAdcpVersionAtLeast(served, '3.3.0-beta.1')) return response; + const accountId = (context.account as { id?: unknown } | undefined)?.id; + if (typeof accountId !== 'string') return response; + const principal = await subscriptions.resolvePrincipal(context); + // Fields settled after 3.3.0-beta.1 only when the served bundle defines them. + const fields = reportingSubscriptionReleaseFieldsV1(served); + const coverage = (await subscriptions.engine.subscriptionCoverage(principal, accountId)).map(entry => + reportingSubscriptionCoverageForReleaseV1(entry as unknown as Record, fields) + ); + if (coverage.length === 0) return response; + return { ...body, subscription_coverage: coverage } as unknown as typeof response; +} diff --git a/src/lib/reporting/subscriptions/calendar.ts b/src/lib/reporting/subscriptions/calendar.ts new file mode 100644 index 000000000..70ba417c7 --- /dev/null +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -0,0 +1,490 @@ +/** + * Normative reporting period generation (`core/reporting-schedule.json` `period_generation`). + * + * Producer and consumer must derive identical half-open periods from `period_duration`, + * `alignment`, `period_anchor` and `period_timezone`: + * + * - `utc` uses 1970-01-01T00:00:00Z as interval zero. + * - `account_timezone` and `source_timezone` use local midnight on 1970-01-01 in the + * resolved IANA timezone. + * - `billing_cycle` uses its `period_anchor`, read in `period_timezone`. + * + * Every boundary is computed directly from the origin and the interval ordinal, never by + * stepping from a previous boundary: each duration component is multiplied by the ordinal, + * then years and months are added to the origin's local calendar date (keeping its local day, + * clamped to the target month's last day), then days, all as local civil-time arithmetic. + * The resulting local wall time is resolved to an instant: a nonexistent local time advances + * by the timezone gap, and an ambiguous one takes the earlier instant. Hours, minutes and + * seconds are then added as exact elapsed time; they are not calendar components, so `PT24H` + * and `P1D` differ across a DST change. This matches how the SDK resolves calendar + * `delivery_sla` durations in buyer reconciliation. + * + * @packageDocumentation + */ +import { CoverageInputError } from '../../errors'; + +const DAY_MS = 86_400_000; +/** Nominal lengths used only to estimate an ordinal before exact correction. */ +const NOMINAL_YEAR_MS = 365.2425 * DAY_MS; +const NOMINAL_MONTH_MS = NOMINAL_YEAR_MS / 12; +/** RFC 3339 year range, so every boundary renders as a valid `date-time`. */ +const MIN_INSTANT = -62_135_596_800_000; // 0001-01-01T00:00:00Z +const MAX_INSTANT = 253_402_300_799_999; // 9999-12-31T23:59:59.999Z +const MAX_CORRECTION_STEPS = 4_096; + +/** + * A parsed ISO 8601 duration as the `reporting-schedule.json` pattern admits it. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingIsoDurationPartsV1 { + years: number; + months: number; + days: number; + /** Hours, minutes and seconds folded into exact seconds. */ + seconds: number; +} + +/** + * Alignments defined by `core/reporting-schedule.json`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingPeriodAlignmentV1 = 'utc' | 'account_timezone' | 'source_timezone' | 'billing_cycle'; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingPeriodCalendarSpecV1 { + /** Strictly positive ISO 8601 duration, such as `P1D`, `PT1H` or `P1M`. */ + period_duration: string; + alignment: ReportingPeriodAlignmentV1; + /** + * IANA timezone for the calendar arithmetic. Ignored for `utc`. Required for every + * other alignment: the account's timezone for `account_timezone`, the explicit or + * account-resolved period timezone for `source_timezone` and `billing_cycle`. + */ + timezone?: string; + /** Required for `billing_cycle`: the immutable instant that is interval zero. */ + period_anchor?: string; +} + +/** + * Ordered, half-open reporting periods derived from one published schedule. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingPeriodCalendarV1 { + readonly spec: Readonly; + /** + * Epoch milliseconds of boundary `ordinal`. Boundary 0 is the schedule origin. Sub-daily + * local calendars (#8588) have no ordinal addressing and throw; use `ceil`/`next`. + */ + boundary(ordinal: number): number; + /** Smallest ordinal whose boundary is at or after `instant`. */ + ceilOrdinal(instant: number): number; + /** Largest ordinal whose boundary is at or before `instant`. */ + floorOrdinal(instant: number): number; + /** First boundary at or after `instant`, in epoch milliseconds. */ + ceil(instant: number): number; + /** Last boundary at or before `instant`, in epoch milliseconds. */ + floor(instant: number): number; + /** + * First boundary strictly after `instant`. Iterating `next` from a boundary yields every + * period exactly once and skips zero-length periods (a repeated boundary). + */ + next(instant: number): number; + /** Stable identity: two calendars with equal keys produce identical boundaries. */ + readonly key: string; +} + +const DURATION = /^P(?=.*[1-9])(?=\d|T)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?=\d)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; + +/** + * Parse a strictly positive reporting `period_duration`. Throws on anything else. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function parseReportingPeriodDurationV1(value: string): ReportingIsoDurationPartsV1 { + if (typeof value !== 'string' || value.length > 64) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Invalid reporting period_duration', + 'invalid_period_duration' + ); + const match = DURATION.exec(value); + if (!match) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Invalid reporting period_duration ${JSON.stringify(value)}`, + 'invalid_period_duration' + ); + const [, years, months, days, hours, minutes, seconds] = match; + const parts = { + years: Number(years ?? 0), + months: Number(months ?? 0), + days: Number(days ?? 0), + seconds: Number(hours ?? 0) * 3_600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0), + }; + if (!Object.values(parts).every(Number.isSafeInteger)) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Reporting period_duration ${JSON.stringify(value)} is out of range`, + 'invalid_period_duration' + ); + } + return parts; +} + +const formatters = new Map(); +const MAX_FORMATTERS = 64; + +function formatterFor(timeZone: string): Intl.DateTimeFormat { + const cached = formatters.get(timeZone); + if (cached) return cached; + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + calendar: 'gregory', + numberingSystem: 'latn', + hourCycle: 'h23', + era: 'short', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + if (formatters.size >= MAX_FORMATTERS) formatters.delete(formatters.keys().next().value!); + formatters.set(timeZone, formatter); + return formatter; +} + +/** + * Validate an IANA zone name or link. Numeric offsets are refused: they define no DST + * transitions, which is exactly the substitution `iana_timezone` forbids. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function assertReportingIanaTimezoneV1(value: unknown, field = 'timezone'): asserts value is string { + if (typeof value !== 'string' || value.length === 0 || value.length > 255 || /^[+-]/.test(value)) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${field} must be an IANA timezone name`, + 'invalid_timezone' + ); + } + try { + formatterFor(value); + } catch { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${field} ${JSON.stringify(value)} is not a recognized IANA timezone`, + 'invalid_timezone' + ); + } +} + +/** Local wall-clock time of `instant` in `timeZone`, as epoch milliseconds of that wall time read as UTC. */ +function wallTime(instant: number, timeZone: string): number { + const parts = formatterFor(timeZone).formatToParts(instant); + const field = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find(part => part.type === type)?.value); + if (parts.find(part => part.type === 'era')?.value !== 'AD') { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Reporting period boundary is outside the supported civil year range', + 'invalid_instant' + ); + } + const wall = new Date(0); + wall.setUTCFullYear(field('year'), field('month') - 1, field('day')); + wall.setUTCHours(field('hour'), field('minute'), field('second'), 0); + return wall.getTime() + (((instant % 1_000) + 1_000) % 1_000); +} + +/** + * The instant at which `timeZone` reads `wall`. A nonexistent wall time advances by the + * gap; an ambiguous one takes the earlier instant. Both follow from preferring the offset + * in effect before the transition. + */ +function instantForWall(wall: number, timeZone: string): number { + if (timeZone === 'UTC') return wall; + const offsetBefore = wallTime(wall - DAY_MS, timeZone) - (wall - DAY_MS); + const offsetAfter = wallTime(wall + DAY_MS, timeZone) - (wall + DAY_MS); + const fromBefore = wall - offsetBefore; + if (wallTime(fromBefore, timeZone) === wall) return fromBefore; + const fromAfter = wall - offsetAfter; + if (wallTime(fromAfter, timeZone) === wall) return fromAfter; + return fromBefore; +} + +/** + * Every instant at which `timeZone` reads `wall`: none inside a DST gap, two inside a fold + * (earlier offset first), otherwise one. + */ +function instantsForWall(wall: number, timeZone: string): number[] { + if (timeZone === 'UTC') return [wall]; + const offsetBefore = wallTime(wall - DAY_MS, timeZone) - (wall - DAY_MS); + const offsetAfter = wallTime(wall + DAY_MS, timeZone) - (wall + DAY_MS); + const found = new Set(); + for (const candidate of [wall - offsetBefore, wall - offsetAfter]) { + if (wallTime(candidate, timeZone) === wall) found.add(candidate); + } + return [...found].sort((left, right) => left - right); +} + +function parseInstant(value: string, field: string): number { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) + ) { + throw new CoverageInputError('COVERAGE_INPUT_INVALID', `${field} must be an RFC 3339 date-time`, 'invalid_instant'); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', `${field} must be an RFC 3339 date-time`, 'invalid_instant'); + return parsed; +} + +/** + * Build the calendar a published schedule implies. Throws a `TypeError` for a schedule the + * protocol does not define (a `billing_cycle` without anchor, a missing or numeric timezone). + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpecV1): ReportingPeriodCalendarV1 { + const duration = parseReportingPeriodDurationV1(spec.period_duration); + const hasDate = duration.years !== 0 || duration.months !== 0 || duration.days !== 0; + const hasTime = duration.seconds !== 0; + if (spec.alignment !== 'utc' && hasDate && hasTime) { + // adcontextprotocol/adcp#8588: under a local alignment a duration cannot mix date and + // time components. + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Reporting period_duration ${JSON.stringify(spec.period_duration)} mixes date and time components under ${spec.alignment} alignment`, + 'invalid_period_duration' + ); + } + if (spec.alignment === 'billing_cycle' && !hasDate) { + // A billing cycle is anchored on period_anchor; a sub-daily one would have to honour the + // anchor's time of day, which the local sub-daily calendar cannot. adcontextprotocol/adcp#8622 + // makes a sub-daily billing_cycle invalid, so refuse it rather than align to local midnight. + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Reporting period_duration ${JSON.stringify(spec.period_duration)} is sub-daily, which billing_cycle alignment does not allow (adcp#8622). Use a period of at least one day.`, + 'invalid_period_duration' + ); + } + if (spec.alignment !== 'utc' && !hasDate) { + return createSubDailyLocalCalendar(spec, duration.seconds * 1_000); + } + let timeZone: string; + let originWall: number; + let anchorKey = ''; + let anchorInstant: number | undefined; + switch (spec.alignment) { + case 'utc': + timeZone = 'UTC'; + originWall = 0; + break; + case 'account_timezone': + case 'source_timezone': + assertReportingIanaTimezoneV1(spec.timezone, `${spec.alignment} timezone`); + timeZone = spec.timezone; + originWall = 0; + break; + case 'billing_cycle': { + assertReportingIanaTimezoneV1(spec.timezone, 'billing_cycle period_timezone'); + if (spec.period_anchor === undefined) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'billing_cycle alignment requires period_anchor', + 'invalid_input' + ); + timeZone = spec.timezone; + const anchor = parseInstant(spec.period_anchor, 'period_anchor'); + originWall = wallTime(anchor, timeZone); + anchorKey = new Date(anchor).toISOString(); + anchorInstant = anchor; + break; + } + default: + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Unknown reporting period alignment ${JSON.stringify((spec as { alignment?: unknown }).alignment)}`, + 'invalid_input' + ); + } + const origin = new Date(originWall); + const originYear = origin.getUTCFullYear(); + const originMonth = origin.getUTCMonth(); + const originDay = origin.getUTCDate(); + const timeOfDay = ((originWall % DAY_MS) + DAY_MS) % DAY_MS; + const calendarMonths = duration.years * 12 + duration.months; + const exactMs = duration.seconds * 1_000; + const nominal = + duration.years * NOMINAL_YEAR_MS + duration.months * NOMINAL_MONTH_MS + duration.days * DAY_MS + exactMs; + const cache = new Map(); + + function boundary(ordinal: number): number { + if (!Number.isSafeInteger(ordinal)) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Reporting period ordinal must be a safe integer', + 'invalid_input' + ); + // Interval zero is the anchor instant itself, even when it falls in a DST fold whose + // wall time would otherwise resolve to the earlier offset. + if (ordinal === 0 && anchorInstant !== undefined) return anchorInstant; + const cached = cache.get(ordinal); + if (cached !== undefined) return cached; + let wall = originWall; + if (calendarMonths !== 0 || duration.days !== 0) { + const totalMonths = originMonth + ordinal * calendarMonths; + const year = originYear + Math.floor(totalMonths / 12); + const month = ((totalMonths % 12) + 12) % 12; + const date = new Date(0); + date.setUTCFullYear(year, month + 1, 0); // last day of the target month + const clampedDay = Math.min(originDay, date.getUTCDate()); + date.setUTCFullYear(year, month, clampedDay + ordinal * duration.days); + date.setUTCHours(0, 0, 0, 0); + wall = date.getTime() + timeOfDay; + } + const value = instantForWall(wall, timeZone) + ordinal * exactMs; + if (!Number.isFinite(value) || value < MIN_INSTANT || value > MAX_INSTANT) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Reporting period boundary is outside the RFC 3339 instant range', + 'invalid_instant' + ); + } + if (cache.size > 4_096) cache.clear(); + cache.set(ordinal, value); + return value; + } + + const originInstant = boundary(0); + + function ceilOrdinal(instant: number): number { + if (!Number.isFinite(instant)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'Reporting instant must be finite', 'invalid_instant'); + let ordinal = Math.ceil((instant - originInstant) / nominal); + let steps = 0; + while (boundary(ordinal) < instant) { + ordinal += 1; + if (++steps > MAX_CORRECTION_STEPS) throw new RangeError('Reporting period ordinal did not converge'); + } + while (boundary(ordinal - 1) >= instant) { + ordinal -= 1; + if (++steps > MAX_CORRECTION_STEPS) throw new RangeError('Reporting period ordinal did not converge'); + } + return ordinal; + } + + function floorOrdinal(instant: number): number { + let ordinal = ceilOrdinal(instant); + if (boundary(ordinal) > instant) return ordinal - 1; + // Equal: a zero-length period (a skipped civil date) can repeat a boundary; take the last. + while (boundary(ordinal + 1) <= instant) ordinal += 1; + return ordinal; + } + + const frozenSpec = Object.freeze({ ...spec }); + return { + spec: frozenSpec, + boundary, + ceilOrdinal, + floorOrdinal, + ceil: instant => boundary(ceilOrdinal(instant)), + floor: instant => boundary(floorOrdinal(instant)), + next: instant => { + let ordinal = floorOrdinal(instant) + 1; + let steps = 0; + while (boundary(ordinal) <= instant) { + ordinal += 1; + if (++steps > MAX_CORRECTION_STEPS) throw new RangeError('Reporting period ordinal did not converge'); + } + return boundary(ordinal); + }, + key: JSON.stringify([spec.period_duration, spec.alignment, timeZone, anchorKey]), + }; +} + +/** + * Sub-daily periods under a local alignment (adcontextprotocol/adcp#8588): boundaries are the + * instants whose local wall-clock time is a whole multiple of the duration after local + * midnight, counted afresh each local day. A wall time inside a DST gap yields no boundary; + * one inside a fold yields one boundary per offset. The duration must divide 24 hours. + */ +function createSubDailyLocalCalendar(spec: ReportingPeriodCalendarSpecV1, stepMs: number): ReportingPeriodCalendarV1 { + if (spec.alignment === 'billing_cycle') { + assertReportingIanaTimezoneV1(spec.timezone, 'billing_cycle period_timezone'); + } else { + assertReportingIanaTimezoneV1(spec.timezone, `${spec.alignment} timezone`); + } + if (!(stepMs > 0) || DAY_MS % stepMs !== 0) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Sub-daily period_duration ${JSON.stringify(spec.period_duration)} must divide 24 hours under ${spec.alignment} alignment`, + 'invalid_period_duration' + ); + } + const timeZone = spec.timezone; + const slots = DAY_MS / stepMs; + const dayCache = new Map(); + /** Boundaries of local day `day` (days since local 1970-01-01), ascending. */ + function boundariesOfDay(day: number): number[] { + const cached = dayCache.get(day); + if (cached) return cached; + const values: number[] = []; + for (let slot = 0; slot < slots; slot += 1) { + for (const value of instantsForWall(day * DAY_MS + slot * stepMs, timeZone)) { + if (value < MIN_INSTANT || value > MAX_INSTANT) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Reporting period boundary is outside the RFC 3339 instant range', + 'invalid_instant' + ); + } + values.push(value); + } + } + values.sort((left, right) => left - right); + if (dayCache.size > 512) dayCache.clear(); + dayCache.set(day, values); + return values; + } + const localDay = (instant: number) => Math.floor(wallTime(instant, timeZone) / DAY_MS); + function ceil(instant: number): number { + if (!Number.isFinite(instant)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'Reporting instant must be finite', 'invalid_instant'); + for (let day = localDay(instant) - 1; day <= localDay(instant) + 2; day += 1) { + const found = boundariesOfDay(day).find(value => value >= instant); + if (found !== undefined) return found; + } + throw new RangeError('Reporting period boundary did not converge'); + } + function next(instant: number): number { + return ceil(instant + 1); + } + function floor(instant: number): number { + if (!Number.isFinite(instant)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'Reporting instant must be finite', 'invalid_instant'); + for (let day = localDay(instant) + 1; day >= localDay(instant) - 2; day -= 1) { + const found = [...boundariesOfDay(day)].reverse().find(value => value <= instant); + if (found !== undefined) return found; + } + throw new RangeError('Reporting period boundary did not converge'); + } + const unsupported = (): never => { + throw new TypeError('Sub-daily local reporting calendars have no ordinal addressing; use ceil, floor and next'); + }; + return { + spec: Object.freeze({ ...spec }), + boundary: unsupported, + ceilOrdinal: unsupported, + floorOrdinal: unsupported, + ceil, + floor, + next, + key: JSON.stringify([spec.period_duration, spec.alignment, timeZone, 'local-sub-daily']), + }; +} diff --git a/src/lib/reporting/subscriptions/capabilities.ts b/src/lib/reporting/subscriptions/capabilities.ts new file mode 100644 index 000000000..5902475c5 --- /dev/null +++ b/src/lib/reporting/subscriptions/capabilities.ts @@ -0,0 +1,94 @@ +/** + * `media_buy.reporting_delivery` registration capabilities for AdCP 3.3. + * + * `configuration_tasks[]` lists every task a seller accepts reporting registrations + * through. `configuration_task: "sync_accounts"` is deprecated but kept for callers pinned + * below 3.3, and is present when and only when `configuration_tasks` lists `sync_accounts` + * or is absent. Advertising `subscriptions` requires `sync_principal` in + * `configuration_tasks`, and vice versa (`core/reporting-delivery-capabilities.json`). + */ +import { PlatformConfigError } from '../../server/decisioning/runtime/validate-platform'; +import type { ReportingDeliveryCapabilities } from '../../types/tools.generated'; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingConfigurationCapabilitiesInputV1 { + /** + * Accept principal-level reporting subscriptions through `sync_principal`. Absent keeps + * the pre-3.3 shape exactly: `configuration_task: "sync_accounts"` and nothing else. + */ + subscriptions?: { + /** Most accounts one subscription serves; beyond it accounts get a `capacity` exception. */ + maxAccounts?: number; + /** Whether periods missed during a loss of authority are delivered after re-grant. */ + backfillOnRegrant?: boolean; + /** Standard dimensions the seller maps its report definitions onto. */ + dimensions?: readonly string[]; + }; + /** + * Keep serving account-level `sync_accounts` `reporting_delivery_configs`. Defaults to + * true; set false to list `sync_principal` alone, which serves reporting registration to + * 3.3 callers only. + */ + accountConfigurations?: boolean; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export type ReportingConfigurationCapabilitiesV1 = Pick< + ReportingDeliveryCapabilities, + 'configuration_task' | 'configuration_tasks' | 'subscriptions' +>; + +/** + * The registration fields of a `reporting_delivery` capability block. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function reportingConfigurationCapabilitiesV1( + input: ReportingConfigurationCapabilitiesInputV1 = {} +): ReportingConfigurationCapabilitiesV1 { + const accountConfigurations = input.accountConfigurations ?? true; + if (input.subscriptions === undefined) { + if (!accountConfigurations) { + throw new PlatformConfigError( + 'Reporting registration requires sync_accounts, sync_principal subscriptions, or both. Enable subscriptions or keep accountConfigurations.', + { configField: 'accountConfigurations' } + ); + } + return { configuration_task: 'sync_accounts' }; + } + const { maxAccounts, backfillOnRegrant, dimensions } = input.subscriptions; + if (maxAccounts !== undefined && (!Number.isSafeInteger(maxAccounts) || maxAccounts < 1)) { + throw new PlatformConfigError( + 'subscriptions.maxAccounts must be a positive integer. Set a positive integer or omit it.', + { + configField: 'subscriptions.maxAccounts', + } + ); + } + if (dimensions !== undefined) { + if ( + dimensions.length === 0 || + new Set(dimensions).size !== dimensions.length || + dimensions.some(value => typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) + ) { + throw new PlatformConfigError( + 'subscriptions.dimensions must be unique reporting-dimension values. List each standard dimension once, in snake_case.', + { configField: 'subscriptions.dimensions' } + ); + } + } + return { + ...(accountConfigurations ? { configuration_task: 'sync_accounts' as const } : {}), + configuration_tasks: accountConfigurations + ? (['sync_principal', 'sync_accounts'] as ['sync_principal', 'sync_accounts']) + : (['sync_principal'] as ['sync_principal']), + subscriptions: { + supported: true, + ...(maxAccounts !== undefined ? { max_accounts: maxAccounts } : {}), + ...(backfillOnRegrant !== undefined ? { backfill_on_regrant: backfillOnRegrant } : {}), + ...(dimensions !== undefined + ? { dimensions: [...dimensions] as NonNullable['dimensions'] } + : {}), + }, + }; +} diff --git a/src/lib/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts new file mode 100644 index 000000000..9e38b3051 --- /dev/null +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -0,0 +1,957 @@ +/** + * Buyer-computable coverage for principal-level reporting subscriptions + * (experimental `media_buy.reporting_subscriptions`, AdCP 3.3). + * + * `expectedSubscriptionPeriods` is a pure function of plain data the buyer already holds: + * its subscription versions and their `accepted_at`, its visible confirmed buys, the + * account's grants and timezone, the seller's offerings, and the exceptions the seller + * published in `get_reporting_status` `subscription_coverage`. It returns every period the + * seller owes, period by period, so a missing report is the seller's to explain. + * + * The rule (`core/reporting-subscription.json` `x-adcp-validation.coverage` and `cutover`): + * + * 1. Each confirmed buy and each offering its packages resolve to under the version's + * selector is owed the complete periods of that offering's published schedule that + * start on or after the first boundary at or after the later of the version's + * `accepted_at` and the account's `authorized_at`, and that end after the buy's + * `confirmed_at` (so the period in which the buy is confirmed is owed in full); + * 2. and that overlap the flight from `start_time` to the stop time, the earliest of + * `end_time`, `canceled_at` and `completed_at`, with flight values taken as of the + * period's `scope_resolved_at` (its end). A flight whose stop is at or before its start + * owes nothing. Periods are never clipped. + * 3. Every change at an instant T that hands periods from an outgoing schedule to an + * incoming one (a new version, deactivation, a resolution change, an account timezone + * change) is a cutover: the outgoing schedule owes its periods that start before T in + * full, and the incoming schedule owes its periods that start at or after E, the end of + * the last of them. + * 4. Exceptions (`excluded[]`, `gaps[]`) and lost authority only ever remove periods. + * + * @packageDocumentation + */ +import { CoverageInputError } from '../../errors'; +import type { ReportingSubscription } from '../../types/core.generated'; +import type { + ReportingSubscriptionCoverageWithOfferingsV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; +import { + assertReportingIanaTimezoneV1, + createReportingPeriodCalendarV1, + parseReportingPeriodDurationV1, + type ReportingPeriodCalendarV1, +} from './calendar'; +import { + compareReportingIdsByteOrderV1, + reportingSubscriptionScopeIncludesV1, + resolveReportingSubscriptionOfferingsV1, + type ReportingSubscriptionDestinationMethodV1, + type ReportingSubscriptionOfferingV1, +} from './selection'; + +const DAY_MS = 86_400_000; +const DEFAULT_MAX_PERIODS = 100_000; +const DEFAULT_MAX_ITERATIONS = 2_000_000; + +/** + * One accepted `subscription_version`, as the buyer recorded it. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionVersionRecordV1 { + subscription_version: number; + /** The version's `accepted_at` from the `sync_principal`/`get_principal` readback. */ + accepted_at: string; + /** + * The next version's `accepted_at` (`prior_versions[].superseded_at`). Inferred from the + * next record when absent. + */ + superseded_at?: string; + /** When `active: false` was applied (`deactivated_at`), for the current version. */ + deactivated_at?: string; + /** That version's subscription, as sent (readback `configuration`). */ + configuration: ReportingSubscription; + /** + * `pattern` and `transport` of the destination `configuration.destination` names. + * Required when the configuration names a destination. + */ + destination_method?: ReportingSubscriptionDestinationMethodV1; +} + +/** + * Flight values a buy carried before an update replaced them. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionPriorFlightV1 { + /** When these values stopped applying. Periods resolved at or after it use later values. */ + replaced_at: string; + start_time: string; + end_time: string; + canceled_at?: string; + completed_at?: string; +} + +/** + * A media buy the caller can see through `get_media_buys`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionBuyV1 { + media_buy_id: string; + /** Null or absent: not confirmed, so not covered. */ + confirmed_at?: string | null; + start_time: string; + end_time: string; + /** `cancellation.canceled_at`. */ + canceled_at?: string; + completed_at?: string; + /** Each package with its product's `reporting_capabilities.reporting_delivery_offering_ids`. */ + packages: ReadonlyArray<{ package_id: string; reporting_delivery_offering_ids: readonly string[] }>; + /** + * Earlier flight values, so an extension or reinstatement never makes an elapsed period + * owed. Not on the wire: `get_media_buys` shows only the current flight, so a buyer MUST + * retain them to agree with the seller after a flight change. + */ + prior_flights?: readonly ReportingSubscriptionPriorFlightV1[]; +} + +/** + * One grant of reporting authority over the account to the caller. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionGrantV1 { + /** + * When this grant began. The wire's `authorization.authorized_at` is the start of the + * authorization chain and stays unchanged after a re-grant inside retention + * (adcontextprotocol/adcp#8585); a buyer that saw only that value lists the continuation + * with it (any value at or before the previous `unauthorized_at`), and the seller's + * `gaps[]` then removes what the interruption cost. A later value starts a new chain, or + * records the observed re-grant instant inside retention. + */ + authorized_at: string; + /** `accounts_unauthorized[].unauthorized_at` when it ended. Absent: still in force. */ + unauthorized_at?: string; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingSubscriptionCoverageAccountV1 { + account_id: string; + /** The account's `operator` domain, needed when a version names `operator_domains`. */ + operator?: string; + /** + * The account's current IANA timezone as `list_accounts` shows it. Before a + * `timezone_cutovers` entry, the entry's `previous_timezone` governs. Required when an + * `account_timezone` offering resolves and no cutover covers the instant. + */ + timezone?: string; + /** + * The seller-resolved upstream reporting timezone, the obligation's + * `period.source_timezone`. Required when an `account_resolved` `source_timezone` + * offering resolves. + */ + source_timezone?: string; + /** + * Grants in order. A re-grant before the previous `accounts_unauthorized` entry expires + * (`status_retention_days` after `unauthorized_at`) continues coverage; a later one is a + * fresh start from its `authorized_at`. + */ + grants: readonly ReportingSubscriptionGrantV1[]; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ExpectedSubscriptionPeriodsInputV1 { + subscription_id: string; + /** Every version whose obligations matter, oldest first. */ + versions: readonly ReportingSubscriptionVersionRecordV1[]; + account: ReportingSubscriptionCoverageAccountV1; + buys: readonly ReportingSubscriptionBuyV1[]; + /** Seller offerings as published, with `standard_dimensions` and `unmapped_dimensions`. */ + offerings: readonly ReportingSubscriptionOfferingV1[]; + /** + * The readback's `inactive_intervals`, oldest first. Reactivation is a fresh start: nothing + * that starts inside an interval is owed, and coverage resumes at the first boundary at or + * after `reactivated_at`. An interval without `reactivated_at` is still open. + */ + inactive_intervals?: readonly ReportingSubscriptionInactiveIntervalV1[]; + /** This subscription's `subscription_coverage` entry for the account, when one was read. */ + coverage?: Pick; + /** The seller's `subscriptions.backfill_on_regrant`. Defaults to false. */ + backfill_on_regrant?: boolean; + /** The seller's `status_retention_days`. Required when a grant was revoked and re-granted. */ + status_retention_days?: number; + /** Only periods that have ended (been resolved) at or before this instant are returned. */ + now: string; + /** Fail closed above this many periods. Defaults to 100000. */ + max_periods?: number; + /** Fail closed above this many boundaries examined. Defaults to 2000000. */ + max_iterations?: number; +} + +/** + * One owed period for one (buy, offering) pair. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ExpectedSubscriptionPeriodV1 { + subscription_id: string; + /** The version that owes it, the obligation's `delivery_config_version`. */ + subscription_version: number; + account_id: string; + media_buy_id: string; + offering_id: string; + report_definition_id: string; + /** RFC 3339 instant, `toISOString()` form. */ + period_start: string; + period_end: string; +} + +/** + * Owed periods grouped the way the seller's obligations carry them. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ExpectedSubscriptionObligationV1 { + subscription_id: string; + subscription_version: number; + account_id: string; + offering_id: string; + report_definition_id: string; + period_start: string; + period_end: string; + /** Sorted in byte order; the obligation's frozen `media_buy_ids`. */ + media_buy_ids: string[]; +} + +interface Flight { + start: number; + stop: number; +} + +interface Window { + from: number; + until: number; +} + +interface ResolvedOffering { + offering: ReportingSubscriptionOfferingV1; + calendar: ReportingPeriodCalendarV1; +} + +interface Regime { + from: number; + until: number; + version: ReportingSubscriptionVersionRecordV1; + offerings: ResolvedOffering[]; + /** The instant each offering owed from (before its first boundary), set while walking. */ + handoffs: Map; +} + +interface AuthorityChain { + start: number; + /** + * Revocations followed by a re-grant inside the retention window. `regrantedAt` is absent + * when only the chain-start `authorized_at` was observed; the seller's gaps[] then applies. + */ + interruptions: Array<{ revokedAt: number; regrantedAt?: number }>; + /** Final revocation, when authority has not returned within retention. */ + end?: number; +} + +/** + * Every period a subscription owes for one account, one row per (buy, offering, period), + * sorted by `period_start`, `subscription_version`, `offering_id` and `media_buy_id`. + * + * Throws `TypeError` for malformed input and `RangeError` when the result would exceed + * `max_periods`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsInputV1): ExpectedSubscriptionPeriodV1[] { + const now = instant(input.now, 'now'); + const maxPeriods = input.max_periods ?? DEFAULT_MAX_PERIODS; + if (!Number.isSafeInteger(maxPeriods) || maxPeriods < 1) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'max_periods must be a positive integer', 'invalid_input'); + if (typeof input.subscription_id !== 'string' || input.subscription_id.length === 0) { + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'subscription_id is required', 'invalid_input'); + } + const versions = normalizeVersions(input.versions, input.subscription_id, inactiveWindows(input.inactive_intervals)); + const chains = authorityChains(input.account.grants, input.status_retention_days); + const timezones = timezoneTimeline(input.account.timezone, input.coverage?.timezone_cutovers); + const removals = exceptionWindows(input.coverage); + const offeringsById = new Map(); + for (const offering of input.offerings) { + if (offeringsById.has(offering.offering_id)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'offering_id values must be unique', 'invalid_input'); + offeringsById.set(offering.offering_id, offering); + } + const calendars = new Map(); + const result: ExpectedSubscriptionPeriodV1[] = []; + const seenBuys = new Set(); + const maxIterations = input.max_iterations ?? DEFAULT_MAX_ITERATIONS; + if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'max_iterations must be a positive integer', + 'invalid_input' + ); + } + let iterations = 0; + + for (const buy of input.buys) { + if (seenBuys.has(buy.media_buy_id)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'media_buy_id values must be unique', 'invalid_input'); + seenBuys.add(buy.media_buy_id); + if (buy.confirmed_at === undefined || buy.confirmed_at === null) continue; + const confirmedAt = instant(buy.confirmed_at, `${buy.media_buy_id}.confirmed_at`); + const flights = flightTimeline(buy); + const latestStop = Math.max(...flights.map(entry => entry.flight.stop)); + const earliestStart = Math.min(...flights.map(entry => entry.flight.start)); + const regimes = buildRegimes(buy, versions, input, offeringsById, timezones, calendars); + + for (let index = 0; index < regimes.length; index += 1) { + const regime = regimes[index]; + if (regime === null || regime === undefined) continue; + const predecessor = index > 0 ? regimes[index - 1] : null; + for (const resolved of regime.offerings) { + const { calendar, offering } = resolved; + const handoff = cutoverEnd(regime.from, predecessor ?? null, resolved); + regime.handoffs.set(offering.offering_id, handoff); + // A period starting before the last boundary at or before the earliest flight + // start ends by then and cannot overlap any flight value, so skip ahead. + let start = Math.max(firstOwedBoundary(calendar, handoff, confirmedAt), calendar.floor(earliestStart)); + while (start < regime.until && start < latestStop) { + if (++iterations > maxIterations) { + throw new CoverageInputError( + 'COVERAGE_BUDGET_EXCEEDED', + `Expected subscription periods exceed the iteration budget (${maxIterations})` + ); + } + // next() is strictly later, so a zero-length period (a skipped civil date) is never owed. + const end = calendar.next(start); + if (end > now) break; + const covering = coveringRemoval(removals, buy.media_buy_id, offering.offering_id, start); + if (covering !== undefined) { + // Skip the whole removed stretch rather than walking it boundary by boundary. + if (!Number.isFinite(covering.until)) break; + start = Math.max(end, calendar.ceil(covering.until)); + continue; + } + const flight = flightAt(flights, end); + const owed = + flight.start < flight.stop && // an empty flight overlaps nothing + start < flight.stop && + end > flight.start && + authorized(chains, calendar, start, input.backfill_on_regrant === true); + if (!owed) { + start = end; + continue; + } + result.push({ + subscription_id: input.subscription_id, + subscription_version: regime.version.subscription_version, + account_id: input.account.account_id, + media_buy_id: buy.media_buy_id, + offering_id: offering.offering_id, + report_definition_id: offering.report_definition_id, + period_start: new Date(start).toISOString(), + period_end: new Date(end).toISOString(), + }); + if (result.length > maxPeriods) { + throw new CoverageInputError( + 'COVERAGE_BUDGET_EXCEEDED', + `Expected subscription periods exceed max_periods (${maxPeriods})` + ); + } + start = end; + } + } + } + } + return result.sort(comparePeriods); +} + +/** + * The start rule, kept in one place (adcp#8593): the first boundary at or after the cutover + * handoff (the version's `accepted_at`, or E after a cutover), and no period ending at or + * before the buy's `confirmed_at`. A period ends after `confirmed_at` exactly when it starts + * at or after the last boundary at or before it, so the period in which the buy is confirmed + * is owed in full. The account's `authorized_at` term is applied per authorization chain in + * `authorized`; ceil is monotone, so together the start bound is the first boundary at or + * after the later of `accepted_at` and `authorized_at`. + */ +function firstOwedBoundary(calendar: ReportingPeriodCalendarV1, handoff: number, confirmedAt: number): number { + return Math.max(calendar.ceil(handoff), calendar.floor(confirmedAt)); +} + +/** + * Group owed periods into the obligations that must carry them, with frozen `media_buy_ids`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function groupExpectedSubscriptionPeriodsV1( + periods: readonly ExpectedSubscriptionPeriodV1[] +): ExpectedSubscriptionObligationV1[] { + const groups = new Map(); + for (const period of periods) { + const key = JSON.stringify([ + period.subscription_id, + period.subscription_version, + period.account_id, + period.offering_id, + period.period_start, + period.period_end, + ]); + let group = groups.get(key); + if (!group) { + group = { + subscription_id: period.subscription_id, + subscription_version: period.subscription_version, + account_id: period.account_id, + offering_id: period.offering_id, + report_definition_id: period.report_definition_id, + period_start: period.period_start, + period_end: period.period_end, + media_buy_ids: [], + }; + groups.set(key, group); + } + if (!group.media_buy_ids.includes(period.media_buy_id)) group.media_buy_ids.push(period.media_buy_id); + } + return [...groups.values()] + .map(group => ({ ...group, media_buy_ids: group.media_buy_ids.sort(compareReportingIdsByteOrderV1) })) + .sort( + (left, right) => + Date.parse(left.period_start) - Date.parse(right.period_start) || + left.subscription_version - right.subscription_version || + compareReportingIdsByteOrderV1(left.offering_id, right.offering_id) + ); +} + +function comparePeriods(left: ExpectedSubscriptionPeriodV1, right: ExpectedSubscriptionPeriodV1): number { + return ( + Date.parse(left.period_start) - Date.parse(right.period_start) || + left.subscription_version - right.subscription_version || + compareReportingIdsByteOrderV1(left.offering_id, right.offering_id) || + compareReportingIdsByteOrderV1(left.media_buy_id, right.media_buy_id) + ); +} + +interface NormalizedVersion { + record: ReportingSubscriptionVersionRecordV1; + acceptedAt: number; + /** Superseded or deactivated: periods starting at or after it are not this version's. */ + endsAt: number; + inactive: Window[]; +} + +function normalizeVersions( + records: readonly ReportingSubscriptionVersionRecordV1[], + subscriptionId: string, + globalInactive: readonly Window[] +): NormalizedVersion[] { + if (!Array.isArray(records) || records.length === 0) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'versions must list at least one version', 'invalid_input'); + const versions: NormalizedVersion[] = []; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]!; + const next = records[index + 1]; + const label = `version ${record.subscription_version}`; + if (!Number.isSafeInteger(record.subscription_version) || record.subscription_version < 1) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'subscription_version must be a positive integer', + 'invalid_input' + ); + } + // The selector's period_duration follows the schedule pattern (no W designator; a week is P7D). A + // selector no offering could ever match is an input error, not an empty owed set. + const selectorDuration = (record.configuration?.selector as { period_duration?: unknown } | undefined) + ?.period_duration; + if (selectorDuration !== undefined) parseReportingPeriodDurationV1(selectorDuration as string); + if (record.configuration?.subscription_id !== subscriptionId) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${label} configuration names a different subscription_id`, + 'invalid_input' + ); + } + if (record.configuration.subscription_version !== record.subscription_version) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${label} configuration carries a different subscription_version`, + 'invalid_input' + ); + } + if (record.configuration.destination !== undefined && record.destination_method === undefined) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `${label} names a destination; destination_method is required`, + 'missing_destination_method' + ); + } + const acceptedAt = instant(record.accepted_at, `${label} accepted_at`); + const previous = versions.at(-1); + if ( + previous && + (record.subscription_version <= previous.record.subscription_version || acceptedAt < previous.acceptedAt) + ) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'versions must be in ascending subscription_version and accepted_at order', + 'invalid_input' + ); + } + const supersededAt = + record.superseded_at !== undefined + ? instant(record.superseded_at, `${label} superseded_at`) + : next !== undefined + ? instant(next.accepted_at, `version ${next.subscription_version} accepted_at`) + : Number.POSITIVE_INFINITY; + if (next !== undefined && supersededAt !== instant(next.accepted_at, 'accepted_at')) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${label} superseded_at must equal the next version's accepted_at`, + 'invalid_input' + ); + } + const inactive: Window[] = []; + if (record.deactivated_at !== undefined) { + inactive.push({ + from: instant(record.deactivated_at, `${label} deactivated_at`), + until: Number.POSITIVE_INFINITY, + }); + } else if ( + record.configuration.active === false && + next === undefined && + !globalInactive.some(window => !Number.isFinite(window.until)) + ) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${label} is inactive; deactivated_at or an open inactive_intervals entry is required`, + 'invalid_input' + ); + } + versions.push({ record, acceptedAt, endsAt: supersededAt, inactive: [...inactive, ...globalInactive] }); + } + return versions; +} + +/** The readback's `inactive_intervals` as half-open windows. */ +function inactiveWindows(intervals: readonly ReportingSubscriptionInactiveIntervalV1[] | undefined): Window[] { + if (intervals !== undefined && !Array.isArray(intervals)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'inactive_intervals must be an array', 'invalid_input'); + return (intervals ?? []).map(interval => { + const from = instant(interval.deactivated_at, 'inactive_intervals.deactivated_at'); + const until = + interval.reactivated_at === undefined + ? Number.POSITIVE_INFINITY + : instant(interval.reactivated_at, 'inactive_intervals.reactivated_at'); + if (!(until > from)) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'inactive_intervals reactivated_at must be after deactivated_at', + 'invalid_input' + ); + return { from, until }; + }); +} + +function authorityChains( + grants: readonly ReportingSubscriptionGrantV1[], + retentionDays: number | undefined +): AuthorityChain[] { + if (!Array.isArray(grants)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'account.grants must be an array', 'invalid_input'); + const chains: AuthorityChain[] = []; + let previousEnd: number | undefined; + for (let index = 0; index < grants.length; index += 1) { + const grant = grants[index]!; + const authorizedAt = instant(grant.authorized_at, 'grants.authorized_at'); + const unauthorizedAt = + grant.unauthorized_at === undefined ? undefined : instant(grant.unauthorized_at, 'grants.unauthorized_at'); + if (unauthorizedAt !== undefined && unauthorizedAt < authorizedAt) { + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'A grant cannot end before it began', 'invalid_input'); + } + if (previousEnd === undefined && index > 0) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'Only the last grant may be unrevoked', 'invalid_input'); + const current = chains.at(-1); + if (current !== undefined && previousEnd !== undefined) { + if (authorizedAt <= previousEnd) { + // The wire's authorized_at stays at the chain's first grant after a re-grant inside + // retention (adcontextprotocol/adcp#8585), so the buyer does not know the re-grant + // instant. The chain continues and the seller's gaps[] says what it removed. + current.interruptions.push({ revokedAt: previousEnd }); + delete current.end; + } else if (retentionDays === undefined || !Number.isSafeInteger(retentionDays) || retentionDays < 1) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + 'status_retention_days is required to evaluate a re-grant', + 'missing_status_retention_days' + ); + } else if (authorizedAt < previousEnd + retentionDays * DAY_MS) { + current.interruptions.push({ revokedAt: previousEnd, regrantedAt: authorizedAt }); + delete current.end; + } else { + chains.push({ start: authorizedAt, interruptions: [] }); + } + } else { + chains.push({ start: authorizedAt, interruptions: [] }); + } + if (unauthorizedAt !== undefined) chains.at(-1)!.end = unauthorizedAt; + previousEnd = unauthorizedAt; + } + return chains; +} + +/** + * Whether the account was authorized for a period starting at `start`, under `calendar`'s + * boundaries. Coverage within a chain begins at the first boundary at or after its first + * grant. A revocation removes periods from the last boundary at or before it; a re-grant + * inside retention restores them from the first boundary at or after it, or, with + * `backfill_on_regrant`, keeps the revoked interval owed. + */ +function authorized( + chains: readonly AuthorityChain[], + calendar: ReportingPeriodCalendarV1, + start: number, + backfill: boolean +): boolean { + return chains.some(chain => { + if (start < calendar.ceil(chain.start)) return false; + if (chain.end !== undefined && start >= calendar.floor(chain.end)) return false; + if (!backfill) { + for (const gap of chain.interruptions) { + if (gap.regrantedAt === undefined) continue; + if (start >= calendar.floor(gap.revokedAt) && start < calendar.ceil(gap.regrantedAt)) return false; + } + } + return true; + }); +} + +interface TimezoneCutover { + at: number; + timezone: string; + previous?: string; + offeringId?: string; +} + +interface TimezoneTimeline { + current: string | undefined; + cutovers: TimezoneCutover[]; +} + +function timezoneTimeline( + current: string | undefined, + entries: readonly ReportingSubscriptionTimezoneCutoverV1[] | undefined +): TimezoneTimeline { + if (current !== undefined) assertReportingIanaTimezoneV1(current, 'account.timezone'); + const cutovers = [...(entries ?? [])] + .map(entry => { + assertReportingIanaTimezoneV1(entry.timezone, 'timezone_cutovers.timezone'); + if (entry.previous_timezone !== undefined) { + assertReportingIanaTimezoneV1(entry.previous_timezone, 'timezone_cutovers.previous_timezone'); + } + return { + at: instant(entry.effective_at, 'timezone_cutovers.effective_at'), + timezone: entry.timezone, + ...(entry.previous_timezone !== undefined ? { previous: entry.previous_timezone } : {}), + ...(entry.offering_id !== undefined ? { offeringId: entry.offering_id } : {}), + }; + }) + .sort((left, right) => left.at - right.at); + return { current, cutovers }; +} + +/** + * The account timezone governing one offering at an instant. Entries without `offering_id` + * apply to every offering. Before an offering's first cutover its `previous_timezone` + * governs; after the last, its `timezone`; with no cutovers, the account's current zone. + */ +function timezoneFor(timeline: TimezoneTimeline, offeringId: string, at: number): string | undefined { + const applicable = timeline.cutovers.filter( + cutover => cutover.offeringId === undefined || cutover.offeringId === offeringId + ); + if (applicable.length === 0) return timeline.current; + if (at < applicable[0]!.at) { + if (applicable[0]!.previous === undefined) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + 'timezone_cutovers entries require previous_timezone', + 'missing_previous_timezone' + ); + } + return applicable[0]!.previous; + } + let zone = applicable[0]!.timezone; + for (const cutover of applicable) if (cutover.at <= at) zone = cutover.timezone; + return zone; +} + +interface ExceptionWindow extends Window { + mediaBuyId?: string; + /** Exclusions and gaps may name one offering; absent applies to every offering. */ + offeringId?: string; +} + +function exceptionWindows(coverage: ExpectedSubscriptionPeriodsInputV1['coverage']): ExceptionWindow[] { + const windows: ExceptionWindow[] = []; + for (const entry of coverage?.excluded ?? []) { + // A package-level offering_unsupported entry removes packages from the buy's + // coverage, not periods: the buy stays covered for its other packages. + if (entry.package_ids !== undefined) continue; + windows.push({ + from: instant(entry.from, 'excluded.from'), + until: entry.until === undefined ? Number.POSITIVE_INFINITY : instant(entry.until, 'excluded.until'), + ...(entry.media_buy_id !== undefined ? { mediaBuyId: entry.media_buy_id } : {}), + // With different period boundaries the seller lists one entry per offering, each with that + // offering's cutover instants; it removes only that offering's periods. + ...(entry.offering_id !== undefined ? { offeringId: entry.offering_id } : {}), + }); + } + for (const gap of coverage?.gaps ?? []) { + windows.push({ + from: instant(gap.from, 'gaps.from'), + until: instant(gap.until, 'gaps.until'), + ...(gap.offering_id !== undefined ? { offeringId: gap.offering_id } : {}), + }); + } + return windows; +} + +function coveringRemoval( + windows: readonly ExceptionWindow[], + mediaBuyId: string, + offeringId: string, + start: number +): ExceptionWindow | undefined { + return windows.find( + window => + (window.mediaBuyId === undefined || window.mediaBuyId === mediaBuyId) && + (window.offeringId === undefined || window.offeringId === offeringId) && + start >= window.from && + start < window.until + ); +} + +function flightTimeline(buy: ReportingSubscriptionBuyV1): Array<{ until: number; flight: Flight }> { + const toFlight = ( + values: Pick, + label: string + ): Flight => { + const start = instant(values.start_time, `${label}.start_time`); + const stops = [instant(values.end_time, `${label}.end_time`)]; + if (values.canceled_at !== undefined) stops.push(instant(values.canceled_at, `${label}.canceled_at`)); + if (values.completed_at !== undefined) stops.push(instant(values.completed_at, `${label}.completed_at`)); + return { start, stop: Math.min(...stops) }; + }; + const priors = [...(buy.prior_flights ?? [])] + .map(prior => ({ + until: instant(prior.replaced_at, `${buy.media_buy_id}.prior_flights.replaced_at`), + flight: toFlight(prior, `${buy.media_buy_id}.prior_flights`), + })) + .sort((left, right) => left.until - right.until); + return [...priors, { until: Number.POSITIVE_INFINITY, flight: toFlight(buy, buy.media_buy_id) }]; +} + +/** Flight values in effect at a period's `scope_resolved_at`: replaced values apply strictly before `replaced_at`. */ +function flightAt(timeline: ReadonlyArray<{ until: number; flight: Flight }>, resolvedAt: number): Flight { + return timeline.find(entry => resolvedAt < entry.until)!.flight; +} + +/** + * Split the timeline into regimes: maximal intervals in which one version is active for the + * account and the buy's resolved offerings and their calendars are unchanged. `null` marks + * an interval with no active version, which breaks cutover continuity. + */ +function buildRegimes( + buy: ReportingSubscriptionBuyV1, + versions: readonly NormalizedVersion[], + input: ExpectedSubscriptionPeriodsInputV1, + offeringsById: ReadonlyMap, + timezones: TimezoneTimeline, + calendars: Map +): Array { + const points = new Set(); + for (const version of versions) { + points.add(version.acceptedAt); + if (Number.isFinite(version.endsAt)) points.add(version.endsAt); + for (const window of version.inactive) { + points.add(window.from); + if (Number.isFinite(window.until)) points.add(window.until); + } + } + for (const offering of offeringsById.values()) { + if (offering.available_from !== undefined) points.add(instant(offering.available_from, 'available_from')); + if (offering.withdrawn_at !== undefined) points.add(instant(offering.withdrawn_at, 'withdrawn_at')); + } + for (const cutover of timezones.cutovers) points.add(cutover.at); + const ordered = [...points].sort((left, right) => left - right); + const regimes: Array = []; + for (let index = 0; index < ordered.length; index += 1) { + const from = ordered[index]!; + const until = ordered[index + 1] ?? Number.POSITIVE_INFINITY; + const version = versions.find( + candidate => + from >= candidate.acceptedAt && + from < candidate.endsAt && + !candidate.inactive.some(window => from >= window.from && from < window.until) + ); + const inScope = + version !== undefined && + reportingSubscriptionScopeIncludesV1(version.record.configuration, { + account_id: input.account.account_id, + ...(input.account.operator !== undefined ? { operator: input.account.operator } : {}), + }); + if (!version || !inScope) { + if (regimes.at(-1) !== null) regimes.push(null); + continue; + } + const offerings = resolveForBuy(buy, version.record, input, offeringsById, timezones, from, calendars); + const previous = regimes.at(-1); + if (previous && previous.version === version.record && sameResolution(previous.offerings, offerings)) { + previous.until = until; + continue; + } + regimes.push({ from, until, version: version.record, offerings, handoffs: new Map() }); + } + return regimes; +} + +function sameResolution(left: readonly ResolvedOffering[], right: readonly ResolvedOffering[]): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.offering.offering_id === right[index]!.offering.offering_id && + entry.calendar.key === right[index]!.calendar.key + ) + ); +} + +function resolveForBuy( + buy: ReportingSubscriptionBuyV1, + version: ReportingSubscriptionVersionRecordV1, + input: ExpectedSubscriptionPeriodsInputV1, + offeringsById: ReadonlyMap, + timezones: TimezoneTimeline, + at: number, + calendars: Map +): ResolvedOffering[] { + const ids = new Set(); + for (const pkg of buy.packages) { + for (const id of resolveReportingSubscriptionOfferingsV1({ + selector: version.configuration.selector, + reconciliation_mode: version.configuration.reconciliation_mode, + ...(version.destination_method ? { destination_method: version.destination_method } : {}), + offerings: [...offeringsById.values()], + eligible_offering_ids: pkg.reporting_delivery_offering_ids, + at, + })) { + ids.add(id); + } + } + return [...ids].sort(compareReportingIdsByteOrderV1).map(id => { + const offering = offeringsById.get(id)!; + const spec = calendarSpec( + offering, + version.configuration.selector.period_timezone, + offering.schedule.alignment === 'account_timezone' && version.configuration.selector.period_timezone === undefined + ? timezoneFor(timezones, id, at) + : undefined, + input.account.source_timezone + ); + const key = JSON.stringify(spec); + let calendar = calendars.get(key); + if (!calendar) { + calendar = createReportingPeriodCalendarV1(spec); + calendars.set(key, calendar); + } + return { offering, calendar }; + }); +} + +/** The published schedule's calendar for this account, with any `period_timezone` override. */ +function calendarSpec( + offering: ReportingSubscriptionOfferingV1, + override: string | undefined, + accountTimezone: string | undefined, + sourceTimezone: string | undefined +) { + const schedule = offering.schedule; + const base = { period_duration: schedule.period_duration, alignment: schedule.alignment }; + switch (schedule.alignment) { + case 'utc': + return base; + case 'account_timezone': { + const timezone = override ?? accountTimezone; + if (timezone === undefined) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `Offering ${offering.offering_id} is account_timezone aligned; account.timezone is required`, + 'missing_account_timezone' + ); + } + return { ...base, timezone }; + } + case 'source_timezone': { + // A period_timezone override only ever matches the offering's own published zone. + const timezone = schedule.period_timezone_policy === 'fixed' ? schedule.period_timezone : sourceTimezone; + if (timezone === undefined) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `Offering ${offering.offering_id} resolves its source timezone per account; account.source_timezone is required`, + 'missing_source_timezone' + ); + } + return { ...base, timezone }; + } + case 'billing_cycle': + return { ...base, timezone: schedule.period_timezone!, period_anchor: schedule.period_anchor! }; + default: + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Offering ${offering.offering_id} has an unknown alignment`, + 'invalid_input' + ); + } +} + +/** + * The instant from which an incoming schedule owes periods (before taking its first + * boundary at or after it). With no adjacent outgoing regime this is the regime start, the + * ordinary start rule. Otherwise it is E, the end of the last outgoing period that started + * before the handoff: the same offering's own outgoing calendar when it continues, or the + * latest such end across the outgoing offerings when the resolution changed, so that + * nothing is owed twice. + */ +function cutoverEnd(handoff: number, predecessor: Regime | null, incoming: ResolvedOffering): number { + if (predecessor === null || predecessor.offerings.length === 0) return handoff; + const same = predecessor.offerings.find(entry => entry.offering.offering_id === incoming.offering.offering_id); + const end = same + ? same.calendar.ceil(handoff) + : Math.max(...predecessor.offerings.map(entry => entry.calendar.ceil(handoff))); + // A predecessor that itself only began owing at a later E (two cutovers inside one + // period) hands off no earlier than that: its outgoing period still runs to there. + const carried = same + ? predecessor.handoffs.get(incoming.offering.offering_id) + : predecessor.handoffs.size > 0 + ? Math.max(...predecessor.handoffs.values()) + : undefined; + return carried === undefined ? end : Math.max(end, carried); +} + +function instant(value: unknown, field: string): number { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) + ) { + throw new CoverageInputError('COVERAGE_INPUT_INVALID', `${field} must be an RFC 3339 date-time`, 'invalid_instant'); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', `${field} must be an RFC 3339 date-time`, 'invalid_instant'); + return parsed; +} diff --git a/src/lib/reporting/subscriptions/engine-state-store.ts b/src/lib/reporting/subscriptions/engine-state-store.ts new file mode 100644 index 000000000..355fe9252 --- /dev/null +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -0,0 +1,151 @@ +/** + * Durable reporting subscription engine store over the SDK state-store port. + * + * The engine shards its state into one CAS document per principal (`max_accounts` slots) and + * one per (principal, account) (grant history, exceptions, timezone cutovers, gaps and the + * frozen owed periods), so a principal with many accounts never rewrites one ever-growing + * row. A PostgreSQL deployment uses the `PostgresStateStore` it already runs for the + * principal row; no further migration is needed. The subscription version history lives in + * the principal row and is never pruned, so `prior_versions` can always be answered for + * retained obligations. + */ +import { ReportingSubscriptionEngineError } from '../../errors'; +import { PlatformConfigError } from '../../server/decisioning/runtime/validate-platform'; +import { createHash } from 'node:crypto'; +import { InMemoryStateStore, type AdcpStateStore } from '../../server/state-store'; +import type { PrincipalStoreScope } from '../../server/principal/types'; +import type { ReportingSubscriptionEngineStoreV1 } from './engine'; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export const DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION = 'adcp_reporting_subscription_engine'; +const MAX_PAGES = 10_000; +const PAGE_SIZE = 500; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface CreateReportingSubscriptionEngineStateStoreOptionsV1 { + store: AdcpStateStore; + collection?: string; + /** Declare durable only when the backend survives process loss (for example `PostgresStateStore`). */ + durability?: 'process-local' | 'durable'; +} + +interface EngineDocument extends Record { + schemaVersion: 2; + tenantId: string; + principalId: string; + key: string; + value: Record; +} + +function documentId(scope: Readonly, key: string): string { + return `subscription_engine_${createHash('sha256') + .update(JSON.stringify([scope.tenantId, scope.principalId, key])) + .digest('hex')}`; +} + +/** + * {@link ReportingSubscriptionEngineStoreV1} over any `AdcpStateStore` with `getWithVersion` and `putIfMatch`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function createReportingSubscriptionEngineStateStoreV1( + options: CreateReportingSubscriptionEngineStateStoreOptionsV1 +): ReportingSubscriptionEngineStoreV1 { + if (!options?.store?.getWithVersion || !options.store.putIfMatch) { + throw new PlatformConfigError( + 'The reporting subscription engine store requires getWithVersion and putIfMatch. Use a state store that implements both, such as PostgresStateStore.', + { configField: 'store' } + ); + } + if (options.durability === 'durable' && options.store instanceof InMemoryStateStore) { + throw new PlatformConfigError( + 'InMemoryStateStore cannot be declared durable. Use durable storage, such as PostgresStateStore, or drop durability: "durable".', + { code: 'REPORTING_STORAGE_NOT_DURABLE', configField: 'durability' } + ); + } + const collection = options.collection ?? DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION; + const store = options.store; + + /** Every document matching `filter`. A truncated walk would drop owed receipts, so it fails closed. */ + async function listAll(filter: Record): Promise { + const documents: EngineDocument[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await store.list(collection, { + filter, + limit: PAGE_SIZE, + ...(cursor === undefined ? {} : { cursor }), + }); + for (const document of result.items) { + // Backends that ignore `filter` still never leak another tenant's or principal's rows. + if (Object.entries(filter).every(([field, value]) => document[field] === value)) documents.push(document); + } + if (result.nextCursor === undefined) return documents; + if (result.nextCursor === cursor) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_STATE_CORRUPT', + 'Reporting subscription engine store returned a repeating cursor. Fix the state store list implementation.' + ); + } + cursor = result.nextCursor; + } + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_STATE_CORRUPT', + `Reporting subscription engine store did not finish within ${MAX_PAGES} pages; refusing a partial list. Check the state store list implementation.` + ); + } + + return { + durability: options.durability ?? 'process-local', + async get(scope, key) { + const row = await store.getWithVersion!(collection, documentId(scope, key)); + if (!row) return null; + const document = row.data; + if ( + document.schemaVersion !== 2 || + document.tenantId !== scope.tenantId || + document.principalId !== scope.principalId || + document.key !== key || + !document.value || + typeof document.value !== 'object' + ) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_STATE_CORRUPT', + 'Reporting subscription engine document is malformed or belongs to another principal. Inspect the engine collection for tampering or a shared collection name.' + ); + } + return { value: structuredClone(document.value), revision: String(row.version) }; + }, + async put(scope, key, value, expectedRevision) { + const expected = expectedRevision === null ? null : Number(expectedRevision); + if (expected !== null && (!Number.isSafeInteger(expected) || expected < 1)) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_STATE_CORRUPT', + 'Reporting subscription engine store returned an invalid revision. The state store must return positive integer versions.' + ); + } + const document: EngineDocument = { + schemaVersion: 2, + tenantId: scope.tenantId, + principalId: scope.principalId, + key, + value: structuredClone(value), + }; + const result = await store.putIfMatch!(collection, documentId(scope, key), document, expected); + return result.ok ? { outcome: 'applied', revision: String(result.version) } : { outcome: 'conflict' }; + }, + async listKeys(scope) { + const documents = await listAll({ tenantId: scope.tenantId, principalId: scope.principalId }); + return documents.map(document => document.key); + }, + async listPrincipals(tenantId) { + const seen = new Map(); + for (const document of await listAll({ tenantId })) { + if (typeof document.principalId === 'string') { + seen.set(document.principalId, { tenantId, principalId: document.principalId }); + } + } + return [...seen.values()]; + }, + }; +} diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts new file mode 100644 index 000000000..773dd5ea6 --- /dev/null +++ b/src/lib/reporting/subscriptions/engine.ts @@ -0,0 +1,1809 @@ +/** + * Seller-side reporting subscription engine (experimental `media_buy.reporting_subscriptions`, + * AdCP 3.3). + * + * The host supplies only the facts it alone knows: + * + * - `listAuthorizedAccounts`: which accounts a principal may read now, with each account's + * chain-start `authorized_at`, and `granted_at` / `timezone_changed_at` when those changed; + * - `listAccountBuys`: which buys an account holds, with each package's offering ids; + * - `resolveRevocationInstant`: when a principal's authority over an account ended; + * - `isPeriodDelivered`: whether an owed period was delivered, for `withheld_period_count`; + * - optionally `listAccountDeliveryConfigs`: the caller's own account-level configurations, + * for precedence, including ended ones with their `active_until`. + * + * The engine keeps everything else: grant history, coverage exceptions opened and closed at + * the instant each change took effect, `max_accounts` slots, timezone cutovers, re-grant gaps + * and lost authority. It derives: + * + * - `subscription_coverage[]` for `get_reporting_status` summary and periods views; + * - `accounts_with_exceptions[]` and `accounts_unauthorized[]` in the principal readback, + * with `principal.changed` (`reporting_subscription_changed`) on every change; + * - the owed periods and obligations. Each reconcile freezes the periods that elapsed since + * the previous one, computed with the same pure {@link expectedSubscriptionPeriods} rule a + * buyer runs. Frozen periods are never recomputed, so later host changes (a withdrawn + * offering, an edited product) or pruning retained exceptions never change an elapsed + * owed set. Reconcile at least once per period so each period is frozen with the facts in + * force when it was resolved; + * - the tenant-scoped receipt roster (there is no post-loss receipt window, adcp#8607). + * + * State is sharded: one document per principal for `max_accounts` slots, and one per + * (principal, account), each updated with compare-and-set. + */ +import { canonicalize } from '../../utils/jcs'; +import { ReportingSubscriptionEngineError } from '../../errors'; +import { PlatformConfigError } from '../../server/decisioning/runtime/validate-platform'; +import type { PendingAction } from '../../types/core.generated'; +import type { + PrincipalLifecycleRuntimeSubscriptions, + PrincipalReportingSubscriptionState, + PrincipalStoreScope, +} from '../../server/principal/types'; +import type { + StoredReportingSubscriptionHistory, + StoredReportingSubscriptionVersion, +} from '../../server/principal/reporting-subscriptions'; +import { createReportingPeriodCalendarV1, type ReportingPeriodCalendarV1 } from './calendar'; +import type { + ReportingSubscriptionGapV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; +import { + expectedSubscriptionPeriods, + groupExpectedSubscriptionPeriodsV1, + type ExpectedSubscriptionObligationV1, + type ExpectedSubscriptionPeriodV1, + type ReportingSubscriptionBuyV1, + type ReportingSubscriptionGrantV1, + type ReportingSubscriptionVersionRecordV1, +} from './coverage'; +import { + compareReportingIdsByteOrderV1, + reportingSubscriptionScopeIncludesV1, + resolveReportingSubscriptionOfferingsV1, + type ReportingSubscriptionOfferingV1, +} from './selection'; + +type MaybePromise = T | Promise; +const DAY_MS = 86_400_000; +const MAX_AUTHORIZED_ACCOUNT_PAGES = 10_000; +const PRINCIPAL_KEY = 'principal'; +const ACCOUNT_KEY_PREFIX = 'account:'; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export type ReportingSubscriptionExclusionReasonV1 = + | 'offering_unsupported' + | 'capacity' + | 'overridden' + | 'excluded_by_buyer' + | 'action_required'; + +/** + * One `subscription_coverage[].excluded[]` entry. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionExclusionV1 { + media_buy_id?: string; + /** The offering whose periods it removes; absent for every offering. */ + offering_id?: string; + reason: ReportingSubscriptionExclusionReasonV1; + from: string; + until?: string; + package_ids?: string[]; + overridden_by?: { subscription_id: string } | { delivery_config_id: string }; +} + +/** + * One `subscription_coverage[]` entry, typed. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionCoverageEntryV1 { + subscription_id: string; + subscription_version: number; + /** + * `inactive` (adcp#8613) for a deactivated subscription; it takes precedence. Callers served + * a release without that value see the account-level state instead. + */ + status: 'active' | 'excluded' | 'action_required' | 'inactive'; + /** Present when and only when `status` is `inactive`. */ + deactivated_at?: string; + pending_action?: PendingAction; + excluded: ReportingSubscriptionExclusionV1[]; + timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; + gaps?: ReportingSubscriptionGapV1[]; +} + +/** + * Facts about one account the principal may currently read. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionAccountFactV1 { + account_id: string; + /** + * `authorization.authorized_at`: when the caller's current authorization chain began. A + * re-grant inside retention keeps it; a later value is a new chain. + */ + authorized_at: string; + /** + * When the current grant began, when it differs from `authorized_at` (a re-grant inside the + * chain). Required once the engine has recorded a revocation that the chain continues past. + */ + granted_at?: string; + operator?: string; + /** The account's IANA timezone as `list_accounts` shows it. */ + timezone?: string; + /** When `timezone` last changed. Required when it differs from the zone the engine recorded. */ + timezone_changed_at?: string; + /** Seller-resolved upstream reporting timezone, for account-resolved source_timezone offerings. */ + source_timezone?: string; + /** The seller serves this account only after this completes (`action_required`). */ + pending_action?: PendingAction; + /** When `pending_action` began. Defaults to the account's coverage start when first observed. */ + pending_action_since?: string; + /** + * The grant's `authorization.allowed_tasks`, as `list_accounts` shows it, when the seller + * advertises it. Coverage requires `get_reporting_status`; absent means the grant restricts no + * task. See {@link reportingSubscriptionCoverageGrantedV1}. + */ + allowed_tasks?: readonly string[]; +} + +/** + * Whether a grant authorizes reporting subscription coverage (`core/reporting-subscription.json` + * `x-adcp-validation.coverage`): "the caller is authorized for it: its authorization object, when + * advertised, lists get_reporting_status in allowed_tasks". A grant that restricts no task counts + * as all tasks. A reduced reconciliation grant (for example `get_reporting_status`, + * `sync_reporting_receipts` and `sync_reporting_status`) keeps coverage; dropping + * `get_reporting_status` from the grant ends it at that instant, as a loss of authority with an + * `accounts_unauthorized` entry. The one place this rule lives. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function reportingSubscriptionCoverageGrantedV1(grant: { + allowed_tasks?: readonly string[] | undefined; +}): boolean { + return grant.allowed_tasks === undefined || grant.allowed_tasks.includes('get_reporting_status'); +} + +/** + * One of the caller's own `sync_accounts` `reporting_delivery_configs` entries, for precedence. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionAccountConfigV1 { + delivery_config_id: string; + feed_purpose: 'pacing' | 'analytics' | 'billing'; + /** When it became active. */ + active_from: string; + /** + * When it ended (for example through `replace_account_configs`). Keep returning an ended + * configuration with `active_until` for `status_retention_days`, so the override closes at + * that instant rather than when the engine notices. + */ + active_until?: string; + /** Its schedule, so a handoff back to the subscription uses the configuration's own period end. */ + schedule?: { + period_duration: string; + alignment: 'utc' | 'account_timezone' | 'source_timezone' | 'billing_cycle'; + timezone?: string; + period_anchor?: string; + }; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingSubscriptionEngineHooksV1 { + /** The accounts this principal may read now, paged. The host owns the answer; the SDK never infers it. */ + listAuthorizedAccounts( + principal: Readonly, + options: { cursor?: string; signal?: AbortSignal } + ): MaybePromise<{ accounts: ReportingSubscriptionAccountFactV1[]; nextCursor?: string }>; + /** + * The media buys in this account the principal can see through `get_media_buys`, with each + * package's product `reporting_delivery_offering_ids`, and earlier flight values when an + * update changed them. + */ + listAccountBuys( + principal: Readonly, + accountId: string, + options: { signal?: AbortSignal } + ): MaybePromise; + /** + * When the principal's authority over the account ended. Called when an account leaves the + * authorized list, or reappears under a later chain without the revocation being observed. + * The answer is clamped to `[lastObservedAt, observedAt]`. + */ + resolveRevocationInstant( + principal: Readonly, + accountId: string, + input: { lastObservedAt?: string; observedAt: string } + ): MaybePromise; + /** Whether an owed period was delivered, for `withheld_period_count`. */ + isPeriodDelivered( + principal: Readonly, + period: ExpectedSubscriptionPeriodV1 + ): MaybePromise; + /** The principal's own account-level configurations on the account. Omit when the seller lists sync_principal alone. */ + listAccountDeliveryConfigs?( + principal: Readonly, + accountId: string, + options: { signal?: AbortSignal } + ): MaybePromise; +} + +interface StoredExclusion extends ReportingSubscriptionExclusionV1 { + /** Stable identity of the condition, so reconcile opens and closes it exactly once. */ + key: string; + pending_action?: PendingAction; +} + +/** An owed period frozen when it was resolved. */ +type FrozenPeriod = Omit; + +interface AccountState { + operator?: string; + /** The account's current timezone. Earlier zones are in each cutover's previous_timezone. */ + timezone?: string; + sourceTimezone?: string; + grants: ReportingSubscriptionGrantV1[]; + /** The chain-start authorized_at last reported. */ + chainStart?: string; + /** Last reconcile that saw the account authorized. */ + lastObservedAt?: string; +} + +interface SubscriptionAccountState { + exclusions: StoredExclusion[]; + gaps: ReportingSubscriptionGapV1[]; + timezoneCutovers: ReportingSubscriptionTimezoneCutoverV1[]; + /** The subscription served this account at some point (for accounts_unauthorized). */ + covered: boolean; + /** Offerings each buy resolved at the previous reconcile, to date resolution changes. */ + resolvedBuys: Record; + /** Owed periods resolved so far, never recomputed, and the instant through which they are complete. */ + frozen: { through?: string; periods: FrozenPeriod[] }; + /** + * Buys whose resolution changed (a product dropped or gained an offering, a package came or + * went, the buy disappeared): the buy as it was, and the E from which the new facts apply. + * Periods of that buy starting before E keep resolving from the earlier snapshot, so an edit + * never rewrites a period that was already owed. + */ + buyRevisions?: BuyRevision[]; +} + +interface BuyRevision { + media_buy_id: string; + effective_at: string; + previous: ReportingSubscriptionBuyV1; +} + +/** + * Durable engine state for one (principal, account). + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionEngineAccountDocumentV1 { + account: AccountState; + subscriptions: Record; + /** The buys as of the previous reconcile, so a product or package edit is dated, not retroactive. */ + lastBuys?: ReportingSubscriptionBuyV1[]; +} + +/** + * Durable engine state for one principal: `max_accounts` slots per subscription. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionEnginePrincipalDocumentV1 { + served: Record; +} + +/** + * Everything the engine holds for one principal, for inspection and conformance. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionEngineStateV1 { + principal: ReportingSubscriptionEnginePrincipalDocumentV1; + accounts: Record; +} + +/** + * Engine persistence: small documents keyed per principal (`principal`) and per account + * (`account:`), each with its own compare-and-set revision. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionEngineStoreV1 { + readonly durability: 'process-local' | 'durable'; + get( + principal: Readonly, + key: string + ): MaybePromise<{ value: Record; revision: string } | null>; + put( + principal: Readonly, + key: string, + value: Record, + expectedRevision: string | null + ): MaybePromise<{ outcome: 'applied'; revision: string } | { outcome: 'conflict' }>; + /** Every key stored for the principal. */ + listKeys(principal: Readonly): MaybePromise; + /** Principals with engine state in one tenant. */ + listPrincipals(tenantId: string): MaybePromise; +} + +/** + * Process-local engine store for tests and single-process sellers. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function createInMemoryReportingSubscriptionEngineStoreV1(): ReportingSubscriptionEngineStoreV1 { + const rows = new Map< + string, + { scope: PrincipalStoreScope; key: string; value: Record; revision: number } + >(); + const id = (scope: Readonly, key: string) => + JSON.stringify([scope.tenantId, scope.principalId, key]); + return { + durability: 'process-local', + get(scope, key) { + const row = rows.get(id(scope, key)); + return row ? { value: structuredClone(row.value), revision: String(row.revision) } : null; + }, + put(scope, key, value, expectedRevision) { + const row = rows.get(id(scope, key)); + if ((row ? String(row.revision) : null) !== expectedRevision) return { outcome: 'conflict' }; + const revision = (row?.revision ?? 0) + 1; + rows.set(id(scope, key), { + scope: { tenantId: scope.tenantId, principalId: scope.principalId }, + key, + value: structuredClone(value), + revision, + }); + return { outcome: 'applied', revision: String(revision) }; + }, + listKeys(scope) { + return [...rows.values()] + .filter(row => row.scope.tenantId === scope.tenantId && row.scope.principalId === scope.principalId) + .map(row => row.key); + }, + listPrincipals(tenantId) { + const seen = new Map(); + for (const row of rows.values()) { + if (row.scope.tenantId === tenantId) seen.set(row.scope.principalId, { ...row.scope }); + } + return [...seen.values()]; + }, + }; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface CreateReportingSubscriptionEngineOptionsV1 { + principal: PrincipalLifecycleRuntimeSubscriptions; + hooks: ReportingSubscriptionEngineHooksV1; + /** The seller's offerings as published, with `standard_dimensions`. Keep withdrawn ones with `withdrawn_at`. */ + offerings: readonly ReportingSubscriptionOfferingV1[]; + /** Defaults to a process-local store; production sellers pass a durable one. */ + store?: ReportingSubscriptionEngineStoreV1; + /** `subscriptions.max_accounts`. */ + maxAccounts?: number; + /** `subscriptions.backfill_on_regrant`. */ + backfillOnRegrant?: boolean; + /** `status_retention_days`. */ + statusRetentionDays: number; + now?: () => Date; + maxCasAttempts?: number; + /** + * Called when a subscription has more accounts to list in `accounts_with_exceptions` or + * `accounts_unauthorized` than the readback's 1000-entry limit (for example a small + * `max_accounts` against a large roster, where every account past the cap carries + * `capacity`). That subscription keeps its previous readback lists rather than publishing a + * silently cut one; every other subscription and all coverage state are still updated. + */ + onReadbackOverflow?: ( + principal: Readonly, + overflow: readonly ReportingSubscriptionReadbackOverflowV1[] + ) => void | Promise; +} + +/** + * A subscription whose readback lists exceed the 1000-entry limit. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionReadbackOverflowV1 { + subscription_id: string; + accounts_with_exceptions: number; + accounts_unauthorized: number; +} + +/** + * What a reconcile pass could not publish. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionReconcileResultV1 { + readback_overflow: ReportingSubscriptionReadbackOverflowV1[]; +} + +/** The readback's `maxItems` for `accounts_with_exceptions` and `accounts_unauthorized`. */ +const MAX_READBACK_ACCOUNTS = 1000; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingSubscriptionEngineV1 { + /** The commitments a capability document advertises for this engine. */ + readonly settings: { maxAccounts?: number; backfillOnRegrant: boolean; statusRetentionDays: number }; + /** + * Reconcile one principal: read its authorized accounts and buys, record grants and + * revocations at the instants they happened, open and close exceptions at the instant each + * change took effect, freeze newly elapsed owed periods, and publish the readback. + */ + reconcile( + principal: Readonly, + options?: { signal?: AbortSignal } + ): Promise; + /** Record an authority change the host observed, at the instant it happened. */ + authorizationChanged( + principal: Readonly, + accountId: string, + change: { + authorized: boolean; + at: string; + account?: Omit; + } + ): Promise; + /** + * `subscription_coverage[]` for `get_reporting_status`: active subscriptions whose current + * scope includes the account, and deactivated ones (`status: "inactive"`, `deactivated_at`) + * only while they still have retained obligations for the account (adcp#8613). Empty for an + * account the principal is not currently authorized for: caller isolation is unchanged. + */ + subscriptionCoverage( + principal: Readonly, + accountId: string + ): Promise; + /** + * Every owed (buy, offering, period) of this principal's subscriptions on the account: + * the frozen elapsed periods, plus any that elapsed since the last reconcile. + */ + owedPeriods( + principal: Readonly, + accountId: string, + options?: { now?: string; signal?: AbortSignal } + ): Promise; + /** The owed periods grouped into obligations with frozen `media_buy_ids`. */ + owedObligations( + principal: Readonly, + accountId: string, + options?: { now?: string; signal?: AbortSignal } + ): Promise; + /** + * The frozen owed set of each of this principal's subscriptions on each account the engine holds + * (or on one account), for writing into the reporting ledger: obligations whose periods ended at + * or before the subscription's frozen horizon `through`, never the provisional periods computed + * past it. Every owed period ending at or before `through` is in `obligations` unless retention + * already pruned it. + */ + frozenOwedSets( + principal: Readonly, + options?: { accountId?: string } + ): Promise; + /** Principals with engine state in one tenant, from the engine store. */ + listPrincipals(tenantId: string): Promise; + /** + * Whether the principal is authorized for the account now: its latest grant has no + * `unauthorized_at`. Every managed delivery of a subscription obligation re-checks it. + */ + isAuthorized(principal: Readonly, accountId: string): Promise; + /** Principals in the tenant whose active subscriptions cover the account: the consumers that owe receipts. */ + receiptRoster(tenantId: string, accountId: string): Promise; + /** + * Accounts whose own `reporting_delivery_configs` a `replace_account_configs` application + * may deactivate: in scope and served, resolving an offering, and not excluded, + * `capacity` or `offering_unsupported`. For the principal runtime's `replaceAccountConfigs` hook. + */ + replaceAccountConfigTargets(principal: Readonly, subscriptionId: string): Promise; + /** The engine state, for inspection and conformance. */ + inspect(principal: Readonly): Promise; +} + +/** + * One subscription's frozen owed set on one account. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionFrozenOwedSetV1 { + account_id: string; + subscription_id: string; + /** The frozen horizon: every owed period ending at or before it is resolved and never recomputed. */ + through: string; + /** Frozen obligations, grouped as the ledger carries them, with byte-sorted `media_buy_ids`. */ + obligations: ExpectedSubscriptionObligationV1[]; + /** Every retained version's configuration, oldest first, for the generation each obligation names. */ + versions: Array<{ + subscription_version: number; + configuration: StoredReportingSubscriptionVersion['configuration']; + /** The destination's pattern and transport, frozen when the version was accepted. */ + destination_method?: StoredReportingSubscriptionVersion['destinationMethod']; + }>; +} + +/** + * The latest `period_end` among periods. Frozen periods are not stored in end order: they are + * appended across reconciles, and offerings with different calendars interleave. + */ +function latestPeriodEnd(periods: ReadonlyArray<{ period_end: string }>): string { + let latest = periods[0]!; + for (const period of periods) if (Date.parse(period.period_end) > Date.parse(latest.period_end)) latest = period; + return latest.period_end; +} + +function iso(value: number): string { + return new Date(value).toISOString(); +} + +function lastGrant(account: AccountState | undefined): ReportingSubscriptionGrantV1 | undefined { + return account?.grants.at(-1); +} + +function isAuthorized(account: AccountState | undefined): boolean { + const grant = lastGrant(account); + return grant !== undefined && grant.unauthorized_at === undefined; +} + +function emptyAccountDocument(): ReportingSubscriptionEngineAccountDocumentV1 { + return { account: { grants: [] }, subscriptions: {} }; +} + +function emptySubscriptionAccount(): SubscriptionAccountState { + return { exclusions: [], gaps: [], timezoneCutovers: [], covered: false, resolvedBuys: {}, frozen: { periods: [] } }; +} + +function parseInstant(value: unknown, field: string): number { + const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN; + if (!Number.isFinite(parsed)) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_HOST_FACT_INVALID', + `${field} must be an RFC 3339 date-time. Return an RFC 3339 timestamp from the hook.` + ); + } + return parsed; +} + +/** Versions in the coverage function's input shape. */ +function versionRecords(history: StoredReportingSubscriptionHistory): ReportingSubscriptionVersionRecordV1[] { + return history.versions.map((version, index) => { + const isCurrent = index === history.versions.length - 1; + return { + subscription_version: version.subscriptionVersion, + accepted_at: version.acceptedAt, + ...(version.supersededAt !== undefined ? { superseded_at: version.supersededAt } : {}), + ...(isCurrent && history.deactivatedAt !== undefined ? { deactivated_at: history.deactivatedAt } : {}), + configuration: { ...version.configuration, active: !(isCurrent && history.deactivatedAt !== undefined) }, + ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), + }; + }); +} + +/** Reversed deactivations, as the readback's `inactive_intervals` lists them. */ +function inactiveIntervals(history: StoredReportingSubscriptionHistory): ReportingSubscriptionInactiveIntervalV1[] { + return history.inactiveIntervals.map(interval => ({ deactivated_at: interval.from, reactivated_at: interval.until })); +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export function createReportingSubscriptionEngineV1( + options: CreateReportingSubscriptionEngineOptionsV1 +): ReportingSubscriptionEngineV1 { + if (!options?.principal?.listReportingSubscriptions) { + throw new PlatformConfigError( + 'createReportingSubscriptionEngineV1 requires a principal runtime with reporting subscriptions. Pass a runtime from createPrincipalLifecycle({ reportingSubscriptions }) as principal.', + { configField: 'principal' } + ); + } + for (const hook of [ + 'listAuthorizedAccounts', + 'listAccountBuys', + 'resolveRevocationInstant', + 'isPeriodDelivered', + ] as const) { + if (typeof options.hooks?.[hook] !== 'function') { + throw new PlatformConfigError( + `createReportingSubscriptionEngineV1 requires the ${hook} hook. Supply hooks.${hook}.`, + { configField: `hooks.${hook}` } + ); + } + } + if (!Number.isSafeInteger(options.statusRetentionDays) || options.statusRetentionDays < 1) { + throw new PlatformConfigError('statusRetentionDays must be a positive integer. Set it to the service value.', { + configField: 'statusRetentionDays', + }); + } + if (options.maxAccounts !== undefined && (!Number.isSafeInteger(options.maxAccounts) || options.maxAccounts < 1)) { + throw new PlatformConfigError('maxAccounts must be a positive integer. Set a positive integer or omit it.', { + configField: 'maxAccounts', + }); + } + const production = process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development'; + const store = options.store ?? createInMemoryReportingSubscriptionEngineStoreV1(); + if (production && store.durability !== 'durable') { + throw new PlatformConfigError( + 'Production reporting subscription engines require a durable store. Pass store: createReportingSubscriptionEngineStateStoreV1({ store, durability: "durable" }) over durable storage, or run with NODE_ENV=test or development.', + { code: 'REPORTING_STORAGE_NOT_DURABLE', configField: 'store' } + ); + } + const now = options.now ?? (() => new Date()); + const maxCasAttempts = options.maxCasAttempts ?? 8; + const retentionMs = options.statusRetentionDays * DAY_MS; + const offeringsById = new Map(options.offerings.map(offering => [offering.offering_id, offering])); + if (offeringsById.size !== options.offerings.length) { + throw new PlatformConfigError('offering_id values must be unique. Remove the duplicate offering.', { + configField: 'offerings', + }); + } + + async function mutate>( + principal: Readonly, + key: string, + empty: () => T, + change: (value: T) => void | Promise + ): Promise { + for (let attempt = 0; attempt < maxCasAttempts; attempt += 1) { + const current = await store.get(principal, key); + const value = current ? (structuredClone(current.value) as T) : empty(); + await change(value); + const result = await store.put(principal, key, value, current?.revision ?? null); + if (result.outcome === 'applied') return value; + } + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_STATE_CONFLICT', + `Reporting subscription engine state conflicted on ${maxCasAttempts} attempts. Another reconcile of this principal is running; retry.` + ); + } + + async function readAccount( + principal: Readonly, + accountId: string + ): Promise { + const row = await store.get(principal, `${ACCOUNT_KEY_PREFIX}${accountId}`); + return row ? (structuredClone(row.value) as unknown as ReportingSubscriptionEngineAccountDocumentV1) : undefined; + } + + async function accountIds(principal: Readonly): Promise { + return (await store.listKeys(principal)) + .filter(key => key.startsWith(ACCOUNT_KEY_PREFIX)) + .map(key => key.slice(ACCOUNT_KEY_PREFIX.length)) + .sort(compareReportingIdsByteOrderV1); + } + + /** The calendar of each offering an account resolves for a version at `at`, by offering_id. */ + function offeringCalendars( + version: StoredReportingSubscriptionVersion, + account: AccountState, + buys: readonly ReportingSubscriptionBuyV1[], + at: number, + timezone = account.timezone + ): Map { + const calendars = new Map(); + for (const buy of buys) { + for (const id of resolveBuy(version, buy, at)) { + const calendar = calendarFor(offeringsById.get(id)!, version, account, timezone); + if (calendar) calendars.set(id, calendar); + } + } + return new Map([...calendars].sort(([left], [right]) => compareReportingIdsByteOrderV1(left, right))); + } + + function resolvePackage(version: StoredReportingSubscriptionVersion, offeringIds: readonly string[], at: number) { + return resolveReportingSubscriptionOfferingsV1({ + selector: version.configuration.selector, + reconciliation_mode: version.configuration.reconciliation_mode, + ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), + offerings: [...offeringsById.values()], + eligible_offering_ids: offeringIds, + at, + }); + } + + function resolveBuy(version: StoredReportingSubscriptionVersion, buy: ReportingSubscriptionBuyV1, at: number) { + const ids = new Set(); + for (const pkg of buy.packages) { + for (const id of resolvePackage(version, pkg.reporting_delivery_offering_ids, at)) ids.add(id); + } + return [...ids].sort(compareReportingIdsByteOrderV1); + } + + function calendarFor( + offering: ReportingSubscriptionOfferingV1, + version: StoredReportingSubscriptionVersion, + account: AccountState, + timezone: string | undefined + ): ReportingPeriodCalendarV1 | undefined { + const schedule = offering.schedule; + const override = version.configuration.selector.period_timezone; + try { + switch (schedule.alignment) { + case 'utc': + return createReportingPeriodCalendarV1({ period_duration: schedule.period_duration, alignment: 'utc' }); + case 'account_timezone': + return createReportingPeriodCalendarV1({ + period_duration: schedule.period_duration, + alignment: 'account_timezone', + timezone: (override ?? timezone)!, + }); + case 'source_timezone': + return createReportingPeriodCalendarV1({ + period_duration: schedule.period_duration, + alignment: 'source_timezone', + timezone: (schedule.period_timezone_policy === 'fixed' + ? schedule.period_timezone + : account.sourceTimezone)!, + }); + case 'billing_cycle': + return createReportingPeriodCalendarV1({ + period_duration: schedule.period_duration, + alignment: 'billing_cycle', + timezone: schedule.period_timezone!, + period_anchor: schedule.period_anchor!, + }); + default: + return undefined; + } + } catch { + return undefined; + } + } + + /** + * E for a change at T: the latest end of an outgoing period that started before T. With + * several calendars this is one account-wide instant (`excluded[]` carries no + * `offering_id`), so an offering whose period ends earlier is cut at the latest end. + */ + function cutoverInstant(calendars: Iterable, at: number): number { + const list = [...calendars]; + return list.length === 0 ? at : Math.max(...list.map(calendar => calendar.ceil(at))); + } + + function primaryCalendar(calendars: Iterable): ReportingPeriodCalendarV1 | undefined { + return [...calendars].sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))[0]; + } + + interface DesiredExclusion extends Omit { + /** The instant the condition took effect. */ + at: number; + /** Calendars whose E opens it; `from` is the raw instant when absent. */ + openCalendars?: Iterable; + } + + /** + * Open newly desired conditions and close vanished ones, both at the E of the instant the + * change took effect. `closeAt` supplies that instant for a closing condition (for example + * the overriding configuration's `active_until`); it defaults to the observation time. + */ + function settleExclusions( + target: SubscriptionAccountState, + desired: readonly DesiredExclusion[], + calendars: Iterable, + nowMs: number, + closeAt: (entry: StoredExclusion) => number | undefined + ): boolean { + let changed = false; + const calendarList = [...calendars]; + const open = target.exclusions.filter(entry => entry.until === undefined); + for (const entry of open) { + if (desired.some(item => item.key === entry.key)) continue; + entry.until = iso(Math.max(Date.parse(entry.from), closeAt(entry) ?? cutoverInstant(calendarList, nowMs))); + changed = true; + } + for (const item of desired) { + const current = open.find(entry => entry.key === item.key); + if (current) { + // An open condition keeps its from. When the host's pending action changed (a new url, + // message or reason), the entry carries the current one, and the change notifies. + if (canonicalize(current.pending_action ?? null) !== canonicalize(item.pending_action ?? null)) { + if (item.pending_action === undefined) delete current.pending_action; + else current.pending_action = structuredClone(item.pending_action); + changed = true; + } + continue; + } + const { at, openCalendars, ...rest } = item; + target.exclusions.push({ ...rest, from: iso(openCalendars ? cutoverInstant(openCalendars, at) : at) }); + changed = true; + } + // Wire retention: closed entries, gaps and cutovers are listed for status_retention_days. + // Elapsed owed periods are frozen, so pruning never changes them. + const kept = target.exclusions.filter( + entry => entry.until === undefined || Date.parse(entry.until) + retentionMs > nowMs + ); + if (kept.length !== target.exclusions.length) changed = true; + target.exclusions = kept; + target.gaps = target.gaps.filter(gap => Date.parse(gap.until) + retentionMs > nowMs); + target.timezoneCutovers = target.timezoneCutovers.filter(cut => Date.parse(cut.effective_at) + retentionMs > nowMs); + return changed; + } + + async function readFacts(principal: Readonly, signal?: AbortSignal) { + const accounts = new Map(); + let cursor: string | undefined; + // A partial roster would read as revocations, so every incomplete walk fails closed. + for (let page = 0; page < MAX_AUTHORIZED_ACCOUNT_PAGES; page += 1) { + signal?.throwIfAborted(); + const result = await options.hooks.listAuthorizedAccounts(principal, { + ...(cursor !== undefined ? { cursor } : {}), + ...(signal ? { signal } : {}), + }); + // A grant without get_reporting_status does not authorize subscription coverage; the + // account then reads exactly as if it had left the list. + for (const account of result.accounts) { + if (reportingSubscriptionCoverageGrantedV1(account)) accounts.set(account.account_id, account); + } + if (result.nextCursor === undefined) return accounts; + if (result.nextCursor === cursor) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_ROSTER_INCOMPLETE', + 'listAuthorizedAccounts returned a repeating cursor. Return a new cursor per page, or none on the last page.' + ); + } + cursor = result.nextCursor; + } + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_ROSTER_INCOMPLETE', + `listAuthorizedAccounts did not finish within ${MAX_AUTHORIZED_ACCOUNT_PAGES} pages; refusing a partial roster. Return larger pages.` + ); + } + + /** When authority ended, from the host, clamped to what the engine observed. */ + async function revocationInstant( + principal: Readonly, + accountId: string, + account: AccountState, + upperBound: number + ): Promise { + const answer = await options.hooks.resolveRevocationInstant(principal, accountId, { + ...(account.lastObservedAt !== undefined ? { lastObservedAt: account.lastObservedAt } : {}), + observedAt: iso(upperBound), + }); + const at = parseInstant(answer, 'resolveRevocationInstant result'); + const lower = Math.max( + Date.parse(lastGrant(account)!.authorized_at), + account.lastObservedAt !== undefined ? Date.parse(account.lastObservedAt) : Number.NEGATIVE_INFINITY + ); + return Math.min(Math.max(at, lower), upperBound); + } + + /** Apply what the host reports about one account's authority, at the instants it happened. */ + async function applyAuthority( + principal: Readonly, + accountId: string, + account: AccountState, + fact: ReportingSubscriptionAccountFactV1 | undefined, + nowMs: number + ): Promise { + const last = lastGrant(account); + if (!fact) { + if (!last || last.unauthorized_at !== undefined) return false; + last.unauthorized_at = iso(await revocationInstant(principal, accountId, account, nowMs)); + return true; + } + if (fact.operator !== undefined) account.operator = fact.operator; + if (fact.source_timezone !== undefined) account.sourceTimezone = fact.source_timezone; + const chainStart = parseInstant(fact.authorized_at, `${accountId} authorized_at`); + let changed = false; + if (!last) { + account.grants.push({ authorized_at: iso(chainStart) }); + changed = true; + } else if (last.unauthorized_at === undefined) { + if (account.chainStart !== undefined && chainStart > Date.parse(account.chainStart)) { + // A later chain start while the engine still holds the grant open: the revocation was + // never observed. Close the old chain when it ended and open the new one. + last.unauthorized_at = iso(await revocationInstant(principal, accountId, account, chainStart)); + account.grants.push({ authorized_at: iso(chainStart) }); + changed = true; + } + } else { + const revokedAt = Date.parse(last.unauthorized_at); + if (chainStart > revokedAt) { + account.grants.push({ authorized_at: iso(chainStart) }); + } else { + // The chain continues past a recorded revocation; only the host knows when. + if (fact.granted_at === undefined) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_HOST_FACT_INVALID', + `Account ${JSON.stringify(accountId)} was re-granted inside its authorization chain. Report granted_at on the account fact.` + ); + } + const grantedAt = parseInstant(fact.granted_at, `${accountId} granted_at`); + account.grants.push({ authorized_at: iso(Math.min(Math.max(grantedAt, revokedAt), nowMs)) }); + } + changed = true; + } + account.chainStart = iso(chainStart); + account.lastObservedAt = iso(nowMs); + return changed; + } + + async function reconcile( + principal: Readonly, + reconcileOptions: { signal?: AbortSignal } = {} + ): Promise { + const signal = reconcileOptions.signal; + const nowMs = now().getTime(); + const hookOptions = signal ? { signal } : {}; + const { history } = await options.principal.listReportingSubscriptions(principal); + const facts = await readFacts(principal, signal); + const known = new Set([...facts.keys(), ...(await accountIds(principal))]); + const documents = new Map(); + for (const accountId of known) + documents.set(accountId, (await readAccount(principal, accountId)) ?? emptyAccountDocument()); + + // Host facts for accounts the principal reads now, or lost inside retention (their buys, + // as they stood, give the withheld periods; nothing is disclosed to the caller). + const buysByAccount = new Map(); + const configsByAccount = new Map(); + for (const accountId of [...known].sort(compareReportingIdsByteOrderV1)) { + signal?.throwIfAborted(); + const grant = lastGrant(documents.get(accountId)!.account); + const retained = + facts.has(accountId) || + (grant !== undefined && + (grant.unauthorized_at === undefined || Date.parse(grant.unauthorized_at) + retentionMs > nowMs)); + if (!retained) continue; + buysByAccount.set(accountId, await options.hooks.listAccountBuys(principal, accountId, hookOptions)); + if (facts.has(accountId)) { + configsByAccount.set( + accountId, + (await options.hooks.listAccountDeliveryConfigs?.(principal, accountId, hookOptions)) ?? [] + ); + } + } + + // max_accounts slots (principal document): served accounts keep their slots, then byte order. + const precedence = new Map< + string, + Map + >(); + const inScopeBySubscription = new Map(); + const capacityBySubscription = new Map>(); + await mutate>( + principal, + PRINCIPAL_KEY, + () => ({ served: {} }), + document => { + for (const entry of history) { + const version = entry.versions.at(-1)!; + const configuration = version.configuration; + const inScope = [...facts.keys()] + .filter(accountId => { + const fact = facts.get(accountId)!; + const operator = fact.operator ?? documents.get(accountId)!.account.operator; + return reportingSubscriptionScopeIncludesV1( + { + scope: configuration.scope, + ...(configuration.operator_domains ? { operator_domains: configuration.operator_domains } : {}), + }, + { account_id: accountId, ...(operator !== undefined ? { operator } : {}) } + ); + }) + .sort(compareReportingIdsByteOrderV1); + inScopeBySubscription.set(entry.subscriptionId, inScope); + const overrides = new Map< + string, + { by: StoredExclusion['overridden_by']; at: number; config?: ReportingSubscriptionAccountConfigV1 } + >(); + for (const accountId of inScope) { + const winner = (configsByAccount.get(accountId) ?? []) + .filter( + item => + item.feed_purpose === configuration.selector.feed_purpose && + (item.active_until === undefined || Date.parse(item.active_until) > nowMs) + ) + .sort((left, right) => + compareReportingIdsByteOrderV1(left.delivery_config_id, right.delivery_config_id) + )[0]; + if (winner) { + overrides.set(accountId, { + by: { delivery_config_id: winner.delivery_config_id }, + at: parseInstant(winner.active_from, `${winner.delivery_config_id} active_from`), + config: winner, + }); + continue; + } + if (configuration.scope.all_authorized_accounts) { + const specific = history.find( + other => + other.subscriptionId !== entry.subscriptionId && + other.deactivatedAt === undefined && + other.versions.at(-1)!.configuration.selector.feed_purpose === configuration.selector.feed_purpose && + other.versions.at(-1)!.configuration.scope.account_ids?.includes(accountId) + ); + if (specific) { + overrides.set(accountId, { + by: { subscription_id: specific.subscriptionId }, + at: Date.parse(specific.versions.at(-1)!.acceptedAt), + }); + } + } + } + precedence.set(entry.subscriptionId, overrides); + const eligible = inScope.filter( + accountId => !overrides.has(accountId) && !facts.get(accountId)!.pending_action + ); + const previous = document.served[entry.subscriptionId] ?? []; + const ordered = [ + ...previous.filter(accountId => eligible.includes(accountId)), + ...eligible.filter(accountId => !previous.includes(accountId)), + ]; + const served = options.maxAccounts === undefined ? ordered : ordered.slice(0, options.maxAccounts); + capacityBySubscription.set(entry.subscriptionId, new Set(ordered.slice(served.length))); + if (entry.deactivatedAt === undefined) document.served[entry.subscriptionId] = served; + } + } + ); + + let notify = false; + for (const accountId of [...known].sort(compareReportingIdsByteOrderV1)) { + signal?.throwIfAborted(); + const buys = buysByAccount.get(accountId) ?? []; + const fact = facts.get(accountId); + const document = await mutate>( + principal, + `${ACCOUNT_KEY_PREFIX}${accountId}`, + () => emptyAccountDocument() as ReportingSubscriptionEngineAccountDocumentV1 & Record, + async current => { + let changed = await applyAuthority(principal, accountId, current.account, fact, nowMs); + const account = current.account; + if (fact?.timezone !== undefined && fact.timezone !== account.timezone) { + if (account.timezone !== undefined) { + if (fact.timezone_changed_at === undefined) { + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_HOST_FACT_INVALID', + `Account ${JSON.stringify(accountId)} changed timezone. Report timezone_changed_at on the account fact.` + ); + } + const changedAt = Math.min( + parseInstant(fact.timezone_changed_at, `${accountId} timezone_changed_at`), + nowMs + ); + recordTimezoneChange(current, history, account, fact.timezone, changedAt, buys); + } + account.timezone = fact.timezone; + changed = true; + } + for (const entry of history) { + if (settleSubscription(current, entry, accountId, fact, buys, nowMs)) changed = true; + } + if (changed) notify = true; + } + ); + // Freeze periods that elapsed since the previous reconcile, with the facts now in force. + await mutate>( + principal, + `${ACCOUNT_KEY_PREFIX}${accountId}`, + () => structuredClone(document) as ReportingSubscriptionEngineAccountDocumentV1 & Record, + current => { + for (const entry of history) { + const target = current.subscriptions[entry.subscriptionId]; + if (target) target.buyRevisions = buyRevisionsFor(current, entry, buys, nowMs); + freeze(current, entry, accountId, buys, nowMs); + } + current.lastBuys = structuredClone(buys); + } + ); + } + + // Readback: accounts_with_exceptions and accounts_unauthorized. An exception that + // opened or closed fires principal.changed even when the summary is unchanged. + const state = await inspect(principal); + const updates = []; + const overflow: ReportingSubscriptionReadbackOverflowV1[] = []; + for (const entry of history) { + const update = await readbackFor(principal, entry, state, nowMs, buysByAccount); + if ( + update.accountsWithExceptions.length > MAX_READBACK_ACCOUNTS || + update.accountsUnauthorized.length > MAX_READBACK_ACCOUNTS + ) { + // Isolate the oversized subscription: it keeps its last published lists. + overflow.push({ + subscription_id: update.subscriptionId, + accounts_with_exceptions: update.accountsWithExceptions.length, + accounts_unauthorized: update.accountsUnauthorized.length, + }); + continue; + } + updates.push(update); + } + await options.principal.recordReportingSubscriptionCoverage(principal, updates, { forceNotification: notify }); + if (overflow.length > 0) await options.onReadbackOverflow?.(principal, overflow); + return { readback_overflow: overflow }; + + function settleSubscription( + current: ReportingSubscriptionEngineAccountDocumentV1, + entry: StoredReportingSubscriptionHistory, + accountId: string, + fact: ReportingSubscriptionAccountFactV1 | undefined, + buys: readonly ReportingSubscriptionBuyV1[], + nowMs: number + ): boolean { + const version = entry.versions.at(-1)!; + const configuration = version.configuration; + const active = entry.deactivatedAt === undefined; + const inScope = inScopeBySubscription.get(entry.subscriptionId) ?? []; + const excludedByBuyer = configuration.exclude_account_ids?.includes(accountId) === true; + if (!current.subscriptions[entry.subscriptionId] && !inScope.includes(accountId) && !excludedByBuyer) + return false; + const isNew = current.subscriptions[entry.subscriptionId] === undefined; + const target = (current.subscriptions[entry.subscriptionId] ??= emptySubscriptionAccount()); + if (!fact) return false; // no current authority: lost authority is reported in accounts_unauthorized + const account = current.account; + const calendars = offeringCalendars(version, account, buys, nowMs); + const accepted = Date.parse(version.acceptedAt); + // A condition first observed for an account this subscription had not yet served began + // with that account's coverage, not at this reconcile. + const coverageStart = Math.max(accepted, Date.parse(account.chainStart ?? lastGrant(account)!.authorized_at)); + const firstObserved = isNew || (!target.covered && target.exclusions.length === 0); + const desired: DesiredExclusion[] = []; + if (active) { + if (excludedByBuyer) { + desired.push({ + key: 'excluded_by_buyer', + reason: 'excluded_by_buyer', + at: accepted, + openCalendars: calendars.values(), + }); + } else if (inScope.includes(accountId)) { + const override = precedence.get(entry.subscriptionId)?.get(accountId); + if (fact.pending_action) { + const since = + fact.pending_action_since !== undefined + ? parseInstant(fact.pending_action_since, `${accountId} pending_action_since`) + : firstObserved + ? coverageStart + : nowMs; + desired.push({ + key: 'action_required', + reason: 'action_required', + at: since, + openCalendars: calendars.values(), + pending_action: structuredClone(fact.pending_action), + }); + } else if (override) { + desired.push({ + key: `overridden:${JSON.stringify(override.by)}`, + reason: 'overridden', + overridden_by: override.by!, + at: Math.max(override.at, accepted), + openCalendars: calendars.values(), + }); + } else if (capacityBySubscription.get(entry.subscriptionId)?.has(accountId)) { + desired.push({ + key: 'capacity', + reason: 'capacity', + at: firstObserved ? coverageStart : nowMs, + openCalendars: calendars.values(), + }); + } + desired.push(...unsupportedBuys(version, target, account, buys, accepted, nowMs)); + } + } + // Only accounts the subscription actually served can later appear in accounts_unauthorized. + if (inScope.includes(accountId) && !desired.some(item => item.media_buy_id === undefined)) target.covered = true; + if (!options.backfillOnRegrant) recordRegrantGaps(target, account, calendars); + return settleExclusions(target, desired, calendars.values(), nowMs, closing => { + if ( + closing.reason !== 'overridden' || + !closing.overridden_by || + !('delivery_config_id' in closing.overridden_by) + ) { + return undefined; + } + const id = closing.overridden_by.delivery_config_id; + const config = configsByAccount.get(accountId)?.find(item => item.delivery_config_id === id); + return closingInstantFor(config, calendars, account); + }); + } + } + + /** + * A buy-level `offering_unsupported` condition and the instant it took effect: when the + * buy resolved before (a resolution change), the withdrawal instant of what it resolved, or + * the observation instant for a product edit, opened at that change's E; when it never + * resolved, from its own coverage start. + */ + function unsupportedBuys( + version: StoredReportingSubscriptionVersion, + target: SubscriptionAccountState, + account: AccountState, + buys: readonly ReportingSubscriptionBuyV1[], + accepted: number, + nowMs: number + ): DesiredExclusion[] { + const desired: DesiredExclusion[] = []; + const resolvedNow: Record = {}; + for (const buy of buys) { + if (buy.confirmed_at === undefined || buy.confirmed_at === null) continue; + const resolved = resolveBuy(version, buy, nowMs); + resolvedNow[buy.media_buy_id] = resolved; + const unsupported = buy.packages + .filter(pkg => resolvePackage(version, pkg.reporting_delivery_offering_ids, nowMs).length === 0) + .map(pkg => pkg.package_id) + .sort(compareReportingIdsByteOrderV1); + if (unsupported.length === 0) continue; + const before = target.resolvedBuys[buy.media_buy_id] ?? []; + let at: number; + let openCalendars: ReportingPeriodCalendarV1[] | undefined; + if (before.length > 0) { + const withdrawals = before + .map(id => offeringsById.get(id)?.withdrawn_at) + .filter((value): value is string => value !== undefined) + .map(value => Date.parse(value)) + .filter(value => Number.isFinite(value) && value <= nowMs); + at = withdrawals.length > 0 ? Math.min(...withdrawals) : nowMs; + openCalendars = before + .map(id => offeringsById.get(id)) + .filter((offering): offering is ReportingSubscriptionOfferingV1 => offering !== undefined) + .map(offering => calendarFor(offering, version, account, account.timezone)) + .filter((calendar): calendar is ReportingPeriodCalendarV1 => calendar !== undefined); + } else { + at = Math.max(accepted, Date.parse(buy.confirmed_at)); + } + desired.push( + unsupported.length === buy.packages.length + ? { + key: `offering_unsupported:${buy.media_buy_id}`, + reason: 'offering_unsupported', + media_buy_id: buy.media_buy_id, + at, + ...(openCalendars ? { openCalendars } : {}), + } + : { + key: `offering_unsupported:${buy.media_buy_id}:${unsupported.join(',')}`, + reason: 'offering_unsupported', + media_buy_id: buy.media_buy_id, + package_ids: unsupported, + at, + ...(openCalendars ? { openCalendars } : {}), + } + ); + } + target.resolvedBuys = resolvedNow; + return desired; + } + + /** When an override ends: the configuration's own `active_until`, on its own schedule when given. */ + function closingInstantFor( + config: ReportingSubscriptionAccountConfigV1 | undefined, + subscriptionCalendars: ReadonlyMap, + account: AccountState + ): number | undefined { + if (!config?.active_until) return undefined; + const until = parseInstant(config.active_until, `${config.delivery_config_id} active_until`); + if (config.schedule) { + try { + const timezone = + config.schedule.timezone ?? (config.schedule.alignment === 'account_timezone' ? account.timezone : undefined); + return createReportingPeriodCalendarV1({ + period_duration: config.schedule.period_duration, + alignment: config.schedule.alignment, + ...(timezone !== undefined ? { timezone } : {}), + ...(config.schedule.period_anchor !== undefined ? { period_anchor: config.schedule.period_anchor } : {}), + }).ceil(until); + } catch { + // Fall through to the subscription's calendars. + } + } + return cutoverInstant(subscriptionCalendars.values(), until); + } + + function recordTimezoneChange( + current: ReportingSubscriptionEngineAccountDocumentV1, + history: readonly StoredReportingSubscriptionHistory[], + account: AccountState, + timezone: string, + changedAt: number, + buys: readonly ReportingSubscriptionBuyV1[] + ) { + for (const entry of history) { + const version = entry.versions.at(-1)!; + if (version.configuration.selector.period_timezone !== undefined) continue; + const calendars = [...offeringCalendars(version, account, buys, changedAt)].filter( + ([, calendar]) => calendar.spec.alignment === 'account_timezone' + ); + if (calendars.length === 0) continue; + const target = (current.subscriptions[entry.subscriptionId] ??= emptySubscriptionAccount()); + // E per offering: the end of its last old-timezone period that started before the change. + // One account-wide entry when the offerings agree, one per offering_id otherwise. + const ends = calendars.map(([offeringId, calendar]) => ({ offeringId, at: calendar.ceil(changedAt) })); + const uniform = ends.every(end => end.at === ends[0]!.at); + for (const end of uniform ? [ends[0]!] : ends) { + target.timezoneCutovers.push({ + ...(uniform ? {} : { offering_id: end.offeringId }), + effective_at: iso(end.at), + timezone, + previous_timezone: account.timezone!, + }); + } + } + } + + function recordRegrantGaps( + target: SubscriptionAccountState, + account: AccountState, + calendars: ReadonlyMap + ) { + for (let index = 1; index < account.grants.length; index += 1) { + const previous = account.grants[index - 1]!; + const grant = account.grants[index]!; + if (previous.unauthorized_at === undefined) continue; + const revokedAt = Date.parse(previous.unauthorized_at); + const regrantedAt = Date.parse(grant.authorized_at); + if (regrantedAt >= revokedAt + retentionMs) continue; // a fresh start, no gap claimed + // Each offering's own boundaries; one account-wide entry when they agree. + const bounds = + calendars.size === 0 + ? [{ offeringId: undefined as string | undefined, from: revokedAt, until: regrantedAt }] + : [...calendars].map(([offeringId, calendar]) => ({ + offeringId: offeringId as string | undefined, + from: calendar.floor(revokedAt), + until: calendar.ceil(regrantedAt), + })); + const uniform = bounds.every(bound => bound.from === bounds[0]!.from && bound.until === bounds[0]!.until); + for (const bound of uniform ? [{ ...bounds[0]!, offeringId: undefined }] : bounds) { + if (bound.until <= bound.from) continue; + if ( + target.gaps.some( + gap => + gap.offering_id === bound.offeringId && + Date.parse(gap.from) <= bound.from && + Date.parse(gap.until) >= bound.until + ) + ) { + continue; + } + target.gaps.push({ + ...(bound.offeringId !== undefined ? { offering_id: bound.offeringId } : {}), + from: iso(bound.from), + until: iso(bound.until), + reason: 'unauthorized', + }); + } + } + } + + /** + * The subscription's buy revisions including any change since the previous reconcile, dated + * at the E of the observation, and without revisions every period of which has left retention. + */ + function buyRevisionsFor( + current: ReportingSubscriptionEngineAccountDocumentV1, + entry: StoredReportingSubscriptionHistory, + buys: readonly ReportingSubscriptionBuyV1[], + nowMs: number + ): BuyRevision[] { + const target = current.subscriptions[entry.subscriptionId]; + const revisions = (target?.buyRevisions ?? []).filter( + revision => Date.parse(revision.effective_at) + retentionMs > nowMs + ); + const previousBuys = current.lastBuys; + if (!previousBuys) return revisions; + const version = entry.versions.at(-1)!; + const after = new Map(buys.map(buy => [buy.media_buy_id, buy])); + for (const previous of previousBuys) { + const next = after.get(previous.media_buy_id); + const resolvedBefore = resolveBuy(version, previous, nowMs); + const resolvedAfter = next ? resolveBuy(version, next, nowMs) : []; + if (JSON.stringify(resolvedBefore) === JSON.stringify(resolvedAfter)) continue; + const calendars = offeringCalendars(version, current.account, [previous], nowMs).values(); + revisions.push({ + media_buy_id: previous.media_buy_id, + effective_at: iso(cutoverInstant(calendars, nowMs)), + previous: structuredClone(previous), + }); + } + return revisions; + } + + /** + * Owed periods from the current buys, except that a period of a revised buy starting before + * a revision's E resolves from the snapshot in force then, so periods owed before an edit + * stay owed and periods the edit adds start at its E. + */ + function owedWithRevisions( + current: ReportingSubscriptionEngineAccountDocumentV1, + entry: StoredReportingSubscriptionHistory, + accountId: string, + buys: readonly ReportingSubscriptionBuyV1[], + revisions: readonly BuyRevision[], + nowMs: number + ): ExpectedSubscriptionPeriodV1[] { + const owed = computeOwed(entry, current, accountId, buys, iso(nowMs)); + if (revisions.length === 0) return owed; + const byBuy = new Map(); + for (const revision of revisions) { + const list = byBuy.get(revision.media_buy_id) ?? []; + list.push(revision); + byBuy.set(revision.media_buy_id, list); + } + const periods = owed.filter(period => { + const list = byBuy.get(period.media_buy_id); + return !list || Date.parse(period.period_start) >= Date.parse(list.at(-1)!.effective_at); + }); + for (const list of byBuy.values()) { + let floor = Number.NEGATIVE_INFINITY; + for (const revision of list) { + const until = Date.parse(revision.effective_at); + for (const period of computeOwed(entry, current, accountId, [revision.previous], iso(nowMs))) { + const start = Date.parse(period.period_start); + if (start >= floor && start < until) periods.push(period); + } + floor = Math.max(floor, until); + } + } + return periods; + } + + /** + * Whether a deactivated subscription still has retained obligations on the account: an owed + * period, frozen or still running, that ends less than `status_retention_days` ago. Only + * periods that started before the deactivation are owed, so the set is finite. + */ + async function hasRetainedObligations( + principal: Readonly, + document: ReportingSubscriptionEngineAccountDocumentV1, + entry: StoredReportingSubscriptionHistory, + accountId: string, + nowMs: number + ): Promise { + const target = document.subscriptions[entry.subscriptionId]!; + const retained = (periodEnd: string) => Date.parse(periodEnd) + retentionMs > nowMs; + if (target.frozen.periods.some(period => retained(period.period_end))) return true; + if (!target.covered) return false; + // Periods not frozen yet, including one still running at the deactivation instant. + const horizon = Math.max(nowMs, Date.parse(entry.deactivatedAt!) + 400 * DAY_MS); + const buys = await options.hooks.listAccountBuys(principal, accountId, {}); + return computeOwed(entry, document, accountId, buys, iso(horizon)).some(period => retained(period.period_end)); + } + + /** Append periods resolved since the last freeze; never recompute earlier ones. */ + function freeze( + current: ReportingSubscriptionEngineAccountDocumentV1, + entry: StoredReportingSubscriptionHistory, + accountId: string, + buys: readonly ReportingSubscriptionBuyV1[], + nowMs: number + ): void { + const target = current.subscriptions[entry.subscriptionId]; + if (!target) return; + const through = target.frozen.through !== undefined ? Date.parse(target.frozen.through) : Number.NEGATIVE_INFINITY; + if (nowMs > through) { + const revisions = target.buyRevisions ?? []; + for (const period of owedWithRevisions(current, entry, accountId, buys, revisions, nowMs)) { + if (Date.parse(period.period_end) <= through) continue; + const { subscription_id: _subscription, account_id: _account, ...frozen } = period; + target.frozen.periods.push(frozen); + } + target.frozen.through = iso(nowMs); + } + // Obligation retention: periods past status_retention_days leave the queryable set. They + // are never re-owed, because only periods ending after `through` are ever added. + target.frozen.periods = target.frozen.periods.filter(period => Date.parse(period.period_end) + retentionMs > nowMs); + } + + async function readbackFor( + principal: Readonly, + entry: StoredReportingSubscriptionHistory, + state: ReportingSubscriptionEngineStateV1, + nowMs: number, + buysByAccount: ReadonlyMap + ) { + const accountsWithExceptions: NonNullable = []; + const accountsUnauthorized: NonNullable = []; + for (const [accountId, document] of Object.entries(state.accounts)) { + const target = document.subscriptions[entry.subscriptionId]; + if (!target) continue; + const account = document.account; + if (isAuthorized(account)) { + const reasons = [ + ...new Set(target.exclusions.filter(item => item.until === undefined).map(item => item.reason)), + ].sort(); + if (reasons.length > 0) accountsWithExceptions.push({ account_id: accountId, reasons: reasons as never }); + continue; + } + const grant = lastGrant(account); + if (!target.covered || grant?.unauthorized_at === undefined) continue; + const unauthorizedAt = Date.parse(grant.unauthorized_at); + if (unauthorizedAt + retentionMs <= nowMs) continue; + const version = entry.versions.at(-1)!; + const buys = buysByAccount.get(accountId) ?? []; + const calendar = primaryCalendar(offeringCalendars(version, account, buys, unauthorizedAt).values()); + const withheld = target.frozen.periods + .filter(period => Date.parse(period.period_end) <= unauthorizedAt) + .map(period => ({ ...period, subscription_id: entry.subscriptionId, account_id: accountId })); + let count = 0; + for (const period of withheld) if (!(await options.hooks.isPeriodDelivered(principal, period))) count += 1; + accountsUnauthorized.push({ + account_id: accountId, + unauthorized_at: grant.unauthorized_at, + ...(calendar + ? { last_owed_period_end: iso(calendar.floor(unauthorizedAt)) } + : withheld.length + ? { last_owed_period_end: latestPeriodEnd(withheld) } + : {}), + withheld_period_count: count, + }); + } + return { subscriptionId: entry.subscriptionId, accountsWithExceptions, accountsUnauthorized }; + } + + function computeOwed( + entry: StoredReportingSubscriptionHistory, + current: ReportingSubscriptionEngineAccountDocumentV1, + accountId: string, + buys: readonly ReportingSubscriptionBuyV1[], + nowIso: string + ): ExpectedSubscriptionPeriodV1[] { + const account = current.account; + if (account.grants.length === 0) return []; + const target = current.subscriptions[entry.subscriptionId]; + const cutovers = target?.timezoneCutovers ?? []; + return expectedSubscriptionPeriods({ + subscription_id: entry.subscriptionId, + versions: versionRecords(entry), + account: { + account_id: accountId, + ...(account.operator !== undefined ? { operator: account.operator } : {}), + ...(account.timezone !== undefined ? { timezone: account.timezone } : {}), + ...(account.sourceTimezone !== undefined ? { source_timezone: account.sourceTimezone } : {}), + grants: account.grants, + }, + buys, + offerings: [...offeringsById.values()], + ...(entry.inactiveIntervals.length > 0 ? { inactive_intervals: inactiveIntervals(entry) } : {}), + coverage: { + excluded: (target?.exclusions ?? []).map(stripExclusion) as never, + ...(target?.gaps.length ? { gaps: target.gaps } : {}), + ...(cutovers.length ? { timezone_cutovers: cutovers } : {}), + }, + backfill_on_regrant: options.backfillOnRegrant === true, + status_retention_days: options.statusRetentionDays, + now: nowIso, + }); + } + + async function owedPeriods( + principal: Readonly, + accountId: string, + owedOptions: { now?: string; signal?: AbortSignal } = {} + ): Promise { + const document = await readAccount(principal, accountId); + if (!document) return []; + const { history } = await options.principal.listReportingSubscriptions(principal); + const nowMs = owedOptions.now !== undefined ? parseInstant(owedOptions.now, 'now') : now().getTime(); + const periods: ExpectedSubscriptionPeriodV1[] = []; + let buys: ReportingSubscriptionBuyV1[] | undefined; + for (const entry of history) { + const target = document.subscriptions[entry.subscriptionId]; + if (!target) continue; + for (const period of target.frozen.periods) { + if (Date.parse(period.period_end) <= nowMs) { + periods.push({ ...period, subscription_id: entry.subscriptionId, account_id: accountId }); + } + } + const through = + target.frozen.through !== undefined ? Date.parse(target.frozen.through) : Number.NEGATIVE_INFINITY; + if (nowMs > through) { + buys ??= await options.hooks.listAccountBuys( + principal, + accountId, + owedOptions.signal ? { signal: owedOptions.signal } : {} + ); + const revisions = buyRevisionsFor(document, entry, buys, nowMs); + for (const period of owedWithRevisions(document, entry, accountId, buys, revisions, nowMs)) { + if (Date.parse(period.period_end) > through) periods.push(period); + } + } + } + return periods.sort( + (left, right) => + Date.parse(left.period_start) - Date.parse(right.period_start) || + compareReportingIdsByteOrderV1(left.subscription_id, right.subscription_id) || + left.subscription_version - right.subscription_version || + compareReportingIdsByteOrderV1(left.offering_id, right.offering_id) || + compareReportingIdsByteOrderV1(left.media_buy_id, right.media_buy_id) + ); + } + + async function inspect(principal: Readonly): Promise { + const principalRow = await store.get(principal, PRINCIPAL_KEY); + const accounts: Record = {}; + for (const accountId of await accountIds(principal)) { + const document = await readAccount(principal, accountId); + if (document) accounts[accountId] = document; + } + return { + principal: principalRow + ? (structuredClone(principalRow.value) as unknown as ReportingSubscriptionEnginePrincipalDocumentV1) + : { served: {} }, + accounts, + }; + } + + const engine: ReportingSubscriptionEngineV1 = { + settings: Object.freeze({ + ...(options.maxAccounts !== undefined ? { maxAccounts: options.maxAccounts } : {}), + backfillOnRegrant: options.backfillOnRegrant === true, + statusRetentionDays: options.statusRetentionDays, + }), + reconcile: (principal, reconcileOptions) => reconcile(principal, reconcileOptions ?? {}), + async authorizationChanged(principal, accountId, reported) { + // A grant that leaves out get_reporting_status is no authority for coverage. + const change = + reported.authorized && reported.account && !reportingSubscriptionCoverageGrantedV1(reported.account) + ? { ...reported, authorized: false } + : reported; + const at = parseInstant(change.at, 'authorizationChanged at'); + let changed = false; + await mutate>( + principal, + `${ACCOUNT_KEY_PREFIX}${accountId}`, + () => emptyAccountDocument() as ReportingSubscriptionEngineAccountDocumentV1 & Record, + current => { + changed = false; + const account = current.account; + if (change.account?.operator !== undefined) account.operator = change.account.operator; + if (change.account?.source_timezone !== undefined) account.sourceTimezone = change.account.source_timezone; + const last = lastGrant(account); + if (change.authorized) { + if (last && last.unauthorized_at === undefined) return; + account.grants.push({ + authorized_at: iso(Math.max(at, last?.unauthorized_at ? Date.parse(last.unauthorized_at) : at)), + }); + account.chainStart ??= iso(at); + changed = true; + } else { + if (!last || last.unauthorized_at !== undefined) return; + last.unauthorized_at = iso(Math.max(at, Date.parse(last.authorized_at))); + changed = true; + } + } + ); + if (changed) await engine.reconcile(principal); + }, + async subscriptionCoverage(principal, accountId) { + const document = await readAccount(principal, accountId); + if (!document || !isAuthorized(document.account)) return []; + const { history } = await options.principal.listReportingSubscriptions(principal); + const entries: ReportingSubscriptionCoverageEntryV1[] = []; + for (const entry of history) { + const target = document.subscriptions[entry.subscriptionId]; + if (!target) continue; + const configuration = entry.versions.at(-1)!.configuration; + const inScope = + reportingSubscriptionScopeIncludesV1( + { + scope: configuration.scope, + ...(configuration.exclude_account_ids ? { exclude_account_ids: configuration.exclude_account_ids } : {}), + ...(configuration.operator_domains ? { operator_domains: configuration.operator_domains } : {}), + }, + { + account_id: accountId, + ...(document.account.operator !== undefined ? { operator: document.account.operator } : {}), + } + ) || configuration.exclude_account_ids?.includes(accountId) === true; + const deactivatedAt = entry.deactivatedAt; + if (deactivatedAt !== undefined) { + // adcp#8613: listed only while it still has retained obligations for the account. + if (!(await hasRetainedObligations(principal, document, entry, accountId, now().getTime()))) continue; + } else if (!inScope) { + continue; + } + const open = target.exclusions.filter(item => item.until === undefined && item.media_buy_id === undefined); + const pending = open.find(item => item.reason === 'action_required'); + entries.push({ + subscription_id: entry.subscriptionId, + subscription_version: entry.versions.at(-1)!.subscriptionVersion, + status: + deactivatedAt !== undefined + ? 'inactive' + : pending + ? 'action_required' + : open.length > 0 + ? 'excluded' + : 'active', + ...(deactivatedAt !== undefined ? { deactivated_at: deactivatedAt } : {}), + ...(pending?.pending_action ? { pending_action: structuredClone(pending.pending_action) } : {}), + excluded: target.exclusions.map(stripExclusion), + ...(target.timezoneCutovers.length ? { timezone_cutovers: structuredClone(target.timezoneCutovers) } : {}), + ...(target.gaps.length ? { gaps: structuredClone(target.gaps) } : {}), + }); + } + return entries; + }, + owedPeriods, + async owedObligations(principal, accountId, owedOptions) { + return groupExpectedSubscriptionPeriodsV1(await owedPeriods(principal, accountId, owedOptions)); + }, + async frozenOwedSets(principal, frozenOptions = {}) { + const { history } = await options.principal.listReportingSubscriptions(principal); + const ids = frozenOptions.accountId !== undefined ? [frozenOptions.accountId] : await accountIds(principal); + const sets: ReportingSubscriptionFrozenOwedSetV1[] = []; + for (const accountId of ids) { + const document = await readAccount(principal, accountId); + if (!document) continue; + for (const entry of history) { + const target = document.subscriptions[entry.subscriptionId]; + if (!target || target.frozen.through === undefined) continue; + const through = Date.parse(target.frozen.through); + const periods = target.frozen.periods + .filter(period => Date.parse(period.period_end) <= through) + .map(period => ({ ...period, subscription_id: entry.subscriptionId, account_id: accountId })); + sets.push({ + account_id: accountId, + subscription_id: entry.subscriptionId, + through: target.frozen.through, + obligations: groupExpectedSubscriptionPeriodsV1(periods), + versions: entry.versions.map(version => ({ + subscription_version: version.subscriptionVersion, + configuration: structuredClone(version.configuration), + ...(version.destinationMethod ? { destination_method: structuredClone(version.destinationMethod) } : {}), + })), + }); + } + } + return sets; + }, + async isAuthorized(principal, accountId) { + return isAuthorized((await readAccount(principal, accountId))?.account); + }, + async listPrincipals(tenantId) { + return (await store.listPrincipals(tenantId)).filter(principal => principal.tenantId === tenantId); + }, + async receiptRoster(tenantId, accountId) { + const roster: PrincipalStoreScope[] = []; + for (const principal of await store.listPrincipals(tenantId)) { + if (principal.tenantId !== tenantId) continue; + const document = await readAccount(principal, accountId); + if (!document || !isAuthorized(document.account)) continue; + const { history } = await options.principal.listReportingSubscriptions(principal); + const covers = history.some(entry => { + if (entry.deactivatedAt !== undefined) return false; + const target = document.subscriptions[entry.subscriptionId]; + return ( + target?.covered === true && + !target.exclusions.some(item => item.until === undefined && item.media_buy_id === undefined) + ); + }); + if (covers) roster.push(principal); + } + return roster.sort((left, right) => compareReportingIdsByteOrderV1(left.principalId, right.principalId)); + }, + async replaceAccountConfigTargets(principal, subscriptionId) { + const targets: string[] = []; + for (const accountId of await accountIds(principal)) { + const document = await readAccount(principal, accountId); + const target = document?.subscriptions[subscriptionId]; + if (!document || !target || !isAuthorized(document.account) || !target.covered) continue; + const blocked = target.exclusions.some( + item => + item.until === undefined && + item.media_buy_id === undefined && + (item.reason === 'excluded_by_buyer' || item.reason === 'capacity' || item.reason === 'action_required') + ); + const resolves = Object.values(target.resolvedBuys).some(ids => ids.length > 0); + if (!blocked && resolves) targets.push(accountId); + } + return targets; + }, + inspect, + }; + return engine; +} + +function stripExclusion(entry: StoredExclusion | ReportingSubscriptionExclusionV1): ReportingSubscriptionExclusionV1 { + const { key: _key, pending_action: _pending, ...rest } = entry as StoredExclusion; + void _key; + void _pending; + return structuredClone(rest); +} diff --git a/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts new file mode 100644 index 000000000..eb4a9da21 --- /dev/null +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -0,0 +1,127 @@ +/** + * Bridge from subscription coverage to the consumer runtime's `ExpectedReportingPeriod` + * (experimental `media_buy.reporting_subscriptions`). + */ +import { CoverageInputError } from '../../errors'; +import type { ReportingSubscription, ReportingSubscriptionState } from '../../types/core.generated'; +import type { ExpectedReportingCoverage, ExpectedReportingPeriod } from '../reconciliation'; +import type { ExpectedSubscriptionObligationV1, ReportingSubscriptionVersionRecordV1 } from './coverage'; +import type { ReportingSubscriptionDestinationMethodV1 } from './selection'; + +/** + * The coverage function's version records from the seller readback. Each version's + * configuration comes from the buyer's own records when given, else from the readback: the + * current `configuration`, and `prior_versions[].configuration` from sellers on + * 3.3.0-beta.2 or later (adcp#8588). Pass the readback's `inactive_intervals` to + * `expectedSubscriptionPeriods` unchanged. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function reportingSubscriptionVersionRecordsV1(input: { + state: ReportingSubscriptionState; + /** Versions the buyer sent, keyed by `subscription_version`. Required for a version the readback does not carry. */ + configurations?: Readonly>; + /** Pattern and transport of the destination each version named. */ + destinationMethods?: Readonly>; +}): ReportingSubscriptionVersionRecordV1[] { + const { state } = input; + // TODO(3.3.0-beta.2): read prior_versions[].configuration from the generated type. + const priors = (state.prior_versions ?? []) as ReadonlyArray< + NonNullable[number] & { configuration?: ReportingSubscription } + >; + const entries = [ + ...[...priors].reverse().map(prior => ({ + version: prior.subscription_version, + accepted_at: prior.accepted_at, + superseded_at: prior.superseded_at as string | undefined, + deactivated_at: undefined as string | undefined, + published: prior.configuration, + })), + { + version: state.subscription_version, + accepted_at: state.accepted_at, + superseded_at: undefined as string | undefined, + deactivated_at: state.deactivated_at, + published: state.configuration as ReportingSubscription | undefined, + }, + ]; + return entries.map(entry => { + const configuration = input.configurations?.[entry.version] ?? entry.published; + if (!configuration) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `No configuration for ${state.subscription_id} version ${entry.version}: the readback does not carry it, so keep every version you send`, + 'missing_version_configuration' + ); + } + const method = input.destinationMethods?.[entry.version]; + return { + subscription_version: entry.version, + accepted_at: entry.accepted_at, + ...(entry.superseded_at !== undefined ? { superseded_at: entry.superseded_at } : {}), + ...(entry.deactivated_at !== undefined ? { deactivated_at: entry.deactivated_at } : {}), + configuration: structuredClone({ + ...configuration, + active: entry.deactivated_at === undefined ? true : false, + }), + ...(method ? { destination_method: method } : {}), + }; + }); +} + +/** + * Per-offering pins an obligation must match beyond what coverage determines. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type SubscriptionExpectationTemplateV1 = Omit< + ExpectedReportingPeriod, + | 'deliveryConfigId' + | 'deliveryConfigVersion' + | 'reportDefinitionId' + | 'mediaBuyIds' + | 'periodStart' + | 'periodEnd' + | 'coverage' +> & { + /** + * The coverage the obligation must report. Required: the seller reports package-level + * detail and `partial` coverage for package-level `offering_unsupported` exceptions, so no + * default could match a real obligation. + */ + coverage: ExpectedReportingCoverage; +}; + +/** + * Turn owed obligations (from `groupExpectedSubscriptionPeriodsV1`) into the consumer + * runtime's expected periods. `template` supplies the pins coverage cannot know: the + * reporting profile and schema pins of the offering, the destination, verification profile, + * finality and reconciliation mode, and the coverage the obligation must report (its package + * sets, and `partial` where packages carry an `offering_unsupported` exception). + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function subscriptionExpectedReportingPeriodsV1( + obligations: readonly ExpectedSubscriptionObligationV1[], + template: (obligation: ExpectedSubscriptionObligationV1) => SubscriptionExpectationTemplateV1 +): ExpectedReportingPeriod[] { + return obligations.map(obligation => { + const pins = template(obligation); + const { coverage, ...rest } = pins; + if (!coverage || typeof coverage !== 'object') { + throw new TypeError( + `The expectation template must supply coverage for ${obligation.subscription_id} ${obligation.period_start}` + ); + } + return { + ...structuredClone(rest), + deliveryConfigId: obligation.subscription_id, + deliveryConfigVersion: obligation.subscription_version, + reportDefinitionId: obligation.report_definition_id, + mediaBuyIds: [...obligation.media_buy_ids], + periodStart: obligation.period_start, + periodEnd: obligation.period_end, + coverage: structuredClone(coverage), + } as ExpectedReportingPeriod; + }); +} diff --git a/src/lib/reporting/subscriptions/index.ts b/src/lib/reporting/subscriptions/index.ts new file mode 100644 index 000000000..474ae19b7 --- /dev/null +++ b/src/lib/reporting/subscriptions/index.ts @@ -0,0 +1,55 @@ +/** + * Principal-level reporting subscriptions (experimental `media_buy.reporting_subscriptions`, + * AdCP 3.3): the normative period calendar, selector resolution, scope membership and the + * buyer-computable coverage rule. Pure functions shared by buyer and seller. + * + * @packageDocumentation + */ +export { + assertReportingIanaTimezoneV1, + createReportingPeriodCalendarV1, + parseReportingPeriodDurationV1, +} from './calendar'; +export type { + ReportingIsoDurationPartsV1, + ReportingPeriodAlignmentV1, + ReportingPeriodCalendarSpecV1, + ReportingPeriodCalendarV1, +} from './calendar'; +export { + compareReportingIdsByteOrderV1, + effectiveReportingSubscriptionMatchV1, + isReportingSubscriptionCandidateV1, + reportingSubscriptionScopeIncludesV1, + resolveReportingSubscriptionOfferingsV1, +} from './selection'; +export type { + ReportingSubscriptionDestinationMethodV1, + ReportingSubscriptionMatchV1, + ReportingSubscriptionOfferingV1, + ReportingSubscriptionScopeAccountV1, + ReportingSubscriptionSelectorV1, + ResolveReportingSubscriptionOfferingsInputV1, +} from './selection'; +export { expectedSubscriptionPeriods, groupExpectedSubscriptionPeriodsV1 } from './coverage'; +export type { + ExpectedSubscriptionObligationV1, + ExpectedSubscriptionPeriodV1, + ExpectedSubscriptionPeriodsInputV1, + ReportingSubscriptionBuyV1, + ReportingSubscriptionCoverageAccountV1, + ReportingSubscriptionGrantV1, + ReportingSubscriptionPriorFlightV1, + ReportingSubscriptionVersionRecordV1, +} from './coverage'; +export { reportingConfigurationCapabilitiesV1 } from './capabilities'; +export type { ReportingConfigurationCapabilitiesInputV1, ReportingConfigurationCapabilitiesV1 } from './capabilities'; +export type { + ReportingDeliveryOfferingWithDimensionsV1, + ReportingSubscriptionCoverageWithOfferingsV1, + ReportingSubscriptionExclusionEntryV1, + ReportingSubscriptionGapV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionStateWithIntervalsV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts new file mode 100644 index 000000000..66d4563df --- /dev/null +++ b/src/lib/reporting/subscriptions/selection.ts @@ -0,0 +1,233 @@ +/** + * Selector resolution and scope membership for principal-level reporting subscriptions + * (`core/reporting-subscription.json`, experimental `media_buy.reporting_subscriptions`). + * + * Pure functions shared by the buyer coverage calculation and the seller obligation engine, + * so both sides resolve the same offering for the same facts. + */ +import { CoverageInputError } from '../../errors'; +import type { ReportingDeliveryOffering, ReportingSubscription } from '../../types/core.generated'; +import type { ReportingDeliveryOfferingWithDimensionsV1 } from './wire'; + +/** + * A seller delivery offering as published in `media_buy.reporting_delivery.offerings`. + * + * Matching reads the offering's own `standard_dimensions` (its breakdown in + * `enums/reporting-dimension.json`) and `unmapped_dimensions` (native dimensions with no + * mapping, which count as distinct under `exact` and never toward `finest`), so buyer and + * seller resolve `match` from the same wire facts. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionOfferingV1 = ReportingDeliveryOfferingWithDimensionsV1 & { + /** + * When the seller began offering it. A resolution that changes because an offering + * appears or is withdrawn is a cutover at that instant. Absent: always available. Not on + * the wire (a withdrawn offering simply disappears from `reporting_delivery.offerings`), so a + * buyer MUST retain offering history to agree with the seller across a resolution change. + */ + available_from?: string; + /** When the seller withdrew it. Absent: still available. */ + withdrawn_at?: string; +}; + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export type ReportingSubscriptionSelectorV1 = ReportingSubscription['selector']; +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export type ReportingSubscriptionMatchV1 = 'exact' | 'finest' | 'all'; + +/** + * `pattern` and `transport` of the principal `reporting_destinations` entry a subscription names. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionDestinationMethodV1 { + pattern: string; + transport: string; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ResolveReportingSubscriptionOfferingsInputV1 { + selector: ReportingSubscriptionSelectorV1; + reconciliation_mode: ReportingSubscription['reconciliation_mode']; + /** + * The destination's method when the subscription names one. Absent selects only + * API-delivered (Core) offerings, which carry no `method`. + */ + destination_method?: ReportingSubscriptionDestinationMethodV1; + /** Seller offerings, with their dimension mapping. */ + offerings: readonly ReportingSubscriptionOfferingV1[]; + /** The product's `reporting_capabilities.reporting_delivery_offering_ids`. */ + eligible_offering_ids: readonly string[]; + /** + * Epoch milliseconds at which to evaluate `available_from`/`withdrawn_at`. Absent: + * availability is not considered. + */ + at?: number; +} + +/** + * `match` defaults to `exact` when `dimensions` is present and `finest` when absent. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function effectiveReportingSubscriptionMatchV1( + selector: ReportingSubscriptionSelectorV1 +): ReportingSubscriptionMatchV1 { + return selector.match ?? (selector.dimensions !== undefined ? 'exact' : 'finest'); +} + +/** + * UTF-8 byte-order comparison, as the spec's "lowest offering_id in byte order" requires. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function compareReportingIdsByteOrderV1(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +/** The UTC aliases `core/reporting-subscription.json` names for a `utc` offering. */ +const UTC_ZONE_NAMES = new Set(['UTC', 'Etc/UTC']); + +/** + * Whether an offering matches a `period_timezone` override + * (`core/reporting-subscription.json` `selector.period_timezone`): an `account_timezone` + * offering matches and uses the override; a `utc` offering only for `UTC` or `Etc/UTC`; a + * `source_timezone` or fixed `billing_cycle` offering only when its published + * `period_timezone` equals the override. A billing cycle whose anchor the buyer configures + * never matches a subscription, which cannot supply the anchor. + */ +function canProduceTimezone(offering: ReportingDeliveryOffering, timezone: string | undefined): boolean { + const schedule = offering.schedule; + if (schedule.alignment === 'billing_cycle' && schedule.period_anchor_policy !== 'fixed') return false; + if (timezone === undefined) return true; + switch (schedule.alignment) { + case 'utc': + return UTC_ZONE_NAMES.has(timezone); + case 'account_timezone': + return true; + case 'source_timezone': + return schedule.period_timezone !== undefined && schedule.period_timezone === timezone; + case 'billing_cycle': + return schedule.period_timezone === timezone; + default: + return false; + } +} + +/** + * Candidate filter from `x-adcp-validation.selection`, before `match` applies. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function isReportingSubscriptionCandidateV1( + offering: ReportingSubscriptionOfferingV1, + input: Omit +): boolean { + const { selector } = input; + if (!input.eligible_offering_ids.includes(offering.offering_id)) return false; + if (offering.feed_purpose !== selector.feed_purpose) return false; + if (offering.schedule.period_duration !== selector.period_duration) return false; + if (offering.reconciliation_mode !== input.reconciliation_mode) return false; + if (!offering.supported_finality.includes(selector.required_finality)) return false; + if (selector.alignment !== undefined && offering.schedule.alignment !== selector.alignment) return false; + if (input.destination_method === undefined) { + if (offering.method !== undefined) return false; + } else { + // The generated wire type drops this object's properties; the schema defines both. + const method = offering.method as { pattern?: unknown; transport?: unknown } | undefined; + if ( + method === undefined || + method.pattern !== input.destination_method.pattern || + method.transport !== input.destination_method.transport + ) { + return false; + } + } + if (!canProduceTimezone(offering, selector.period_timezone)) return false; + if (input.at !== undefined) { + if (offering.available_from !== undefined && Date.parse(offering.available_from) > input.at) return false; + if (offering.withdrawn_at !== undefined && Date.parse(offering.withdrawn_at) <= input.at) return false; + } + return true; +} + +/** + * Resolve a selector against one product's eligible offerings. Returns the resolved + * offering IDs in byte order: at most one for `exact` and `finest`, every match for `all`, + * none when nothing matches (the seller then records `offering_unsupported`). + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function resolveReportingSubscriptionOfferingsV1(input: ResolveReportingSubscriptionOfferingsInputV1): string[] { + const match = effectiveReportingSubscriptionMatchV1(input.selector); + const wanted = input.selector.dimensions; + const candidates = input.offerings + .filter(offering => isReportingSubscriptionCandidateV1(offering, input)) + .sort((left, right) => compareReportingIdsByteOrderV1(left.offering_id, right.offering_id)); + const standard = (offering: ReportingSubscriptionOfferingV1): readonly string[] => { + // Sellers advertising subscriptions MUST publish it; without it match cannot be resolved. + if (!Array.isArray(offering.standard_dimensions)) { + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `Offering ${JSON.stringify(offering.offering_id)} does not publish standard_dimensions; ` + + 'a subscription selector cannot be resolved against it', + 'missing_standard_dimensions' + ); + } + return offering.standard_dimensions; + }; + const includesWanted = (offering: ReportingSubscriptionOfferingV1) => + wanted === undefined || wanted.every(dimension => standard(offering).includes(dimension)); + if (match === 'exact') { + const equal = candidates.filter(offering => { + const own = new Set(standard(offering)); + // With no dimensions every candidate qualifies and the lowest offering_id wins. + if (wanted === undefined) return true; + return ( + (offering.unmapped_dimensions?.length ?? 0) === 0 && + own.size === new Set(wanted).size && + wanted.every(dimension => own.has(dimension)) + ); + }); + return equal.length > 0 ? [equal[0]!.offering_id] : []; + } + const including = candidates.filter(includesWanted); + if (match === 'all') return including.map(offering => offering.offering_id); + let best: ReportingSubscriptionOfferingV1 | undefined; + for (const offering of including) { + if (best === undefined || new Set(standard(offering)).size > new Set(standard(best)).size) best = offering; + } + return best ? [best.offering_id] : []; +} + +/** @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md */ +export interface ReportingSubscriptionScopeAccountV1 { + account_id: string; + /** The account's `operator` domain, needed when the scope names `operator_domains`. */ + operator?: string; +} + +/** + * Whether a subscription's scope selects an account: `account_ids` naming it, or + * `all_authorized_accounts` with the account outside `exclude_account_ids` and, when + * `operator_domains` is present, its operator listed. Authorization is checked separately. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export function reportingSubscriptionScopeIncludesV1( + subscription: Pick, + account: ReportingSubscriptionScopeAccountV1 +): boolean { + const scope = subscription.scope as { all_authorized_accounts?: true; account_ids?: string[] }; + let selected: boolean; + if (scope.account_ids !== undefined) selected = scope.account_ids.includes(account.account_id); + else + selected = + scope.all_authorized_accounts === true && !subscription.exclude_account_ids?.includes(account.account_id); + if (!selected) return false; + if (subscription.operator_domains !== undefined) { + return account.operator !== undefined && subscription.operator_domains.includes(account.operator); + } + return true; +} diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts new file mode 100644 index 000000000..e22365e27 --- /dev/null +++ b/src/lib/reporting/subscriptions/wire.ts @@ -0,0 +1,101 @@ +/** + * Reporting-subscription wire fields settled after AdCP 3.3.0-beta.1 (adcontextprotocol/adcp#8585), + * hand-modeled until the next published bundle. + * + * TODO(3.3.0-beta.2): regenerate types from the published bundle and replace these with the + * generated `ReportingDeliveryOffering`, `ReportingSubscriptionState` and + * `ReportingSubscriptionCoverage` shapes. + */ +import type { + ReportingDeliveryOffering, + ReportingDimension, + ReportingSubscriptionCoverage, + ReportingSubscriptionState, +} from '../../types/core.generated'; + +/** + * `core/reporting-delivery-offering.json` with the 3.3 subscription matching fields. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingDeliveryOfferingWithDimensionsV1 = ReportingDeliveryOffering & { + /** + * The offering's breakdown in the standard vocabulary. Empty for totals only. Sellers + * advertising `media_buy.reporting_subscriptions` MUST include it on every offering. + */ + standard_dimensions?: ReportingDimension[] | string[]; + /** Native report-definition dimensions with no standard mapping. Present when any exist. */ + unmapped_dimensions?: string[]; +}; + +/** + * One `inactive_intervals[]` readback entry. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionInactiveIntervalV1 { + deactivated_at: string; + /** Absent for the current interval while the subscription is inactive. */ + reactivated_at?: string; +} + +/** + * `core/reporting-subscription-state.json` with `inactive_intervals`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionStateWithIntervalsV1 = ReportingSubscriptionState & { + inactive_intervals?: ReportingSubscriptionInactiveIntervalV1[]; +}; + +/** + * One `timezone_cutovers[]` entry with `previous_timezone` and an optional `offering_id`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionTimezoneCutoverV1 { + /** Sellers MUST include it. */ + previous_timezone?: string; + /** Absent: every account_timezone offering on the account. */ + offering_id?: string; + effective_at: string; + timezone: string; +} + +/** + * One `gaps[]` entry with an optional `offering_id`. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export interface ReportingSubscriptionGapV1 { + /** Absent: every offering on the account. */ + offering_id?: string; + from: string; + until: string; + reason: 'unauthorized'; +} + +/** + * One `excluded[]` entry with an optional `offering_id`: the offering whose periods it removes, when the + * account's covered offerings have different period boundaries. Absent: every offering on the account + * (or the buy, with `media_buy_id`). + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionExclusionEntryV1 = ReportingSubscriptionCoverage['excluded'][number] & { + offering_id?: string; +}; + +/** + * `core/reporting-subscription-coverage.json` with per-offering exclusions, cutovers and gaps. + * + * @experimental Low-level reporting-subscription building block, published from `@adcp/sdk/reporting/advanced`: breaking changes may ship in minor releases. See https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/REPORTING-STABILITY.md + */ +export type ReportingSubscriptionCoverageWithOfferingsV1 = Omit< + ReportingSubscriptionCoverage, + 'timezone_cutovers' | 'gaps' | 'excluded' +> & { + excluded: ReportingSubscriptionExclusionEntryV1[]; + timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; + gaps?: ReportingSubscriptionGapV1[]; +}; diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 17fe34771..9144c848d 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -93,6 +93,7 @@ import { isMovingAdcpPrereleaseFamilyAlias, resolveAdcpVersion, } from '../utils/adcp-version-config'; +import { reportingSubscriptionReleaseFieldsV1 } from './reporting-subscription-release'; import { ConfigurationError } from '../errors'; import { getValidator, @@ -1386,6 +1387,13 @@ export interface AdcpCapabilitiesConfig { }; /** Freshness and notification support for the capability document. */ capability_changes?: NonNullable; + /** + * The `adcp.principal` block (experimental `protocol.principal`): the caller-scoped + * configuration sections `sync_principal` accepts. Use the principal runtime's + * `capabilities`, which derives `supported_sections` from what it was configured with. + * Advertised to callers pinned to 3.2 or later; `reporting_subscriptions` only to 3.3+. + */ + principal?: NonNullable; portfolio?: { publisher_domains: string[]; primary_channels?: MediaChannel[]; @@ -9525,6 +9533,7 @@ export function createAdcpServer(config: AdcpServerConfig(config: AdcpServerConfig feature !== 'protocol.principal' + ) as typeof data.experimental_features; + } + } else if (selected.major === 3 && selected.minor === 2 && principalBlock.supported_sections) { + // reporting_subscriptions is a 3.3 section. + principalBlock.supported_sections = principalBlock.supported_sections.filter( + section => section !== 'reporting_subscriptions' + ); + if (principalBlock.supported_sections.length === 0) delete (data.adcp as { principal?: unknown }).principal; + } + } + const reportingOfferings = (data.media_buy?.reporting_delivery as { offerings?: unknown[] } | undefined) + ?.offerings; + if ( + Array.isArray(reportingOfferings) && + !reportingSubscriptionReleaseFieldsV1(release.validationVersion).offeringDimensions + ) { + // standard_dimensions and unmapped_dimensions (adcontextprotocol/adcp#8585) only when + // the served bundle's offering schema defines them; earlier ones are closed. + for (const offering of reportingOfferings as Array>) { + delete offering.standard_dimensions; + delete offering.unmapped_dimensions; + } + } + if (selected !== undefined && selected.major === 3 && selected.minor < 3) { + // AdCP 3.3 reporting registration fields. A seller listing only sync_principal + // serves reporting registration to 3.3 callers only. + const reporting = data.media_buy?.reporting_delivery as + | (Record & { configuration_task?: string }) + | undefined; + const features = data.experimental_features as string[] | undefined; + if (reporting) { + delete reporting.configuration_tasks; + delete reporting.subscriptions; + if (reporting.configuration_task === undefined) { + delete (data.media_buy as { reporting_delivery?: unknown }).reporting_delivery; + if (features) { + data.experimental_features = features.filter( + feature => feature !== 'media_buy.reporting_delivery' + ) as typeof data.experimental_features; + } + } + } + if (data.experimental_features) { + data.experimental_features = (data.experimental_features as string[]).filter( + feature => feature !== 'media_buy.reporting_subscriptions' + ) as typeof data.experimental_features; + } + } if ( selected !== undefined && (selected.major > 3 || (selected.major === 3 && selected.minor >= 2)) && diff --git a/src/lib/server/decisioning/account.ts b/src/lib/server/decisioning/account.ts index 64b441887..a843c208e 100644 --- a/src/lib/server/decisioning/account.ts +++ b/src/lib/server/decisioning/account.ts @@ -239,6 +239,10 @@ export interface Account> { * * Absence is silence, not denial: it means the adapter does not expose * introspectable account authorization for this caller/account tuple. + * + * To end a relationship while letting the caller finish reporting reconciliation, set + * {@link windDownGrant} here instead of removing the account: removal is revocation, which + * ends every task, receipts included (adcp#8607). */ authorization?: AccountAuthorization; @@ -1217,6 +1221,49 @@ function projectAccountAuthorization(authorization: AccountAuthorization): Accou return projected; } +/** + * The tasks a wind-down grant keeps: reconciliation only. A principal holding them can still read + * `get_reporting_status` (so its reporting subscriptions keep covering the account and it keeps + * receiving final revisions and adjustments), file `sync_reporting_receipts`, and report + * `sync_reporting_status`, and nothing else. + * + * @experimental Reporting subscriptions (`media_buy.reporting_subscriptions`, AdCP 3.3). + */ +export const REPORTING_WIND_DOWN_TASKS = Object.freeze([ + 'get_reporting_status', + 'sync_reporting_receipts', + 'sync_reporting_status', +] as const); + +/** + * A reduced grant that lets a principal finish reconciling an account after the relationship ends, + * for example after an agency change. Use it instead of revoking. + * + * Revocation means no access at all (adcp#8607): once a grant ends, the account is unknown to every + * task, `sync_reporting_receipts` and `sync_reporting_status` included, and its reporting + * subscriptions stop covering it. Revoke only for fraud or a compromise. To end a relationship, + * replace the account's `authorization` with this grant: `allowed_tasks` is + * {@link REPORTING_WIND_DOWN_TASKS} and `read_only` is `false`, because receipts and statuses are + * writes. Coverage continues while the grant lists `get_reporting_status`. + * + * Pass the grant being wound down to keep its `authorized_at`: a reduced grant continues the + * current authorization chain, so `authorized_at` must not move. Its `field_scopes` and + * `scope_name` describe the old task set and are dropped. + * + * ```ts + * account.authorization = windDownGrant(account.authorization); + * ``` + * + * @experimental Reporting subscriptions (`media_buy.reporting_subscriptions`, AdCP 3.3). + */ +export function windDownGrant(current?: Pick): AccountAuthorization { + return { + allowed_tasks: [...REPORTING_WIND_DOWN_TASKS], + read_only: false, + ...(current?.authorized_at !== undefined ? { authorized_at: current.authorized_at } : {}), + }; +} + export type WireSyncAccountRow = SyncAccountsSuccess['accounts'][number]; /** diff --git a/src/lib/server/decisioning/capabilities.ts b/src/lib/server/decisioning/capabilities.ts index d728f276c..5ee561009 100644 --- a/src/lib/server/decisioning/capabilities.ts +++ b/src/lib/server/decisioning/capabilities.ts @@ -340,6 +340,21 @@ export interface DecisioningCapabilities { */ supported_versions?: string[]; + /** + * The `adcp.principal` block for sellers serving `sync_principal` (experimental + * `protocol.principal`). Pass the principal runtime's `capabilities`. The framework adds + * `protocol.principal` to `experimental_features`, and requires `reporting_subscriptions` + * in `supported_sections` when the reporting capability advertises subscriptions. + */ + principal?: NonNullable; + + /** + * The `adcp.capability_changes` block. A principal block listing `notification_configs` + * requires `notifications` with `registration_task: 'sync_principal'`; the framework then + * also requires the compatibility `sync_agent_notification_configs` handler. + */ + capability_changes?: NonNullable; + /** * Platform-specific config. Strongly typed when the adopter uses the generic. * Example: `class GAM extends DecisioningPlatform<{ networkId: string }>`. diff --git a/src/lib/server/decisioning/index.ts b/src/lib/server/decisioning/index.ts index f9397719b..0a79b2005 100644 --- a/src/lib/server/decisioning/index.ts +++ b/src/lib/server/decisioning/index.ts @@ -127,10 +127,12 @@ export type { export { AccountNotFoundError, + REPORTING_WIND_DOWN_TASKS, isAccountResolutionMode, normalizeAccountResolution, refAccountId, refHasNaturalKey, + windDownGrant, } from './account'; // Multi-tenant AccountStore builder. Bakes in the two-path resolution diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index 5bc039ba3..622e488c4 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -3160,20 +3160,53 @@ export function createAdcpServerFromPlatform

= 32 + advertisesSubscriptions && + !(principalCapability?.supported_sections as readonly string[] | undefined)?.includes('reporting_subscriptions') ) { throw new PlatformConfigError( - 'Reliable Reporting Core cannot be added because experimental_features already contains 32 entries' + 'Advertising reporting subscriptions requires capabilities.principal with reporting_subscriptions in ' + + 'supported_sections; pass the principal runtime capabilities created with reportingSubscriptions' ); } - const experimentalFeatures = reportingDelivery - ? Array.from(new Set([...adopterExperimentalFeatures, 'media_buy.reporting_delivery'])) - : adopterOverrides?.experimental_features; + const principalSections = (principalCapability?.supported_sections as readonly string[] | undefined) ?? []; + if ( + principalSections.includes('reporting_subscriptions') && + !(reportingDelivery?.configuration_tasks as readonly string[] | undefined)?.includes('sync_principal') + ) { + throw new PlatformConfigError( + 'capabilities.principal lists reporting_subscriptions, but the reporting capability does not list sync_principal ' + + 'in configuration_tasks; create the reliable reporting service with reportingSubscriptions' + ); + } + const derivedFeatureIds = [ + ...(reportingDelivery ? ['media_buy.reporting_delivery'] : []), + ...(advertisesSubscriptions ? ['media_buy.reporting_subscriptions'] : []), + ...(principalCapability ? ['protocol.principal'] : []), + ]; + if ( + derivedFeatureIds.some(id => !adopterExperimentalFeatures.includes(id)) && + new Set([...adopterExperimentalFeatures, ...derivedFeatureIds]).size > 32 + ) { + throw new PlatformConfigError( + reportingDelivery + ? 'Reliable Reporting Core cannot be added because experimental_features already contains 32 entries' + : 'protocol.principal cannot be added because experimental_features already contains 32 entries' + ); + } + const experimentalFeatures = + derivedFeatureIds.length > 0 + ? Array.from(new Set([...adopterExperimentalFeatures, ...derivedFeatureIds])) + : adopterOverrides?.experimental_features; const adopterSupportedVersions = platform.capabilities.supported_versions; - const hasOverridesObject = hasOverridesProjection || adopterOverrides !== undefined; + const hasOverridesObject = + hasOverridesProjection || adopterOverrides !== undefined || platform.capabilities.principal !== undefined; const projectedCapabilitiesConfig = { ...(hasSpecialisms && { @@ -3192,6 +3225,10 @@ export function createAdcpServerFromPlatform

; + /** Last version whose `replace_account_configs` the host hook confirmed. */ + replaceAppliedVersion?: number; + /** + * A `replace_account_configs` application committed but not yet confirmed by the host + * hook. It is retried on every later write until the hook succeeds. + */ + replacePending?: { subscriptionVersion: number; feedPurpose: string; at: string }; +} + +export class ReportingSubscriptionValidationError extends Error { + constructor( + message: string, + readonly field = 'configuration.reporting_subscriptions' + ) { + super(message); + this.name = 'ReportingSubscriptionValidationError'; + } +} + +export interface ApplyReportingSubscriptionsInput { + previous: readonly ReportingSubscriptionState[]; + history: readonly StoredReportingSubscriptionHistory[]; + desired: readonly ReportingSubscription[]; + /** The principal's `reporting_destinations` after this same request applies. */ + destinations: readonly PrincipalReportingDestination[]; + /** The caller's `sync_accounts` `delivery_config_id` values on any account. */ + reservedDeliveryConfigIds: ReadonlySet; + now: string; + maxSubscriptions: number; +} + +export interface ApplyReportingSubscriptionsResult { + states: ReportingSubscriptionState[]; + history: StoredReportingSubscriptionHistory[]; + /** Versions whose `replace_account_configs` must now be applied, by cutover at `at` (this request). */ + replaceAccountConfigs: Array<{ + subscriptionId: string; + subscriptionVersion: number; + feedPurpose: string; + at: string; + }>; +} + +/** `reporting-subscription-state.json` `inactive_intervals` maxItems. */ +const MAX_INACTIVE_INTERVALS = 64; +const SUBSCRIPTION_ID = /^(?!\.{1,2}$)[A-Za-z0-9_.:-]{1,64}$/; +const PATH_TEMPLATE = /^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))[A-Za-z0-9_.=/{}-]+$/; +const PLACEHOLDERS = new Set(['{account_id}', '{subscription_id}', '{offering_id}', '{period_start}']); +const DIMENSION = /^[a-z][a-z0-9_]*$/; + +/** `{name}` placeholders in one linear pass, or undefined when braces do not pair. */ +function templatePlaceholders(template: string): string[] | undefined { + const found: string[] = []; + let open = -1; + for (let index = 0; index < template.length; index += 1) { + const char = template[index]; + if (char === '{') { + if (open !== -1) return undefined; + open = index; + } else if (char === '}') { + if (open === -1) return undefined; + found.push(template.slice(open, index + 1)); + open = -1; + } + } + return open === -1 ? found : undefined; +} + +/** Fields whose change requires a new `subscription_version`. `active` and `replace_account_configs` change in place. */ +export function reportingSubscriptionSemanticFingerprint(subscription: ReportingSubscription): string { + const { active: _active, replace_account_configs: _replace, ...semantic } = subscription; + void _active; + void _replace; + return canonicalJsonSha256(semantic); +} + +function isMultiAccount(subscription: ReportingSubscription): boolean { + return subscription.scope.all_authorized_accounts === true || (subscription.scope.account_ids?.length ?? 0) > 1; +} + +function destinationPattern(destination: PrincipalReportingDestination): ReportingSubscriptionDestinationMethodV1 { + const configuration = destination.configuration as { pattern?: unknown; transport?: unknown }; + return { pattern: String(configuration.pattern), transport: String(configuration.transport) }; +} + +/** Cross-field rules of `core/reporting-subscription.json` that a JSON Schema pass alone may not enforce. */ +export function validateReportingSubscription( + subscription: ReportingSubscription, + destinations: readonly PrincipalReportingDestination[] +): void { + const fail = (message: string): never => { + throw new ReportingSubscriptionValidationError( + `Reporting subscription ${JSON.stringify(subscription?.subscription_id)}: ${message}` + ); + }; + if (!subscription || typeof subscription !== 'object') fail('must be an object'); + if (typeof subscription.subscription_id !== 'string' || !SUBSCRIPTION_ID.test(subscription.subscription_id)) { + fail('subscription_id must be 1-64 of [A-Za-z0-9_.:-] and not . or ..'); + } + if (!Number.isSafeInteger(subscription.subscription_version) || subscription.subscription_version < 1) { + fail('subscription_version must be a positive integer'); + } + if (typeof subscription.active !== 'boolean') fail('active is required'); + const scope = subscription.scope as { all_authorized_accounts?: unknown; account_ids?: unknown } | undefined; + const scopeKeys = scope && typeof scope === 'object' ? Object.keys(scope) : []; + if (scopeKeys.length !== 1) fail('scope must name exactly one of all_authorized_accounts or account_ids'); + if (scope!.all_authorized_accounts !== undefined && scope!.all_authorized_accounts !== true) { + fail('scope.all_authorized_accounts must be true when present'); + } + if (scope!.account_ids !== undefined) { + const ids = scope!.account_ids; + if ( + !Array.isArray(ids) || + ids.length < 1 || + ids.length > 1000 || + new Set(ids).size !== ids.length || + ids.some(id => typeof id !== 'string' || id.length === 0) + ) { + fail('scope.account_ids must be 1-1000 unique account ids'); + } + } + if (subscription.exclude_account_ids !== undefined && scope!.all_authorized_accounts !== true) { + fail('exclude_account_ids is valid only with all_authorized_accounts scope'); + } + const selector = subscription.selector; + if (!selector || typeof selector !== 'object') fail('selector is required'); + if (!['pacing', 'analytics', 'billing'].includes(selector.feed_purpose)) fail('selector.feed_purpose is invalid'); + try { + parseReportingPeriodDurationV1(selector.period_duration); + } catch { + fail('selector.period_duration must be a positive ISO 8601 duration'); + } + if (!['snapshot', 'official'].includes(selector.required_finality)) fail('selector.required_finality is invalid'); + if (selector.match !== undefined && !['exact', 'finest', 'all'].includes(selector.match)) { + fail('selector.match must be exact, finest or all'); + } + if (selector.dimensions !== undefined) { + const dims = selector.dimensions as unknown[]; + if ( + !Array.isArray(dims) || + dims.length < 1 || + dims.length > 16 || + new Set(dims).size !== dims.length || + dims.some(value => typeof value !== 'string' || !DIMENSION.test(value)) + ) { + fail('selector.dimensions must be 1-16 unique reporting-dimension values'); + } + } + if (selector.period_timezone !== undefined) { + if (subscription.scope.account_ids?.length !== 1) { + fail('selector.period_timezone is valid only when scope.account_ids names exactly one account'); + } + try { + assertReportingIanaTimezoneV1(selector.period_timezone, 'selector.period_timezone'); + } catch (error) { + fail((error as Error).message); + } + } + if (selector.feed_purpose === 'billing') { + if (selector.required_finality !== 'official') fail('billing subscriptions require official finality'); + if (subscription.reconciliation_mode !== 'consumer_receipt') { + fail('billing subscriptions require consumer_receipt reconciliation'); + } + } + if (subscription.reconciliation_mode === 'consumer_receipt' && subscription.destination === undefined) { + fail('consumer_receipt reconciliation requires a destination'); + } + if (subscription.destination !== undefined) { + const destination = destinations.find(item => item.destination_id === subscription.destination!.destination_id); + if (!destination) { + fail( + `destination ${JSON.stringify(subscription.destination.destination_id)} is not in reporting_destinations after this request` + ); + } + const template = subscription.destination.path_template; + if (template !== undefined) { + if (typeof template !== 'string' || template.length > 512 || !PATH_TEMPLATE.test(template)) { + fail('destination.path_template must be a relative path without . or .. segments'); + } + const placeholders = templatePlaceholders(template); + if (placeholders === undefined) fail('destination.path_template has an unbalanced brace'); + for (const placeholder of placeholders!) { + if (!PLACEHOLDERS.has(placeholder)) fail(`destination.path_template has unknown placeholder ${placeholder}`); + } + } + if (isMultiAccount(subscription)) { + if (template === undefined && destinationPattern(destination!).pattern === 'file_transfer') { + fail( + 'destination.path_template is required for a file_transfer destination when the scope can cover several accounts' + ); + } + if (template !== undefined && !template.includes('{account_id}')) { + fail('destination.path_template must contain {account_id} when the scope can cover several accounts'); + } + } + } +} + +/** + * Apply a complete desired `reporting_subscriptions` section. Throws + * {@link ReportingSubscriptionValidationError} (INVALID_REQUEST) for any rule violation; + * nothing is applied unless every entry is valid. + */ +export function applyReportingSubscriptions( + input: ApplyReportingSubscriptionsInput +): ApplyReportingSubscriptionsResult { + const { desired, now } = input; + if (!Array.isArray(desired)) + throw new ReportingSubscriptionValidationError('reporting_subscriptions must be an array'); + if (desired.length > input.maxSubscriptions) { + throw new ReportingSubscriptionValidationError( + `reporting_subscriptions exceeds this seller's limit of ${input.maxSubscriptions}` + ); + } + const seen = new Set(); + for (const subscription of desired) { + validateReportingSubscription(subscription, input.destinations); + if (seen.has(subscription.subscription_id)) { + throw new ReportingSubscriptionValidationError( + `Duplicate subscription_id ${JSON.stringify(subscription.subscription_id)}` + ); + } + seen.add(subscription.subscription_id); + if (input.reservedDeliveryConfigIds.has(subscription.subscription_id)) { + throw new ReportingSubscriptionValidationError( + `subscription_id ${JSON.stringify(subscription.subscription_id)} equals one of the caller's delivery_config_id values` + ); + } + } + assertNoSameLevelOverlap(desired); + assertSharedDestinationTemplates(desired); + + const historyById = new Map(input.history.map(entry => [entry.subscriptionId, structuredClone(entry)])); + const previousById = new Map(input.previous.map(state => [state.subscription_id, state])); + const replaceAccountConfigs: ApplyReportingSubscriptionsResult['replaceAccountConfigs'] = []; + const touched = new Set(); + + for (const subscription of desired) { + touched.add(subscription.subscription_id); + const normalized: ReportingSubscription = structuredClone({ ...subscription, active: true }); + const destination = subscription.destination + ? input.destinations.find(item => item.destination_id === subscription.destination!.destination_id) + : undefined; + const method = destination ? destinationPattern(destination) : undefined; + let history = historyById.get(subscription.subscription_id); + if (!history) { + history = { + subscriptionId: subscription.subscription_id, + versions: [ + { + subscriptionVersion: subscription.subscription_version, + acceptedAt: now, + configuration: normalized, + ...(method ? { destinationMethod: method } : {}), + }, + ], + inactiveIntervals: [], + }; + historyById.set(subscription.subscription_id, history); + } else { + const current = history.versions.at(-1)!; + if (subscription.subscription_version < current.subscriptionVersion) { + throw new ReportingSubscriptionValidationError( + `subscription_version for ${JSON.stringify(subscription.subscription_id)} cannot regress below ${current.subscriptionVersion}` + ); + } + if (subscription.subscription_version === current.subscriptionVersion) { + if ( + method && + current.destinationMethod && + (method.pattern !== current.destinationMethod.pattern || + method.transport !== current.destinationMethod.transport) + ) { + throw new ReportingSubscriptionValidationError( + `Destination ${JSON.stringify(subscription.destination!.destination_id)} changes pattern or transport under subscription ${JSON.stringify(subscription.subscription_id)}; increment its subscription_version` + ); + } + if ( + reportingSubscriptionSemanticFingerprint(normalized) !== + reportingSubscriptionSemanticFingerprint(current.configuration) + ) { + throw new ReportingSubscriptionValidationError( + `subscription_version ${current.subscriptionVersion} of ${JSON.stringify(subscription.subscription_id)} is reused with different content; increment it` + ); + } + // active and replace_account_configs change in place. + current.configuration = { ...current.configuration, ...pickInPlace(subscription) }; + } else { + current.supersededAt = now; + history.versions.push({ + subscriptionVersion: subscription.subscription_version, + acceptedAt: now, + configuration: normalized, + ...(method ? { destinationMethod: method } : {}), + }); + } + } + // Activation state. + if (subscription.active === false) { + if (history.deactivatedAt === undefined) history.deactivatedAt = now; + } else if (history.deactivatedAt !== undefined) { + if (Date.parse(now) > Date.parse(history.deactivatedAt)) { + history.inactiveIntervals.push({ from: history.deactivatedAt, until: now }); + } + delete history.deactivatedAt; + } + // replace_account_configs applies once per version, or when the flag turns true. It is + // recorded as pending here and marked applied only after the host hook succeeds. + const currentVersion = history.versions.at(-1)!; + if ( + subscription.active !== false && + subscription.replace_account_configs === true && + history.replaceAppliedVersion !== currentVersion.subscriptionVersion && + history.replacePending?.subscriptionVersion !== currentVersion.subscriptionVersion + ) { + history.replacePending = { + subscriptionVersion: currentVersion.subscriptionVersion, + feedPurpose: subscription.selector.feed_purpose, + at: now, + }; + } else if (subscription.replace_account_configs === false) { + // Only an explicit false turns the flag off, so a later true applies again. An omitted flag + // leaves both untouched: buyers don't re-assert a one-shot action on every write, and a + // committed application whose hook failed must keep being retried. + delete history.replaceAppliedVersion; + delete history.replacePending; + } + if (history.inactiveIntervals.length + (history.deactivatedAt !== undefined ? 1 : 0) > MAX_INACTIVE_INTERVALS) { + throw new ReportingSubscriptionValidationError( + `Reporting subscription ${JSON.stringify(subscription.subscription_id)} has reached ${MAX_INACTIVE_INTERVALS} inactive intervals, the most the readback can list` + ); + } + } + for (const history of historyById.values()) { + if (history.replacePending) { + replaceAccountConfigs.push({ subscriptionId: history.subscriptionId, ...history.replacePending }); + } + } + // Omission deactivates; the subscription stays readable while its obligations are retained. + for (const history of historyById.values()) { + if (touched.has(history.subscriptionId)) continue; + if (history.deactivatedAt !== undefined) continue; + // The open interval this opens is listed too; bound it exactly as `active: false` does. + if (history.inactiveIntervals.length + 1 > MAX_INACTIVE_INTERVALS) { + throw new ReportingSubscriptionValidationError( + `Reporting subscription ${JSON.stringify(history.subscriptionId)} has reached ${MAX_INACTIVE_INTERVALS} inactive intervals, the most the readback can list` + ); + } + history.deactivatedAt = now; + } + + const states: ReportingSubscriptionState[] = []; + for (const history of [...historyById.values()].sort((left, right) => + left.subscriptionId < right.subscriptionId ? -1 : left.subscriptionId > right.subscriptionId ? 1 : 0 + )) { + const desiredEntry = desired.find(item => item.subscription_id === history.subscriptionId); + states.push( + projectReportingSubscriptionState( + history, + input.destinations, + previousById.get(history.subscriptionId), + desiredEntry + ) + ); + } + return { states, history: [...historyById.values()], replaceAccountConfigs }; +} + +function pickInPlace(subscription: ReportingSubscription): Partial { + return subscription.replace_account_configs === undefined + ? {} + : { replace_account_configs: subscription.replace_account_configs }; +} + +/** + * Readback for one subscription. `accounts_with_exceptions` and `accounts_unauthorized` + * are carried from the previous readback; the coverage engine maintains them. + */ +export function projectReportingSubscriptionState( + history: StoredReportingSubscriptionHistory, + destinations: readonly PrincipalReportingDestination[], + previous?: ReportingSubscriptionState, + desired?: ReportingSubscription +): ReportingSubscriptionState { + const current = history.versions.at(-1)!; + const configuration: ReportingSubscription = structuredClone( + desired ?? previous?.configuration ?? current.configuration + ); + if (history.deactivatedAt !== undefined) configuration.active = false; + const destination = configuration.destination + ? destinations.find(item => item.destination_id === configuration.destination!.destination_id) + : undefined; + const issues: NonNullable = []; + if (history.deactivatedAt === undefined && configuration.destination) { + if (!destination) { + issues.push({ + code: 'REFERENCE_NOT_FOUND', + message: `Reporting destination ${JSON.stringify(configuration.destination.destination_id)} was removed`, + field: 'destination.destination_id', + } as never); + } else if (destination.state === 'inactive' || destination.configuration.active === false) { + issues.push({ + code: 'DESTINATION_SUSPENDED', + message: `Reporting destination ${JSON.stringify(destination.destination_id)} is suspended`, + field: 'destination.destination_id', + } as never); + } else if (destination.state === 'action_required' || destination.state === 'rejected') { + issues.push({ + code: 'DESTINATION_NOT_READY', + message: `Reporting destination ${JSON.stringify(destination.destination_id)} is ${destination.state}`, + field: 'destination.destination_id', + } as never); + } + } + const state: ReportingSubscriptionState['state'] = + history.deactivatedAt !== undefined ? 'inactive' : issues.length > 0 ? 'action_required' : 'active'; + // Oldest first; the open interval while inactive has no reactivated_at. Bounded at 64. + const intervals = [ + ...history.inactiveIntervals.map(interval => ({ deactivated_at: interval.from, reactivated_at: interval.until })), + ...(history.deactivatedAt !== undefined ? [{ deactivated_at: history.deactivatedAt }] : []), + ]; + const prior = history.versions + .slice(0, -1) + .reverse() + .map(version => ({ + subscription_version: version.subscriptionVersion, + accepted_at: version.acceptedAt, + superseded_at: version.supersededAt!, + // adcontextprotocol/adcp#8588: each prior version carries its configuration. + configuration: structuredClone(version.configuration), + })); + return { + subscription_id: history.subscriptionId, + subscription_version: current.subscriptionVersion, + state, + accepted_at: current.acceptedAt, + ...(prior.length > 0 ? { prior_versions: prior } : {}), + ...(history.deactivatedAt !== undefined ? { deactivated_at: history.deactivatedAt } : {}), + ...(intervals.length > 0 ? { inactive_intervals: intervals } : {}), + ...(destination ? { destination_ref: destination.destination_ref } : {}), + configuration, + ...(issues.length > 0 ? { issues: issues.slice(0, 16) } : {}), + ...(previous?.accounts_with_exceptions?.length + ? { accounts_with_exceptions: structuredClone(previous.accounts_with_exceptions) } + : {}), + ...(previous?.accounts_unauthorized?.length + ? { accounts_unauthorized: structuredClone(previous.accounts_unauthorized) } + : {}), + }; +} + +/** + * Two active subscriptions at the same scope level must not both apply to one account and + * feed purpose. All-accounts subscriptions overlap unless their `operator_domains` are + * disjoint; account-scoped ones overlap when they share an account. + */ +function assertNoSameLevelOverlap(desired: readonly ReportingSubscription[]): void { + const active = desired.filter(item => item.active !== false); + for (let left = 0; left < active.length; left += 1) { + for (let right = left + 1; right < active.length; right += 1) { + const a = active[left]!; + const b = active[right]!; + if (a.selector.feed_purpose !== b.selector.feed_purpose) continue; + let overlap = false; + if (a.scope.all_authorized_accounts && b.scope.all_authorized_accounts) { + overlap = + a.operator_domains === undefined || + b.operator_domains === undefined || + a.operator_domains.some(domain => b.operator_domains!.includes(domain)); + } else if (a.scope.account_ids && b.scope.account_ids) { + overlap = a.scope.account_ids.some(id => b.scope.account_ids!.includes(id)); + } + if (overlap) { + throw new ReportingSubscriptionValidationError( + `Active subscriptions ${JSON.stringify(a.subscription_id)} and ${JSON.stringify(b.subscription_id)} ` + + `would both apply to one account for feed_purpose ${a.selector.feed_purpose}` + ); + } + } + } +} + +/** + * Active subscriptions sharing a destination each need a `{account_id}` template, and no two + * expansions may land on the same path. `{subscription_id}` is known per subscription, so it + * is substituted first; a remaining placeholder fills one non-empty segment. Two templates + * are refused when some values could make them equal (decided segment by segment, erring + * toward refusal), not merely when their text is equal. + */ +function assertSharedDestinationTemplates(desired: readonly ReportingSubscription[]): void { + const byDestination = new Map(); + for (const subscription of desired) { + if (subscription.active === false || !subscription.destination) continue; + const list = byDestination.get(subscription.destination.destination_id) ?? []; + list.push(subscription); + byDestination.set(subscription.destination.destination_id, list); + } + for (const [destinationId, list] of byDestination) { + if (list.length < 2) continue; + const templates = list.map(item => item.destination!.path_template); + if (templates.some(template => template === undefined || !template.includes('{account_id}'))) { + throw new ReportingSubscriptionValidationError( + `Active subscriptions sharing destination ${JSON.stringify(destinationId)} must each set a path_template containing {account_id}` + ); + } + const expanded = list.map(item => + item.destination!.path_template!.split('{subscription_id}').join(encodePathSegment(item.subscription_id)) + ); + for (let left = 0; left < expanded.length; left += 1) { + for (let right = left + 1; right < expanded.length; right += 1) { + if (templatesMayCollide(expanded[left]!, expanded[right]!)) { + throw new ReportingSubscriptionValidationError( + `Active subscriptions ${JSON.stringify(list[left]!.subscription_id)} and ${JSON.stringify(list[right]!.subscription_id)} sharing destination ${JSON.stringify(destinationId)} have path_template values that can expand to the same path` + ); + } + } + } + } +} + +function templatesMayCollide(left: string, right: string): boolean { + const a = left.split('/'); + const b = right.split('/'); + if (a.length !== b.length) return false; + return a.every((segment, index) => segmentsMayCollide(segment, b[index]!)); +} + +/** Whether some placeholder values make two template segments equal. Errs toward true. */ +function segmentsMayCollide(left: string, right: string): boolean { + const placeholder = /\{[a-z_]+\}/; + const hasLeft = placeholder.test(left); + const hasRight = placeholder.test(right); + if (!hasLeft && !hasRight) return left === right; + const literalPrefix = (value: string) => value.split(placeholder)[0]!; + const literalSuffix = (value: string) => value.split(placeholder).at(-1)!; + if (!hasLeft || !hasRight) { + const [literal, pattern] = hasLeft ? [right, left] : [left, right]; + const regex = new RegExp( + `^${pattern + .split(/\{[a-z_]+\}/) + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[^/]+')}$` + ); + return regex.test(literal); + } + const prefixes = [literalPrefix(left), literalPrefix(right)]; + const suffixes = [literalSuffix(left), literalSuffix(right)]; + const prefixCompatible = prefixes[0]!.startsWith(prefixes[1]!) || prefixes[1]!.startsWith(prefixes[0]!); + const suffixCompatible = suffixes[0]!.endsWith(suffixes[1]!) || suffixes[1]!.endsWith(suffixes[0]!); + return prefixCompatible && suffixCompatible; +} + +/** Expand a destination `path_template`; each value fills exactly one percent-encoded segment. */ +export function expandReportingSubscriptionPathTemplate( + template: string, + values: { account_id: string; subscription_id: string; offering_id: string; period_start: string } +): string { + return template.replace( + /\{(account_id|subscription_id|offering_id|period_start)\}/g, + (_match, key: keyof typeof values) => encodePathSegment(values[key]) + ); +} + +function encodePathSegment(value: string): string { + if (value === '.' || value === '..') return value.replace(/\./g, '%2E'); + // RFC 3986 pchar: keep unreserved, sub-delims, ':' and '@'; encode everything else, + // including '/', so no value can add or escape a segment. + return Array.from(new TextEncoder().encode(value)) + .map(byte => { + const char = String.fromCharCode(byte); + return byte < 0x80 && /[A-Za-z0-9\-._~!$&'()*+,;=:@]/.test(char) + ? char + : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + }) + .join(''); +} diff --git a/src/lib/server/principal/runtime.ts b/src/lib/server/principal/runtime.ts index 09a803cb7..a24385c06 100644 --- a/src/lib/server/principal/runtime.ts +++ b/src/lib/server/principal/runtime.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { compare as compareSemver } from 'semver'; import { canonicalJsonSha256 } from '../../utils/jcs'; import type { + GetAdCPCapabilitiesResponse, GetPrincipalRequest, GetPrincipalResponse, SyncAgentNotificationConfigsRequest, @@ -21,6 +22,17 @@ import { projectStoredNotificationSubscriptionReadback, } from '../notification-subscriptions/runtime'; import { PRINCIPAL_NOTIFICATION_STORE_OWNER } from './store'; +import { + reportingSubscriptionReleaseFieldsV1, + reportingSubscriptionStatesForReleaseV1, +} from '../reporting-subscription-release'; +import { + ReportingSubscriptionValidationError, + applyReportingSubscriptions, + projectReportingSubscriptionState, + type ApplyReportingSubscriptionsResult, + type StoredReportingSubscriptionHistory, +} from './reporting-subscriptions'; import type { PendingPrincipalNotification, PrepareReportingDestination, @@ -31,8 +43,11 @@ import type { PrincipalDeclarationsState, PrincipalDestinationTransition, PrincipalKind, + PrincipalLifecycleRuntimeSubscriptions, PrincipalNotificationRecoveryResult, PrincipalReportingDestination, + PrincipalReportingSubscriptionState, + PrincipalReportingSubscriptionSupport, PrincipalReportingDestinationInput, ResolvedReportingDestination, PrincipalStateStore, @@ -56,6 +71,13 @@ export interface CreatePrincipalLifecycleOptions { agentUrl: string; declarations?: PrincipalDeclarationSupport; prepareReportingDestination?: PrepareReportingDestination; + /** + * Opt in to the `reporting_subscriptions` section (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3). Requires `prepareReportingDestination` + * when subscriptions name destinations. Without it the section is refused with + * `UNSUPPORTED_FEATURE` and every response is unchanged. + */ + reportingSubscriptions?: PrincipalReportingSubscriptionSupport; issuePrincipalId?: (scope: Readonly) => string; issueConfigurationVersion?: () => string; issueDestinationRef?: () => string; @@ -69,11 +91,25 @@ export interface CreatePrincipalLifecycleOptions { maxReportingGenerations?: number; /** Expose the legacy notification-only protocol task in addition to sync_principal. */ includeCompatibilityNotificationTask?: boolean; + /** + * Seller facts the `adcp.principal` capability block carries beside `supported_sections`, + * such as `max_reporting_destinations`, `reporting_destination_offerings` and + * `suspension_interval_seconds` (required when `reporting_destinations` is supported). + */ + capabilityDetails?: Omit< + NonNullable, + 'supported' | 'sync_task' | 'read_task' | 'supported_sections' | 'optimistic_concurrency' + >; /** Explicitly accept crash-window duplicate delivery without an attempt checkpoint. */ acknowledgeMissingAttemptCheckpoint?: boolean; } -export interface PrincipalLifecycleRuntime { +export interface PrincipalLifecycleRuntime extends Partial { + /** + * The `adcp.principal` capability block this runtime serves, for + * `capabilities.principal`. `supported_sections` follows the options it was created with. + */ + readonly capabilities?: NonNullable; protocol: Pick, 'resolvePrincipalScope' | 'getPrincipal' | 'syncPrincipal'> & Partial, 'resolveScope' | 'syncAgentNotificationConfigs'>>; recognizePrincipal(input: { @@ -111,7 +147,10 @@ export interface PrincipalLifecycleRuntime { */ export function createPrincipalLifecycle( options: CreatePrincipalLifecycleOptions -): PrincipalLifecycleRuntime { +): PrincipalLifecycleRuntime & + PrincipalLifecycleRuntimeSubscriptions & { + readonly capabilities: NonNullable; + } { if (!options?.store) throw new TypeError('createPrincipalLifecycle requires store'); if (!options.notifications?.prepareReplacement) { throw new TypeError('createPrincipalLifecycle requires a notification runtime with prepareReplacement'); @@ -149,6 +188,16 @@ export function createPrincipalLifecycle( const maxPendingNotifications = boundedOption(options.maxPendingNotifications, 256, 'maxPendingNotifications'); const maxNotificationsPerFlush = boundedOption(options.maxNotificationsPerFlush, 100, 'maxNotificationsPerFlush'); const maxReportingGenerations = boundedOption(options.maxReportingGenerations, 256, 'maxReportingGenerations'); + const maxSubscriptions = boundedOption(options.reportingSubscriptions?.maxSubscriptions, 64, 'maxSubscriptions'); + if ( + options.reportingSubscriptions && + options.reportingSubscriptions.accountConfigurations !== false && + typeof options.reportingSubscriptions.listDeliveryConfigIds !== 'function' + ) { + // subscription_id and the caller's delivery_config_id values share one namespace, and + // sellers MUST reject a clash; that needs the caller's account-level configurations. + throw new TypeError('reportingSubscriptions requires listDeliveryConfigIds unless accountConfigurations is false'); + } async function resolve(ctx: HandlerContext): Promise { const cached = ctx.callerMutationScope as (ResolvedPrincipalScope & Record) | undefined; @@ -161,6 +210,10 @@ export function createPrincipalLifecycle( params: GetPrincipalRequest, ctx: HandlerContext ): Promise> { + return forRelease(await readPrincipal(ctx), ctx.servedAdcpVersion, options.reportingSubscriptions !== undefined); + } + + async function readPrincipal(ctx: HandlerContext): Promise> { const resolved = await resolve(ctx); const scope = storeScope(resolved); const current = await options.store.get(scope); @@ -194,7 +247,11 @@ export function createPrincipalLifecycle( ctx: HandlerContext ): Promise> { const resolved = await resolve(ctx); - return syncConfiguration(resolved, params); + return forRelease( + await syncConfiguration(resolved, params), + ctx.servedAdcpVersion, + options.reportingSubscriptions !== undefined + ); } async function syncConfiguration( @@ -237,13 +294,22 @@ export function createPrincipalLifecycle( notificationPlan = prepared.plan; } + const subscriptionPlan: { result?: ApplyReportingSubscriptionsResult } = {}; const nextConfiguration = await buildConfiguration( scope, current?.record.configuration, params.configuration, notificationPlan, - params.dry_run === true + params.dry_run === true, + current?.record.reportingSubscriptionHistory ?? [], + subscriptionPlan ); + if ( + (subscriptionPlan.result?.replaceAccountConfigs.length ?? 0) > 0 && + !options.reportingSubscriptions?.replaceAccountConfigs + ) { + throw new UnsupportedPrincipalFeatureError('replace_account_configs is not supported by this seller'); + } const reportingGenerations = mergeReportingGenerations( current?.record.reportingGenerations ?? [], current?.record.configuration?.reporting_destinations ?? [], @@ -271,6 +337,8 @@ export function createPrincipalLifecycle( if (!changed && current?.record.configurationVersion) { await notificationPlan?.discardCredentials(); + // A resend retries a replace_account_configs the host hook has not yet confirmed. + await runPendingReplacements(scope); return { result: { ...currentResult(current.record), @@ -297,6 +365,9 @@ export function createPrincipalLifecycle( reportingGenerations, pendingNotifications: structuredClone(current?.record.pendingNotifications ?? []), }; + const subscriptionHistory = + subscriptionPlan.result?.history ?? current?.record.reportingSubscriptionHistory ?? undefined; + if (subscriptionHistory !== undefined) record.reportingSubscriptionHistory = structuredClone(subscriptionHistory); const persisted = await options.store.replace({ scope, expectedRevision: current?.revision ?? null, @@ -307,6 +378,7 @@ export function createPrincipalLifecycle( return syncFailure('CONFLICT', 'The principal configuration changed; read current state and retry'); } await notificationPlan?.commitCredentials(); + await runPendingReplacements(scope); return { result: { ...currentResult(persisted.value.record), @@ -320,6 +392,9 @@ export function createPrincipalLifecycle( if (error instanceof NotificationSubscriptionValidationError) { return syncFailure('INVALID_REQUEST', error.message); } + if (error instanceof ReportingSubscriptionValidationError) { + return syncFailure('INVALID_REQUEST', error.message); + } if (error instanceof UnsupportedPrincipalFeatureError) { return syncFailure('UNSUPPORTED_FEATURE', error.message); } @@ -330,17 +405,48 @@ export function createPrincipalLifecycle( } } + /** + * Call the host's replace_account_configs hook for every committed but unconfirmed + * replacement, and mark each applied only after the hook succeeds. A failure leaves it + * pending, so the next write (including an identical resend) retries it. + */ + async function runPendingReplacements(scope: Readonly): Promise { + const hook = options.reportingSubscriptions?.replaceAccountConfigs; + if (!hook) return; + const current = await options.store.get(scope); + for (const entry of current?.record.reportingSubscriptionHistory ?? []) { + const pending = entry.replacePending; + if (!pending) continue; + await hook({ scope: structuredClone(scope), subscriptionId: entry.subscriptionId, ...pending }); + for (let attempt = 0; attempt < maxCasAttempts; attempt++) { + const latest = await options.store.get(scope); + if (!latest) return; + const record = structuredClone(latest.record); + const history = record.reportingSubscriptionHistory?.find(item => item.subscriptionId === entry.subscriptionId); + if (!history?.replacePending || history.replacePending.subscriptionVersion !== pending.subscriptionVersion) + break; + history.replaceAppliedVersion = pending.subscriptionVersion; + delete history.replacePending; + const persisted = await options.store.replace({ scope, expectedRevision: latest.revision, record }); + if (persisted.outcome === 'applied') break; + } + } + } + async function buildConfiguration( scope: Readonly, current: PrincipalConfiguration | undefined, input: PrincipalConfigurationInput, notifications: PreparedNotificationReplacement | undefined, - dryRun: boolean + dryRun: boolean, + subscriptionHistory: readonly StoredReportingSubscriptionHistory[] = [], + subscriptionPlan: { result?: ApplyReportingSubscriptionsResult } = {} ): Promise { const next: PrincipalConfiguration = structuredClone( current ?? { notification_configs: [], ...(options.prepareReportingDestination ? { reporting_destinations: [], retired_destinations: [] } : {}), + ...(options.reportingSubscriptions ? { reporting_subscriptions: [] } : {}), ...(options.declarations ? { declarations: intersectDeclarations({}, options.declarations) } : {}), } ); @@ -364,10 +470,52 @@ export function createPrincipalLifecycle( next.retired_destinations = reporting.retired; } if (input.reporting_subscriptions !== undefined) { - // AdCP 3.3 (experimental media_buy.reporting_subscriptions): unsupported - // sections are rejected rather than silently ignored. This runtime does - // not yet resolve principal-level subscriptions into account obligations. - throw new UnsupportedPrincipalFeatureError('reporting_subscriptions are not supported by this seller'); + // AdCP 3.3 (experimental media_buy.reporting_subscriptions): without the + // opt-in, unsupported sections are rejected rather than silently ignored. + if (!options.reportingSubscriptions) { + throw new UnsupportedPrincipalFeatureError('reporting_subscriptions are not supported by this seller'); + } + const reserved = new Set(await (options.reportingSubscriptions.listDeliveryConfigIds?.(scope) ?? [])); + const result = applyReportingSubscriptions({ + previous: current?.reporting_subscriptions ?? [], + history: subscriptionHistory, + desired: input.reporting_subscriptions as never, + destinations: next.reporting_destinations ?? [], + reservedDeliveryConfigIds: reserved, + now: now().toISOString(), + maxSubscriptions, + }); + next.reporting_subscriptions = result.states as PrincipalConfiguration['reporting_subscriptions']; + subscriptionPlan.result = result; + } else if (input.reporting_destinations !== undefined && subscriptionHistory.length > 0) { + // A destination change must leave every active subscription naming a destination + // that still exists, and re-derives action_required states. + for (const state of current?.reporting_subscriptions ?? []) { + if (state.state === 'inactive' || !state.configuration.destination) continue; + const id = state.configuration.destination.destination_id; + const destination = (next.reporting_destinations ?? []).find(item => item.destination_id === id); + if (!destination) { + throw new ReportingSubscriptionValidationError( + `Active reporting subscription ${JSON.stringify(state.subscription_id)} names destination ${JSON.stringify(id)}, which this request removes` + ); + } + // Offering resolution depends on the destination's pattern and transport, frozen + // per subscription version; changing them needs a new version. + const frozen = subscriptionHistory + .find(item => item.subscriptionId === state.subscription_id) + ?.versions.at(-1)?.destinationMethod; + const configured = destination.configuration as { pattern?: unknown; transport?: unknown }; + if (frozen && (configured.pattern !== frozen.pattern || configured.transport !== frozen.transport)) { + throw new ReportingSubscriptionValidationError( + `Destination ${JSON.stringify(id)} changes pattern or transport under active subscription ${JSON.stringify(state.subscription_id)}; increment its subscription_version in the same request` + ); + } + } + next.reporting_subscriptions = reprojectSubscriptionStates( + subscriptionHistory, + next.reporting_destinations ?? [], + current?.reporting_subscriptions ?? [] + ); } if (input.declarations !== undefined) { if (!options.declarations) { @@ -635,6 +783,7 @@ export function createPrincipalLifecycle( storedGeneration.state = transition.state; storedGeneration.updatedAt = now().toISOString(); const timestamp = now().toISOString(); + const subscriptionMovedToActionRequired = reprojectStoredSubscriptions(record); enqueuePrincipalNotification( record, { @@ -648,6 +797,20 @@ export function createPrincipalLifecycle( }, maxPendingNotifications ); + if (subscriptionMovedToActionRequired) { + enqueuePrincipalNotification( + record, + { + notificationId: `principal_${randomUUID()}`, + emissionId: randomUUID(), + changedAt: timestamp, + firedAt: timestamp, + reason: 'reporting_subscription_changed', + recipients: principalNotificationRecipients(record, scope), + }, + maxPendingNotifications + ); + } const result = await options.store.replace({ scope, expectedRevision: current.revision, @@ -824,6 +987,81 @@ export function createPrincipalLifecycle( throw new Error('Principal declaration reconciliation conflicted repeatedly'); } + async function listReportingSubscriptions(scope: Readonly) { + assertStoreScope(scope); + const current = await options.store.get(scope); + return { + states: structuredClone(current?.record.configuration?.reporting_subscriptions ?? []), + history: structuredClone(current?.record.reportingSubscriptionHistory ?? []), + }; + } + + async function reportingSubscriptionIds(scope: Readonly): Promise { + const { history } = await listReportingSubscriptions(scope); + return history.map(entry => entry.subscriptionId); + } + + async function recordReportingSubscriptionCoverage( + scope: Readonly, + updates: Parameters[1], + recordOptions: { deferNotification?: boolean; forceNotification?: boolean } = {} + ): Promise { + assertStoreScope(scope); + for (let attempt = 0; attempt < maxCasAttempts; attempt++) { + const current = await options.store.get(scope); + const states = current?.record.configuration?.reporting_subscriptions; + if (!current || !states) return false; + const record = structuredClone(current.record); + const nextStates = record.configuration!.reporting_subscriptions!; + let changed = false; + for (const update of updates) { + const state = nextStates.find(item => item.subscription_id === update.subscriptionId); + if (!state) continue; + const before = canonicalJsonSha256([state.accounts_with_exceptions ?? [], state.accounts_unauthorized ?? []]); + if (update.accountsWithExceptions.length > 1000 || update.accountsUnauthorized.length > 1000) { + // The readback lists at most 1000 of each; a silently cut list would misreport. + throw new RangeError( + `Reporting subscription ${JSON.stringify(update.subscriptionId)} has more than 1000 accounts to list in its readback` + ); + } + const withExceptions = [...update.accountsWithExceptions].sort((left, right) => + compareBytes(left.account_id, right.account_id) + ); + const unauthorized = [...update.accountsUnauthorized].sort((left, right) => + compareBytes(left.account_id, right.account_id) + ); + if (withExceptions.length > 0) state.accounts_with_exceptions = structuredClone(withExceptions); + else delete state.accounts_with_exceptions; + if (unauthorized.length > 0) state.accounts_unauthorized = structuredClone(unauthorized); + else delete state.accounts_unauthorized; + if (canonicalJsonSha256([state.accounts_with_exceptions ?? [], state.accounts_unauthorized ?? []]) !== before) { + changed = true; + } + } + if (!changed && !recordOptions.forceNotification) return false; + const timestamp = now().toISOString(); + // Engine readback is seller-driven state, not a caller mutation: configuration_version + // advances only on caller writes, so a guarded sync_principal never conflicts on it. + enqueuePrincipalNotification( + record, + { + notificationId: `principal_${randomUUID()}`, + emissionId: randomUUID(), + changedAt: timestamp, + firedAt: timestamp, + reason: 'reporting_subscription_changed', + recipients: principalNotificationRecipients(record, scope), + }, + maxPendingNotifications + ); + const persisted = await options.store.replace({ scope, expectedRevision: current.revision, record }); + if (persisted.outcome === 'conflict') continue; + if (!recordOptions.deferNotification) await flushPrincipalNotifications(scope); + return changed; + } + throw new Error('Reporting subscription coverage update conflicted repeatedly'); + } + const resolveProtocolScope = async (ctx: HandlerContext) => { return resolve(ctx); }; @@ -841,7 +1079,22 @@ export function createPrincipalLifecycle( : {}), }; + const capabilities = Object.freeze({ + supported: true as const, + sync_task: 'sync_principal' as const, + read_task: 'get_principal' as const, + supported_sections: [ + 'notification_configs' as const, + ...(options.prepareReportingDestination ? ['reporting_destinations' as const] : []), + ...(options.declarations ? ['declarations' as const] : []), + ...(options.reportingSubscriptions ? ['reporting_subscriptions' as const] : []), + ] as NonNullable['supported_sections'], + optimistic_concurrency: true, + ...structuredClone(options.capabilityDetails ?? {}), + }) as NonNullable; + return { + capabilities, protocol, recognizePrincipal, transitionDestination, @@ -849,9 +1102,69 @@ export function createPrincipalLifecycle( recoverPrincipalNotifications, resolveReportingDestination, reconcileDeclarations, + listReportingSubscriptions, + reportingSubscriptionIds, + recordReportingSubscriptionCoverage, }; } +function compareBytes(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +function reprojectSubscriptionStates( + history: readonly StoredReportingSubscriptionHistory[], + destinations: readonly PrincipalReportingDestination[], + previous: readonly PrincipalReportingSubscriptionState[] +): PrincipalConfiguration['reporting_subscriptions'] { + return previous.map(state => { + const entry = history.find(item => item.subscriptionId === state.subscription_id); + return entry ? projectReportingSubscriptionState(entry, destinations, state, state.configuration) : state; + }) as PrincipalConfiguration['reporting_subscriptions']; +} + +/** Re-derive subscription states after a destination transition; true when one newly needs action. */ +function reprojectStoredSubscriptions(record: StoredPrincipalRecord): boolean { + const configuration = record.configuration; + const previous = configuration?.reporting_subscriptions; + if (!configuration || !previous || !record.reportingSubscriptionHistory) return false; + const next = reprojectSubscriptionStates( + record.reportingSubscriptionHistory, + configuration.reporting_destinations ?? [], + previous + )!; + const moved = next.some( + state => + state.state === 'action_required' && + previous.find(item => item.subscription_id === state.subscription_id)?.state !== 'action_required' + ); + configuration.reporting_subscriptions = next; + return moved; +} + +/** + * Project a principal response onto the fields the served release's bundle defines. With + * subscription support off, a section a row still carries is not served at all. + */ +function forRelease(response: T, servedAdcpVersion: string | undefined, subscriptionsEnabled: boolean): T { + const result = (response as { result?: { configuration?: { reporting_subscriptions?: unknown } } }).result; + if (!result?.configuration || result.configuration.reporting_subscriptions === undefined) return response; + if (!subscriptionsEnabled) { + const { reporting_subscriptions: _hidden, ...configuration } = result.configuration; + return { ...response, result: { ...result, configuration } } as T; + } + return { + ...response, + result: { + ...result, + configuration: reportingSubscriptionStatesForReleaseV1( + result.configuration, + reportingSubscriptionReleaseFieldsV1(servedAdcpVersion) + ), + }, + } as T; +} + function storeScope(resolved: Readonly): PrincipalStoreScope { return { tenantId: resolved.tenant_id, principalId: resolved.principal_id }; } diff --git a/src/lib/server/principal/types.ts b/src/lib/server/principal/types.ts index b06901eb1..7b40da691 100644 --- a/src/lib/server/principal/types.ts +++ b/src/lib/server/principal/types.ts @@ -1,5 +1,6 @@ import type { GetPrincipalResponse, SyncPrincipalRequest, SyncPrincipalResponse } from '../../types/tools.generated'; import type { MaybePromise } from '../create-adcp-server'; +import type { StoredReportingSubscriptionHistory } from './reporting-subscriptions'; import type { NotificationRecipientRef, NotificationSubscriptionMatch, @@ -51,6 +52,7 @@ export interface PendingPrincipalNotification { | 'setup_expired' | 'proof_invalidated' | 'declarations_intersection_changed' + | 'reporting_subscription_changed' | 'other'; destinationId?: string; recipients: NotificationRecipientRef[]; @@ -79,6 +81,11 @@ export interface StoredPrincipalRecord extends Record { notificationSubscriptions: StoredNotificationSubscription[]; reportingGenerations: StoredReportingDestinationGeneration[]; pendingNotifications: PendingPrincipalNotification[]; + /** + * Reporting subscription history the readback omits (experimental, AdCP 3.3). Absent on + * rows written without subscription support. + */ + reportingSubscriptionHistory?: StoredReportingSubscriptionHistory[]; } export interface VersionedPrincipalRecord { @@ -164,3 +171,69 @@ export interface ResolvedReportingDestination { lifecycle: StoredReportingDestinationGeneration['lifecycle']; deliveryEligible: boolean; } + +export type PrincipalReportingSubscriptionState = NonNullable< + PrincipalConfiguration['reporting_subscriptions'] +>[number]; + +/** + * Opt-in support for the `sync_principal` `reporting_subscriptions` section + * (experimental `media_buy.reporting_subscriptions`). Without it the section is refused + * with `UNSUPPORTED_FEATURE`, as before. + */ +export interface PrincipalReportingSubscriptionSupport { + /** + * The caller's `sync_accounts` `delivery_config_id` values on any account. A + * `subscription_id` equal to one is rejected; the host's `sync_accounts` must reject the + * reverse clash using {@link PrincipalLifecycleRuntimeSubscriptions.reportingSubscriptionIds}. + */ + listDeliveryConfigIds?(scope: Readonly): MaybePromise; + /** + * Apply `replace_account_configs`: deactivate the caller's own active + * `reporting_delivery_configs` with this `feed_purpose`, by cutover at `at`, on in-scope + * accounts where the subscription resolves an offering, without advancing any account's + * revision. Called after the principal row commits, never on `dry_run`. Without it a + * subscription with `replace_account_configs: true` is refused with `UNSUPPORTED_FEATURE`. + */ + replaceAccountConfigs?(input: { + scope: Readonly; + subscriptionId: string; + subscriptionVersion: number; + feedPurpose: string; + at: string; + }): MaybePromise; + /** Most subscriptions per principal. Defaults to 64. */ + maxSubscriptions?: number; + /** + * Whether the seller also serves account-level `sync_accounts` configurations (the + * default). When true, `listDeliveryConfigIds` is required so a `subscription_id` that + * equals one of the caller's `delivery_config_id` values is always rejected. + */ + accountConfigurations?: boolean; +} + +/** Runtime surface the reporting subscription engine and hosts use. */ +export interface PrincipalLifecycleRuntimeSubscriptions { + /** Current readback plus the retained version history, for coverage computation. */ + listReportingSubscriptions(scope: Readonly): Promise<{ + states: PrincipalReportingSubscriptionState[]; + history: StoredReportingSubscriptionHistory[]; + }>; + /** Subscription IDs of this principal, active or retained, for the reverse namespace check. */ + reportingSubscriptionIds(scope: Readonly): Promise; + /** + * Record engine-maintained readback (`accounts_with_exceptions`, `accounts_unauthorized`). + * Any change enqueues `principal.changed` with reason `reporting_subscription_changed`. + * Returns whether anything changed. + */ + recordReportingSubscriptionCoverage( + scope: Readonly, + updates: ReadonlyArray<{ + subscriptionId: string; + accountsWithExceptions: NonNullable; + accountsUnauthorized: NonNullable; + }>, + /** `forceNotification` fires even when the readback is unchanged, for example when an exception opened and another closed. */ + options?: { deferNotification?: boolean; forceNotification?: boolean } + ): Promise; +} diff --git a/src/lib/server/reporting-subscription-release.ts b/src/lib/server/reporting-subscription-release.ts new file mode 100644 index 000000000..0ff9d842b --- /dev/null +++ b/src/lib/server/reporting-subscription-release.ts @@ -0,0 +1,131 @@ +/** + * Which reporting-subscription wire fields a served release defines. + * + * Fields settled after AdCP 3.3.0-beta.1 (adcontextprotocol/adcp#8585, #8588) are emitted only + * when the served release's own bundled schema declares them: earlier response shapes are + * closed (`additionalProperties: false`). The answer comes from the bundle, not from a + * version literal, so a release cut without these fields never opens the gate. A response + * without a served release (a direct handler call outside protocol dispatch) gets none of + * them; read the internal state APIs for the complete record. + */ +import { getSchemaDocumentByRef } from '../validation/schema-loader'; + +export interface ReportingSubscriptionReleaseFieldsV1 { + /** `reporting-delivery-offering.json` `standard_dimensions` / `unmapped_dimensions`. */ + offeringDimensions: boolean; + /** `reporting-subscription-state.json` `inactive_intervals`. */ + inactiveIntervals: boolean; + /** `reporting-subscription-state.json` `prior_versions[].configuration`. */ + priorVersionConfiguration: boolean; + /** `reporting-subscription-coverage.json` `timezone_cutovers[].previous_timezone`. */ + cutoverPreviousTimezone: boolean; + /** `reporting-subscription-coverage.json` `offering_id` on cutovers and gaps. */ + perOfferingEntries: boolean; + /** `reporting-subscription-coverage.json` `status: "inactive"` with `deactivated_at` (adcp#8613). */ + inactiveCoverageStatus: boolean; +} + +const NONE: ReportingSubscriptionReleaseFieldsV1 = Object.freeze({ + offeringDimensions: false, + inactiveIntervals: false, + priorVersionConfiguration: false, + cutoverPreviousTimezone: false, + perOfferingEntries: false, + inactiveCoverageStatus: false, +}); + +const cache = new Map(); + +type Schema = Readonly> | undefined; +const properties = (schema: Schema): Record => + ((schema?.properties as Record | undefined) ?? {}) as Record; +const itemProperties = (schema: Schema, field: string): Record => + properties((properties(schema)[field] as { items?: Schema } | undefined)?.items); + +function read(ref: string, version: string): Schema { + try { + return getSchemaDocumentByRef(ref, version)?.schema; + } catch { + return undefined; + } +} + +/** The settled fields the served release's bundle defines; none when no release is served. */ +export function reportingSubscriptionReleaseFieldsV1( + servedAdcpVersion: string | undefined +): ReportingSubscriptionReleaseFieldsV1 { + if (servedAdcpVersion === undefined) return NONE; + const cached = cache.get(servedAdcpVersion); + if (cached) return cached; + const offering = read('core/reporting-delivery-offering.json', servedAdcpVersion); + const state = read('core/reporting-subscription-state.json', servedAdcpVersion); + const coverage = read('core/reporting-subscription-coverage.json', servedAdcpVersion); + const result = Object.freeze({ + offeringDimensions: 'standard_dimensions' in properties(offering), + inactiveIntervals: 'inactive_intervals' in properties(state), + priorVersionConfiguration: 'configuration' in itemProperties(state, 'prior_versions'), + cutoverPreviousTimezone: 'previous_timezone' in itemProperties(coverage, 'timezone_cutovers'), + perOfferingEntries: + 'offering_id' in itemProperties(coverage, 'timezone_cutovers') && + 'offering_id' in itemProperties(coverage, 'gaps'), + inactiveCoverageStatus: + 'deactivated_at' in properties(coverage) && + ((properties(coverage).status as { enum?: unknown[] } | undefined)?.enum ?? []).includes('inactive'), + }); + if (cache.size > 64) cache.clear(); + cache.set(servedAdcpVersion, result); + return result; +} + +/** Project a `subscription_coverage[]` entry onto the fields a release defines. */ +export function reportingSubscriptionCoverageForReleaseV1>( + entry: T, + fields: ReportingSubscriptionReleaseFieldsV1 +): T { + const copy = structuredClone(entry) as Record; + if (copy.status === 'inactive' && !fields.inactiveCoverageStatus) { + // Earlier releases have no inactive value: report the account-level state as before. + const open = ((copy.excluded as Array> | undefined) ?? []).filter( + item => item.until === undefined && item.media_buy_id === undefined + ); + copy.status = open.some(item => item.reason === 'action_required') + ? 'action_required' + : open.length > 0 + ? 'excluded' + : 'active'; + delete copy.deactivated_at; + } + if (Array.isArray(copy.timezone_cutovers)) { + copy.timezone_cutovers = (copy.timezone_cutovers as Array>).map(cutover => { + const next = { ...cutover }; + if (!fields.cutoverPreviousTimezone) delete next.previous_timezone; + if (!fields.perOfferingEntries) delete next.offering_id; + return next; + }); + } + if (Array.isArray(copy.gaps) && !fields.perOfferingEntries) { + copy.gaps = (copy.gaps as Array>).map(({ offering_id: _offering, ...gap }) => gap); + } + return copy as T; +} + +/** Project a principal configuration's `reporting_subscriptions` onto a release. */ +export function reportingSubscriptionStatesForReleaseV1( + configuration: T, + fields: ReportingSubscriptionReleaseFieldsV1 +): T { + if (!Array.isArray(configuration.reporting_subscriptions)) return configuration; + return { + ...configuration, + reporting_subscriptions: (configuration.reporting_subscriptions as Array>).map(state => { + const next = { ...state }; + if (!fields.inactiveIntervals) delete next.inactive_intervals; + if (!fields.priorVersionConfiguration && Array.isArray(next.prior_versions)) { + next.prior_versions = (next.prior_versions as Array>).map( + ({ configuration: _configuration, ...prior }) => prior + ); + } + return next; + }), + }; +} diff --git a/src/lib/validation/schema-loader.ts b/src/lib/validation/schema-loader.ts index 618197918..ff02ab89e 100644 --- a/src/lib/validation/schema-loader.ts +++ b/src/lib/validation/schema-loader.ts @@ -680,9 +680,42 @@ function walkJsonFiles(dir: string): string[] { function loadJson(file: string): LoadedSchema { if (file.includes(`${path.sep}bundled${path.sep}`)) { const bundled = loadBundledSchemaFile(file); - if (bundled) return bundled; + if (bundled) return openExtensibleReportingDimensions(bundled); } - return JSON.parse(readFileSync(file, 'utf-8')) as LoadedSchema; + return openExtensibleReportingDimensions(JSON.parse(readFileSync(file, 'utf-8')) as LoadedSchema); +} + +const REPORTING_DIMENSION_PATTERN = '^[a-z][a-z0-9_]*$'; + +/** + * `enums/reporting-dimension.json` is an extensible vocabulary: "Receivers MUST accept + * unknown values; a selector naming a value the seller does not map matches no offering." + * Its JSON Schema `enum` is closed, so validate it against its declared `x-pattern` + * instead, wherever a copy is inlined. Other extensible enums are unchanged. + */ +function openExtensibleReportingDimensions(schema: T): T { + const visit = (node: unknown, key: string | undefined): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item, undefined); + return; + } + if (!node || typeof node !== 'object') return; + const record = node as Record; + const id = typeof record.$id === 'string' ? record.$id : ''; + if ( + Array.isArray(record.enum) && + (id.endsWith('/enums/reporting-dimension.json') || + record.title === 'Reporting Dimension' || + key?.endsWith('enums/reporting-dimension.json')) + ) { + delete record.enum; + record.type = 'string'; + record.pattern = typeof record['x-pattern'] === 'string' ? record['x-pattern'] : REPORTING_DIMENSION_PATTERN; + } + for (const [childKey, child] of Object.entries(record)) visit(child, childKey); + }; + visit(schema, undefined); + return schema; } /** diff --git a/test/fixtures/reporting-subscriptions/canonical/PINNED.json b/test/fixtures/reporting-subscriptions/canonical/PINNED.json new file mode 100644 index 000000000..a3bd45a64 --- /dev/null +++ b/test/fixtures/reporting-subscriptions/canonical/PINNED.json @@ -0,0 +1,13 @@ +{ + "repository": "adcontextprotocol/adcp", + "pr": 8616, + "sha": "023bbd26e7c230dcc74bea4087336ea6bc8d0f6a", + "path": "static/compliance/source/test-vectors/reporting-subscriptions", + "vendored": "Byte-for-byte copies of the listed files at the pinned commit (the README stays upstream). test/lib/reporting-subscription-coverage.test.js fails when a file's sha256 differs from this pin. Refresh with: npm run sync:reporting-subscription-vectors -- --commit [--from ]. The Python SDK runs the same vectors.", + "sha256": { + "case.schema.json": "0550b51097ea2c9a6bbd8721de5b5855acfcbd097dff31edeb0df2f397f7dc42", + "error-codes.json": "4e8e5142ada936fc8923735155d786a0801258922fb85b492785730479713481", + "reason-codes.json": "2574b2a32fb8260fc7f9a7d9cd1eac1b588aa22c54a350e54964a893b80e77c4", + "vectors.json": "b392df771fc2bf635c5c441ff2dd5107e1cda13ab483fe902a6a56661f5e7904" + } +} diff --git a/test/fixtures/reporting-subscriptions/canonical/case.schema.json b/test/fixtures/reporting-subscriptions/canonical/case.schema.json new file mode 100644 index 000000000..cbf0750ad --- /dev/null +++ b/test/fixtures/reporting-subscriptions/canonical/case.schema.json @@ -0,0 +1,616 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "case.schema.json", + "title": "Reporting subscription coverage vector case", + "description": "One case in vectors.json: the input a buyer holds for one subscription and one account, and either every period the seller owes (expected and expected_obligations) or the input error (expected_error). Wire objects inside the input (versions[].configuration, offerings[], coverage entries, inactive_intervals) are AdCP objects and validate against the AdCP schemas, except in cases whose expected_error.wire_valid is false.", + "type": "object", + "required": [ + "id", + "name", + "origin", + "rule", + "spec_refs", + "input" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Stable identifier, unique in the file." + }, + "name": { + "type": "string", + "minLength": 1 + }, + "origin": { + "type": "string", + "enum": [ + "adcp", + "adcp-client#3505" + ], + "description": "adcp-client#3505: ported from the TypeScript SDK's parity table and re-derived from the spec text. adcp: authored here." + }, + "changed_from_origin": { + "type": "string", + "description": "How a ported case differs from its origin, and why." + }, + "rule": { + "type": "string", + "minLength": 1, + "description": "The derivation: which clause applies and why the expected rows follow." + }, + "spec_refs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(?:static/schemas/source/|docs/)[^#\\s]+(?:#\\S*)?$" + }, + "description": "Repository paths, with JSON Pointer or heading anchors, of the normative text the case derives from." + }, + "timezones": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "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" + }, + "expected": { + "type": "array", + "items": { + "$ref": "#/definitions/row" + } + }, + "expected_obligations": { + "type": "array", + "items": { + "$ref": "#/definitions/obligation" + } + }, + "expected_error": { + "type": "object", + "required": [ + "code", + "detail", + "wire_valid" + ], + "description": "The error an SDK raises for this input: code and detail from error-codes.json.", + "properties": { + "code": { + "type": "string", + "enum": [ + "COVERAGE_INPUT_INCOMPLETE", + "COVERAGE_INPUT_INVALID" + ] + }, + "detail": { + "$ref": "#/definitions/coverage_input_error_detail" + }, + "wire_valid": { + "type": "boolean", + "description": "false when the input also fails AdCP schema validation." + } + }, + "allOf": [ + { + "if": { + "properties": { + "code": { + "const": "COVERAGE_INPUT_INCOMPLETE" + } + } + }, + "then": { + "properties": { + "detail": { + "enum": [ + "missing_account_timezone", + "missing_source_timezone", + "missing_previous_timezone", + "missing_standard_dimensions", + "missing_status_retention_days", + "missing_version_configuration", + "missing_destination_method" + ] + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "COVERAGE_INPUT_INVALID" + } + } + }, + "then": { + "properties": { + "detail": { + "enum": [ + "invalid_timezone", + "invalid_period_duration", + "invalid_instant", + "invalid_input" + ] + } + } + } + } + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "required": [ + "expected", + "expected_obligations", + "timezones" + ], + "not": { + "required": [ + "expected_error" + ] + } + }, + { + "required": [ + "expected_error" + ], + "not": { + "anyOf": [ + { + "required": [ + "expected" + ] + }, + { + "required": [ + "expected_obligations" + ] + } + ] + } + } + ], + "additionalProperties": false, + "definitions": { + "instant": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:\\d{2})$", + "description": "RFC 3339 date-time, at most millisecond precision. Compare instants, never strings." + }, + "z_instant": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$", + "description": "An expected instant, written in Z form with fractional seconds only when non-zero." + }, + "input": { + "type": "object", + "required": [ + "subscription_id", + "versions", + "account", + "buys", + "offerings", + "now" + ], + "properties": { + "subscription_id": { + "type": "string", + "minLength": 1 + }, + "versions": { + "type": "array", + "minItems": 1, + "description": "Every subscription_version whose obligations matter, oldest first: the current version and prior_versions from the get_principal readback (reporting-subscription-state.json).", + "items": { + "type": "object", + "required": [ + "subscription_version", + "accepted_at", + "configuration" + ], + "properties": { + "subscription_version": { + "type": "integer", + "minimum": 1 + }, + "accepted_at": { + "$ref": "#/definitions/instant" + }, + "superseded_at": { + "$ref": "#/definitions/instant", + "description": "prior_versions[].superseded_at: the next version's accepted_at." + }, + "deactivated_at": { + "$ref": "#/definitions/instant", + "description": "The readback's deactivated_at while the subscription is inactive: an open inactive interval." + }, + "configuration": { + "type": "object", + "description": "The version's reporting-subscription.json object." + }, + "destination_method": { + "type": "object", + "required": [ + "pattern", + "transport" + ], + "properties": { + "pattern": { + "type": "string" + }, + "transport": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "pattern and transport of the destination configuration.destination names, from the caller's reporting_destinations. Present when the configuration names a destination." + } + }, + "additionalProperties": false + } + }, + "account": { + "type": "object", + "required": [ + "account_id", + "grants" + ], + "properties": { + "account_id": { + "type": "string", + "minLength": 1 + }, + "timezone": { + "type": "string", + "description": "The account's current timezone as list_accounts shows it." + }, + "operator": { + "type": "string", + "description": "The account's operator domain, read when a version names operator_domains." + }, + "source_timezone": { + "type": "string", + "description": "The seller-resolved upstream reporting timezone (the obligation's period.source_timezone), read by an account_resolved source_timezone offering." + }, + "grants": { + "type": "array", + "minItems": 1, + "description": "The caller's authority over the account, oldest first: intervals during which its authorization lists get_reporting_status. Each grant but the last has unauthorized_at (from accounts_unauthorized). A re-grant at or before the previous unauthorized_at carries the wire's chain-start authorized_at, so its re-grant instant is unknown and only the seller's gaps[] say what the interruption removed. A later re-grant before unauthorized_at plus status_retention_days continues the chain; otherwise it starts a new one.", + "items": { + "type": "object", + "required": [ + "authorized_at" + ], + "properties": { + "authorized_at": { + "$ref": "#/definitions/instant" + }, + "unauthorized_at": { + "$ref": "#/definitions/instant" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "buys": { + "type": "array", + "description": "Media buys the caller can see through get_media_buys.", + "items": { + "type": "object", + "required": [ + "media_buy_id", + "start_time", + "end_time", + "packages" + ], + "properties": { + "media_buy_id": { + "type": "string", + "minLength": 1 + }, + "confirmed_at": { + "oneOf": [ + { + "$ref": "#/definitions/instant" + }, + { + "type": "null" + } + ], + "description": "Null or absent: not confirmed, so not covered." + }, + "start_time": { + "$ref": "#/definitions/instant" + }, + "end_time": { + "$ref": "#/definitions/instant" + }, + "canceled_at": { + "$ref": "#/definitions/instant", + "description": "cancellation.canceled_at." + }, + "completed_at": { + "$ref": "#/definitions/instant" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "required": [ + "package_id", + "reporting_delivery_offering_ids" + ], + "properties": { + "package_id": { + "type": "string", + "minLength": 1 + }, + "reporting_delivery_offering_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The package's product reporting_capabilities.reporting_delivery_offering_ids." + } + }, + "additionalProperties": false + } + }, + "prior_flights": { + "type": "array", + "description": "Not on the wire. Flight values before an update replaced them; a value applies to periods resolved before its replaced_at.", + "items": { + "type": "object", + "required": [ + "replaced_at", + "start_time", + "end_time" + ], + "properties": { + "replaced_at": { + "$ref": "#/definitions/instant" + }, + "start_time": { + "$ref": "#/definitions/instant" + }, + "end_time": { + "$ref": "#/definitions/instant" + }, + "canceled_at": { + "$ref": "#/definitions/instant" + }, + "completed_at": { + "$ref": "#/definitions/instant" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "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.", + "items": { + "type": "object", + "required": [ + "offering_id", + "schedule" + ], + "properties": { + "available_from": { + "$ref": "#/definitions/instant" + }, + "withdrawn_at": { + "$ref": "#/definitions/instant" + } + } + } + }, + "inactive_intervals": { + "type": "array", + "description": "The readback's inactive_intervals (reporting-subscription-state.json)." + }, + "coverage": { + "type": "object", + "description": "This subscription's get_reporting_status subscription_coverage entry for the account: its exceptions only.", + "properties": { + "excluded": { + "type": "array" + }, + "gaps": { + "type": "array" + }, + "timezone_cutovers": { + "type": "array" + } + }, + "additionalProperties": false + }, + "status_retention_days": { + "type": "integer", + "minimum": 1, + "description": "The seller's reporting_delivery status_retention_days." + }, + "backfill_on_regrant": { + "type": "boolean", + "description": "The seller's subscriptions.backfill_on_regrant. Absent means false." + }, + "now": { + "$ref": "#/definitions/instant", + "description": "Only periods that end at or before now are expected." + } + }, + "additionalProperties": false + }, + "row": { + "type": "object", + "required": [ + "subscription_version", + "media_buy_id", + "offering_id", + "period_start", + "period_end" + ], + "properties": { + "subscription_version": { + "type": "integer", + "minimum": 1 + }, + "media_buy_id": { + "type": "string" + }, + "offering_id": { + "type": "string" + }, + "period_start": { + "$ref": "#/definitions/z_instant" + }, + "period_end": { + "$ref": "#/definitions/z_instant" + } + }, + "additionalProperties": false + }, + "obligation": { + "type": "object", + "required": [ + "subscription_version", + "offering_id", + "period_start", + "period_end", + "media_buy_ids" + ], + "properties": { + "subscription_version": { + "type": "integer", + "minimum": 1 + }, + "offering_id": { + "type": "string" + }, + "period_start": { + "$ref": "#/definitions/z_instant" + }, + "period_end": { + "$ref": "#/definitions/z_instant" + }, + "media_buy_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "error_code": { + "type": "string", + "enum": [ + "SUBSCRIPTION_WOULD_DEACTIVATE", + "DESTINATION_WOULD_REVOKE", + "SUBSCRIPTION_ID_CLASH", + "SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED", + "SUBSCRIPTION_SELECTOR_UNMATCHED", + "SUBSCRIPTION_CONFLICT_EXHAUSTED", + "SUBSCRIPTION_READBACK_MISMATCH", + "COVERAGE_INPUT_INCOMPLETE", + "COVERAGE_INPUT_INVALID", + "COVERAGE_BUDGET_EXCEEDED", + "REPORTING_STORAGE_NOT_DURABLE", + "REPORTING_SCHEMA_BEHIND", + "SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS", + "SUBSCRIPTION_ROSTER_UNAVAILABLE" + ], + "description": "The frozen SDK error codes in error-codes.json." + }, + "coverage_input_error_detail": { + "type": "string", + "enum": [ + "missing_account_timezone", + "missing_source_timezone", + "missing_previous_timezone", + "missing_standard_dimensions", + "missing_status_retention_days", + "missing_version_configuration", + "missing_destination_method", + "invalid_timezone", + "invalid_period_duration", + "invalid_instant", + "invalid_input" + ], + "description": "The details of COVERAGE_INPUT_INCOMPLETE and COVERAGE_INPUT_INVALID in error-codes.json." + }, + "coverage_reason": { + "type": "string", + "enum": [ + "not_in_scope", + "excluded:excluded_by_buyer", + "not_authorized", + "before_coverage_start", + "subscription_inactive", + "excluded:offering_unsupported", + "outside_flight", + "after_stop", + "before_confirmation", + "excluded:capacity", + "excluded:overridden", + "excluded:action_required", + "gap:unauthorized", + "cutover_stretch", + "owed_under_other_version", + "period_not_ended", + "owed", + "owed:regrant_backfill", + "owed:withheld", + "buy_not_visible" + ], + "description": "The frozen coverage reason codes in reason-codes.json: why a period is or is not owed. For expected traces (CoverageTrace reason) and reconciliation lines." + }, + "coverage_rule": { + "type": "string", + "enum": [ + "scope", + "authorization", + "version", + "activity", + "offering", + "flight", + "confirmation", + "exception", + "gap", + "cutover", + "timezone" + ], + "description": "The coverage rules in reason-codes.json rule_order, in evaluation order." + } + } +} diff --git a/test/fixtures/reporting-subscriptions/canonical/error-codes.json b/test/fixtures/reporting-subscriptions/canonical/error-codes.json new file mode 100644 index 000000000..81023d078 --- /dev/null +++ b/test/fixtures/reporting-subscriptions/canonical/error-codes.json @@ -0,0 +1,181 @@ +{ + "vocabulary_version": 1, + "contract_version": "3.3", + "feature": "media_buy.reporting_subscriptions", + "status": "experimental", + "description": "Frozen error codes for reporting subscriptions that SDKs raise themselves, before or instead of a wire error. TypeScript (@adcp/sdk) and Python (adcp) use these codes and recovery classes verbatim, so callers can branch on them across languages. Wire error codes returned by a seller (for example INVALID_REQUEST, UNSUPPORTED_FEATURE, or CONFLICT from sync_principal) are not redefined here; SDKs surface them unchanged. Adding a code is additive; renaming or removing one, or changing its recovery, needs a new vocabulary_version.", + "error_shape": { + "code": "One of the codes below, or the wire error code for a seller-returned error.", + "recovery": "transient: retry the same call later. correctable: the caller must change its input or configuration first. terminal: retrying cannot help; a person must act. This is the TypeScript adcpError.recovery vocabulary.", + "message": "Human-readable text whose last sentence says how to fix it (message_fix below). Untrusted display data; never parse it.", + "dispatched": "false when no bytes of the task left the process, so the operation certainly had no effect on the seller. Otherwise the property is absent. Local computations that never dispatch anything (coverage, configuration checks) omit it as well; see dispatched below.", + "detail": "For codes that list details, the detail code naming the specific cause." + }, + "dispatched_values": { + "false": "The property is present and false: the SDK refused before sending.", + "absent": "The property is omitted: the request may have reached the seller.", + "not_applicable": "The property is omitted: the error comes from a local computation or configuration check with no task to dispatch." + }, + "codes": [ + { + "code": "SUBSCRIPTION_WOULD_DEACTIVATE", + "raised_by": "buyer", + "dispatched": "false", + "recovery": "correctable", + "raised_when": "A sync_principal reporting_subscriptions write would omit a subscription that is currently active, or set it active false, without the caller asking to deactivate it. The section is complete desired state, so omitting an entry deactivates that subscription.", + "message_fix": "Use unsubscribe(), or pass allowDeactivation: true.", + "context": { "would_deactivate": "subscription_id values the write would deactivate." }, + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["docs/protocol/sync_principal.mdx#reporting-subscriptions", "docs/protocol/sync_principal.mdx#synchronization-semantics"] + }, + { + "code": "DESTINATION_WOULD_REVOKE", + "raised_by": "buyer", + "dispatched": "false", + "recovery": "correctable", + "raised_when": "A sync_principal reporting_destinations write would omit a current destination without the caller asking to revoke it. Omission is revocation and cuts delivery for every account bound to the destination.", + "message_fix": "Omitting a destination revokes it for every account. Pass allowRevocation: true.", + "context": { "would_revoke": "destination_id values the write would revoke." }, + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["docs/protocol/sync_principal.mdx#suspension-and-revocation", "docs/protocol/sync_principal.mdx#synchronization-semantics"] + }, + { + "code": "SUBSCRIPTION_ID_CLASH", + "raised_by": "buyer", + "dispatched": "false", + "recovery": "correctable", + "raised_when": "A subscription_id equals a delivery_config_id the caller uses on an account with this seller. Subscription IDs and delivery_config_id values share one caller-scoped namespace, and the seller rejects a collision.", + "message_fix": "Choose a subscription_id that no account-level delivery config uses; the message names the clashing delivery_config_id and account.", + "context": { "delivery_config_id": "The clashing delivery_config_id.", "account_id": "The account that uses it." }, + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/properties/subscription_id", "docs/protocol/sync_principal.mdx#reporting-subscriptions"] + }, + { + "code": "SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED", + "raised_by": "buyer", + "dispatched": "false", + "recovery": "correctable", + "raised_when": "An all_authorized_accounts subscription names a destination and no operator_domains. Such a subscription follows every account the principal is granted, including accounts another operator grants later, and would send their data to that destination.", + "message_fix": "Add operator_domains, or pass acknowledgeCrossOperator: true.", + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/properties/operator_domains", "docs/protocol/sync_principal.mdx#reporting-subscriptions"] + }, + { + "code": "SUBSCRIPTION_SELECTOR_UNMATCHED", + "raised_by": "buyer", + "dispatched": "false", + "recovery": "correctable", + "raised_when": "No offering in the seller's advertised reporting_delivery offerings is a candidate for the selector (feed_purpose, period_duration, reconciliation_mode, required_finality, alignment, destination method, period_timezone) with a matching dimension set, so the subscription could only ever record offering_unsupported.", + "message_fix": "Change the selector; the message lists the nearest offerings and the attribute each one failed.", + "context": { "nearest": "Offerings that fail on the fewest attributes, each with the attributes it fails." }, + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection"] + }, + { + "code": "SUBSCRIPTION_CONFLICT_EXHAUSTED", + "raised_by": "buyer", + "dispatched": "absent", + "recovery": "transient", + "raised_when": "A guarded sync_principal write kept returning CONFLICT (a stale expected_configuration_version) after the SDK re-read the principal and retried up to its limit.", + "message_fix": "Another writer is updating this principal; retry later.", + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["docs/protocol/sync_principal.mdx#synchronization-semantics"] + }, + { + "code": "SUBSCRIPTION_READBACK_MISMATCH", + "raised_by": "buyer", + "dispatched": "absent", + "recovery": "terminal", + "raised_when": "The applied readback of a subscription differs from the submitted configuration in a field the seller must not alter.", + "message_fix": "The seller altered a submitted field; contact the seller.", + "context": { "readback": "The readback configuration as returned." }, + "sdk_classes": { "typescript": "ReportingSubscriptionError", "python": "ReportingSubscriptionError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription-state.json#/properties/configuration"] + }, + { + "code": "COVERAGE_INPUT_INCOMPLETE", + "raised_by": "both", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "Coverage cannot be computed because the input lacks a value the rule needs. detail names it.", + "message_fix": "Supply the missing value; the message names the version configuration, zone, or field.", + "details": { + "missing_account_timezone": "An account_timezone offering resolves and the account has no timezone (list_accounts timezone).", + "missing_source_timezone": "An account_resolved source_timezone offering resolves and the seller-resolved zone (the obligation's period.source_timezone) is not known.", + "missing_previous_timezone": "A timezone_cutovers entry has no previous_timezone, which sellers MUST include.", + "missing_standard_dimensions": "A candidate offering does not publish standard_dimensions, which sellers advertising subscriptions MUST publish.", + "missing_status_retention_days": "A grant was revoked and re-granted later, and status_retention_days, which decides whether the re-grant continues the chain, is not known.", + "missing_version_configuration": "A version whose obligations matter has no configuration (prior_versions[].configuration, which sellers MUST include).", + "missing_destination_method": "A version names a destination whose pattern and transport are not known." + }, + "sdk_classes": { "typescript": "CoverageInputError", "python": "CoverageInputError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers", "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions"] + }, + { + "code": "COVERAGE_INPUT_INVALID", + "raised_by": "both", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "Coverage cannot be computed because a value the rule reads is malformed. detail names it.", + "message_fix": "Correct the value; the message names the field and the rule it breaks.", + "details": { + "invalid_timezone": "A zone a schedule reads is not an IANA zone or link name (reporting-schedule.json iana_timezone); a numeric offset is not one.", + "invalid_period_duration": "A period_duration does not match the schedule pattern (which has no W designator), mixes date and time components under a local alignment, or is sub-daily under a local alignment without dividing 24 hours.", + "invalid_instant": "A timestamp is not an RFC 3339 date-time.", + "invalid_input": "The input breaks a structural rule: duplicate offering_id or media_buy_id values, versions out of accepted_at or subscription_version order, a superseded_at that is not the next version's accepted_at, no grants, a grant followed by another without unauthorized_at, or an unknown alignment." + }, + "sdk_classes": { "typescript": "CoverageInputError", "python": "CoverageInputError" }, + "spec_refs": ["static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/iana_timezone", "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", "static/schemas/source/core/reporting-schedule.json#/properties/period_duration"] + }, + { + "code": "COVERAGE_BUDGET_EXCEEDED", + "raised_by": "both", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "Computing coverage would produce more periods, or examine more boundaries, than the caller's budget allows. SDKs fail closed rather than return a partial set.", + "message_fix": "Narrow the input or raise the budget; the message names maxPeriods (or the boundary budget) and the input that exceeded it.", + "sdk_classes": { "typescript": "CoverageInputError", "python": "CoverageInputError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage"] + }, + { + "code": "REPORTING_STORAGE_NOT_DURABLE", + "raised_by": "seller", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "Reliable reporting with subscriptions is enabled on storage that does not survive a restart, so obligations, grants, and frozen coverage would be lost.", + "message_fix": "Configure durable storage; the message names the configuration key.", + "sdk_classes": { "typescript": "PlatformConfigError", "python": "ReliableReportingConfigurationError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions"] + }, + { + "code": "REPORTING_SCHEMA_BEHIND", + "raised_by": "seller", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "The reporting storage schema is older than the SDK requires.", + "message_fix": "Run the migration; the message names the command.", + "sdk_classes": { "typescript": "PlatformConfigError", "python": "ReliableReportingConfigurationError" }, + "spec_refs": [] + }, + { + "code": "SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS", + "raised_by": "seller", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "A seller enabling reporting subscriptions advertises an offering without standard_dimensions.", + "message_fix": "Publish standard_dimensions (empty for totals-only) on every offering; the message names the offering.", + "sdk_classes": { "typescript": "PlatformConfigError", "python": "ReliableReportingConfigurationError" }, + "spec_refs": ["static/schemas/source/core/reporting-delivery-offering.json#/properties/standard_dimensions"] + }, + { + "code": "SUBSCRIPTION_ROSTER_UNAVAILABLE", + "raised_by": "seller", + "dispatched": "not_applicable", + "recovery": "correctable", + "raised_when": "The seller host supplied no way to enumerate the accounts a principal is authorized for, which all_authorized_accounts subscriptions follow.", + "message_fix": "Provide the account roster hook; the message names the configuration key.", + "sdk_classes": { "typescript": "PlatformConfigError", "python": "ReliableReportingConfigurationError" }, + "spec_refs": ["static/schemas/source/core/reporting-subscription.json#/properties/scope"] + } + ] +} diff --git a/test/fixtures/reporting-subscriptions/canonical/reason-codes.json b/test/fixtures/reporting-subscriptions/canonical/reason-codes.json new file mode 100644 index 000000000..838d2816f --- /dev/null +++ b/test/fixtures/reporting-subscriptions/canonical/reason-codes.json @@ -0,0 +1,255 @@ +{ + "vocabulary_version": 1, + "contract_version": "3.3", + "feature": "media_buy.reporting_subscriptions", + "status": "experimental", + "description": "Frozen coverage reason codes: why one period of one (media buy, resolved offering) pair under one subscription version is or is not owed. Buyer and seller SDKs use these codes verbatim in coverage explanations (CoverageTrace reason), reconciliation lines (PeriodLine reason), and telemetry, so a buyer's and a seller's explanation of the same period compare by code. Adding a code is additive; renaming or removing one, or changing its rule, needs a new vocabulary_version.", + "classification": "Classify one period [start, end) of one schedule that a version's resolved offering uses for one media buy (for an account_timezone offering, of either zone across a timezone change). Evaluate the rules in rule_order. A period that a rule rejects takes that rule's reason, so a period several rules reject takes the reason of the first. A period no rule rejects is owed if it has ended at or before now (owed, owed:regrant_backfill, or owed:withheld), and otherwise takes period_not_ended. Each period has exactly one reason. Instants compare as instants; a period starts at a boundary of its schedule, so starting on or after the first boundary at or after an instant X is the same as starting at or after X.", + "rule_order": [ + "scope", + "authorization", + "version", + "activity", + "offering", + "flight", + "confirmation", + "exception", + "gap", + "cutover", + "timezone" + ], + "rules": { + "scope": "The version's scope selects the account: account_ids names it, or all_authorized_accounts with exclude_account_ids not naming it; and operator_domains, when present, lists the account's operator.", + "authorization": "The caller is authorized for the account over the period, by the authorization chains (account-authorization.json authorized_at, reporting-subscription-state.json accounts_unauthorized).", + "version": "The period starts at or after the coverage start: the later of the accepted_at of the first version whose scope selects the account and the first authorization chain's authorized_at. (Later chains are the authorization rule's.)", + "activity": "The subscription is active: the period does not start inside an inactive interval, or after a reactivation but before reactivated_at.", + "offering": "The buy's packages resolve an offering under the version's selector, and this offering is one of them.", + "flight": "The period overlaps the buy's flight [start_time, stop) with flight values as of the period end (scope_resolved_at), where stop is the earliest of end_time, canceled_at, and completed_at.", + "confirmation": "The buy is confirmed and the period ends after confirmed_at.", + "exception": "No get_reporting_status excluded[] entry applying to the buy and offering covers the period start in [from, until).", + "gap": "No gaps[] entry applying to the offering covers the period start, and without backfill_on_regrant the period does not end after an unauthorized_at and start before the re-grant that continued the chain.", + "cutover": "The version's side owns the period: it starts at or after the side's handoff E (the side's start instant when it has no outgoing predecessor) and before the next change instant T, at which the next side or a deactivation takes over.", + "timezone": "For an account_timezone offering whose account changed timezone, the period is one of the zone in effect at its start: the previous zone's periods start before effective_at, the new zone's at or after it." + }, + "reasons": [ + { + "code": "not_in_scope", + "verdict": "not_owed", + "rule": "scope", + "side": "both", + "definition": "No version in effect at the period's start selects the account: account_ids does not name it, the account is not one the caller's all_authorized_accounts covers, or operator_domains does not list its operator. An account listed in exclude_account_ids takes excluded:excluded_by_buyer instead.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/properties/scope" + ] + }, + { + "code": "excluded:excluded_by_buyer", + "verdict": "not_owed", + "rule": "scope", + "side": "both", + "definition": "The version is all_authorized_accounts and its exclude_account_ids names the account; the seller records it as an excluded[] entry with reason excluded_by_buyer.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/properties/exclude_account_ids", + "static/schemas/source/core/reporting-subscription-coverage.json#/definitions/ReportingSubscriptionExclusionReason" + ] + }, + { + "code": "not_authorized", + "verdict": "not_owed", + "rule": "authorization", + "side": "both", + "definition": "The period starts at or after the first chain's authorized_at, but no chain authorizes it: it ends after the unauthorized_at that ended a chain (none is delivered for periods ending after unauthorized_at), and it starts before the authorized_at of any later chain. Periods inside a chain that continued after a re-grant take gap:unauthorized or owed:regrant_backfill instead.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at" + ] + }, + { + "code": "before_coverage_start", + "verdict": "not_owed", + "rule": "version", + "side": "both", + "definition": "The period starts before the coverage start: before the accepted_at of the first version whose scope selects the account, or before the first authorization chain's authorized_at. A version accepted or an authorization begun mid-period starts at the next boundary. Periods before a later chain's authorized_at take not_authorized.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accepted_at", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at" + ] + }, + { + "code": "subscription_inactive", + "verdict": "not_owed", + "rule": "activity", + "side": "both", + "definition": "The period starts at or after a deactivated_at and before the matching reactivated_at (or deactivated_at is current). Periods that started before deactivated_at stay owed in full; after reactivation, coverage resumes at the first boundary at or after reactivated_at.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-state.json#/properties/inactive_intervals" + ] + }, + { + "code": "excluded:offering_unsupported", + "verdict": "not_owed", + "rule": "offering", + "side": "both", + "definition": "No offering matching the selector is available on the account's or the buy's products, so the buy (or the account) resolves nothing, whether computed from the wire or read from an excluded[] entry with reason offering_unsupported. An entry with package_ids removes those packages, not periods; the buy stays covered for its other packages.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded" + ] + }, + { + "code": "outside_flight", + "verdict": "not_owed", + "rule": "flight", + "side": "both", + "definition": "The period ends at or before the buy's start_time, or the flight is empty (its stop is at or before start_time), with flight values as of the period end. An extension or reinstatement made after the period ended does not change this.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ] + }, + { + "code": "after_stop", + "verdict": "not_owed", + "rule": "flight", + "side": "both", + "definition": "The period starts at or after the buy's stop time (the earliest of end_time, canceled_at, and completed_at as of the period end). The period containing the stop is owed in full.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ] + }, + { + "code": "before_confirmation", + "verdict": "not_owed", + "rule": "confirmation", + "side": "both", + "definition": "The buy is not confirmed (confirmed_at null or absent), or the period ends at or before confirmed_at. The period in which the buy is confirmed ends after it and is owed in full.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ] + }, + { + "code": "excluded:capacity", + "verdict": "not_owed", + "rule": "exception", + "side": "both", + "definition": "An excluded[] entry with reason capacity (for example beyond subscriptions.max_accounts), applying to the buy and offering, covers the period start in [from, until).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions" + ] + }, + { + "code": "excluded:overridden", + "verdict": "not_owed", + "rule": "exception", + "side": "both", + "definition": "An excluded[] entry with reason overridden covers the period start: the caller's own sync_accounts delivery config, or its own account_ids subscription replacing an all-accounts one, takes the account and feed_purpose.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/precedence" + ] + }, + { + "code": "excluded:action_required", + "verdict": "not_owed", + "rule": "exception", + "side": "both", + "definition": "An account-level excluded[] entry with reason action_required covers the period start: the seller serves the account only after pending_action completes.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded" + ] + }, + { + "code": "gap:unauthorized", + "verdict": "not_owed", + "rule": "gap", + "side": "both", + "definition": "A gaps[] entry applying to the offering covers the period start in [from, until), or, without backfill_on_regrant, the period ends after an unauthorized_at and starts before the re-grant that continued the chain (from is the last boundary at or before unauthorized_at, until the first at or after the re-grant).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends" + ] + }, + { + "code": "cutover_stretch", + "verdict": "not_owed", + "rule": "cutover", + "side": "both", + "definition": "The period straddles a handoff that leaves part of it owed under neither schedule. Under rule cutover: the period starts before the side's handoff E and ends after E, so the outgoing side, which owes up to E, covers only part of it; this is the incoming period containing the stretch from E to the incoming schedule's first boundary after E, which is shorter than one incoming period. Under rule timezone: every period of a zone outside that zone's span (a previous-zone period starting at or after effective_at, or a new-zone period starting before it).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers" + ] + }, + { + "code": "owed_under_other_version", + "verdict": "not_owed", + "rule": "cutover", + "side": "both", + "definition": "Another version's side owns the time: the period starts before the side's handoff E and ends at or before E (the outgoing version owes that time; for the same schedule, that very period), or it starts at or after the next change instant T, where the next version's side takes over. Periods after a deactivation take subscription_inactive.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions" + ] + }, + { + "code": "period_not_ended", + "verdict": "not_owed", + "rule": null, + "side": "both", + "definition": "No rule rejects the period, but it ends after now, so it has not resolved and is not yet owed. It becomes owed when it ends unless a later change removes it.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage" + ] + }, + { + "code": "owed", + "verdict": "owed", + "rule": null, + "side": "both", + "definition": "No rule rejects the period and it has ended at or before now. A missing report for it is the seller's to explain.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ] + }, + { + "code": "owed:regrant_backfill", + "verdict": "owed", + "rule": null, + "side": "both", + "definition": "Owed because backfill_on_regrant is true: without it the period would take gap:unauthorized (it ends after an unauthorized_at and starts before the re-grant that continued the chain). Due at the re-grant time plus the offering's delivery_sla.", + "spec_refs": [ + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends" + ] + }, + { + "code": "owed:withheld", + "verdict": "owed", + "rule": null, + "side": "both", + "definition": "Owed, and the caller's authority is currently lost: the period ended at or before the latest unauthorized_at and no re-grant has followed yet. The seller withholds it (counted in withheld_period_count when not yet delivered); it is never missing or delayed, and is owed again on re-grant.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends" + ] + }, + { + "code": "buy_not_visible", + "verdict": "not_owed", + "rule": "scope", + "side": "seller", + "definition": "Seller-only. The buy exists in the account, but the caller cannot see it through get_media_buys (for example it was created in the seller's UI or by another agent), so it is not covered. Never sent to the buyer.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage" + ] + } + ] +} diff --git a/test/fixtures/reporting-subscriptions/canonical/vectors.json b/test/fixtures/reporting-subscriptions/canonical/vectors.json new file mode 100644 index 000000000..d71be543a --- /dev/null +++ b/test/fixtures/reporting-subscriptions/canonical/vectors.json @@ -0,0 +1,15513 @@ +{ + "contract_version": "3.3", + "feature": "media_buy.reporting_subscriptions", + "status": "experimental", + "description": "Canonical, SDK-independent coverage vectors for experimental principal-level reporting subscriptions: for each input, every period the seller owes. Each case gives the buyer-side input and either the exact owed periods (expected, one row per buy, resolved offering, and period, and expected_obligations, the same rows grouped the way obligations carry them) or the input error (expected_error). Every expected row is derived from the spec text cited in spec_refs. scripts/reference/reporting-coverage is a brute-force reference model, written from the same text, that reproduces every case; tests/reporting-subscription-coverage-vectors.test.cjs runs it. Each case validates against case.schema.json in this directory. Like the feature, the vectors are experimental and change with it.", + "tzdata": { + "version": "2026f", + "source": "https://data.iana.org/time-zones/releases/tzdata2026f.tar.gz", + "source_sha512": "26189803c8b3e903dcc467404e2feefb7d190cb1870249a23577ce31e9dcbf187e4704e0b45c8d1366d1c501bb5b53a2e80f8e2828b2163be15e532f6acab52d", + "stability": "Expected rows are derived with this IANA tzdata release. Each case lists the zones its rows depend on in timezones. Dates are chosen where releases 2026b through 2026f agree, so an implementation on any of them reproduces every case; compare against this release when they differ." + }, + "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.", + "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." + }, + "cases": [ + { + "id": "baseline", + "name": "baseline", + "origin": "adcp-client#3505", + "rule": "adcp#8593: the start bound is the first boundary at or after max(accepted_at 11-01T12, authorized_at 10-01) = 11-02; owed are the periods from it that end after confirmed_at 11-02T15 and overlap the flight (11-03 on), through the period containing the stop time.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_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-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "mid-period-confirmed-at", + "name": "mid-period confirmed_at", + "origin": "adcp-client#3505", + "rule": "adcp#8593: confirmed_at 11-03T10 falls inside the first flight day. That period ends after confirmed_at, so it is owed in full; confirmed_at no longer moves the start to the next boundary.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "confirmed-long-before-the-flight", + "name": "confirmed long before the flight", + "origin": "adcp-client#3505", + "rule": "A buy confirmed before accepted_at with a later start owes nothing before start_time.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-20T00:00:00Z", + "start_time": "2026-11-10T00:00:00Z", + "end_time": "2026-11-12T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "stop-time-mid-period-is-owed-in-full", + "name": "stop time mid-period is owed in full", + "origin": "adcp-client#3505", + "rule": "end_time 11-05T12 is inside the 11-05 period, which is owed in full: periods are never clipped.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "cancellation-mid-flight", + "name": "cancellation mid-flight", + "origin": "adcp-client#3505", + "rule": "canceled_at 11-05T06 is the stop time (earlier than end_time 11-10); its period is owed and none after.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "canceled_at": "2026-11-05T06:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "early-completion-on-a-boundary", + "name": "early completion on a boundary", + "origin": "adcp-client#3505", + "rule": "completed_at 11-04T00 stops the flight on a boundary: the 11-04 period does not overlap [start, stop).", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "completed_at": "2026-11-04T00:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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" + } + ], + "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"] + } + ] + }, + { + "id": "flight-extension-adds-only-unresolved-periods", + "name": "flight extension adds only unresolved periods", + "origin": "adcp-client#3505", + "rule": "end_time moved 11-06 -> 11-08 at 11-07T12. The 11-06 period resolved at 11-07T00 under the old end_time and is never owed retroactively; 11-07 resolves after the extension.", + "spec_refs": [ + "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": [], + "non_wire_inputs": ["buys[].prior_flights"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "prior_flights": [{"replaced_at": "2026-11-07T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "end_time": "2026-11-06T00:00:00Z"}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "reinstatement-after-cancellation", + "name": "reinstatement after cancellation", + "origin": "adcp-client#3505", + "rule": "Canceled at 11-04T12, reinstated at 11-06T12. 11-05 resolved while canceled and stays unowed; 11-06 onward resolve after reinstatement.", + "spec_refs": [ + "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": [], + "non_wire_inputs": ["buys[].prior_flights"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "prior_flights": [ + { + "replaced_at": "2026-11-06T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "canceled_at": "2026-11-04T12:00:00Z" + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "only-resolved-periods-are-returned", + "name": "only resolved periods are returned", + "origin": "adcp-client#3505", + "rule": "With now at 11-05T12, the 11-05 period has not ended.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-11-05T12:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ], + "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"] + } + ] + }, + { + "id": "unconfirmed-buy", + "name": "unconfirmed buy", + "origin": "adcp-client#3505", + "rule": "A buy with confirmed_at null is not covered.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": null, + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "late-authorization-authorized-at-refinement", + "name": "late authorization (authorized_at refinement)", + "origin": "adcp-client#3505", + "rule": "authorized_at 11-04T10 is the latest of the three start terms: coverage starts at 11-05.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_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-11-04T10:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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-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-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": "new-subscription-version-aligned-schedules", + "name": "new subscription version, aligned schedules", + "origin": "adcp-client#3505", + "rule": "v2 accepted 11-04T12. v1 owes periods starting before T in full (11-03, 11-04); E = 11-05 and v2 owes from there. Contiguous, nothing twice.", + "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", + "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-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "new-subscription-version-misaligned-boundaries", + "name": "new subscription version, misaligned boundaries", + "origin": "adcp-client#3505", + "rule": "v1 resolves a UTC day offering, v2 (alignment account_timezone) a New York day offering. v1 owes UTC days starting before 11-04T12 (E = 11-05T00Z); v2 owes New York days starting at or after E, the first being 11-05T05Z. 11-05T00Z-05Z is owed under neither.", + "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-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "deactivation", + "name": "deactivation", + "origin": "adcp-client#3505", + "rule": "deactivated_at 11-04T12: the 11-04 period started before it and stays owed in full; nothing later.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "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": false, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + }, + "deactivated_at": "2026-11-04T12:00:00Z" + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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" + } + ], + "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"] + } + ] + }, + { + "id": "deactivation-then-reactivation-in-place", + "name": "deactivation then reactivation in place", + "origin": "adcp-client#3505", + "rule": "Readback inactive_intervals [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", + "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/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" + } + } + ], + "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": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "account-new-in-a-later-version", + "name": "account new in a later version", + "origin": "adcp-client#3505", + "rule": "v1 names another account; v2 (all accounts, accepted 11-04T12) brings acct_1 in under the ordinary start rule: 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", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", + "static/schemas/source/core/reporting-subscription.json#/properties/scope", + "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": {"account_ids": ["acct_other"]}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "excluded-by-buyer-scope", + "name": "excluded_by_buyer scope", + "origin": "adcp-client#3505", + "rule": "An all-accounts subscription that lists the account in exclude_account_ids covers nothing for it.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/properties/scope", + "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", + "exclude_account_ids": ["acct_1"] + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "operator-domains-scope", + "name": "operator_domains scope", + "origin": "adcp-client#3505", + "rule": "operator_domains keeps only accounts whose operator is listed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/properties/scope", + "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", + "operator_domains": ["agency.example"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}], + "operator": "other-agency.example" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "revocation", + "name": "revocation", + "origin": "adcp-client#3505", + "rule": "unauthorized_at 11-05T06: periods starting at or after the last boundary at or before it (11-05) are not owed. 11-03 and 11-04 ended before it and stay owed (withheld until re-grant if undelivered).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-05T06:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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" + } + ], + "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"] + } + ] + }, + { + "id": "re-grant-without-backfill", + "name": "re-grant without backfill", + "origin": "adcp-client#3505", + "rule": "Re-granted 11-06T18 within status_retention_days: the gap [11-05, 11-07) is not owed; coverage resumes at 11-07 and the earlier periods stay owed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "re-grant-with-backfill-on-regrant", + "name": "re-grant with backfill_on_regrant", + "origin": "adcp-client#3505", + "rule": "With backfill_on_regrant no gap is recorded and every period in the revoked interval stays owed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "re-grant-after-the-unauthorized-entry-expired", + "name": "re-grant after the unauthorized entry expired", + "origin": "adcp-client#3505", + "rule": "status_retention_days 1: the entry expired at 11-06T06, so the 11-06T18 re-grant is a fresh start from authorized_at (11-07) and backfill does not apply.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "seller-gaps-also-remove-periods", + "name": "seller gaps also remove periods", + "origin": "adcp-client#3505", + "rule": "A gaps[] entry the seller published removes the periods starting in [from, until), like an exception.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "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"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": {"gaps": [{"from": "2026-11-04T00:00:00Z", "until": "2026-11-05T00:00:00Z", "reason": "unauthorized"}]} + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "exceptions-only-remove-periods", + "name": "exceptions only remove periods", + "origin": "adcp-client#3505", + "rule": "Account-level capacity [11-04, 11-06) removes 11-04 and 11-05 for every buy; a buy-level offering_unsupported removes mb_2 from 11-03; a package_ids entry removes packages, not periods; an exception before coverage adds nothing.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "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-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + }, + { + "media_buy_id": "mb_2", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + {"reason": "capacity", "from": "2026-11-04T00:00:00Z", "until": "2026-11-06T00:00:00Z"}, + {"reason": "offering_unsupported", "media_buy_id": "mb_2", "from": "2026-11-03T00:00:00Z"}, + {"reason": "offering_unsupported", "media_buy_id": "mb_1", "package_ids": ["pkg_9"], "from": "2026-11-03T00:00:00Z"}, + {"reason": "excluded_by_buyer", "from": "2026-10-01T00:00:00Z", "until": "2026-10-05T00:00:00Z"} + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "max-accounts-capacity-exception", + "name": "max_accounts capacity exception", + "origin": "adcp-client#3505", + "rule": "Beyond subscriptions.max_accounts the seller records an open account-level capacity exception; nothing is owed from its from.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": {"excluded": [{"reason": "capacity", "from": "2026-11-04T00:00:00Z"}]} + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + } + ], + "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"] + } + ] + }, + { + "id": "precedence-over-an-account-level-configuration", + "name": "precedence over an account-level configuration", + "origin": "adcp-client#3505", + "rule": "The caller's own sync_accounts configuration takes the account from 11-04 to 11-05 (overridden), after which replace_account_configs hands it back.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/precedence", + "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"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "overridden", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "overridden_by": {"delivery_config_id": "acme-analytics"} + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-exact-default-with-dimensions", + "name": "match exact (default with dimensions)", + "origin": "adcp-client#3505", + "rule": "exact [package] picks the offering whose dimension set equals it; package x device (unmapped device) is distinct.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "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"] + } + ] + }, + { + "id": "match-exact-with-no-equal-offering", + "name": "match exact with no equal offering", + "origin": "adcp-client#3505", + "rule": "exact [package] against package x creative, package x country and package x device (unmapped) resolves nothing: offering_unsupported, no periods.", + "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_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "match-exact-on-two-dimensions", + "name": "match exact on two dimensions", + "origin": "adcp-client#3505", + "rule": "exact [package, country] picks package x country.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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", "country"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-finest", + "name": "match finest", + "origin": "adcp-client#3505", + "rule": "finest [package] picks the most mapped dimensions among offerings including package; package x creative and package x country tie at two, so the lowest offering_id in byte order wins.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-defaults-to-finest-without-dimensions", + "name": "match defaults to finest without dimensions", + "origin": "adcp-client#3505", + "rule": "No dimensions: match defaults to finest over every 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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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"}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-all", + "name": "match all", + "origin": "adcp-client#3505", + "rule": "all [package] picks every offering including package, each owed its own periods (package x device included: it includes package).", + "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"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "product-eligibility-gates-candidates", + "name": "product eligibility gates candidates", + "origin": "adcp-client#3505", + "rule": "Only offerings in the product's reporting_delivery_offering_ids are candidates; a package with none leaves the buy covered by its other packages.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection", + "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"]}, + {"package_id": "pkg_2", "reporting_delivery_offering_ids": []} + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "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"] + } + ] + }, + { + "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_d_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_d_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "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.", + "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.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": [], + "non_wire_inputs": ["offerings[].available_from/withdrawn_at"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"], + "available_from": "2026-11-04T12:00:00Z" + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "account-timezone", + "name": "account timezone", + "origin": "adcp-client#3505", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). accepted_at 11-01T12Z: first boundary 11-02T05Z. The 11-02T05Z day contains confirmed_at 11-02T15Z and overlaps the flight from 11-03T00Z, so it is owed (adcp#8593).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "account-timezone-across-a-dst-change", + "name": "account timezone across a DST change", + "origin": "adcp-client#3505", + "rule": "New York falls back on 2026-11-01: the 11-01 local day is 25 hours, 04:00Z to 05:00Z the next day.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-10-31T12:00:00Z", + "end_time": "2026-11-02T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-10-31T04:00:00Z", + "period_end": "2026-11-01T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-10-31T04:00:00Z", + "period_end": "2026-11-01T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "account-timezone-change-is-a-cutover", + "name": "account timezone change is a cutover", + "origin": "adcp-client#3505", + "rule": "The account moves to Los Angeles (account.timezone is the current zone); timezone_cutovers.effective_at 11-05T05Z is E and previous_timezone New York governs before it. New York days starting before E are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", + "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-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Los_Angeles", "America/New_York"], + "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/Los_Angeles", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T05:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles", "previous_timezone": "America/New_York"}] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "period-timezone-override", + "name": "period_timezone override", + "origin": "adcp-client#3505", + "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary, so the period ending at it is not owed and the one starting at it is.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/period_timezone", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["Asia/Tokyo"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T15:00:00Z", + "period_end": "2026-11-03T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T15:00:00Z", + "period_end": "2026-11-04T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T15:00:00Z", + "period_end": "2026-11-05T15:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T15:00:00Z", + "period_end": "2026-11-03T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T15:00:00Z", + "period_end": "2026-11-04T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T15:00:00Z", + "period_end": "2026-11-05T15:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "hourly-periods-are-never-clipped", + "name": "hourly periods are never clipped", + "origin": "adcp-client#3505", + "rule": "Flight 10:30-13:15, confirmed 10:20: the 10:00 period ends after confirmed_at and overlaps the flight, so it is owed in full (adcp#8593), and the 13:00 period containing the stop is owed in full.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "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": "pacing", "period_duration": "PT1H", "required_finality": "snapshot", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:20:00Z", + "start_time": "2026-11-03T10:30:00Z", + "end_time": "2026-11-03T13:15:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h_pkg_hourly"]}] + } + ], + "offerings": [ + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "monthly-billing-cycle", + "name": "monthly billing cycle", + "origin": "adcp-client#3505", + "changed_from_origin": "managed offerings completed to validate against reporting-delivery-offering.json (method.provider and method.format; canonicalization fields for consumer_receipt); coverage is unchanged", + "rule": "A fixed billing_cycle anchored at New York midnight: month boundaries move between 04:00Z (EDT) and 05:00Z (EST). The month containing the stop is owed in full.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P1M", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-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": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "asap-buy-confirmed-mid-day-owes-the-confirmation-day", + "name": "ASAP buy confirmed mid-day owes the confirmation day", + "origin": "adcp-client#3505", + "rule": "adcp#8593: an ASAP buy starts when it is confirmed, 11-03T14:30Z. The 11-03 period ends after confirmed_at and overlaps the flight, so it is owed in full.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T14:30:00Z", + "start_time": "2026-11-03T14:30:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "monthly-billing-buy-confirmed-on-the-12th-owes-that-month", + "name": "monthly billing buy confirmed on the 12th owes that month", + "origin": "adcp-client#3505", + "changed_from_origin": "managed offerings completed to validate against reporting-delivery-offering.json (method.provider and method.format; canonicalization fields for consumer_receipt); coverage is unchanged", + "rule": "adcp#8593: the version was accepted 10-15, so the start bound is the November boundary (11-01T04Z). The buy is confirmed and starts on 11-12; the November month ends after confirmed_at and overlaps the flight, so it is owed in full rather than starting in December.", + "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-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P1M", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-12T15:00:00Z", + "start_time": "2026-11-12T15:00:00Z", + "end_time": "2027-01-15T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m_billing_monthly"]}] + } + ], + "offerings": [ + { + "offering_id": "off_m_billing_monthly", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m_billing_monthly", + "report_definition_uri": "https://seller.example/report-definitions/off_m_billing_monthly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"], + "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": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "destination-selects-managed-offerings-only", + "name": "destination selects managed offerings only", + "origin": "adcp-client#3505", + "changed_from_origin": "managed offerings completed to validate against reporting-delivery-offering.json (method.provider and method.format; canonicalization fields for consumer_receipt); coverage is unchanged", + "rule": "With a destination only offerings whose method matches its pattern and transport are candidates; without one only Core (method-less) offerings are.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/destination", + "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", + "destination": {"destination_id": "archive", "path_template": "a/{account_id}"} + }, + "destination_method": {"pattern": "file_transfer", "transport": "gcs"} + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_s3_pkg", "off_t_gcs_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_s3_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s3_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_s3_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + }, + { + "offering_id": "off_t_gcs_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_t_gcs_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_t_gcs_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "gcs", + "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_t_gcs_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "multiple-buys-group-into-obligations", + "name": "multiple buys group into obligations", + "origin": "adcp-client#3505", + "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids. mb_0, confirmed 11-03T12, is owed the 11-03 period it was confirmed in (adcp#8593).", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + }, + { + "media_buy_id": "mb_0", + "confirmed_at": "2026-11-03T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_0", "mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_0", "mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "timezone-cutover-scoped-to-one-offering", + "name": "timezone cutover scoped to one offering", + "origin": "adcp-client#3505", + "rule": "A timezone_cutovers entry with offering_id applies to that offering only; an offering with no applicable entry uses account.timezone (Los Angeles) throughout.", + "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.json#/properties/scope", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Los_Angeles", "America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Los_Angeles", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T08:00:00Z", + "end_time": "2026-11-06T08:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz", "off_f_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_f_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_f_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_f_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "offering_id": "off_e_pkg_acct_tz", + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-03T08:00:00Z", + "period_end": "2026-11-04T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-04T08:00:00Z", + "period_end": "2026-11-05T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-03T08:00:00Z", + "period_end": "2026-11-04T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-04T08:00:00Z", + "period_end": "2026-11-05T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_f_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "gap-scoped-to-one-offering", + "name": "gap scoped to one offering", + "origin": "adcp-client#3505", + "rule": "A gaps[] entry with offering_id removes only that offering's periods; an entry without offering_id applies to every offering.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription.json#/properties/scope", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "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"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": {"gaps": [{"offering_id": "off_a_pkg", "from": "2026-11-04T00:00:00Z", "until": "2026-11-05T00:00:00Z", "reason": "unauthorized"}]} + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-exact-without-dimensions", + "name": "match exact without dimensions", + "origin": "adcp-client#3505", + "rule": "exact with no dimensions: every candidate qualifies and the lowest offering_id in byte order wins.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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", "match": "exact"}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "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"] + } + ] + }, + { + "id": "period-timezone-never-matches-an-account-resolved-source-timezone-offering", + "name": "period_timezone never matches an account-resolved source_timezone offering", + "origin": "adcp-client#3505", + "rule": "A source_timezone offering matches a period_timezone override only when its published period_timezone equals it; an account_resolved offering publishes none.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/period_timezone", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "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": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}], + "source_timezone": "America/New_York" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_resolved"]}] + } + ], + "offerings": [ + { + "offering_id": "off_s_resolved", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_resolved", + "report_definition_uri": "https://seller.example/report-definitions/off_s_resolved.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "source_timezone", "delivery_sla": "PT6H", "period_timezone_policy": "account_resolved"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "period-timezone-matches-a-fixed-source-timezone-offering-in-the-same-zone", + "name": "period_timezone matches a fixed source_timezone offering in the same zone", + "origin": "adcp-client#3505", + "rule": "A fixed source_timezone offering publishing America/New_York matches a New York override and keeps its own boundaries (05:00Z in November).", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/period_timezone", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_fixed"]}] + } + ], + "offerings": [ + { + "offering_id": "off_s_fixed", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_fixed", + "report_definition_uri": "https://seller.example/report-definitions/off_s_fixed.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "delivery_sla": "PT6H", + "period_timezone_policy": "fixed", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "a-utc-offering-matches-only-utc-or-etc-utc", + "name": "a utc offering matches only UTC or Etc/UTC", + "origin": "adcp-client#3505", + "rule": "period_timezone GMT is not one of the UTC aliases core/reporting-subscription.json names, so the utc offering does not match.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/period_timezone", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "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": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "GMT" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "empty-flight-canceled-before-start-time", + "name": "empty flight: canceled before start_time", + "origin": "adcp-client#3505", + "rule": "canceled_at 11-05T02 is before start_time 11-05T06: the flight [start, stop) is empty and overlaps nothing, though the 11-05 period contains both instants.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-05T06:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "canceled_at": "2026-11-05T02:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "empty-flight-stop-equal-to-start", + "name": "empty flight: stop equal to start", + "origin": "adcp-client#3505", + "rule": "A zero-length flight (end_time equal to start_time at 11-03T12) is empty and overlaps nothing.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T12:00:00Z", + "end_time": "2026-11-03T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "two-cutovers-inside-one-period", + "name": "two cutovers inside one period", + "origin": "adcp-client#3505", + "rule": "v2 (11-04T12Z) switches to a Tokyo period_timezone and v3 (11-04T13Z) changes nothing else. v1 owes its New York day starting 11-04T05Z, through 11-05T05Z. v2 owes from that E and starts nothing before 13Z. v3 must carry v2's own start forward: it owes Tokyo days from the first boundary at or after 11-05T05Z (11-05T15Z), never the overlapping 11-04T15Z.", + "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.json#/properties/selector/properties/period_timezone", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York", "Asia/Tokyo"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "superseded_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "superseded_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 3, + "accepted_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 3, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo", + "match": "exact" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "simultaneous-version-and-timezone-change-at-the-same-t", + "name": "simultaneous version and timezone change at the same T", + "origin": "adcp-client#3505", + "rule": "v2 is accepted at 11-05T05Z, exactly the timezone cutover effective_at (a New York boundary). v1 owes New York days starting before it; v2 owes Los Angeles days from the first boundary at or after it (11-05T08Z).", + "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.json#/x-adcp-validation/selection", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/match", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Los_Angeles", "America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T05:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Los_Angeles", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles", "previous_timezone": "America/New_York"}] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "billing-cycle-anchored-on-the-31st-clamps-to-month-end", + "name": "billing cycle anchored on the 31st clamps to month end", + "origin": "adcp-client#3505", + "changed_from_origin": "managed offerings completed to validate against reporting-delivery-offering.json (method.provider and method.format; canonicalization fields for consumer_receipt); coverage is unchanged", + "rule": "P1M from 2026-01-31 New York midnight: the 31st clamps to Feb 28 and returns to the 31st in March (a clamped February boundary does not shift the anchor day).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-01-15T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P1M", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2025-12-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-01-20T00:00:00Z", + "start_time": "2026-02-10T00:00:00Z", + "end_time": "2026-04-15T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m31"]}] + } + ], + "offerings": [ + { + "offering_id": "off_m31", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m31", + "report_definition_uri": "https://seller.example/report-definitions/off_m31.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"], + "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": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-31T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2026-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "billing-cycle-anchored-on-february-29", + "name": "billing cycle anchored on February 29", + "origin": "adcp-client#3505", + "changed_from_origin": "managed offerings completed to validate against reporting-delivery-offering.json (method.provider and method.format; canonicalization fields for consumer_receipt); coverage is unchanged", + "rule": "P1Y from 2024-02-29T00:00Z: non-leap years clamp to Feb 28 and 2028 returns to Feb 29.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["UTC"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-12-01T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P1Y", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2025-11-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-12-15T00:00:00Z", + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2027-06-01T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_y29"]}] + } + ], + "offerings": [ + { + "offering_id": "off_y29", + "feed_purpose": "billing", + "report_definition_id": "rd_off_y29", + "report_definition_uri": "https://seller.example/report-definitions/off_y29.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"], + "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": "P1Y", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2024-02-29T00:00:00Z", + "period_timezone": "UTC" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2028-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "dst-fold-at-midnight-america-santiago", + "name": "DST fold at midnight (America/Santiago)", + "origin": "adcp-client#3505", + "rule": "Chile falls back at 24:00 on 2026-04-04 to 23:00, so April 4 is a 25-hour local day; local midnight April 5 occurs once, at 04:00Z.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Santiago"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Santiago", "grants": [{"authorized_at": "2026-02-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-04-03T00:00:00Z", + "end_time": "2026-04-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "dst-gap-at-midnight-america-santiago", + "name": "DST gap at midnight (America/Santiago)", + "origin": "adcp-client#3505", + "rule": "Chile springs forward at 24:00 on 2026-09-05 to 01:00: local midnight September 6 does not exist and the boundary advances by the gap to 04:00Z, so September 6 is a 23-hour local day.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/Santiago"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Santiago", "grants": [{"authorized_at": "2026-02-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-09-05T12:00:00Z", + "end_time": "2026-09-07T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "zero-length-period-on-a-skipped-civil-date-pacific-apia", + "name": "zero-length period on a skipped civil date (Pacific/Apia)", + "origin": "adcp-client#3505", + "rule": "Samoa skipped 2011-12-30: that local day has zero length (its midnight and the next coincide at 10:00Z) and is never owed; the surrounding days are.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["Pacific/Apia"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2011-12-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Pacific/Apia", "grants": [{"authorized_at": "2011-11-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2011-12-01T00:00:00Z", + "start_time": "2011-12-28T12:00:00Z", + "end_time": "2011-12-31T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2012-02-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "pt24h-under-a-local-alignment-restarts-at-local-midnight", + "name": "PT24H under a local alignment restarts at local midnight (#8588)", + "origin": "adcp-client#3505", + "rule": "Under account_timezone a duration of only hours restarts at each local midnight (adcp#8588), so PT24H in New York across the 2026-03-08 spring-forward has a 23-hour period, exactly like P1D.", + "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", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "PT24H", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-02-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-01T00:00:00Z", + "start_time": "2026-03-07T12:00:00Z", + "end_time": "2026-03-09T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h24_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_h24_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_h24_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_h24_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT24H", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "pt24h-and-p1d-under-utc-are-the-same-days", + "name": "PT24H and P1D under utc are the same days", + "origin": "adcp-client#3505", + "rule": "UTC has no DST, so PT24H and P1D yield identical boundaries under utc alignment.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "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": "PT24H", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_u24"]}] + } + ], + "offerings": [ + { + "offering_id": "off_u24", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_u24", + "report_definition_uri": "https://seller.example/report-definitions/off_u24.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT24H", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "sub-daily-local-periods-start-on-the-local-hour-asia-singapore", + "name": "sub-daily local periods start on the local hour (Asia/Singapore)", + "origin": "adcp-client#3505", + "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z falls in the 00:00Z (08:00 local) hour, which ends after it and overlaps the flight, so it is owed (adcp#8593).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["Asia/Singapore"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Asia/Singapore", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T00:05:00Z", + "start_time": "2026-11-03T00:10:00Z", + "end_time": "2026-11-03T02:30:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "sub-daily-local-periods-on-a-25-hour-day-america-new-york-fall-back", + "name": "sub-daily local periods on a 25-hour day (America/New_York fall-back)", + "origin": "adcp-client#3505", + "rule": "adcp#8588: the repeated 01:00 yields one boundary per offset, so 2026-11-01 in New York has 25 hourly periods.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-11-01T04:00:00Z", + "end_time": "2026-11-02T05:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "re-grant-chain-of-three-grants-without-backfill", + "name": "re-grant chain of three grants without backfill", + "origin": "adcp-client#3505", + "rule": "Revoked 11-04T06 and re-granted 11-05T18, revoked 11-07T06 and re-granted 11-08T18, all inside retention: one chain with gaps [11-04, 11-06) and [11-07, 11-09).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-04T06:00:00Z"}, + {"authorized_at": "2026-11-05T18:00:00Z", "unauthorized_at": "2026-11-07T06:00:00Z"}, + {"authorized_at": "2026-11-08T18:00:00Z"} + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "re-grant-chain-of-three-grants-with-backfill-on-regrant", + "name": "re-grant chain of three grants with backfill_on_regrant", + "origin": "adcp-client#3505", + "rule": "The same chain with backfill_on_regrant: no gap, every period owed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-04T06:00:00Z"}, + {"authorized_at": "2026-11-05T18:00:00Z", "unauthorized_at": "2026-11-07T06:00:00Z"}, + {"authorized_at": "2026-11-08T18:00:00Z"} + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "re-grant-seen-only-through-the-chain-start-authorized-at", + "name": "re-grant seen only through the chain-start authorized_at", + "origin": "adcp-client#3505", + "rule": "The wire keeps authorized_at at the chain start after a re-grant inside retention (adcp#8585), so the buyer lists the continuation with that value; the seller's gaps[] says which periods the interruption removed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-04T06:00:00Z"}, {"authorized_at": "2026-10-01T00:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "coverage": {"gaps": [{"from": "2026-11-04T00:00:00Z", "until": "2026-11-06T00:00:00Z", "reason": "unauthorized"}]} + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "two-authorization-chains", + "name": "two authorization chains", + "origin": "adcp-client#3505", + "rule": "Revoked 11-04T06; re-granted 11-10 after retention (1 day) expired, a new chain from 11-10; revoked again 11-12T06.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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", "unauthorized_at": "2026-11-04T06:00:00Z"}, + {"authorized_at": "2026-11-10T00:00:00Z", "unauthorized_at": "2026-11-12T06:00:00Z"} + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-15T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "match-all-adds-an-offering-on-a-different-alignment", + "name": "match all adds an offering on a different alignment", + "origin": "adcp-client#3505", + "rule": "v2 (11-04T12Z) moves from exact to all and adds a New York offering. The UTC offering continues (E = 11-05T00Z); the New York offering is new and starts at the latest outgoing end, so its first period is 11-05T05Z and 00Z-05Z is owed by neither.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/period_timezone", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "timezones": ["America/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "version-change-exactly-on-a-boundary", + "name": "version change exactly on a boundary", + "origin": "adcp-client#3505", + "rule": "v2 accepted at 11-05T00Z, itself a boundary: E is T, v1 owes 11-03 and 11-04, v2 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", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", + "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" + }, + "superseded_at": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "reactivation-exactly-on-a-boundary", + "name": "reactivation exactly on a boundary", + "origin": "adcp-client#3505", + "rule": "Inactive [11-04T12, 11-06T00): 11-04 started before deactivation and stays owed; coverage resumes at 11-06 itself.", + "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/inactive_intervals", + "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-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-06T00:00:00Z"}] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "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-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "exception-bounds-between-boundaries", + "name": "exception bounds between boundaries", + "origin": "adcp-client#3505", + "rule": "capacity [11-04T12, 11-06T12) removes the periods whose start is in it, 11-05 and 11-06; 11-04 started before from and stays owed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "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-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": {"excluded": [{"reason": "capacity", "from": "2026-11-04T12:00:00Z", "until": "2026-11-06T12:00:00Z"}]} + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "finest-tie-in-byte-order-b-before-a", + "name": "finest tie in byte order (B before a)", + "origin": "adcp-client#3505", + "rule": "Two offerings tie at two mapped dimensions; byte order puts uppercase B (0x42) before lowercase a (0x61), unlike locale order.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["a", "B"]}] + } + ], + "offerings": [ + { + "offering_id": "a", + "feed_purpose": "analytics", + "report_definition_id": "rd_a", + "report_definition_uri": "https://seller.example/report-definitions/a.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "B", + "feed_purpose": "analytics", + "report_definition_id": "rd_B", + "report_definition_uri": "https://seller.example/report-definitions/B.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "unknown-dimension-values-parse-and-match-only-where-published", + "name": "unknown dimension values parse and match only where published", + "origin": "adcp-client#3505", + "rule": "The dimension vocabulary is extensible: an offering may publish placement, and a selector naming it matches that offering. A value no offering publishes (audience_segment) matches nothing.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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", "placement"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_q_pkg_placement", "off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_q_pkg_placement", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_q_pkg_placement", + "report_definition_uri": "https://seller.example/report-definitions/off_q_pkg_placement.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "placement"] + }, + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "an-unknown-selector-dimension-no-offering-publishes-matches-nothing", + "name": "an unknown selector dimension no offering publishes matches nothing", + "origin": "adcp-client#3505", + "rule": "A selector naming a value the seller does not map matches no offering.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "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", "audience_segment"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "id": "numeric-offset-timezone-is-refused", + "name": "numeric offset timezone is refused", + "origin": "adcp-client#3505", + "changed_from_origin": "offering is account_timezone-aligned instead of utc-aligned", + "rule": "iana_timezone: an account_timezone offering reads its boundaries in the account timezone, and +05:00 is a numeric offset, not an IANA zone, so it defines no DST rules. Changed from the adcp-client#3505 table, whose offering was utc-aligned: a utc schedule never reads the account timezone, so its periods are derivable and the refusal did not follow from the coverage rule.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/iana_timezone", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "+05:00", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_timezone", "wire_valid": true} + }, + { + "id": "account-timezone-offering-requires-the-account-timezone", + "name": "account_timezone offering requires the account timezone", + "origin": "adcp-client#3505", + "rule": "Boundaries cannot be derived without the account timezone.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_account_timezone", "wire_valid": true} + }, + { + "id": "a-timezone-cutover-without-previous-timezone-is-refused", + "name": "a timezone cutover without previous_timezone is refused", + "origin": "adcp-client#3505", + "rule": "Sellers MUST include previous_timezone: account.timezone already shows the new zone, so the old schedule cannot be recovered without it.", + "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-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/timezone_cutovers", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": {"timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles"}]} + }, + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_previous_timezone", "wire_valid": true} + }, + { + "id": "an-offering-without-standard-dimensions-cannot-be-resolved", + "name": "an offering without standard_dimensions cannot be resolved", + "origin": "adcp-client#3505", + "rule": "Sellers advertising subscriptions MUST publish standard_dimensions on every offering; matching never guesses.", + "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", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/dimensions", + "static/schemas/source/core/reporting-delivery-offering.json#/properties/standard_dimensions", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only" + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_standard_dimensions", "wire_valid": true} + }, + { + "id": "re-grant-requires-status-retention-days", + "name": "re-grant requires status_retention_days", + "origin": "adcp-client#3505", + "rule": "Whether a re-grant continues coverage depends on the retention window.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "docs/media-buy/task-reference/get_reporting_status.mdx#which-periods-are-owed" + ], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_status_retention_days", "wire_valid": true} + }, + { + "id": "havana-spring-forward-at-midnight", + "name": "DST gap at midnight (America/Havana)", + "origin": "adcp", + "rule": "Cuba springs forward at 00:00 standard time on 2026-03-08 (Rule Cuba Mar Sun>=8 0:00s). Local midnight March 8 does not exist, so the boundary advances by the one-hour gap to 01:00 CDT, 05:00Z. March 7 is a 24-hour local day (05:00Z to 05:00Z) and March 8 a 23-hour one (05:00Z to 04: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": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "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-07T12:00:00Z", + "end_time": "2026-03-09T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_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_e_pkg_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_e_pkg_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "havana-spring-forward-hourly", + "name": "Sub-daily local periods on a day whose midnight does not exist (America/Havana)", + "origin": "adcp", + "rule": "adcp#8588: hourly boundaries are the instants whose local wall time is a whole hour, and a wall time that does not exist yields no boundary. 00:00 on 2026-03-08 does not exist in Havana; 01:00 CDT (05:00Z) is the first boundary of the day, so March 8 has 23 hourly periods, each one hour long.", + "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": "PT1H", + "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-08T05:00:00Z", + "end_time": "2026-03-09T04:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-08T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T06:00:00Z", + "period_end": "2026-03-08T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T07:00:00Z", + "period_end": "2026-03-08T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T08:00:00Z", + "period_end": "2026-03-08T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T09:00:00Z", + "period_end": "2026-03-08T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T10:00:00Z", + "period_end": "2026-03-08T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T11:00:00Z", + "period_end": "2026-03-08T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T12:00:00Z", + "period_end": "2026-03-08T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T13:00:00Z", + "period_end": "2026-03-08T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T14:00:00Z", + "period_end": "2026-03-08T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T15:00:00Z", + "period_end": "2026-03-08T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T16:00:00Z", + "period_end": "2026-03-08T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T17:00:00Z", + "period_end": "2026-03-08T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T18:00:00Z", + "period_end": "2026-03-08T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T19:00:00Z", + "period_end": "2026-03-08T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T20:00:00Z", + "period_end": "2026-03-08T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T21:00:00Z", + "period_end": "2026-03-08T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T22:00:00Z", + "period_end": "2026-03-08T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T23:00:00Z", + "period_end": "2026-03-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T00:00:00Z", + "period_end": "2026-03-09T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T01:00:00Z", + "period_end": "2026-03-09T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T02:00:00Z", + "period_end": "2026-03-09T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-09T03:00:00Z", + "period_end": "2026-03-09T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_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-08T06:00:00Z", + "period_end": "2026-03-08T07:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T07: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-08T08:00:00Z", + "period_end": "2026-03-08T09:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T09: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-08T10:00:00Z", + "period_end": "2026-03-08T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-03-08T11:00:00Z", + "period_end": "2026-03-08T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "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"] + }, + { + "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-fall-back-at-midnight", + "name": "DST fold at midnight: the earlier offset (America/Havana)", + "origin": "adcp", + "rule": "Cuba fell back at 00:00 standard time on 2025-11-02 (01:00 CDT became 00:00 CST), so local midnight November 2 occurred twice, at 04:00Z and 05:00Z. An ambiguous local boundary uses the earlier offset: November 2 starts at 04:00Z and is a 25-hour local day, ending at 05:00Z on November 3. Chosen in 2025 because tzdata 2026f removed Cuba's 2026-11-01 fall-back; no release from 2026b through 2026f differs on these instants.", + "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": "2025-10-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Havana", "grants": [{"authorized_at": "2025-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-10-15T00:00:00Z", + "start_time": "2025-11-01T12:00:00Z", + "end_time": "2025-11-03T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2025-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-01T04:00:00Z", + "period_end": "2025-11-02T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-02T04:00:00Z", + "period_end": "2025-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-03T05:00:00Z", + "period_end": "2025-11-04T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-01T04:00:00Z", + "period_end": "2025-11-02T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-02T04:00:00Z", + "period_end": "2025-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-11-03T05:00:00Z", + "period_end": "2025-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "havana-fall-back-hourly", + "name": "Sub-daily local periods when midnight repeats (America/Havana)", + "origin": "adcp", + "rule": "adcp#8588: a repeated wall time yields one boundary per offset. On 2025-11-02 in Havana 00:00 occurs at 04:00Z (CDT) and again at 05:00Z (CST), so both are boundaries and the 25-hour local day has 25 hourly periods.", + "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": "2025-10-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/Havana", "grants": [{"authorized_at": "2025-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-10-15T00:00:00Z", + "start_time": "2025-11-02T04:00:00Z", + "end_time": "2025-11-03T05:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2025-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T04:00:00Z", + "period_end": "2025-11-02T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T05:00:00Z", + "period_end": "2025-11-02T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T06:00:00Z", + "period_end": "2025-11-02T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T07:00:00Z", + "period_end": "2025-11-02T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T08:00:00Z", + "period_end": "2025-11-02T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T09:00:00Z", + "period_end": "2025-11-02T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T10:00:00Z", + "period_end": "2025-11-02T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T11:00:00Z", + "period_end": "2025-11-02T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T12:00:00Z", + "period_end": "2025-11-02T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T13:00:00Z", + "period_end": "2025-11-02T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T14:00:00Z", + "period_end": "2025-11-02T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T15:00:00Z", + "period_end": "2025-11-02T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T16:00:00Z", + "period_end": "2025-11-02T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T17:00:00Z", + "period_end": "2025-11-02T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T18:00:00Z", + "period_end": "2025-11-02T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T19:00:00Z", + "period_end": "2025-11-02T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T20:00:00Z", + "period_end": "2025-11-02T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T21:00:00Z", + "period_end": "2025-11-02T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T22:00:00Z", + "period_end": "2025-11-02T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T23:00:00Z", + "period_end": "2025-11-03T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T00:00:00Z", + "period_end": "2025-11-03T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T01:00:00Z", + "period_end": "2025-11-03T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T02:00:00Z", + "period_end": "2025-11-03T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T03:00:00Z", + "period_end": "2025-11-03T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T04:00:00Z", + "period_end": "2025-11-03T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T04:00:00Z", + "period_end": "2025-11-02T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T05:00:00Z", + "period_end": "2025-11-02T06:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T06:00:00Z", + "period_end": "2025-11-02T07:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T07:00:00Z", + "period_end": "2025-11-02T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T08:00:00Z", + "period_end": "2025-11-02T09:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T09:00:00Z", + "period_end": "2025-11-02T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T10:00:00Z", + "period_end": "2025-11-02T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T11:00:00Z", + "period_end": "2025-11-02T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T12:00:00Z", + "period_end": "2025-11-02T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T13:00:00Z", + "period_end": "2025-11-02T14:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T14:00:00Z", + "period_end": "2025-11-02T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T15:00:00Z", + "period_end": "2025-11-02T16:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T16:00:00Z", + "period_end": "2025-11-02T17:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T17:00:00Z", + "period_end": "2025-11-02T18:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T18:00:00Z", + "period_end": "2025-11-02T19:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T19:00:00Z", + "period_end": "2025-11-02T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T20:00:00Z", + "period_end": "2025-11-02T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T21:00:00Z", + "period_end": "2025-11-02T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T22:00:00Z", + "period_end": "2025-11-02T23:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-02T23:00:00Z", + "period_end": "2025-11-03T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T00:00:00Z", + "period_end": "2025-11-03T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T01:00:00Z", + "period_end": "2025-11-03T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T02:00:00Z", + "period_end": "2025-11-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T03:00:00Z", + "period_end": "2025-11-03T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-11-03T04:00:00Z", + "period_end": "2025-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "cairo-spring-forward-at-midnight", + "name": "DST gap at midnight (Africa/Cairo)", + "origin": "adcp", + "rule": "Egypt springs forward at 00:00 on the last Friday of April (Rule Egypt Apr lastFri 0:00): 2025-04-25 00:00 EET does not exist and advances by the gap to 01:00 EEST, 22:00Z on April 24. April 24 is 24 hours (22:00Z to 22:00Z) and April 25 is 23 (22:00Z to 21: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": ["Africa/Cairo"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-04-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Africa/Cairo", "grants": [{"authorized_at": "2025-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-04-10T00:00:00Z", + "start_time": "2025-04-24T12:00:00Z", + "end_time": "2025-04-26T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2025-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-23T22:00:00Z", + "period_end": "2025-04-24T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-24T22:00:00Z", + "period_end": "2025-04-25T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-25T21:00:00Z", + "period_end": "2025-04-26T21:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-23T22:00:00Z", + "period_end": "2025-04-24T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-24T22:00:00Z", + "period_end": "2025-04-25T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-04-25T21:00:00Z", + "period_end": "2025-04-26T21:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "cairo-fall-back-at-midnight", + "name": "DST fold at 24:00 (Africa/Cairo)", + "origin": "adcp", + "rule": "Egypt falls back at 24:00 on the last Thursday of October (Rule Egypt Oct lastThu 24:00): on 2025-10-30 at 21:00Z the clock reads 24:00 EEST and returns to 23:00 EET. The repeated hour is 23:00 on October 30, so October 31 00:00 occurs once (22:00Z) and October 30 is a 25-hour local day, 21:00Z on October 29 to 22:00Z on October 30.", + "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": ["Africa/Cairo"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-10-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Africa/Cairo", "grants": [{"authorized_at": "2025-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-10-10T00:00:00Z", + "start_time": "2025-10-29T12:00:00Z", + "end_time": "2025-10-31T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2025-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-28T21:00:00Z", + "period_end": "2025-10-29T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-29T21:00:00Z", + "period_end": "2025-10-30T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-30T22:00:00Z", + "period_end": "2025-10-31T22:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-28T21:00:00Z", + "period_end": "2025-10-29T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-29T21:00:00Z", + "period_end": "2025-10-30T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2025-10-30T22:00:00Z", + "period_end": "2025-10-31T22:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "lord-howe-spring-forward-hourly", + "name": "Sub-daily local periods across a 30-minute spring-forward (Australia/Lord_Howe)", + "origin": "adcp", + "rule": "adcp#8588: hourly periods start on the local hour. Lord Howe moves from +10:30 to +11 at 02:00 on 2025-10-05, skipping 02:00-02:30, so wall 02:00 does not exist and yields no boundary. The day has 23 boundaries and 23 periods; the one from 01:00 (14:30Z) to 03:00 (16:00Z) lasts 90 minutes. The local day is 23.5 hours.", + "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": ["Australia/Lord_Howe"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-09-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Australia/Lord_Howe", "grants": [{"authorized_at": "2025-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-09-15T00:00:00Z", + "start_time": "2025-10-04T13:30:00Z", + "end_time": "2025-10-05T13:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2025-11-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T13:30:00Z", + "period_end": "2025-10-04T14:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T14:30:00Z", + "period_end": "2025-10-04T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T16:00:00Z", + "period_end": "2025-10-04T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T17:00:00Z", + "period_end": "2025-10-04T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T18:00:00Z", + "period_end": "2025-10-04T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T19:00:00Z", + "period_end": "2025-10-04T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T20:00:00Z", + "period_end": "2025-10-04T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T21:00:00Z", + "period_end": "2025-10-04T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T22:00:00Z", + "period_end": "2025-10-04T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T23:00:00Z", + "period_end": "2025-10-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T00:00:00Z", + "period_end": "2025-10-05T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T01:00:00Z", + "period_end": "2025-10-05T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T02:00:00Z", + "period_end": "2025-10-05T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T03:00:00Z", + "period_end": "2025-10-05T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T04:00:00Z", + "period_end": "2025-10-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T05:00:00Z", + "period_end": "2025-10-05T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T06:00:00Z", + "period_end": "2025-10-05T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T07:00:00Z", + "period_end": "2025-10-05T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T08:00:00Z", + "period_end": "2025-10-05T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T09:00:00Z", + "period_end": "2025-10-05T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T10:00:00Z", + "period_end": "2025-10-05T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T11:00:00Z", + "period_end": "2025-10-05T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T12:00:00Z", + "period_end": "2025-10-05T13:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T13:30:00Z", + "period_end": "2025-10-04T14:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T14:30:00Z", + "period_end": "2025-10-04T16:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T16:00:00Z", + "period_end": "2025-10-04T17:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T17:00:00Z", + "period_end": "2025-10-04T18:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T18:00:00Z", + "period_end": "2025-10-04T19:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T19:00:00Z", + "period_end": "2025-10-04T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T20:00:00Z", + "period_end": "2025-10-04T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T21:00:00Z", + "period_end": "2025-10-04T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T22:00:00Z", + "period_end": "2025-10-04T23:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-04T23:00:00Z", + "period_end": "2025-10-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T00:00:00Z", + "period_end": "2025-10-05T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T01:00:00Z", + "period_end": "2025-10-05T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T02:00:00Z", + "period_end": "2025-10-05T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T03:00:00Z", + "period_end": "2025-10-05T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T04:00:00Z", + "period_end": "2025-10-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T05:00:00Z", + "period_end": "2025-10-05T06:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T06:00:00Z", + "period_end": "2025-10-05T07:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T07:00:00Z", + "period_end": "2025-10-05T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T08:00:00Z", + "period_end": "2025-10-05T09:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T09:00:00Z", + "period_end": "2025-10-05T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T10:00:00Z", + "period_end": "2025-10-05T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T11:00:00Z", + "period_end": "2025-10-05T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2025-10-05T12:00:00Z", + "period_end": "2025-10-05T13:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "lord-howe-fall-back-hourly", + "name": "Sub-daily local periods across a 30-minute fall-back (Australia/Lord_Howe)", + "origin": "adcp", + "rule": "Lord Howe returns from +11 to +10:30 at 02:00 on 2026-04-05: at 15:00Z the clock reads 02:00 and goes back to 01:30. Only 01:30-02:00 repeats, so every whole hour occurs once: 24 boundaries and 24 periods, the one from 01:00 (14:00Z) to 02:00 (15:30Z) lasting 90 minutes. The local day is 24.5 hours.", + "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": ["Australia/Lord_Howe"], + "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": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Australia/Lord_Howe", "grants": [{"authorized_at": "2026-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-04-04T13:00:00Z", + "end_time": "2026-04-05T13:30:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-05-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T13:00:00Z", + "period_end": "2026-04-04T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T14:00:00Z", + "period_end": "2026-04-04T15:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T15:30:00Z", + "period_end": "2026-04-04T16:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T16:30:00Z", + "period_end": "2026-04-04T17:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T17:30:00Z", + "period_end": "2026-04-04T18:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T18:30:00Z", + "period_end": "2026-04-04T19:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T19:30:00Z", + "period_end": "2026-04-04T20:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T20:30:00Z", + "period_end": "2026-04-04T21:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T21:30:00Z", + "period_end": "2026-04-04T22:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T22:30:00Z", + "period_end": "2026-04-04T23:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T23:30:00Z", + "period_end": "2026-04-05T00:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T00:30:00Z", + "period_end": "2026-04-05T01:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T01:30:00Z", + "period_end": "2026-04-05T02:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T02:30:00Z", + "period_end": "2026-04-05T03:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T03:30:00Z", + "period_end": "2026-04-05T04:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T04:30:00Z", + "period_end": "2026-04-05T05:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T05:30:00Z", + "period_end": "2026-04-05T06:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T06:30:00Z", + "period_end": "2026-04-05T07:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T07:30:00Z", + "period_end": "2026-04-05T08:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T08:30:00Z", + "period_end": "2026-04-05T09:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T09:30:00Z", + "period_end": "2026-04-05T10:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T10:30:00Z", + "period_end": "2026-04-05T11:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T11:30:00Z", + "period_end": "2026-04-05T12:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T12:30:00Z", + "period_end": "2026-04-05T13:30:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T13:00:00Z", + "period_end": "2026-04-04T14:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T14:00:00Z", + "period_end": "2026-04-04T15:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T15:30:00Z", + "period_end": "2026-04-04T16:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T16:30:00Z", + "period_end": "2026-04-04T17:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T17:30:00Z", + "period_end": "2026-04-04T18:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T18:30:00Z", + "period_end": "2026-04-04T19:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T19:30:00Z", + "period_end": "2026-04-04T20:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T20:30:00Z", + "period_end": "2026-04-04T21:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T21:30:00Z", + "period_end": "2026-04-04T22:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T22:30:00Z", + "period_end": "2026-04-04T23:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-04T23:30:00Z", + "period_end": "2026-04-05T00:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T00:30:00Z", + "period_end": "2026-04-05T01:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T01:30:00Z", + "period_end": "2026-04-05T02:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T02:30:00Z", + "period_end": "2026-04-05T03:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T03:30:00Z", + "period_end": "2026-04-05T04:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T04:30:00Z", + "period_end": "2026-04-05T05:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T05:30:00Z", + "period_end": "2026-04-05T06:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T06:30:00Z", + "period_end": "2026-04-05T07:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T07:30:00Z", + "period_end": "2026-04-05T08:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T08:30:00Z", + "period_end": "2026-04-05T09:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T09:30:00Z", + "period_end": "2026-04-05T10:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T10:30:00Z", + "period_end": "2026-04-05T11:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T11:30:00Z", + "period_end": "2026-04-05T12:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-04-05T12:30:00Z", + "period_end": "2026-04-05T13:30:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "lord-howe-fall-back-half-hourly", + "name": "A repeated half hour yields one boundary per offset (Australia/Lord_Howe)", + "origin": "adcp", + "rule": "With PT30M, wall 01:30 on 2026-04-05 occurs at 14:30Z (+11) and again at 15:00Z (+10:30); both are boundaries. The four half-hour periods from 14:00Z to 16:00Z are each 30 minutes long.", + "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": ["Australia/Lord_Howe"], + "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": "PT30M", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Australia/Lord_Howe", "grants": [{"authorized_at": "2026-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-04-04T14:00:00Z", + "end_time": "2026-04-04T16:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_half_hourly_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_half_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_half_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_half_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT30M", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-05-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T14:00:00Z", + "period_end": "2026-04-04T14:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T14:30:00Z", + "period_end": "2026-04-04T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T15:00:00Z", + "period_end": "2026-04-04T15:30:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T15:30:00Z", + "period_end": "2026-04-04T16:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T14:00:00Z", + "period_end": "2026-04-04T14:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T14:30:00Z", + "period_end": "2026-04-04T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T15:00:00Z", + "period_end": "2026-04-04T15:30:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_half_hourly_local", + "period_start": "2026-04-04T15:30:00Z", + "period_end": "2026-04-04T16:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "kathmandu-hourly-local-and-utc", + "name": "Sub-daily local periods at a +05:45 offset (Asia/Kathmandu)", + "origin": "adcp", + "rule": "adcp#8588: local hours in Kathmandu (+05:45) start at 15 minutes past each UTC hour, while a utc-aligned hourly offering starts on the UTC hour. match all resolves both offerings and each is owed its own periods: local hours from 23:15Z (the one containing the 00:00Z flight start) through the one containing the 03:00Z stop, and UTC hours 00:00Z to 03: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/selection", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/match", + "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": ["Asia/Kathmandu"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "Asia/Kathmandu", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T00:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-03T03:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local", "off_h_pkg_hourly"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T23:15:00Z", + "period_end": "2026-11-03T00:15:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:15:00Z", + "period_end": "2026-11-03T01:15:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:15:00Z", + "period_end": "2026-11-03T02:15:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:15:00Z", + "period_end": "2026-11-03T03:15:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T23:15:00Z", + "period_end": "2026-11-03T00:15:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:15:00Z", + "period_end": "2026-11-03T01:15:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:15:00Z", + "period_end": "2026-11-03T02:15:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:15:00Z", + "period_end": "2026-11-03T03:15:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "utc-sub-daily-counts-from-the-epoch", + "name": "utc sub-daily periods count from 1970-01-01T00:00:00Z", + "origin": "adcp", + "rule": "utc alignment uses 1970-01-01T00:00:00Z as interval zero and the sub-daily local rule does not apply to it, so PT7H boundaries are multiples of seven hours since the epoch, not since midnight: 2026-11-03T00:00Z is 498240 hours after the epoch, one past a multiple of seven, so boundaries fall at 23:00Z, 06:00Z, 13:00Z and 20: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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "pacing", "period_duration": "PT7H", "required_finality": "snapshot", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-04T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_u7"]}] + } + ], + "offerings": [ + { + "offering_id": "off_u7", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_u7", + "report_definition_uri": "https://seller.example/report-definitions/off_u7.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": "PT7H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u7", + "period_start": "2026-11-02T23:00:00Z", + "period_end": "2026-11-03T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u7", + "period_start": "2026-11-03T06:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u7", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u7", + "period_start": "2026-11-03T20:00:00Z", + "period_end": "2026-11-04T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_u7", + "period_start": "2026-11-02T23:00:00Z", + "period_end": "2026-11-03T06:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u7", + "period_start": "2026-11-03T06:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u7", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u7", + "period_start": "2026-11-03T20:00:00Z", + "period_end": "2026-11-04T03:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "billing-cycle-31st-anchor-across-30-day-months", + "name": "Billing cycle anchored on the 31st across 30-day months", + "origin": "adcp", + "rule": "Month addition preserves the anchor day and clamps to the target month's last day, computed from the anchor for each ordinal: March 31, April 30 (clamped), May 31 (back to the 31st, not April 30 plus one month), June 30, July 31.", + "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": ["UTC"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-15T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P1M", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-01-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-20T00:00:00Z", + "start_time": "2026-04-15T00:00:00Z", + "end_time": "2026-07-15T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m31_utc"]}] + } + ], + "offerings": [ + { + "offering_id": "off_m31_utc", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m31_utc", + "report_definition_uri": "https://seller.example/report-definitions/off_m31_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": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-03-31T00:00:00Z", + "period_timezone": "UTC" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "seller.example"}, + "format": "jsonl" + } + } + ], + "now": "2026-09-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31_utc", + "period_start": "2026-03-31T00:00:00Z", + "period_end": "2026-04-30T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31_utc", + "period_start": "2026-04-30T00:00:00Z", + "period_end": "2026-05-31T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31_utc", + "period_start": "2026-05-31T00:00:00Z", + "period_end": "2026-06-30T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31_utc", + "period_start": "2026-06-30T00:00:00Z", + "period_end": "2026-07-31T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m31_utc", + "period_start": "2026-03-31T00:00:00Z", + "period_end": "2026-04-30T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31_utc", + "period_start": "2026-04-30T00:00:00Z", + "period_end": "2026-05-31T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31_utc", + "period_start": "2026-05-31T00:00:00Z", + "period_end": "2026-06-30T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31_utc", + "period_start": "2026-06-30T00:00:00Z", + "period_end": "2026-07-31T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "billing-quarter-anchored-on-october-31", + "name": "Quarterly billing cycle anchored on October 31 in New York", + "origin": "adcp", + "rule": "P3M from 2025-10-31 00:00 New York: January 31, April 30 (clamped), July 31, October 31. Each is local midnight, so the UTC instant moves between 04:00Z (EDT) and 05:00Z (EST, January 31).", + "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/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-10-01T00:00:00Z", + "destination_method": {"pattern": "file_transfer", "transport": "s3"}, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"account_ids": ["acct_1"]}, + "selector": {"feed_purpose": "billing", "period_duration": "P3M", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "consumer_receipt", + "destination": {"destination_id": "billing-archive"} + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2025-09-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-10-15T00:00:00Z", + "start_time": "2025-11-15T00:00:00Z", + "end_time": "2026-08-15T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_q31"]}] + } + ], + "offerings": [ + { + "offering_id": "off_q31", + "feed_purpose": "billing", + "report_definition_id": "rd_off_q31", + "report_definition_uri": "https://seller.example/report-definitions/off_q31.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": "P3M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2025-10-31T04: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": "2026-11-15T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q31", + "period_start": "2025-10-31T04:00:00Z", + "period_end": "2026-01-31T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-04-30T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q31", + "period_start": "2026-04-30T04:00:00Z", + "period_end": "2026-07-31T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q31", + "period_start": "2026-07-31T04:00:00Z", + "period_end": "2026-10-31T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_q31", + "period_start": "2025-10-31T04:00:00Z", + "period_end": "2026-01-31T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-04-30T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q31", + "period_start": "2026-04-30T04:00:00Z", + "period_end": "2026-07-31T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q31", + "period_start": "2026-07-31T04:00:00Z", + "period_end": "2026-10-31T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "p7d-utc-weeks-start-on-thursday", + "name": "Weekly utc periods (P7D) start on Thursdays", + "origin": "adcp", + "rule": "The schedule pattern has no W designator, so a week is P7D. utc alignment counts from 1970-01-01T00:00:00Z, a Thursday, so every utc week starts on a Thursday: 2026-11-05, 11-12 and 11-19 cover a Monday-to-Monday flight.", + "spec_refs": [ + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation", + "static/schemas/source/core/reporting-schedule.json#/properties/period_duration", + "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": [], + "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": "P7D", "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-09T00:00:00Z", + "end_time": "2026-11-23T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_w_utc"]}] + } + ], + "offerings": [ + { + "offering_id": "off_w_utc", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_w_utc", + "report_definition_uri": "https://seller.example/report-definitions/off_w_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"] + }, + "schedule": {"period_duration": "P7D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-15T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_w_utc", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_w_utc", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-19T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_w_utc", + "period_start": "2026-11-19T00:00:00Z", + "period_end": "2026-11-26T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_w_utc", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_w_utc", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-19T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_w_utc", + "period_start": "2026-11-19T00:00:00Z", + "period_end": "2026-11-26T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "p7d-account-timezone-week-across-fall-back", + "name": "Weekly local periods (P7D) across a fall-back", + "origin": "adcp", + "rule": "Under account_timezone the origin is local midnight on 1970-01-01, a Thursday, and days are civil days: the week from Thursday 2026-10-29 00:00 EDT (04:00Z) to Thursday 2026-11-05 00:00 EST (05:00Z) is 7 days and 1 hour.", + "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/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P7D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-15T00:00:00Z", + "start_time": "2026-10-30T12:00:00Z", + "end_time": "2026-11-06T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_w_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_w_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_w_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_w_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": "P7D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-15T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_w_acct_tz", + "period_start": "2026-10-29T04:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_w_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-12T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_w_acct_tz", + "period_start": "2026-10-29T04:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_w_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-12T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "p1w-is-not-a-schedule-duration", + "name": "P1W is not a schedule period_duration", + "origin": "adcp", + "rule": "The period_duration pattern in core/reporting-schedule.json and the selector allows Y, M and D date components and no W designator; a week is P7D. A P1W selector fails schema validation and cannot be resolved.", + "spec_refs": [ + "static/schemas/source/core/reporting-schedule.json#/properties/period_duration", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation" + ], + "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": "P1W", "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-09T00:00:00Z", + "end_time": "2026-11-23T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_w_utc"]}] + } + ], + "offerings": [ + { + "offering_id": "off_w_utc", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_w_utc", + "report_definition_uri": "https://seller.example/report-definitions/off_w_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"] + }, + "schedule": {"period_duration": "P7D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_period_duration", "wire_valid": false} + }, + { + "id": "p1y-account-timezone-calendar-years", + "name": "Yearly local periods (P1Y) are calendar years", + "origin": "adcp", + "rule": "Under account_timezone, P1Y from local midnight on 1970-01-01 gives January 1 local midnight each year (05:00Z in New York). Accepted 2025-12-01, the first owed year starts 2026-01-01; a flight into 2027 owes 2027 in full.", + "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/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-12-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1Y", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2025-11-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-12-15T00:00:00Z", + "start_time": "2026-06-01T00:00:00Z", + "end_time": "2027-03-01T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_y_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_y_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_y_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_y_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": "P1Y", "alignment": "account_timezone", "delivery_sla": "P5D"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2028-02-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y_acct_tz", + "period_start": "2026-01-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y_acct_tz", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2028-01-01T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_y_acct_tz", + "period_start": "2026-01-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_y_acct_tz", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2028-01-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "local-sub-daily-must-divide-24-hours", + "name": "A local sub-daily duration that does not divide 24 hours is refused", + "origin": "adcp", + "rule": "period_generation: with account_timezone, source_timezone, or billing_cycle alignment, a period_duration made only of hours, minutes, and seconds MUST divide 24 hours. PT7H does not.", + "spec_refs": ["static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "pacing", "period_duration": "PT7H", "required_finality": "snapshot", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-04T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p7_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_p7_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p7_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p7_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": "PT7H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_period_duration", "wire_valid": true} + }, + { + "id": "local-duration-cannot-mix-date-and-time", + "name": "A local alignment cannot mix date and time components", + "origin": "adcp", + "rule": "period_generation: \"With those alignments a period_duration cannot mix date and time components.\" P1DT12H under account_timezone is refused.", + "spec_refs": ["static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation"], + "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": "P1DT12H", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_mixed_local"]}] + } + ], + "offerings": [ + { + "offering_id": "off_mixed_local", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_mixed_local", + "report_definition_uri": "https://seller.example/report-definitions/off_mixed_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": "P1DT12H", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INVALID", "detail": "invalid_period_duration", "wire_valid": true} + }, + { + "id": "fractional-seconds-and-offsets", + "name": "Fractional seconds and non-Z offsets are instants", + "origin": "adcp", + "rule": "Every timestamp is an RFC 3339 instant; compare instants, never strings. accepted_at 2026-11-01T19:00:00.001-05:00 is one millisecond after the 11-02T00:00Z boundary, so the first owed boundary is 11-03. authorized_at, confirmed_at, the flight and now use +05:30, +01:00, -05:00 and +09:00 offsets and fractional seconds; the flight is 11-02T00:00Z to 11-05T00:00Z and now is 11-06T00:00Z. Expected instants are written in Z form.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T19:00:00.001-05:00", + "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-01T05:30:00+05:30"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-01T10:00:00.5+01:00", + "start_time": "2026-11-01T19:00:00-05:00", + "end_time": "2026-11-05T01:00:00.000+01:00", + "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-11-06T09:00:00+09:00" + }, + "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" + } + ], + "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"] + } + ] + }, + { + "id": "confirmed-one-millisecond-before-a-boundary", + "name": "Confirmed one millisecond before a boundary owes the period containing it", + "origin": "adcp", + "rule": "adcp#8593: a period is owed when it ends after confirmed_at. confirmed_at 10:59:59.999Z is inside the 10:00Z hour, which ends after it and overlaps the 10:30Z flight start, so it is owed in full.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "pacing", "period_duration": "PT1H", "required_finality": "snapshot", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:59:59.999Z", + "start_time": "2026-11-03T10:30:00Z", + "end_time": "2026-11-03T13:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h_pkg_hourly"]}] + } + ], + "offerings": [ + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "confirmed-exactly-on-a-boundary", + "name": "Confirmed exactly on a boundary owes from that boundary", + "origin": "adcp", + "rule": "confirmed_at 11:00:00.000+00:00 is a boundary: the 10:00Z hour ends at it, not after it, so it is not owed even though it overlaps the flight; 11:00Z and 12:00Z are.", + "spec_refs": [ + "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": [], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "pacing", "period_duration": "PT1H", "required_finality": "snapshot", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T11:00:00.000+00:00", + "start_time": "2026-11-03T10:30:00Z", + "end_time": "2026-11-03T13:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h_pkg_hourly"]}] + } + ], + "offerings": [ + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "confirmed-after-the-stop", + "name": "A buy confirmed after its stop owes only a period that ends after confirmation", + "origin": "adcp", + "rule": "mb_1 stops at 11-05T12 and is confirmed at 11-05T06: only the 11-05 period both ends after confirmed_at and overlaps the flight. mb_2 has the same flight and is confirmed at 11-06T01, after the period containing its stop ended, so it owes nothing.", + "spec_refs": [ + "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": [], + "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-05T06:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + }, + { + "media_buy_id": "mb_2", + "confirmed_at": "2026-11-06T01:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T12:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "extension-of-an-empty-flight", + "name": "Extending an empty flight never owes the periods resolved while it was empty", + "origin": "adcp", + "rule": "Until 11-04T12 the flight was empty (start_time equal to end_time, 11-03T00), so the 11-03 period, resolved at 11-04T00, overlapped nothing. The update at 11-04T12 sets end_time 11-07; periods resolved after it (11-04, 11-05, 11-06) use the new flight and are owed, and 11-03 never becomes owed retroactively.", + "spec_refs": [ + "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": [], + "non_wire_inputs": ["buys[].prior_flights"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], + "prior_flights": [{"replaced_at": "2026-11-04T12:00:00Z", "start_time": "2026-11-03T00:00:00Z", "end_time": "2026-11-03T00: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-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-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": "packages-on-different-products-resolve-different-offerings", + "name": "Each package resolves the selector against its own product", + "origin": "adcp", + "rule": "The seller resolves the selector separately for each buy's products. Under finest [package], pkg_1's product lists package and package x creative and resolves package x creative; pkg_2's product lists only package and resolves it. The buy is covered by both, and each (buy, resolved offering) pair is owed its own periods.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/selection", + "static/schemas/source/core/reporting-subscription.json#/properties/selector/properties/match", + "docs/protocol/sync_principal.mdx#reporting-subscriptions", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage" + ], + "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"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + {"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}, + {"package_id": "pkg_2", "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"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "reactivation-and-new-version-at-one-instant", + "name": "Reactivation and a new version at the same instant", + "origin": "adcp", + "rule": "Deactivated at 11-04T12 under v1; v2 is accepted and the subscription reactivated at the same instant, 11-05T12. v1 owes the periods that started before the deactivation (11-03, 11-04). Reactivation is a fresh start, so v2 owes from the first boundary at or after 11-05T12 (11-06), and nothing that started while inactive.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-state.json#/properties/inactive_intervals", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", + "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-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-05T12:00:00Z"}] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "new-version-deactivated-at-acceptance", + "name": "A new version deactivated at the instant it is accepted", + "origin": "adcp", + "rule": "v2 is accepted at 11-04T12 with active false, deactivated_at 11-04T12. v1 is outgoing at T and owes the periods that start before it (11-03, 11-04); deactivation has no incoming schedule, so v2 owes nothing.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/reporting-subscription-state.json#/properties/prior_versions", + "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-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": false, + "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"] + }, + "deactivated_at": "2026-11-04T12:00:00Z" + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "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" + } + ], + "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"] + } + ] + }, + { + "id": "revocation-and-new-version-at-one-instant", + "name": "A revocation and a new version at the same instant", + "origin": "adcp", + "rule": "Authority ends at 11-04T12, the instant v2 is accepted, and returns at 11-05T18 inside retention without backfill. v1 owes the periods starting before T, but the 11-04 period ends after unauthorized_at and falls in the gap [11-04, 11-06); 11-03 stays owed. v2 owes from E = 11-05 except the gap, so from 11-06.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/cutover", + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "static/schemas/source/core/account-authorization.json#/properties/authorized_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" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-04T12:00:00Z"}, {"authorized_at": "2026-11-05T18:00:00Z"}] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "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": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "grant-chain-of-four-without-backfill", + "name": "Four grants: two chains, each with an interruption, without backfill", + "origin": "adcp", + "rule": "status_retention_days 2. The 11-04T00 re-grant is before 11-03T06 + 2 days and continues the first chain: gap [11-03, 11-04). The 11-07T12 re-grant is exactly 11-05T12 + 2 days, not before it, so the entry has expired and a new chain starts at 11-07T12 (first owed boundary 11-08); 11-05 to 11-07 end after the first chain's final unauthorized_at and start before the new chain. The 11-10T00 re-grant continues the second chain: gap [11-09, 11-10).", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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-10-20T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + {"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-03T06:00:00Z"}, + {"authorized_at": "2026-11-04T00:00:00Z", "unauthorized_at": "2026-11-05T12:00:00Z"}, + {"authorized_at": "2026-11-07T12:00:00Z", "unauthorized_at": "2026-11-09T06:00:00Z"}, + {"authorized_at": "2026-11-10T00:00:00Z"} + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-25T00:00:00Z", + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-13T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 2 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-01T00:00:00Z", + "period_end": "2026-11-02T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-03T00: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-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-13T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-01T00:00:00Z", + "period_end": "2026-11-02T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-03T00: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-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-13T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "grant-chain-of-four-with-backfill", + "name": "Four grants: two chains, each with an interruption, with backfill_on_regrant", + "origin": "adcp", + "rule": "The same grants with backfill_on_regrant: the interruptions inside each chain record no gap, so 11-03 and 11-09 are owed. Backfill never bridges two chains: 11-05 to 11-07 stay unowed.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "static/schemas/source/core/account-authorization.json#/properties/authorized_at", + "static/schemas/source/core/reporting-subscription-state.json#/properties/accounts_unauthorized", + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/gaps", + "docs/media-buy/task-reference/get_reporting_status.mdx#when-authorization-ends", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "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-10-20T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + {"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-03T06:00:00Z"}, + {"authorized_at": "2026-11-04T00:00:00Z", "unauthorized_at": "2026-11-05T12:00:00Z"}, + {"authorized_at": "2026-11-07T12:00:00Z", "unauthorized_at": "2026-11-09T06:00:00Z"}, + {"authorized_at": "2026-11-10T00:00:00Z"} + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-25T00:00:00Z", + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-13T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 2, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-01T00:00:00Z", + "period_end": "2026-11-02T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-03T00:00:00Z" + }, + { + "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-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-13T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-01T00:00:00Z", + "period_end": "2026-11-02T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-03T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "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-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-12T00:00:00Z", + "period_end": "2026-11-13T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "max-accounts-capacity-per-offering", + "name": "max_accounts capacity exception per offering on different boundaries", + "origin": "adcp", + "rule": "Beyond subscriptions.max_accounts the seller excludes the account with reason capacity from 11-04T12 until it is served again at 11-06T12. Under match all the covered offerings have different boundaries, so the seller lists one entry per offering_id with that offering's E instants: UTC days [11-05T00, 11-07T00) and New York days [11-05T05, 11-07T05). Each removes only its own offering's periods.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription-coverage.json#/properties/excluded", + "docs/media-buy/task-reference/get_reporting_status.mdx#exceptions", + "static/schemas/source/core/reporting-delivery-capabilities.json#/properties/subscriptions", + "docs/protocol/sync_principal.mdx#reporting-subscriptions", + "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/New_York"], + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": {"all_authorized_accounts": true}, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-09T00:00:00Z", + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"]}] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + {"offering_id": "off_a_pkg", "reason": "capacity", "from": "2026-11-05T00:00:00Z", "until": "2026-11-07T00:00:00Z"}, + {"offering_id": "off_e_pkg_acct_tz", "reason": "capacity", "from": "2026-11-05T05:00:00Z", "until": "2026-11-07T05:00:00Z"} + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "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_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_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_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T05:00:00Z", + "period_end": "2026-11-08T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-08T05:00:00Z", + "period_end": "2026-11-09T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_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_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_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_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T05:00:00Z", + "period_end": "2026-11-08T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-08T05:00:00Z", + "period_end": "2026-11-09T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "id": "account-resolved-source-timezone-requires-the-resolved-zone", + "name": "An account-resolved source_timezone offering needs the seller-resolved zone", + "origin": "adcp", + "rule": "A source_timezone offering with period_timezone_policy account_resolved publishes no zone; the seller resolves the account's upstream reporting timezone and echoes it as the obligation's period.source_timezone. Without that value the boundaries cannot be derived.", + "spec_refs": [ + "static/schemas/source/core/reporting-schedule-offering.json#/properties/period_timezone_policy", + "static/schemas/source/core/reporting-schedule.json#/x-adcp-validation/period_generation" + ], + "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_s_resolved"]}] + } + ], + "offerings": [ + { + "offering_id": "off_s_resolved", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_resolved", + "report_definition_uri": "https://seller.example/report-definitions/off_s_resolved.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": {"period_duration": "P1D", "alignment": "source_timezone", "delivery_sla": "PT6H", "period_timezone_policy": "account_resolved"}, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_source_timezone", "wire_valid": true} + } + ] +} diff --git a/test/fixtures/reporting-subscriptions/grant-cases.json b/test/fixtures/reporting-subscriptions/grant-cases.json new file mode 100644 index 000000000..1923bc9c4 --- /dev/null +++ b/test/fixtures/reporting-subscriptions/grant-cases.json @@ -0,0 +1,171 @@ +{ + "description": "Reduced grants and reporting-subscription coverage (experimental media_buy.reporting_subscriptions, AdCP 3.3). Revocation means no access (adcp#8607): a seller that wants a buyer to finish reconciling keeps a reduced grant instead of revoking. Coverage follows the grant: an account is covered while its authorization, when advertised, lists get_reporting_status in allowed_tasks. A grant without get_reporting_status reads exactly as a loss of authority at the instant it took effect.", + "rule": "covered = authorization is absent, or authorization.allowed_tasks lists get_reporting_status. read_only does not affect coverage.", + "spec_refs": [ + "static/schemas/source/core/reporting-subscription.json#/x-adcp-validation/coverage", + "docs/media-buy/task-reference/get_reporting_status.mdx (Winding down instead of revoking)", + "static/schemas/source/media-buy/sync-reporting-receipts-request.json#/x-adcp-validation/after_revocation" + ], + "wind_down_grant": { + "allowed_tasks": ["get_reporting_status", "sync_reporting_receipts", "sync_reporting_status"], + "read_only": false + }, + "grant_cases": [ + { "name": "no authorization object: restricts no task", "authorization": null, "covered": true }, + { + "name": "wind-down grant", + "authorization": { + "allowed_tasks": ["get_reporting_status", "sync_reporting_receipts", "sync_reporting_status"], + "read_only": false + }, + "covered": true + }, + { + "name": "get_reporting_status alone", + "authorization": { "allowed_tasks": ["get_reporting_status"], "read_only": true }, + "covered": true + }, + { + "name": "full buying grant listing get_reporting_status", + "authorization": { + "allowed_tasks": ["get_media_buys", "create_media_buy", "get_reporting_status", "sync_reporting_receipts"] + }, + "covered": true + }, + { + "name": "reduced grant without get_reporting_status: receipts and statuses only", + "authorization": { "allowed_tasks": ["sync_reporting_receipts", "sync_reporting_status"], "read_only": false }, + "covered": false + }, + { + "name": "reduced grant without get_reporting_status: statuses only", + "authorization": { "allowed_tasks": ["sync_reporting_status"], "read_only": false }, + "covered": false + }, + { + "name": "reduced grant without get_reporting_status: buying tasks only", + "authorization": { "allowed_tasks": ["get_media_buys", "create_media_buy"] }, + "covered": false + }, + { + "name": "empty allowed_tasks", + "authorization": { "allowed_tasks": [], "read_only": true }, + "covered": false + }, + { + "name": "task names match exactly", + "authorization": { "allowed_tasks": ["get_reporting_status_v2", "GET_REPORTING_STATUS"] }, + "covered": false + } + ], + "transition_conventions": { + "offering": "One delivery offering: period_duration P1D, alignment utc, delivery_sla PT0S, feed_purpose analytics, finality snapshot, delivery_only.", + "subscription": "analytics-daily version 1, scope all_authorized_accounts, accepted at 2026-09-01T12:00:00Z.", + "buy": "mb-1 on account-a, confirmed 2026-09-01T06:00:00Z, flight 2026-09-02T00:00:00Z to 2026-09-11T00:00:00Z, one package mapped to the offering.", + "grant": "authorized_at 2026-08-01T00:00:00Z. Each step replaces the account's authorization at its instant; the seller reconciles at that instant and the revocation instant is the observation time. allowed_tasks null means an authorization that restricts no task; revoked means the account leaves the list.", + "expected": "Read at evaluate_at: authorized is the engine's current authority, covered is whether get_reporting_status shows the subscription on the account, unauthorized_at lists accounts_unauthorized for account-a, owed lists every owed period start in order." + }, + "transition_cases": [ + { + "name": "wind-down keeps coverage and opens no accounts_unauthorized entry", + "steps": [ + { "at": "2026-09-01T18:00:00Z", "allowed_tasks": null }, + { + "at": "2026-09-05T12:00:00Z", + "allowed_tasks": ["get_reporting_status", "sync_reporting_receipts", "sync_reporting_status"] + } + ], + "evaluate_at": "2026-09-13T00:00:00Z", + "expected": { + "authorized": true, + "covered": true, + "unauthorized_at": [], + "owed": [ + "2026-09-02T00:00:00Z", + "2026-09-03T00:00:00Z", + "2026-09-04T00:00:00Z", + "2026-09-05T00:00:00Z", + "2026-09-06T00:00:00Z", + "2026-09-07T00:00:00Z", + "2026-09-08T00:00:00Z", + "2026-09-09T00:00:00Z", + "2026-09-10T00:00:00Z" + ] + } + }, + { + "name": "a reduced grant without get_reporting_status ends coverage like a revocation", + "steps": [ + { "at": "2026-09-01T18:00:00Z", "allowed_tasks": null }, + { "at": "2026-09-05T12:00:00Z", "allowed_tasks": ["sync_reporting_receipts", "sync_reporting_status"] } + ], + "evaluate_at": "2026-09-13T00:00:00Z", + "expected": { + "authorized": false, + "covered": false, + "unauthorized_at": ["2026-09-05T12:00:00Z"], + "owed": ["2026-09-02T00:00:00Z", "2026-09-03T00:00:00Z", "2026-09-04T00:00:00Z"] + } + }, + { + "name": "a reduced grant without get_reporting_status owes the same periods as a full revocation", + "steps": [ + { "at": "2026-09-01T18:00:00Z", "allowed_tasks": null }, + { "at": "2026-09-05T12:00:00Z", "allowed_tasks": "revoked" } + ], + "evaluate_at": "2026-09-13T00:00:00Z", + "expected": { + "authorized": false, + "covered": false, + "unauthorized_at": ["2026-09-05T12:00:00Z"], + "owed": ["2026-09-02T00:00:00Z", "2026-09-03T00:00:00Z", "2026-09-04T00:00:00Z"] + } + }, + { + "name": "dropping get_reporting_status from a wind-down grant ends coverage then", + "steps": [ + { "at": "2026-09-01T18:00:00Z", "allowed_tasks": null }, + { + "at": "2026-09-04T12:00:00Z", + "allowed_tasks": ["get_reporting_status", "sync_reporting_receipts", "sync_reporting_status"] + }, + { "at": "2026-09-07T12:00:00Z", "allowed_tasks": ["sync_reporting_receipts"] } + ], + "evaluate_at": "2026-09-13T00:00:00Z", + "expected": { + "authorized": false, + "covered": false, + "unauthorized_at": ["2026-09-07T12:00:00Z"], + "owed": [ + "2026-09-02T00:00:00Z", + "2026-09-03T00:00:00Z", + "2026-09-04T00:00:00Z", + "2026-09-05T00:00:00Z", + "2026-09-06T00:00:00Z" + ] + } + }, + { + "name": "restoring get_reporting_status inside retention is a re-grant with a gap", + "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" } + ], + "evaluate_at": "2026-09-13T00:00:00Z", + "expected": { + "authorized": true, + "covered": true, + "unauthorized_at": [], + "owed": [ + "2026-09-02T00:00:00Z", + "2026-09-03T00:00:00Z", + "2026-09-07T00:00:00Z", + "2026-09-08T00:00:00Z", + "2026-09-09T00:00:00Z", + "2026-09-10T00:00:00Z" + ] + } + } + ] +} diff --git a/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json new file mode 100644 index 000000000..f0867e9b6 --- /dev/null +++ b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json @@ -0,0 +1,970 @@ +{ + "capabilities": { + "supported": true, + "reliable_reporting_version": "1.0", + "configuration_task": "sync_accounts", + "status_task": "get_reporting_status", + "consumer_status_task": "sync_reporting_status", + "revision_content_task": "get_media_buy_delivery", + "offerings": [ + { + "offering_id": "fixture-daily-snapshot", + "feed_purpose": "analytics", + "report_definition_id": "delivery-daily-v1", + "report_definition_uri": "https://contracts.example/reporting/delivery-daily-v1", + "report_definition_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reporting_profile": { + "id": "delivery", + "version": "1.0.0", + "schema_uri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schema_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "source_day", + "primary_keys": ["media_buy_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT0S" + }, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only" + } + ], + "automated_recovery_window_seconds": 86400, + "status_retention_days": 30 + }, + "installed": { + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "offeringId": "fixture-daily-snapshot", + "report_definition_id": "delivery-daily-v1", + "feedPurpose": "analytics", + "requiredFinality": "snapshot", + "requestedMetrics": ["impressions", "spend"], + "requestedDimensions": ["media_buy_id"], + "schedule": { + "anchor": "2026-09-01T00:00:00.000Z", + "periodMilliseconds": 86400000, + "deliverySlaMilliseconds": 0, + "recoveryWindowMilliseconds": 86400000, + "periodDuration": "P1D", + "deliverySlaDuration": "PT0S", + "alignment": "utc" + }, + "account": { + "account_id": "account-a" + }, + "sourceScope": { + "network_id": "network-account-a", + "_adcp_reporting_adapter": "fixture" + }, + "sourceTimezone": "UTC", + "sourceSettings": { + "attributionModel": "source_default", + "attributionWindow": "source_default", + "sourceSettingsVersion": "1", + "sourceSettingsSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settingsSnapshotRef": "fixture-settings", + "currency": "USD" + }, + "contract": { + "report_definition_id": "delivery-daily-v1", + "reportDefinitionUri": "https://contracts.example/reporting/delivery-daily-v1", + "reportDefinitionSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reportingProfile": "delivery", + "schemaVersion": "1.0.0", + "schemaUri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schemaSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "schemaRefPolicy": "local_fragment_only", + "mappingId": "fixture-delivery-v1", + "mappingVersion": "1", + "mappingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "constituents": [ + { + "constituentId": "constituent-account-a", + "constituentKind": "media_buy", + "productId": "fixture-product", + "mediaBuyId": "media-buy-account-a", + "productBinding": { + "owner": "caller", + "bindingId": "fixture-product-binding", + "bindingVersion": 1, + "bindingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "productId": "fixture-product", + "bindingKind": "media_buy_product", + "mediaBuyId": "media-buy-account-a" + } + } + ], + "mediaBuyIds": ["media-buy-account-a"], + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "installedAt": "", + "semanticFingerprint": "sha256:462d031a7e11c7171ec7d3dd7e4235a7d8366431376e0c0e577d8eca7d2762fb" + }, + "cycle": { + "planned": 3, + "claimed": 3, + "revisionsCommitted": 3, + "notReady": 0, + "failed": 0, + "reconcilesDeferred": 0 + }, + "configurations": [ + { + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "offeringId": "fixture-daily-snapshot", + "report_definition_id": "delivery-daily-v1", + "feedPurpose": "analytics", + "requiredFinality": "snapshot", + "requestedMetrics": ["impressions", "spend"], + "requestedDimensions": ["media_buy_id"], + "schedule": { + "anchor": "2026-09-01T00:00:00.000Z", + "periodMilliseconds": 86400000, + "deliverySlaMilliseconds": 0, + "recoveryWindowMilliseconds": 86400000, + "periodDuration": "P1D", + "deliverySlaDuration": "PT0S", + "alignment": "utc" + }, + "account": { + "account_id": "account-a" + }, + "sourceScope": { + "network_id": "network-account-a", + "_adcp_reporting_adapter": "fixture" + }, + "sourceTimezone": "UTC", + "sourceSettings": { + "attributionModel": "source_default", + "attributionWindow": "source_default", + "sourceSettingsVersion": "1", + "sourceSettingsSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settingsSnapshotRef": "fixture-settings", + "currency": "USD" + }, + "contract": { + "report_definition_id": "delivery-daily-v1", + "reportDefinitionUri": "https://contracts.example/reporting/delivery-daily-v1", + "reportDefinitionSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reportingProfile": "delivery", + "schemaVersion": "1.0.0", + "schemaUri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schemaSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "schemaRefPolicy": "local_fragment_only", + "mappingId": "fixture-delivery-v1", + "mappingVersion": "1", + "mappingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "constituents": [ + { + "constituentId": "constituent-account-a", + "constituentKind": "media_buy", + "productId": "fixture-product", + "mediaBuyId": "media-buy-account-a", + "productBinding": { + "owner": "caller", + "bindingId": "fixture-product-binding", + "bindingVersion": 1, + "bindingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "productId": "fixture-product", + "bindingKind": "media_buy_product", + "mediaBuyId": "media-buy-account-a" + } + } + ], + "mediaBuyIds": ["media-buy-account-a"], + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "installedAt": "", + "semanticFingerprint": "sha256:462d031a7e11c7171ec7d3dd7e4235a7d8366431376e0c0e577d8eca7d2762fb" + } + ], + "obligations": [ + { + "reporting_obligation_id": "robl_e09e4257780e4c490e74060ae367cc4b", + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "account": { + "account_id": "account-a" + }, + "sourceScope": { + "network_id": "network-account-a", + "_adcp_reporting_adapter": "fixture" + }, + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "offeringId": "fixture-daily-snapshot", + "report_definition_id": "delivery-daily-v1", + "feedPurpose": "analytics", + "requiredFinality": "snapshot", + "periodOrdinal": 0, + "period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z", + "sourceTimezone": "UTC" + }, + "schedule": { + "anchor": "2026-09-01T00:00:00.000Z", + "periodMilliseconds": 86400000, + "deliverySlaMilliseconds": 0, + "recoveryWindowMilliseconds": 86400000, + "periodDuration": "P1D", + "deliverySlaDuration": "PT0S", + "alignment": "utc" + }, + "scopeResolvedAt": "2026-09-02T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluatedAt": "2026-09-02T00:00:00.000Z", + "mediaBuyIds": ["media-buy-account-a"], + "fullyCoveredMediaBuyIds": ["media-buy-account-a"], + "partiallyCoveredMediaBuyIds": [], + "unsupportedMediaBuyIds": [], + "unknownMediaBuyIds": [] + }, + "requestedMetrics": ["impressions", "spend"], + "requestedDimensions": ["media_buy_id"], + "constituents": [ + { + "constituentId": "constituent-account-a", + "constituentKind": "media_buy", + "productId": "fixture-product", + "mediaBuyId": "media-buy-account-a", + "productBinding": { + "owner": "caller", + "bindingId": "fixture-product-binding", + "bindingVersion": 1, + "bindingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "productId": "fixture-product", + "bindingKind": "media_buy_product", + "mediaBuyId": "media-buy-account-a" + } + } + ], + "mediaBuyIds": ["media-buy-account-a"], + "sourceSettings": { + "attributionModel": "source_default", + "attributionWindow": "source_default", + "sourceSettingsVersion": "1", + "sourceSettingsSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settingsSnapshotRef": "fixture-settings", + "currency": "USD" + }, + "contract": { + "report_definition_id": "delivery-daily-v1", + "reportDefinitionUri": "https://contracts.example/reporting/delivery-daily-v1", + "reportDefinitionSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reportingProfile": "delivery", + "schemaVersion": "1.0.0", + "schemaUri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schemaSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "schemaRefPolicy": "local_fragment_only", + "mappingId": "fixture-delivery-v1", + "mappingVersion": "1", + "mappingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "expectedAt": "2026-09-02T00:00:00.000Z", + "recoveryDeadlineAt": "2026-09-03T00:00:00.000Z", + "publicationOffsets": [], + "nextAttemptAt": "2026-09-04T00:01:00.000Z", + "attemptCount": 1, + "state": "terminal", + "semanticFingerprint": "sha256:e09e4257780e4c490e74060ae367cc4b22c649b43ebc8195f6b7fda47a9d9557", + "createdAt": "" + }, + { + "reporting_obligation_id": "robl_8b727e3ab603b2fb63c50b4ce0d37482", + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "account": { + "account_id": "account-a" + }, + "sourceScope": { + "network_id": "network-account-a", + "_adcp_reporting_adapter": "fixture" + }, + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "offeringId": "fixture-daily-snapshot", + "report_definition_id": "delivery-daily-v1", + "feedPurpose": "analytics", + "requiredFinality": "snapshot", + "periodOrdinal": 1, + "period": { + "start": "2026-09-02T00:00:00.000Z", + "end": "2026-09-03T00:00:00.000Z", + "sourceTimezone": "UTC" + }, + "schedule": { + "anchor": "2026-09-01T00:00:00.000Z", + "periodMilliseconds": 86400000, + "deliverySlaMilliseconds": 0, + "recoveryWindowMilliseconds": 86400000, + "periodDuration": "P1D", + "deliverySlaDuration": "PT0S", + "alignment": "utc" + }, + "scopeResolvedAt": "2026-09-03T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluatedAt": "2026-09-03T00:00:00.000Z", + "mediaBuyIds": ["media-buy-account-a"], + "fullyCoveredMediaBuyIds": ["media-buy-account-a"], + "partiallyCoveredMediaBuyIds": [], + "unsupportedMediaBuyIds": [], + "unknownMediaBuyIds": [] + }, + "requestedMetrics": ["impressions", "spend"], + "requestedDimensions": ["media_buy_id"], + "constituents": [ + { + "constituentId": "constituent-account-a", + "constituentKind": "media_buy", + "productId": "fixture-product", + "mediaBuyId": "media-buy-account-a", + "productBinding": { + "owner": "caller", + "bindingId": "fixture-product-binding", + "bindingVersion": 1, + "bindingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "productId": "fixture-product", + "bindingKind": "media_buy_product", + "mediaBuyId": "media-buy-account-a" + } + } + ], + "mediaBuyIds": ["media-buy-account-a"], + "sourceSettings": { + "attributionModel": "source_default", + "attributionWindow": "source_default", + "sourceSettingsVersion": "1", + "sourceSettingsSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settingsSnapshotRef": "fixture-settings", + "currency": "USD" + }, + "contract": { + "report_definition_id": "delivery-daily-v1", + "reportDefinitionUri": "https://contracts.example/reporting/delivery-daily-v1", + "reportDefinitionSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reportingProfile": "delivery", + "schemaVersion": "1.0.0", + "schemaUri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schemaSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "schemaRefPolicy": "local_fragment_only", + "mappingId": "fixture-delivery-v1", + "mappingVersion": "1", + "mappingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "expectedAt": "2026-09-03T00:00:00.000Z", + "recoveryDeadlineAt": "2026-09-04T00:00:00.000Z", + "publicationOffsets": [], + "nextAttemptAt": "2026-09-04T00:01:00.000Z", + "attemptCount": 1, + "state": "terminal", + "semanticFingerprint": "sha256:8b727e3ab603b2fb63c50b4ce0d37482c5214bbab7c5f44b75cdcb6182b5f515", + "createdAt": "" + }, + { + "reporting_obligation_id": "robl_333d9c1171590c380bb75ec2c5218110", + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "account": { + "account_id": "account-a" + }, + "sourceScope": { + "network_id": "network-account-a", + "_adcp_reporting_adapter": "fixture" + }, + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "offeringId": "fixture-daily-snapshot", + "report_definition_id": "delivery-daily-v1", + "feedPurpose": "analytics", + "requiredFinality": "snapshot", + "periodOrdinal": 2, + "period": { + "start": "2026-09-03T00:00:00.000Z", + "end": "2026-09-04T00:00:00.000Z", + "sourceTimezone": "UTC" + }, + "schedule": { + "anchor": "2026-09-01T00:00:00.000Z", + "periodMilliseconds": 86400000, + "deliverySlaMilliseconds": 0, + "recoveryWindowMilliseconds": 86400000, + "periodDuration": "P1D", + "deliverySlaDuration": "PT0S", + "alignment": "utc" + }, + "scopeResolvedAt": "2026-09-04T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluatedAt": "2026-09-04T00:00:00.000Z", + "mediaBuyIds": ["media-buy-account-a"], + "fullyCoveredMediaBuyIds": ["media-buy-account-a"], + "partiallyCoveredMediaBuyIds": [], + "unsupportedMediaBuyIds": [], + "unknownMediaBuyIds": [] + }, + "requestedMetrics": ["impressions", "spend"], + "requestedDimensions": ["media_buy_id"], + "constituents": [ + { + "constituentId": "constituent-account-a", + "constituentKind": "media_buy", + "productId": "fixture-product", + "mediaBuyId": "media-buy-account-a", + "productBinding": { + "owner": "caller", + "bindingId": "fixture-product-binding", + "bindingVersion": 1, + "bindingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "productId": "fixture-product", + "bindingKind": "media_buy_product", + "mediaBuyId": "media-buy-account-a" + } + } + ], + "mediaBuyIds": ["media-buy-account-a"], + "sourceSettings": { + "attributionModel": "source_default", + "attributionWindow": "source_default", + "sourceSettingsVersion": "1", + "sourceSettingsSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settingsSnapshotRef": "fixture-settings", + "currency": "USD" + }, + "contract": { + "report_definition_id": "delivery-daily-v1", + "reportDefinitionUri": "https://contracts.example/reporting/delivery-daily-v1", + "reportDefinitionSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reportingProfile": "delivery", + "schemaVersion": "1.0.0", + "schemaUri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schemaSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schemaDialect": "https://json-schema.org/draft/2020-12/schema", + "schemaRefPolicy": "local_fragment_only", + "mappingId": "fixture-delivery-v1", + "mappingVersion": "1", + "mappingSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "expectedAt": "2026-09-04T00:00:00.000Z", + "recoveryDeadlineAt": "2026-09-05T00:00:00.000Z", + "publicationOffsets": [], + "nextAttemptAt": "2026-09-04T00:01:00.000Z", + "attemptCount": 1, + "state": "terminal", + "semanticFingerprint": "sha256:333d9c1171590c380bb75ec2c521811041456595af5c86140ad81dc90b45cab5", + "createdAt": "" + } + ], + "summary": { + "status": "completed", + "adcp_version": "3.3-beta.2", + "adcp_major_version": 3, + "view": "summary", + "ledger_snapshot_id": "snapshot-0", + "ledger_as_of": "2026-09-04T00:01:00.000Z", + "account_id": "account-a", + "scope": { + "period_start": "2026-09-01T00:00:00.000Z", + "period_end": "2026-09-04T00:00:00.000Z", + "scope_closed": true, + "all_accessible_media_buys": true, + "delivery_config_generations": [ + { + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "feed_purpose": "analytics" + } + ], + "feed_purposes": ["analytics"], + "finality": ["snapshot"], + "ledger_retained_from": "2026-09-01T00:00:00.000Z", + "coverage_complete": true + }, + "health": "complete", + "coverage": { + "status": "full", + "evaluated_at": "2026-09-04T00:01:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "data_through": "2026-09-02T00:00:00.000Z", + "next_expected_at": "2026-09-05T00:00:00.000Z", + "obligation_counts": { + "total": 3, + "waiting": 0, + "healthy": 0, + "delayed": 0, + "action_required": 0, + "complete": 3, + "consumer_status_pending": 2 + }, + "issues": [] + }, + "periods": { + "status": "completed", + "adcp_version": "3.3-beta.2", + "adcp_major_version": 3, + "view": "periods", + "ledger_snapshot_id": "snapshot-1", + "ledger_as_of": "2026-09-04T00:01:00.000Z", + "account_id": "account-a", + "changes_checkpoint": "2026-09-04T00:01:00.000Z", + "scope": { + "period_start": "2026-09-01T00:00:00.000Z", + "period_end": "2026-09-04T00:00:00.000Z", + "scope_closed": true, + "all_accessible_media_buys": true, + "delivery_config_generations": [ + { + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "feed_purpose": "analytics" + } + ], + "feed_purposes": ["analytics"], + "finality": ["snapshot"], + "ledger_retained_from": "2026-09-01T00:00:00.000Z", + "coverage_complete": true + }, + "periods": [ + { + "reporting_obligation_id": "robl_e09e4257780e4c490e74060ae367cc4b", + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "report_definition_id": "delivery-daily-v1", + "feed_purpose": "analytics", + "reporting_profile": "delivery", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "scope_resolved_at": "2026-09-02T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluated_at": "2026-09-02T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z", + "source_timezone": "UTC" + }, + "expected_at": "2026-09-02T00:00:00.000Z", + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT0S" + }, + "required_finality": "snapshot", + "reconciliation_mode": "delivery_only", + "reconciliation_status": "not_required", + "health": "complete", + "production_status": "published", + "revision_count": 1, + "adjustment_count": 0, + "consumer_status_count": 0, + "issues": [] + }, + { + "reporting_obligation_id": "robl_8b727e3ab603b2fb63c50b4ce0d37482", + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "report_definition_id": "delivery-daily-v1", + "feed_purpose": "analytics", + "reporting_profile": "delivery", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "scope_resolved_at": "2026-09-03T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluated_at": "2026-09-03T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-02T00:00:00.000Z", + "end": "2026-09-03T00:00:00.000Z", + "source_timezone": "UTC" + }, + "expected_at": "2026-09-03T00:00:00.000Z", + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT0S" + }, + "required_finality": "snapshot", + "reconciliation_mode": "delivery_only", + "reconciliation_status": "not_required", + "health": "complete", + "production_status": "published", + "revision_count": 1, + "adjustment_count": 0, + "consumer_status_count": 0, + "issues": [] + }, + { + "reporting_obligation_id": "robl_333d9c1171590c380bb75ec2c5218110", + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "report_definition_id": "delivery-daily-v1", + "feed_purpose": "analytics", + "reporting_profile": "delivery", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "scope_resolved_at": "2026-09-04T00:00:00.000Z", + "coverage": { + "status": "full", + "evaluated_at": "2026-09-04T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-03T00:00:00.000Z", + "end": "2026-09-04T00:00:00.000Z", + "source_timezone": "UTC" + }, + "expected_at": "2026-09-04T00:00:00.000Z", + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT0S" + }, + "required_finality": "snapshot", + "reconciliation_mode": "delivery_only", + "reconciliation_status": "not_required", + "health": "complete", + "production_status": "published", + "revision_count": 1, + "adjustment_count": 0, + "consumer_status_count": 0, + "issues": [] + } + ], + "revisions": [ + { + "reporting_revision_id": "rrev_328f2ee75243aed6f8ca8417885718a4", + "revision_content_sha256": "6d7b9b012026385b00138b6c82bdf99fa36c8d4606bde5b23da833605f9d1576", + "report_definition_id": "delivery-daily-v1", + "report_definition_uri": "https://contracts.example/reporting/delivery-daily-v1", + "report_definition_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reporting_profile": "delivery", + "schema_version": "1.0.0", + "schema_uri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schema_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "coverage": { + "status": "full", + "evaluated_at": "2026-09-02T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z", + "source_timezone": "UTC" + }, + "finality": "snapshot", + "observed_at": "2026-09-02T00:00:00.000Z", + "data_through": "2026-09-02T00:00:00.000Z", + "data_through_precision": "exact", + "row_count": 1, + "control_totals": [ + { + "name": "impressions", + "value": "7", + "value_type": "integer" + }, + { + "name": "spend", + "value": "2.5", + "value_type": "decimal" + } + ], + "created_at": "" + }, + { + "reporting_revision_id": "rrev_8fd3b428b979ed1ad5230e992e85fb24", + "revision_content_sha256": "3ca917826f0c945b31ec1042f65e378c8800d700cc4d61787203345f971f9a73", + "report_definition_id": "delivery-daily-v1", + "report_definition_uri": "https://contracts.example/reporting/delivery-daily-v1", + "report_definition_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reporting_profile": "delivery", + "schema_version": "1.0.0", + "schema_uri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schema_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "coverage": { + "status": "full", + "evaluated_at": "2026-09-03T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-02T00:00:00.000Z", + "end": "2026-09-03T00:00:00.000Z", + "source_timezone": "UTC" + }, + "finality": "snapshot", + "observed_at": "2026-09-03T00:00:00.000Z", + "data_through": "2026-09-03T00:00:00.000Z", + "data_through_precision": "exact", + "row_count": 1, + "control_totals": [ + { + "name": "impressions", + "value": "7", + "value_type": "integer" + }, + { + "name": "spend", + "value": "2.5", + "value_type": "decimal" + } + ], + "created_at": "" + }, + { + "reporting_revision_id": "rrev_7ec1110de85e1d68bc48ffbbd489e4c1", + "revision_content_sha256": "ee4e7e62d7644ffad1aa416a47ea66aada4566220eb6a40dd8a49e3a23a2f40b", + "report_definition_id": "delivery-daily-v1", + "report_definition_uri": "https://contracts.example/reporting/delivery-daily-v1", + "report_definition_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reporting_profile": "delivery", + "schema_version": "1.0.0", + "schema_uri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schema_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "coverage": { + "status": "full", + "evaluated_at": "2026-09-04T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-03T00:00:00.000Z", + "end": "2026-09-04T00:00:00.000Z", + "source_timezone": "UTC" + }, + "finality": "snapshot", + "observed_at": "2026-09-04T00:00:00.000Z", + "data_through": "2026-09-04T00:00:00.000Z", + "data_through_precision": "exact", + "row_count": 1, + "control_totals": [ + { + "name": "impressions", + "value": "7", + "value_type": "integer" + }, + { + "name": "spend", + "value": "2.5", + "value_type": "decimal" + } + ], + "created_at": "" + } + ], + "adjustments": [], + "consumer_statuses": [], + "adjustment_receipts": [], + "materializations": [], + "receipts": [], + "pagination": { + "has_more": false, + "total_count": 6 + } + }, + "delivery": { + "reporting_period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z" + }, + "media_buy_deliveries": [], + "reporting_rows": [ + { + "media_buy_id": "media-buy-account-a", + "impressions": 7, + "spend": "2.50" + } + ], + "notification_type": "scheduled", + "partial_data": false, + "unavailable_count": 0, + "sequence_number": 1, + "reporting_revision_binding": { + "reporting_revision_id": "rrev_328f2ee75243aed6f8ca8417885718a4", + "content_sha256": "6d7b9b012026385b00138b6c82bdf99fa36c8d4606bde5b23da833605f9d1576", + "row_count": 1, + "control_totals": [ + { + "name": "impressions", + "value": "7", + "value_type": "integer" + }, + { + "name": "spend", + "value": "2.5", + "value_type": "decimal" + } + ] + }, + "reporting_revision": { + "reporting_revision_id": "rrev_328f2ee75243aed6f8ca8417885718a4", + "revision_content_sha256": "6d7b9b012026385b00138b6c82bdf99fa36c8d4606bde5b23da833605f9d1576", + "report_definition_id": "delivery-daily-v1", + "report_definition_uri": "https://contracts.example/reporting/delivery-daily-v1", + "report_definition_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reporting_profile": "delivery", + "schema_version": "1.0.0", + "schema_uri": "https://contracts.example/reporting/delivery-daily-v1/schema", + "schema_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "account_id": "account-a", + "media_buy_ids": ["media-buy-account-a"], + "coverage": { + "status": "full", + "evaluated_at": "2026-09-02T00:00:00.000Z", + "media_buy_ids": ["media-buy-account-a"], + "fully_covered_media_buy_ids": ["media-buy-account-a"], + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [] + }, + "period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z", + "source_timezone": "UTC" + }, + "finality": "snapshot", + "observed_at": "2026-09-02T00:00:00.000Z", + "data_through": "2026-09-02T00:00:00.000Z", + "data_through_precision": "exact", + "row_count": 1, + "control_totals": [ + { + "name": "impressions", + "value": "7", + "value_type": "integer" + }, + { + "name": "spend", + "value": "2.5", + "value_type": "decimal" + } + ], + "created_at": "" + }, + "currency": "USD", + "errors": [], + "pagination": { + "has_more": false, + "total_count": 1 + } + }, + "consumerStatus": { + "adcp_version": "3.3-beta.2", + "adcp_major_version": 3, + "status": "completed", + "results": [ + { + "result": "recorded", + "consumer_status": { + "reporting_status_id": "option-off-status-0001", + "delivery_config_id": "delivery-config", + "delivery_config_version": 1, + "report_definition_id": "delivery-daily-v1", + "period": { + "start": "2026-09-01T00:00:00.000Z", + "end": "2026-09-02T00:00:00.000Z", + "source_timezone": "UTC" + }, + "reporting_obligation_id": "robl_e09e4257780e4c490e74060ae367cc4b", + "reporting_revision_id": "rrev_328f2ee75243aed6f8ca8417885718a4", + "observed_revision_content_sha256": "6d7b9b012026385b00138b6c82bdf99fa36c8d4606bde5b23da833605f9d1576", + "consumer_status": "received", + "status_as_of": "2026-09-04T00:01:00.000Z", + "recorded_at": "" + } + } + ] + } +} diff --git a/test/fixtures/reporting-subscriptions/principal-option-off-golden.json b/test/fixtures/reporting-subscriptions/principal-option-off-golden.json new file mode 100644 index 000000000..faf69b640 --- /dev/null +++ b/test/fixtures/reporting-subscriptions/principal-option-off-golden.json @@ -0,0 +1,255 @@ +[ + [ + "get-unconfigured", + { + "result": { + "kind": "unconfigured" + } + } + ], + [ + "sync-dry-run", + { + "result": { + "kind": "validated", + "action": "would_update", + "dry_run": true + } + } + ], + [ + "sync-destinations", + { + "result": { + "kind": "applied", + "principal_id": "record-t-p", + "principal_kind": "buyer_agent", + "configuration_version": "cfg-1", + "configuration": { + "notification_configs": [], + "reporting_destinations": [ + { + "destination_id": "archive", + "destination_ref": "dest-ref-2", + "state": "validating", + "configuration": { + "pattern": "file_transfer", + "destination_id": "archive", + "active": true, + "provider": { + "domain": "storage.example" + }, + "transport": "s3", + "location": "s3://bucket/prefix", + "accepted_formats": ["jsonl"], + "accepted_verification_profiles": ["manifest_checksums"] + } + } + ], + "retired_destinations": [], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"] + }, + "accepted": { + "async_adcp_versions": ["3.2"] + }, + "selected_async_adcp_version": "3.2" + } + }, + "action": "updated", + "dry_run": false + } + } + ], + [ + "transition-ready", + { + "notification_configs": [], + "reporting_destinations": [ + { + "destination_id": "archive", + "destination_ref": "dest-ref-2", + "state": "ready", + "configuration": { + "pattern": "file_transfer", + "destination_id": "archive", + "active": true, + "provider": { + "domain": "storage.example" + }, + "transport": "s3", + "location": "s3://bucket/prefix", + "accepted_formats": ["jsonl"], + "accepted_verification_profiles": ["manifest_checksums"] + } + } + ], + "retired_destinations": [], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"] + }, + "accepted": { + "async_adcp_versions": ["3.2"] + }, + "selected_async_adcp_version": "3.2" + } + } + ], + [ + "sync-unchanged", + { + "result": { + "kind": "applied", + "principal_id": "record-t-p", + "principal_kind": "buyer_agent", + "configuration_version": "cfg-1", + "configuration": { + "notification_configs": [], + "reporting_destinations": [ + { + "destination_id": "archive", + "destination_ref": "dest-ref-2", + "state": "ready", + "configuration": { + "pattern": "file_transfer", + "destination_id": "archive", + "active": true, + "provider": { + "domain": "storage.example" + }, + "transport": "s3", + "location": "s3://bucket/prefix", + "accepted_formats": ["jsonl"], + "accepted_verification_profiles": ["manifest_checksums"] + } + } + ], + "retired_destinations": [], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"] + }, + "accepted": { + "async_adcp_versions": ["3.2"] + }, + "selected_async_adcp_version": "3.2" + } + }, + "action": "unchanged", + "dry_run": false + } + } + ], + [ + "sync-subscriptions-refused", + { + "status": "failed", + "result": { + "kind": "failed", + "errors": [ + { + "code": "UNSUPPORTED_FEATURE", + "message": "reporting_subscriptions are not supported by this seller" + } + ] + } + } + ], + [ + "sync-stale-version", + { + "status": "failed", + "result": { + "kind": "failed", + "errors": [ + { + "code": "CONFLICT", + "message": "The principal configuration changed; read current state and retry" + } + ] + } + } + ], + [ + "sync-remove-destination", + { + "result": { + "kind": "applied", + "principal_id": "record-t-p", + "principal_kind": "buyer_agent", + "configuration_version": "cfg-2", + "configuration": { + "notification_configs": [], + "reporting_destinations": [], + "retired_destinations": [ + { + "destination_id": "archive", + "destination_refs": ["dest-ref-2"], + "revoked_at": "2026-11-01T00:00:00.000Z" + } + ], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"] + }, + "accepted": { + "async_adcp_versions": ["3.2"] + }, + "selected_async_adcp_version": "3.2" + } + }, + "action": "cleared", + "dry_run": false + } + } + ], + [ + "get-final", + { + "result": { + "kind": "current", + "principal_id": "record-t-p", + "principal_kind": "buyer_agent", + "configuration_version": "cfg-2", + "configuration": { + "notification_configs": [], + "reporting_destinations": [], + "retired_destinations": [ + { + "destination_id": "archive", + "destination_refs": ["dest-ref-2"], + "revoked_at": "2026-11-01T00:00:00.000Z" + } + ], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"] + }, + "accepted": { + "async_adcp_versions": ["3.2"] + }, + "selected_async_adcp_version": "3.2" + } + } + } + } + ], + [ + "stored-record-keys", + [ + "configuration", + "configurationVersion", + "notificationGeneration", + "notificationSubscriptions", + "pendingNotifications", + "principalId", + "principalKind", + "reportingGenerations", + "schemaVersion", + "scopePrincipalId", + "tenantId" + ] + ] +] diff --git a/test/helpers/memory-reporting-ledger-store.js b/test/helpers/memory-reporting-ledger-store.js index 7c66d8281..84b01914a 100644 --- a/test/helpers/memory-reporting-ledger-store.js +++ b/test/helpers/memory-reporting-ledger-store.js @@ -17,6 +17,10 @@ const { reportingPeriodSchedule, projectReportingObligationHealthV1, reportingLedgerScopeClosed, + reportingLedgerCoverageClosedV1, + reportingLedgerSubscriptionKeyV1, + reportingLedgerSubscriptionVisibleV1, + mergeReportingLedgerOwedSetV1, } = require('@adcp/sdk/reporting/ledger'); const { canonicalJsonV1 } = require('@adcp/sdk/reporting/source'); @@ -50,11 +54,13 @@ class MemoryLedgerStore { } else if (value.historyStartOrdinal !== undefined) { throw new TypeError('Reporting historyStartOrdinal requires historyStart'); } + // Mirrors the PostgreSQL unique key: subscription generations add the owner and offering. const existing = [...this.configurations.values()].find( item => item.account.account_id === value.account.account_id && item.delivery_config_id === value.delivery_config_id && - item.delivery_config_version === value.delivery_config_version + item.delivery_config_version === value.delivery_config_version && + reportingLedgerSubscriptionKeyV1(item) === reportingLedgerSubscriptionKeyV1(value) ); if (existing && existing.semanticFingerprint !== value.semanticFingerprint) throw new Error('immutable conflict'); if (!existing) { @@ -88,6 +94,24 @@ class MemoryLedgerStore { this.configurations.set(input.configurationId, structuredClone(value)); return structuredClone(value); } + async recordOwedSetProgress({ configurationId, account_id, ...progress }) { + const stored = this.configurations.get(configurationId); + if (!stored || stored.account.account_id !== account_id || !stored.subscription) { + throw new Error('Unknown subscription reporting configuration'); + } + const value = { ...stored, owedSet: mergeReportingLedgerOwedSetV1(stored.owedSet, progress) }; + this.configurations.set(configurationId, structuredClone(value)); + return structuredClone(value); + } + async findObligationByOrdinal({ account_id, configurationId, periodOrdinal }) { + const value = [...this.obligations.values()].find( + item => + item.account.account_id === account_id && + item.configurationId === configurationId && + item.periodOrdinal === periodOrdinal + ); + return value ? structuredClone(value) : null; + } async listConfigurations(accountId) { return [...this.configurations.values()].filter(value => !accountId || value.account.account_id === accountId); } @@ -303,8 +327,11 @@ class MemoryLedgerStore { .map(value => structuredClone(value)); } async createSnapshot(query) { + // Caller isolation: account generations plus the caller's own subscription generations. + const visible = value => reportingLedgerSubscriptionVisibleV1(value, query.subscription_principal); let obligations = (await this.listObligations(query.account_id)).filter( value => + visible(value) && Date.parse(value.period.end) <= Date.parse(this.ledgerAsOf ?? new Date().toISOString()) && (!query.delivery_config_ids || query.delivery_config_ids.includes(value.delivery_config_id)) ); @@ -316,8 +343,10 @@ class MemoryLedgerStore { .flat() .map(({ rows: _rows, ...value }) => value); const ledgerAsOf = this.ledgerAsOf ?? new Date().toISOString(); - const configurations = (await this.listConfigurations(query.account_id)).map(value => structuredClone(value)); - const coverageOrdinals = (await this.listObligations(query.account_id)).map(value => ({ + const configurations = (await this.listConfigurations(query.account_id)) + .filter(visible) + .map(value => structuredClone(value)); + const coverageOrdinals = (await this.listObligations(query.account_id)).filter(visible).map(value => ({ configurationId: value.configurationId, periodOrdinal: value.periodOrdinal, period: { start: value.period.start, end: value.period.end }, @@ -337,7 +366,7 @@ class MemoryLedgerStore { value, revisions.filter(item => item.reporting_obligation_id === value.reporting_obligation_id), ledgerAsOf, - reportingLedgerScopeClosed(query, ledgerAsOf, ledgerCoverage.complete) + reportingLedgerScopeClosed(query, ledgerAsOf, reportingLedgerCoverageClosedV1(ledgerCoverage)) ).health ) ) diff --git a/test/helpers/principal-option-off-transcript.js b/test/helpers/principal-option-off-transcript.js new file mode 100644 index 000000000..52a76a554 --- /dev/null +++ b/test/helpers/principal-option-off-transcript.js @@ -0,0 +1,124 @@ +// Deterministic sync_principal/get_principal transcript for a principal runtime without +// reporting subscription support. The golden output was captured from the runtime before +// AdCP 3.3 subscriptions were added (test/fixtures/reporting-subscriptions/principal-option-off-golden.json). +const { + InMemoryStateStore, + createPersistentNotificationRuntime, + createPrincipalLifecycle, + createPrincipalStateStore, + principalNotificationSubscriptionStore, +} = require('../../dist/lib/server/index.js'); + +async function principalOptionOffTranscript() { + const store = createPrincipalStateStore({ store: new InMemoryStateStore() }); + const notifications = createPersistentNotificationRuntime({ + store: principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: true }) }, + validateDestination: async () => ({ allowed: true }), + authorizeDelivery: async () => ({ authorized: true }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 1, + delivered: true, + terminal: true, + errors: [], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); + let version = 0; + let ref = 0; + const lifecycle = createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + issuePrincipalId: scope => `record-${scope.tenantId}-${scope.principalId}`, + issueConfigurationVersion: () => `cfg-${++version}`, + issueDestinationRef: () => `dest-ref-${++ref}`, + now: () => new Date('2026-11-01T00:00:00.000Z'), + declarations: { asyncAdcpVersions: ['3.2'], webhookSigningAlgorithms: ['ed25519'] }, + prepareReportingDestination: ({ destination }) => ({ + state: 'validating', + configuration: structuredClone(destination), + }), + }); + const ctx = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; + const scope = { tenantId: 't', principalId: 'p' }; + const destination = { + pattern: 'file_transfer', + destination_id: 'archive', + active: true, + provider: { domain: 'storage.example' }, + transport: 's3', + location: 's3://bucket/prefix', + accepted_formats: ['jsonl'], + accepted_verification_profiles: ['manifest_checksums'], + }; + const transcript = []; + const record = async (label, call) => transcript.push([label, await call()]); + await record('get-unconfigured', () => lifecycle.protocol.getPrincipal({}, ctx)); + await record('sync-dry-run', () => + lifecycle.protocol.syncPrincipal( + { idempotency_key: 'k-0000000001', dry_run: true, configuration: { reporting_destinations: [destination] } }, + ctx + ) + ); + await record('sync-destinations', () => + lifecycle.protocol.syncPrincipal( + { + idempotency_key: 'k-0000000002', + configuration: { reporting_destinations: [destination], declarations: { async_adcp_versions: ['3.2'] } }, + }, + ctx + ) + ); + const destinationRef = transcript.at(-1)[1].result.configuration.reporting_destinations[0].destination_ref; + await record('transition-ready', () => + lifecycle.transitionDestination(scope, { destinationId: 'archive', destinationRef, state: 'ready' }) + ); + await record('sync-unchanged', () => + lifecycle.protocol.syncPrincipal( + { idempotency_key: 'k-0000000003', configuration: { declarations: { async_adcp_versions: ['3.2'] } } }, + ctx + ) + ); + await record('sync-subscriptions-refused', () => + lifecycle.protocol.syncPrincipal( + { idempotency_key: 'k-0000000004', configuration: { reporting_subscriptions: [] } }, + ctx + ) + ); + await record('sync-stale-version', () => + lifecycle.protocol.syncPrincipal( + { + idempotency_key: 'k-0000000005', + expected_configuration_version: 'cfg-0', + configuration: { reporting_destinations: [] }, + }, + ctx + ) + ); + await record('sync-remove-destination', () => + lifecycle.protocol.syncPrincipal( + { idempotency_key: 'k-0000000006', configuration: { reporting_destinations: [] } }, + ctx + ) + ); + await record('get-final', () => lifecycle.protocol.getPrincipal({}, ctx)); + const stored = await store.get(scope); + transcript.push(['stored-record-keys', Object.keys(stored.record).sort()]); + return transcript; +} + +module.exports = { principalOptionOffTranscript }; diff --git a/test/helpers/principal-runtime.js b/test/helpers/principal-runtime.js new file mode 100644 index 000000000..e538869ca --- /dev/null +++ b/test/helpers/principal-runtime.js @@ -0,0 +1,61 @@ +// In-memory principal runtime for reporting subscription tests. +const { + InMemoryStateStore, + createPersistentNotificationRuntime, + createPrincipalLifecycle, + createPrincipalStateStore, + principalNotificationSubscriptionStore, +} = require('../../dist/lib/server/index.js'); + +function createTestPrincipalLifecycle({ + clock = { now: new Date('2026-11-01T12:00:00.000Z') }, + ...lifecycleOptions +} = {}) { + const store = createPrincipalStateStore({ store: new InMemoryStateStore() }); + const emitted = []; + const notifications = createPersistentNotificationRuntime({ + store: principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: true }) }, + validateDestination: async () => ({ allowed: true }), + authorizeDelivery: async () => ({ authorized: true }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + emitted.push(params); + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 1, + delivered: true, + terminal: true, + errors: [], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); + let version = 0; + let ref = 0; + const lifecycle = createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + issuePrincipalId: scope => `record-${scope.tenantId}-${scope.principalId}`, + issueConfigurationVersion: () => `cfg-${++version}`, + issueDestinationRef: () => `dest-ref-${++ref}`, + now: () => clock.now, + ...lifecycleOptions, + ...(lifecycleOptions.reportingSubscriptions + ? { reportingSubscriptions: { listDeliveryConfigIds: () => [], ...lifecycleOptions.reportingSubscriptions } } + : {}), + }); + return { lifecycle, store, emitted, clock }; +} + +module.exports = { createTestPrincipalLifecycle }; diff --git a/test/helpers/reporting-ledger-option-off-transcript.js b/test/helpers/reporting-ledger-option-off-transcript.js new file mode 100644 index 000000000..663f19094 --- /dev/null +++ b/test/helpers/reporting-ledger-option-off-transcript.js @@ -0,0 +1,180 @@ +// Deterministic Reliable Reporting transcript for a service without reporting subscriptions: +// install, one cycle (plan, execute, commit), get_reporting_status summary and periods, +// sync_reporting_status, get_media_buy_delivery, and the ledger records behind them. The golden +// output was captured from the service before subscription obligations were written into the +// ledger (test/fixtures/reporting-subscriptions/ledger-option-off-golden.json). Wall-clock values +// the ledger stamps with the real time (install and creation instants) are replaced by a marker. +const { createReliableReportingService } = require('../../dist/lib/reporting/service/index.js'); +const { + redactedReportingSourceOfferingV1, + redactedReportingSourceRequestV1, +} = require('../../dist/lib/reporting/source/index.js'); +const { MemoryLedgerStore } = require('./memory-reporting-ledger-store.js'); + +const DAY = 86_400_000; +const ANCHOR = '2026-09-01T00:00:00.000Z'; +const WALL_CLOCK_KEYS = new Set(['installedAt', 'createdAt', 'created_at', 'recorded_at']); + +function deliveryOffering() { + const source = redactedReportingSourceOfferingV1; + return { + offering_id: source.offeringId, + feed_purpose: 'analytics', + report_definition_id: source.contract.report_definition_id, + report_definition_uri: source.contract.reportDefinitionUri, + report_definition_sha256: source.contract.reportDefinitionSha256, + reporting_profile: { + id: source.contract.reportingProfile, + version: source.contract.schemaVersion, + schema_uri: source.contract.schemaUri, + schema_sha256: source.contract.schemaSha256, + schema_dialect: source.contract.schemaDialect, + schema_ref_policy: source.contract.schemaRefPolicy, + grain: source.grain, + primary_keys: ['media_buy_id'], + }, + // UTC alignment keeps the transcript independent of the runtime's timezone database + // and of the install day, which a source-calendar generation freezes into its identity. + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT0S' }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + }; +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, WALL_CLOCK_KEYS.has(key) ? '' : normalize(item)]) + ); + } + return value; +} + +async function reportingLedgerOptionOffTranscript() { + const store = new MemoryLedgerStore(); + const request = redactedReportingSourceRequestV1(); + const sourceOffering = structuredClone(redactedReportingSourceOfferingV1); + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const [template] = request.coverage.constituents; + const service = createReliableReportingService({ + store, + adapters: { + fixture: { + sourceOffering, + deliveryOffering: deliveryOffering(), + fetchSlice: slice => ({ + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: 'USD', + reporting_rows: slice.media_buy_ids.map(mediaBuyId => ({ + media_buy_id: mediaBuyId, + impressions: 7, + spend: '2.50', + })), + }), + }, + }, + contact: { name: 'Reporting operations', email: 'reporting@example.com' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: sourceOffering.retentionDays, + resolveSource: account => ({ + adapterId: 'fixture', + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + resolveCoverage: account => ({ + constituents: [ + { + ...structuredClone(template), + constituentId: `constituent-${account.id}`, + mediaBuyId: `media-buy-${account.id}`, + productBinding: { ...structuredClone(template.productBinding), mediaBuyId: `media-buy-${account.id}` }, + }, + ], + }), + resolveConsumerId: context => context.authInfo?.clientId ?? 'buyer-a', + }); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const installed = await service.installConfiguration( + { + delivery_config_id: 'delivery-config', + delivery_config_version: 1, + offeringId: sourceOffering.offeringId, + report_definition_id: sourceOffering.contract.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'snapshot', + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + schedule: { + anchor: ANCHOR, + periodMilliseconds: DAY, + deliverySlaMilliseconds: 0, + recoveryWindowMilliseconds: DAY, + }, + sourceSettings, + }, + { account: { id: 'account-a', ctx_metadata: {} } } + ); + // A controlled install instant, so the planned periods do not depend on today. + store.configurations.get(installed.configurationId).installedAt = ANCHOR; + const now = new Date(Date.parse(ANCHOR) + 3 * DAY + 60_000); + const cycle = await service.runCycle({ accountId: 'account-a', now }); + store.ledgerAsOf = now.toISOString(); + const context = { account: { id: 'account-a', ctx_metadata: {} }, authInfo: { clientId: 'buyer-a' } }; + const period = { start: ANCHOR, end: new Date(Date.parse(ANCHOR) + 3 * DAY).toISOString() }; + const summary = await service.platform.getReportingStatus( + { account: { account_id: 'account-a' }, view: 'summary', period }, + context + ); + const periods = await service.platform.getReportingStatus( + { account: { account_id: 'account-a' }, view: 'periods', period, pagination: { max_results: 100 } }, + context + ); + const firstRevision = periods.revisions?.[0]; + const delivery = firstRevision + ? await service.platform.getMediaBuyDelivery( + { account: { account_id: 'account-a' }, reporting_revision_id: firstRevision.reporting_revision_id }, + context + ) + : undefined; + const firstPeriod = periods.periods[0]; + const consumerStatus = await service.platform.syncReportingStatus( + { + account: { account_id: 'account-a' }, + idempotency_key: 'option-off-transcript-0001', + statuses: [ + { + reporting_status_id: 'option-off-status-0001', + delivery_config_id: firstPeriod.delivery_config_id, + delivery_config_version: firstPeriod.delivery_config_version, + report_definition_id: firstPeriod.report_definition_id, + reporting_obligation_id: firstPeriod.reporting_obligation_id, + reporting_revision_id: firstRevision.reporting_revision_id, + observed_revision_content_sha256: firstRevision.revision_content_sha256, + period: firstPeriod.period, + consumer_status: 'received', + status_as_of: now.toISOString(), + }, + ], + }, + context + ); + return normalize({ + capabilities: service.capabilities, + installed, + cycle, + configurations: await store.listConfigurations(), + obligations: await store.listObligations(), + summary, + periods, + delivery, + consumerStatus, + }); +} + +module.exports = { reportingLedgerOptionOffTranscript }; diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js new file mode 100644 index 000000000..badd221b5 --- /dev/null +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -0,0 +1,785 @@ +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +process.env.NODE_ENV = 'test'; + +const { + InMemoryStateStore, + createPersistentNotificationRuntime, + createPrincipalLifecycle, + createPrincipalStateStore, + expandReportingSubscriptionPathTemplate, + principalNotificationSubscriptionStore, +} = require('../../dist/lib/server/index.js'); +const { validateRequest, validateResponse } = require('../../dist/lib/validation/schema-validator.js'); +const { principalOptionOffTranscript } = require('../helpers/principal-option-off-transcript.js'); + +const PINNED = require('node:fs') + .readFileSync(require('node:path').join(__dirname, '..', '..', 'ADCP_VERSION'), 'utf8') + .trim(); +const SCOPE = { tenantId: 't', principalId: 'p' }; +// Served at the release the SDK pins, so every response is valid against its schemas. +const CTX = { + callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' }, + servedAdcpVersion: PINNED, +}; +const CTX_UNPINNED = { callerMutationScope: CTX.callerMutationScope }; + +function assertSchemaValid(toolName, response) { + const outcome = validateResponse(toolName, response, PINNED); + assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); +} + +function setup({ support = {}, clock } = {}) { + const store = createPrincipalStateStore({ store: new InMemoryStateStore() }); + const emitted = []; + const notifications = createPersistentNotificationRuntime({ + store: principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: true }) }, + validateDestination: async () => ({ allowed: true }), + authorizeDelivery: async () => ({ authorized: true }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + emitted.push(params); + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 1, + delivered: true, + terminal: true, + errors: [], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); + let version = 0; + let ref = 0; + const time = clock ?? { now: new Date('2026-11-01T12:00:00.000Z') }; + const replacements = []; + const lifecycle = createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + issuePrincipalId: scope => `record-${scope.tenantId}-${scope.principalId}`, + issueConfigurationVersion: () => `cfg-${++version}`, + issueDestinationRef: () => `dest-ref-${++ref}`, + now: () => time.now, + prepareReportingDestination: ({ destination }) => ({ + state: 'validating', + configuration: structuredClone(destination), + }), + reportingSubscriptions: { + listDeliveryConfigIds: () => ['acme-pacing'], + replaceAccountConfigs: input => { + replacements.push(input); + }, + ...support, + }, + }); + return { lifecycle, store, notifications, emitted, time, replacements }; +} + +function destination(id = 'archive', extra = {}) { + return { + pattern: 'file_transfer', + destination_id: id, + active: true, + provider: { domain: 'storage.example' }, + transport: 's3', + location: `s3://bucket/${id}`, + accepted_formats: ['jsonl'], + accepted_verification_profiles: ['manifest_checksums'], + ...extra, + }; +} + +function subscription(extra = {}) { + return { + 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', 'creative'], + }, + reconciliation_mode: 'delivery_only', + destination: { destination_id: 'archive', path_template: 'analytics/{account_id}/{period_start}' }, + ...extra, + }; +} + +let keyCounter = 0; +function sync(lifecycle, configuration, extra = {}) { + keyCounter += 1; + return lifecycle.protocol.syncPrincipal( + { idempotency_key: `idem-key-${String(keyCounter).padStart(6, '0')}`, configuration, ...extra }, + CTX + ); +} + +function errorOf(response) { + assert.equal(response.result.kind, 'failed', JSON.stringify(response)); + return response.result.errors[0]; +} + +describe('sync_principal reporting_subscriptions (option off)', () => { + test('is byte-for-byte unchanged from the runtime before subscriptions existed', async () => { + const golden = require( + path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'principal-option-off-golden.json') + ); + assert.deepEqual(JSON.parse(JSON.stringify(await principalOptionOffTranscript())), golden); + }); +}); + +describe('sync_principal reporting_subscriptions (option on)', () => { + test('accepts, versions and reads back a subscription', async () => { + const { lifecycle, time } = setup(); + const created = await sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription()], + }); + assert.equal(created.result.kind, 'applied', JSON.stringify(created)); + assertSchemaValid('sync_principal', created); + const [state] = created.result.configuration.reporting_subscriptions; + assert.equal(state.subscription_id, 'analytics-daily'); + assert.equal(state.subscription_version, 1); + assert.equal(state.state, 'active'); + assert.equal(state.accepted_at, '2026-11-01T12:00:00.000Z'); + assert.equal(state.destination_ref, created.result.configuration.reporting_destinations[0].destination_ref); + assert.equal(state.prior_versions, undefined); + + // A resend changes nothing, including accepted_at. + time.now = new Date('2026-11-02T00:00:00.000Z'); + const resent = await sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription()], + }); + assert.equal(resent.result.action, 'unchanged'); + assert.equal(resent.result.configuration.reporting_subscriptions[0].accepted_at, '2026-11-01T12:00:00.000Z'); + + // A semantic change without a new version is refused. + const reused = await sync(lifecycle, { + reporting_subscriptions: [subscription({ exclude_account_ids: ['acct_x'] })], + }); + assert.equal(errorOf(reused).code, 'INVALID_REQUEST'); + assert.match(errorOf(reused).message, /reused with different content/); + + // A new version records the cutover and keeps the prior version readable. + time.now = new Date('2026-11-04T12:00:00.000Z'); + const v2 = await sync(lifecycle, { + reporting_subscriptions: [subscription({ subscription_version: 2, exclude_account_ids: ['acct_x'] })], + }); + assertSchemaValid('sync_principal', v2); + const next = v2.result.configuration.reporting_subscriptions[0]; + assert.equal(next.subscription_version, 2); + assert.equal(next.accepted_at, '2026-11-04T12:00:00.000Z'); + assert.deepEqual( + next.prior_versions.map(({ configuration: _configuration, ...prior }) => prior), + [{ subscription_version: 1, accepted_at: '2026-11-01T12:00:00.000Z', superseded_at: '2026-11-04T12:00:00.000Z' }] + ); + assert.equal(next.prior_versions[0].configuration.subscription_version, 1, 'adcp#8588, served from 3.3.0-beta.2'); + + // Versions cannot regress. + const regress = await sync(lifecycle, { reporting_subscriptions: [subscription()] }); + assert.match(errorOf(regress).message, /cannot regress/); + + // History keeps the configuration the readback no longer carries. + const { history } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.equal(history[0].versions.length, 2); + assert.deepEqual(history[0].versions[0].configuration.exclude_account_ids, undefined); + assert.deepEqual(history[0].versions[0].destinationMethod, { pattern: 'file_transfer', transport: 's3' }); + assert.deepEqual(await lifecycle.reportingSubscriptionIds(SCOPE), ['analytics-daily']); + }); + + test('deactivation, omission and reactivation are recorded as cutovers', async () => { + const { lifecycle, time } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + time.now = new Date('2026-11-04T12:00:00.000Z'); + const off = await sync(lifecycle, { reporting_subscriptions: [subscription({ active: false })] }); + assertSchemaValid('sync_principal', off); + const state = off.result.configuration.reporting_subscriptions[0]; + assert.equal(state.state, 'inactive'); + assert.equal(state.deactivated_at, '2026-11-04T12:00:00.000Z'); + assert.equal(state.configuration.active, false); + + time.now = new Date('2026-11-06T12:00:00.000Z'); + const on = await sync(lifecycle, { reporting_subscriptions: [subscription()] }); + assert.equal(on.result.configuration.reporting_subscriptions[0].state, 'active'); + assert.equal(on.result.configuration.reporting_subscriptions[0].deactivated_at, undefined); + const { history } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.deepEqual(history[0].inactiveIntervals, [ + { from: '2026-11-04T12:00:00.000Z', until: '2026-11-06T12:00:00.000Z' }, + ]); + // The readback lists inactive_intervals (adcontextprotocol/adcp#8585) to callers served a + // release whose bundle defines it (3.3.0-beta.2 on), and never to a response without a + // served release; the internal state carries it. + const { states } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.deepEqual(states[0].inactive_intervals, [ + { deactivated_at: '2026-11-04T12:00:00.000Z', reactivated_at: '2026-11-06T12:00:00.000Z' }, + ]); + assert.deepEqual( + on.result.configuration.reporting_subscriptions[0].inactive_intervals, + states[0].inactive_intervals + ); + assertSchemaValid('sync_principal', on); + const unpinned = await lifecycle.protocol.getPrincipal({}, CTX_UNPINNED); + assert.equal(unpinned.result.configuration.reporting_subscriptions[0].inactive_intervals, undefined); + + time.now = new Date('2026-11-08T00:00:00.000Z'); + const omitted = await sync(lifecycle, { reporting_subscriptions: [] }); + const retained = omitted.result.configuration.reporting_subscriptions[0]; + assert.equal(retained.state, 'inactive', 'omission deactivates but keeps the subscription readable'); + assert.equal(retained.deactivated_at, '2026-11-08T00:00:00.000Z'); + assertSchemaValid('sync_principal', omitted); + const closed = (await lifecycle.listReportingSubscriptions(SCOPE)).states; + assert.deepEqual(closed[0].inactive_intervals, [ + { deactivated_at: '2026-11-04T12:00:00.000Z', reactivated_at: '2026-11-06T12:00:00.000Z' }, + { deactivated_at: '2026-11-08T00:00:00.000Z' }, + ]); + }); + + test('omission refuses to open a 65th inactive interval, as active: false does', async () => { + const { lifecycle, time } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + let at = Date.parse('2026-11-02T00:00:00.000Z'); + for (let index = 0; index < 64; index += 1) { + time.now = new Date((at += 3_600_000)); + assert.equal( + (await sync(lifecycle, { reporting_subscriptions: [subscription({ active: false })] })).result.kind, + 'applied' + ); + time.now = new Date((at += 3_600_000)); + assert.equal((await sync(lifecycle, { reporting_subscriptions: [subscription()] })).result.kind, 'applied'); + } + assert.equal((await lifecycle.listReportingSubscriptions(SCOPE)).history[0].inactiveIntervals.length, 64); + time.now = new Date((at += 3_600_000)); + const explicit = errorOf(await sync(lifecycle, { reporting_subscriptions: [subscription({ active: false })] })); + const omitted = errorOf(await sync(lifecycle, { reporting_subscriptions: [] })); + assert.match(omitted.message, /64 inactive intervals/); + assert.deepEqual( + { code: omitted.code, message: omitted.message }, + { code: explicit.code, message: explicit.message }, + 'the same error on both paths' + ); + const [state] = (await lifecycle.listReportingSubscriptions(SCOPE)).states; + assert.equal(state.state, 'active', 'nothing was written'); + }); + + test('validates the cross-field rules of reporting-subscription.json', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination(), destination('shared')] }); + const cases = [ + [subscription({ scope: { all_authorized_accounts: true, account_ids: ['a'] } }), /exactly one/], + [subscription({ scope: { account_ids: ['a'] }, exclude_account_ids: ['b'] }), /exclude_account_ids/], + [ + subscription({ + scope: { account_ids: ['a', 'b'] }, + selector: { ...subscription().selector, period_timezone: 'America/New_York' }, + }), + /period_timezone/, + ], + [ + subscription({ + selector: { ...subscription().selector, feed_purpose: 'billing', required_finality: 'snapshot' }, + }), + /official/, + ], + [subscription({ selector: { ...subscription().selector, feed_purpose: 'billing' } }), /consumer_receipt/], + [subscription({ reconciliation_mode: 'consumer_receipt', destination: undefined }), /requires a destination/], + [ + subscription({ destination: { destination_id: 'missing', path_template: 'x/{account_id}' } }), + /not in reporting_destinations/, + ], + [subscription({ destination: { destination_id: 'archive', path_template: 'analytics/all' } }), /\{account_id\}/], + [subscription({ destination: { destination_id: 'archive' } }), /required for a file_transfer/], + [ + subscription({ destination: { destination_id: 'archive', path_template: 'a/../{account_id}' } }), + /relative path/, + ], + [ + subscription({ destination: { destination_id: 'archive', path_template: 'a/{buyer}/{account_id}' } }), + /unknown placeholder/, + ], + [ + subscription({ destination: { destination_id: 'archive', path_template: 'a/{{account_id}}' } }), + /unbalanced brace/, + ], + [ + subscription({ destination: { destination_id: 'archive', path_template: 'a/{account_id' } }), + /unbalanced brace/, + ], + [ + subscription({ destination: { destination_id: 'archive', path_template: 'a/account_id}/{account_id}' } }), + /unbalanced brace/, + ], + [subscription({ subscription_id: 'acme-pacing' }), /delivery_config_id/], + [subscription({ subscription_id: '..' }), /subscription_id/], + ]; + for (const [entry, pattern] of cases) { + const response = await sync(lifecycle, { reporting_subscriptions: [JSON.parse(JSON.stringify(entry))] }); + const error = errorOf(response); + assert.equal(error.code, 'INVALID_REQUEST', pattern.toString()); + assert.match(error.message, pattern); + } + // A single-account subscription may omit {account_id} and set period_timezone. + const single = await sync(lifecycle, { + reporting_subscriptions: [ + subscription({ + scope: { account_ids: ['acct_1'] }, + selector: { ...subscription().selector, period_timezone: 'America/New_York' }, + destination: { destination_id: 'archive', path_template: 'acct_1/{period_start}' }, + }), + ], + }); + assert.equal(single.result.kind, 'applied', JSON.stringify(single)); + }); + + test('refuses duplicates, same-level overlap and ambiguous shared destinations', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()] }); + const duplicate = await sync(lifecycle, { reporting_subscriptions: [subscription(), subscription()] }); + assert.match(errorOf(duplicate).message, /Duplicate subscription_id/); + + const overlap = await sync(lifecycle, { + reporting_subscriptions: [ + subscription(), + subscription({ + subscription_id: 'analytics-2', + destination: { destination_id: 'archive', path_template: 'b/{account_id}' }, + }), + ], + }); + assert.match(errorOf(overlap).message, /both apply/); + + // Disjoint operator domains, or account_ids versus all-accounts, are allowed. + const disjoint = await sync(lifecycle, { + reporting_subscriptions: [ + subscription({ operator_domains: ['one.example'] }), + subscription({ + subscription_id: 'analytics-2', + operator_domains: ['two.example'], + destination: { destination_id: 'archive', path_template: 'b/{account_id}' }, + }), + subscription({ + subscription_id: 'analytics-acct', + scope: { account_ids: ['acct_1'] }, + destination: { destination_id: 'archive', path_template: 'c/{account_id}' }, + }), + ], + }); + assert.equal(disjoint.result.kind, 'applied', JSON.stringify(disjoint)); + + const sameTemplate = await sync(lifecycle, { + reporting_subscriptions: [ + subscription({ operator_domains: ['one.example'] }), + subscription({ subscription_id: 'analytics-2', operator_domains: ['two.example'] }), + ], + }); + assert.match(errorOf(sameTemplate).message, /can expand to the same path/); + }); + + test('a destination still named by an active subscription cannot be removed', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + const removed = await sync(lifecycle, { reporting_destinations: [] }); + assert.equal(errorOf(removed).code, 'INVALID_REQUEST'); + const together = await sync(lifecycle, { reporting_destinations: [], reporting_subscriptions: [] }); + assert.equal(together.result.kind, 'applied', JSON.stringify(together)); + }); + + test('honours dry_run and expected_configuration_version', async () => { + const { lifecycle } = setup(); + const dry = await sync( + lifecycle, + { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }, + { dry_run: true } + ); + assert.deepEqual(dry.result, { kind: 'validated', action: 'would_update', dry_run: true }); + assert.deepEqual(await lifecycle.listReportingSubscriptions(SCOPE), { states: [], history: [] }); + const invalidDry = await sync(lifecycle, { reporting_subscriptions: [subscription()] }, { dry_run: true }); + assert.equal(errorOf(invalidDry).code, 'INVALID_REQUEST'); + + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + const stale = await sync( + lifecycle, + { reporting_subscriptions: [subscription({ active: false })] }, + { expected_configuration_version: 'cfg-0' } + ); + assert.equal(errorOf(stale).code, 'CONFLICT'); + }); + + test('applies replace_account_configs once per version, after commit', async () => { + const { lifecycle, replacements, time } = setup(); + await sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription({ replace_account_configs: true })], + }); + assert.deepEqual(replacements, [ + { + scope: SCOPE, + subscriptionId: 'analytics-daily', + subscriptionVersion: 1, + feedPurpose: 'analytics', + at: '2026-11-01T12:00:00.000Z', + }, + ]); + await sync(lifecycle, { reporting_subscriptions: [subscription({ replace_account_configs: true })] }); + assert.equal(replacements.length, 1, 'a resend does not reapply'); + time.now = new Date('2026-11-03T00:00:00.000Z'); + await sync(lifecycle, { + reporting_subscriptions: [subscription({ subscription_version: 2, replace_account_configs: true })], + }); + assert.equal(replacements.length, 2, 'a new version applies again'); + await sync(lifecycle, { + reporting_subscriptions: [subscription({ subscription_version: 2, replace_account_configs: false })], + }); + await sync(lifecycle, { + reporting_subscriptions: [subscription({ subscription_version: 2, replace_account_configs: true })], + }); + assert.equal(replacements.length, 3, 'turning the flag back on applies again'); + + const { lifecycle: without } = setup({ support: { replaceAccountConfigs: undefined } }); + const refused = await sync(without, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription({ replace_account_configs: true })], + }); + assert.equal(errorOf(refused).code, 'UNSUPPORTED_FEATURE'); + }); + + test('resending replace_account_configs: true never reapplies the destructive hook', async () => { + const { lifecycle, replacements, time } = setup(); + const other = subscription({ + subscription_id: 'pacing-daily', + selector: { + feed_purpose: 'pacing', + period_duration: 'P1D', + required_finality: 'official', + dimensions: ['package'], + }, + destination: { destination_id: 'archive', path_template: 'pacing/{account_id}/{period_start}' }, + }); + const full = (extra = {}, more = []) => ({ + reporting_destinations: [destination()], + reporting_subscriptions: [subscription({ replace_account_configs: true, ...extra }), ...more], + }); + await sync(lifecycle, full()); + assert.equal(replacements.length, 1); + await sync(lifecycle, full(), { dry_run: true }); + await sync(lifecycle, full()); + for (const write of [full({}, [other]), full({ active: false }, [other]), full({}, [other])]) { + time.now = new Date(time.now.getTime() + 3_600_000); + { + const r = await sync(lifecycle, write); + assert.equal(r.result.kind, 'applied', JSON.stringify(r.result)); + } + } + assert.equal(replacements.length, 1, 'resends, dry runs, other subscriptions and an active toggle reuse version 1'); + // An omitted flag is not a change: buyers don't re-assert a one-shot action on every write. + assert.equal((await sync(lifecycle, { reporting_subscriptions: [subscription(), other] })).result.kind, 'applied'); + assert.equal((await sync(lifecycle, full({}, [other]))).result.kind, 'applied'); + assert.equal(replacements.length, 1, 'an omission then true does not reapply'); + // The spec's other trigger: the flag explicitly turning false, then true again. + const off = { reporting_subscriptions: [subscription({ replace_account_configs: false }), other] }; + assert.equal((await sync(lifecycle, off)).result.kind, 'applied'); + assert.equal((await sync(lifecycle, full({}, [other]))).result.kind, 'applied'); + assert.equal(replacements.length, 2, 'an explicit false then true applies again'); + }); + + test('an omitted flag after a failed replace_account_configs hook keeps the pending retry', async () => { + let failures = 1; + const calls = []; + const { lifecycle } = setup({ + support: { + replaceAccountConfigs: input => { + calls.push(input); + if (failures-- > 0) throw new Error('host temporarily unavailable'); + }, + }, + }); + await assert.rejects( + sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription({ replace_account_configs: true })], + }), + /temporarily unavailable/ + ); + // The next write omits the one-shot flag: the committed application is still owed. + await sync(lifecycle, { reporting_subscriptions: [subscription()] }); + assert.equal(calls.length, 2, 'the next write retries the pending replacement'); + const { history } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.equal(history[0].replacePending, undefined); + assert.equal(history[0].replaceAppliedVersion, 1); + }); + + test('a failed replace_account_configs hook is retried, and marked applied only after it succeeds', async () => { + let failures = 1; + const calls = []; + const { lifecycle } = setup({ + support: { + replaceAccountConfigs: input => { + calls.push(input); + if (failures-- > 0) throw new Error('host temporarily unavailable'); + }, + }, + }); + await assert.rejects( + sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription({ replace_account_configs: true })], + }), + /temporarily unavailable/ + ); + let { history } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.equal(history[0].replaceAppliedVersion, undefined); + assert.equal(history[0].replacePending.subscriptionVersion, 1); + // The identical resend is unchanged, but still retries the pending replacement. + const resent = await sync(lifecycle, { + reporting_subscriptions: [subscription({ replace_account_configs: true })], + }); + assert.equal(resent.result.action, 'unchanged'); + assert.equal(calls.length, 2); + ({ history } = await lifecycle.listReportingSubscriptions(SCOPE)); + assert.equal(history[0].replaceAppliedVersion, 1); + assert.equal(history[0].replacePending, undefined); + await sync(lifecycle, { reporting_subscriptions: [subscription({ replace_account_configs: true })] }); + assert.equal(calls.length, 2, 'applied once'); + }); + + test('engine readback never advances configuration_version, so a guarded write does not conflict', async () => { + const { lifecycle } = setup(); + const created = await sync(lifecycle, { + reporting_destinations: [destination()], + reporting_subscriptions: [subscription()], + }); + const version = created.result.configuration_version; + assert.equal( + await lifecycle.recordReportingSubscriptionCoverage(SCOPE, [ + { + subscriptionId: 'analytics-daily', + accountsWithExceptions: [{ account_id: 'acct_2', reasons: ['capacity'] }], + accountsUnauthorized: [], + }, + ]), + true + ); + const guarded = await sync( + lifecycle, + { reporting_subscriptions: [subscription({ active: false })] }, + { expected_configuration_version: version } + ); + assert.equal(guarded.result.kind, 'applied', JSON.stringify(guarded)); + await assert.rejects( + lifecycle.recordReportingSubscriptionCoverage(SCOPE, [ + { + subscriptionId: 'analytics-daily', + accountsWithExceptions: Array.from({ length: 1001 }, (_, index) => ({ + account_id: `acct_${index}`, + reasons: ['capacity'], + })), + accountsUnauthorized: [], + }, + ]), + /more than 1000/, + 'the readback is never silently cut' + ); + }); + + test('a destination changing pattern or transport under an active subscription needs a new version', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + const moved = destination('archive', { transport: 'gcs', location: 'gs://bucket/archive' }); + const refused = await sync(lifecycle, { reporting_destinations: [moved] }); + assert.equal(errorOf(refused).code, 'INVALID_REQUEST'); + assert.match(errorOf(refused).message, /increment its subscription_version/); + const sameVersion = await sync(lifecycle, { + reporting_destinations: [moved], + reporting_subscriptions: [subscription()], + }); + assert.match(errorOf(sameVersion).message, /increment its subscription_version/); + const bumped = await sync(lifecycle, { + reporting_destinations: [moved], + reporting_subscriptions: [subscription({ subscription_version: 2 })], + }); + assert.equal(bumped.result.kind, 'applied', JSON.stringify(bumped)); + const { history } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.deepEqual(history[0].versions.at(-1).destinationMethod, { pattern: 'file_transfer', transport: 'gcs' }); + }); + + test('templates that can expand to the same path are refused', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()] }); + const colliding = await sync(lifecycle, { + reporting_subscriptions: [ + subscription({ + subscription_id: 'x', + operator_domains: ['one.example'], + destination: { destination_id: 'archive', path_template: '{account_id}/{subscription_id}' }, + }), + subscription({ + subscription_id: 'y', + operator_domains: ['two.example'], + destination: { destination_id: 'archive', path_template: '{account_id}/x' }, + }), + ], + }); + assert.match(errorOf(colliding).message, /can expand to the same path/); + }); + + test('accepts dimension values outside the published vocabulary', async () => { + const { lifecycle } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()] }); + const request = { + idempotency_key: 'unknown-dimension-0001', + configuration: { + reporting_subscriptions: [ + subscription({ selector: { ...subscription().selector, dimensions: ['package', 'placement'] } }), + ], + }, + }; + // reporting-dimension.json is extensible: receivers MUST accept unknown values. + const outcome = validateRequest('sync_principal', request, PINNED); + assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); + const applied = await lifecycle.protocol.syncPrincipal(request, CTX); + assert.equal(applied.result.kind, 'applied', JSON.stringify(applied)); + }); + + test('prior versions keep their configuration in the readback', async () => { + const { lifecycle, time } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + time.now = new Date('2026-11-04T12:00:00.000Z'); + await sync(lifecycle, { + reporting_subscriptions: [subscription({ subscription_version: 2, exclude_account_ids: ['acct_x'] })], + }); + const { states } = await lifecycle.listReportingSubscriptions(SCOPE); + assert.equal(states[0].prior_versions[0].configuration.subscription_version, 1); + assert.equal(states[0].prior_versions[0].configuration.exclude_account_ids, undefined); + // Served at a release that defines it (adcp#8588, 3.3.0-beta.2 on), the field is emitted; + // without a served release it is not. + const served = await lifecycle.protocol.getPrincipal({}, CTX); + assert.equal( + served.result.configuration.reporting_subscriptions[0].prior_versions[0].configuration.subscription_version, + 1 + ); + const unpinned = await lifecycle.protocol.getPrincipal({}, CTX_UNPINNED); + assert.equal(unpinned.result.configuration.reporting_subscriptions[0].prior_versions[0].configuration, undefined); + assertSchemaValid('get_principal', served); + }); + + test('with the option off, a row written with subscriptions on serves no reporting_subscriptions', async () => { + const { lifecycle, store, notifications } = setup(); + await sync(lifecycle, { reporting_destinations: [destination()], reporting_subscriptions: [subscription()] }); + const off = createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + prepareReportingDestination: ({ destination: value }) => ({ + state: 'validating', + configuration: structuredClone(value), + }), + }); + const read = await off.protocol.getPrincipal({}, CTX); + assert.equal(read.result.configuration.reporting_subscriptions, undefined); + assertSchemaValid('get_principal', read); + }); + + test('the delivery_config_id clash check is required while account configurations are served', () => { + assert.throws( + () => setup({ support: { listDeliveryConfigIds: undefined } }), + /requires listDeliveryConfigIds unless accountConfigurations is false/ + ); + assert.doesNotThrow(() => setup({ support: { listDeliveryConfigIds: undefined, accountConfigurations: false } })); + }); + + test('a destination needing action moves the subscription to action_required and notifies', async () => { + const { lifecycle, emitted } = setup(); + const created = await sync(lifecycle, { + notification_configs: [ + { subscriber_id: 'events', url: 'https://buyer.example/events', event_types: ['principal.changed'] }, + ], + reporting_destinations: [destination()], + reporting_subscriptions: [subscription()], + }); + const ref = created.result.configuration.reporting_destinations[0].destination_ref; + const configuration = await lifecycle.transitionDestination(SCOPE, { + destinationId: 'archive', + destinationRef: ref, + state: 'action_required', + setup: { action: 'grant_access' }, + }); + const state = configuration.reporting_subscriptions[0]; + assert.equal(state.state, 'action_required'); + assert.equal(state.issues[0].code, 'DESTINATION_NOT_READY'); + const reasons = emitted.map(event => event.payload.reason); + assert.ok(reasons.includes('reporting_subscription_changed'), JSON.stringify(reasons)); + const read = await lifecycle.protocol.getPrincipal({}, CTX); + assertSchemaValid('get_principal', read); + }); + + test('records engine-maintained readback and notifies only on change', async () => { + const { lifecycle, emitted } = setup(); + await sync(lifecycle, { + notification_configs: [ + { subscriber_id: 'events', url: 'https://buyer.example/events', event_types: ['principal.changed'] }, + ], + reporting_destinations: [destination()], + reporting_subscriptions: [subscription()], + }); + const update = [ + { + subscriptionId: 'analytics-daily', + accountsWithExceptions: [{ account_id: 'acct_2', reasons: ['capacity'] }], + accountsUnauthorized: [ + { + account_id: 'acct_9', + unauthorized_at: '2026-11-05T06:00:00.000Z', + last_owed_period_end: '2026-11-05T00:00:00.000Z', + withheld_period_count: 1, + }, + ], + }, + ]; + assert.equal(await lifecycle.recordReportingSubscriptionCoverage(SCOPE, update), true); + assert.equal(await lifecycle.recordReportingSubscriptionCoverage(SCOPE, update), false); + assert.equal(emitted.filter(event => event.payload.reason === 'reporting_subscription_changed').length, 1); + const read = await lifecycle.protocol.getPrincipal({}, CTX); + assertSchemaValid('get_principal', read); + const state = read.result.configuration.reporting_subscriptions[0]; + assert.deepEqual(state.accounts_with_exceptions, update[0].accountsWithExceptions); + assert.deepEqual(state.accounts_unauthorized, update[0].accountsUnauthorized); + // A caller write keeps engine-maintained readback. + const resent = await sync(lifecycle, { + reporting_subscriptions: [subscription({ replace_account_configs: false })], + }); + assert.deepEqual( + resent.result.configuration.reporting_subscriptions[0].accounts_unauthorized, + update[0].accountsUnauthorized + ); + }); +}); + +describe('expandReportingSubscriptionPathTemplate', () => { + test('fills one percent-encoded segment per value', () => { + assert.equal( + expandReportingSubscriptionPathTemplate('a/{account_id}/{subscription_id}/{offering_id}/{period_start}', { + account_id: 'acct/../x', + subscription_id: '..', + offering_id: 'off_1', + period_start: '2026-11-03T00:00:00Z', + }), + 'a/acct%2F..%2Fx/%2E%2E/off_1/2026-11-03T00:00:00Z' + ); + }); +}); diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 7221e18be..62fff62fd 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -5,9 +5,15 @@ process.env.NODE_ENV = 'test'; const { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, + ReportingConfigurationImmutableError, createReliableReportingService, + isReportingConfigurationImmutableError, runReliableReportingServiceConformanceV1, } = require('../../dist/lib/reporting/service/index.js'); +const { + createReportingSubscriptionEngineV1, + reportingConfigurationCapabilitiesV1, +} = require('../../dist/lib/reporting/advanced/index.js'); const { createInlineReportingSourceExecutor, redactedReportingSourceOfferingV1, @@ -16,6 +22,7 @@ const { } = require('../../dist/lib/reporting/source/index.js'); const { createAdcpServerFromPlatform } = require('../../dist/lib/server/decisioning/runtime/from-platform.js'); const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); const { canonicalize } = require('../../dist/lib/utils/jcs.js'); const { createHash } = require('node:crypto'); @@ -270,7 +277,16 @@ describe('ReliableReportingService', () => { assert.equal(store.revisions.size, 1, 'dynamic products must produce a validated revision'); products.push('per_buy_awareness_buy2__product_c'); - await assert.rejects(service.installConfiguration(configuration(), context), /immutable/i); + await assert.rejects(service.installConfiguration(configuration(), context), error => { + // Typed so successor-installing hosts need not match on message text. + assert.ok(error instanceof ReportingConfigurationImmutableError); + assert.ok(isReportingConfigurationImmutableError(error)); + assert.equal(error.code, 'REPORTING_CONFIGURATION_IMMUTABLE'); + assert.equal(error.accountId, 'account-a'); + assert.equal(error.deliveryConfigVersion, configuration().delivery_config_version); + assert.match(error.message, /Reporting configuration generation is immutable/); + return true; + }); const next = await service.installConfiguration(configuration({ delivery_config_version: 2 }), context); assert.deepEqual( next.constituents.map(value => value.productId), @@ -562,6 +578,217 @@ describe('ReliableReportingService', () => { assert.equal(wireExact.structuredContent.reporting_revision.reporting_revision_id, revision.reporting_revision_id); }); + test('builds 3.3 registration capabilities without changing the default shape', async () => { + const { service } = serviceFixture(); + assert.deepEqual(reportingConfigurationCapabilitiesV1(), { configuration_task: 'sync_accounts' }); + assert.equal(service.capabilities.configuration_task, 'sync_accounts'); + assert.equal(service.capabilities.configuration_tasks, undefined, 'the default shape is unchanged'); + const registration = reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 500 } }); + assert.deepEqual(registration, { + configuration_task: 'sync_accounts', + configuration_tasks: ['sync_principal', 'sync_accounts'], + subscriptions: { supported: true, max_accounts: 500 }, + }); + assert.deepEqual( + reportingConfigurationCapabilitiesV1({ subscriptions: {}, accountConfigurations: false }), + { configuration_tasks: ['sync_principal'], subscriptions: { supported: true } }, + 'configuration_task appears only when configuration_tasks lists sync_accounts' + ); + assert.throws(() => reportingConfigurationCapabilitiesV1({ accountConfigurations: false }), { + name: 'PlatformConfigError', + code: 'PLATFORM_CONFIG_INVALID', + configField: 'accountConfigurations', + }); + assert.throws(() => reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 0 } }), { + name: 'PlatformConfigError', + configField: 'subscriptions.maxAccounts', + }); + }); + + test('advertises reporting subscriptions end to end, valid under strict 3.3 validation', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ + reportingSubscriptions: {}, + includeCompatibilityNotificationTask: true, + }); + const capabilityChanges = { + capabilities_version: 'caps-1', + cache_ttl_seconds: 3600, + notifications: { supported: true, registration_task: 'sync_principal', event_types: ['capabilities.changed'] }, + }; + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [], + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + maxAccounts: 100, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: 'account-a', authorized_at: '2026-10-01T00:00:00.000Z' }], + }), + listAccountBuys: () => [], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + const request = redactedReportingSourceRequestV1(); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const subscriptionOptions = { + engine, + resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }), + listSubscriptionPrincipals: () => [{ tenantId: 't', principalId: 'p' }], + resolveSubscriptionConfiguration: () => ({ + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceSettings, + }), + }; + assert.throws( + () => serviceFixture({ reportingSubscriptions: subscriptionOptions }), + /standard_dimensions/, + 'sellers advertising subscriptions publish standard_dimensions on every offering' + ); + const reportingAdapter = adapter(); + reportingAdapter.deliveryOffering.standard_dimensions = ['package']; + const { service } = serviceFixture({ + adapters: { fixture: reportingAdapter }, + reportingSubscriptions: subscriptionOptions, + }); + assert.deepEqual(service.capabilities.offerings[0].standard_dimensions, ['package']); + assert.deepEqual(service.capabilities.configuration_tasks, ['sync_principal', 'sync_accounts']); + assert.equal(service.capabilities.configuration_task, 'sync_accounts'); + assert.deepEqual(service.capabilities.subscriptions, { + supported: true, + max_accounts: 100, + backfill_on_regrant: false, + }); + assert.deepEqual(lifecycle.capabilities.supported_sections, ['notification_configs', 'reporting_subscriptions']); + const accounts = { + resolution: 'explicit', + resolve: async ref => ({ id: ref?.account_id ?? 'account-a', ctx_metadata: {} }), + upsert: async () => [], + }; + assert.throws( + () => + createAdcpServerFromPlatform(service.install({ capabilities: { specialisms: [], config: {} }, accounts }), { + name: 'reporting-subscriptions-without-principal', + version: '1.0.0', + }), + /capabilities\.principal/, + 'subscriptions without the principal section fail at construction, not on the wire' + ); + const { service: plain } = serviceFixture(); + assert.throws( + () => + createAdcpServerFromPlatform( + plain.install({ capabilities: { specialisms: [], config: {}, principal: lifecycle.capabilities }, accounts }), + { name: 'reporting-subscriptions-section-without-task', version: '1.0.0' } + ), + /does not list sync_principal/, + 'the principal section without the registration task is refused too' + ); + const platform = service.install({ + capabilities: { + specialisms: [], + config: {}, + principal: lifecycle.capabilities, + capability_changes: capabilityChanges, + }, + accounts, + }); + const server = createAdcpServerFromPlatform(platform, { + name: 'reporting-subscriptions-capabilities-test', + version: '1.0.0', + protocol: lifecycle.protocol, + validation: { requests: 'strict', responses: 'strict' }, + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { name: 'get_adcp_capabilities', arguments: {} }, + }); + assert.notEqual(result.isError, true, JSON.stringify(result.structuredContent)); + assert.deepEqual(result.structuredContent.experimental_features, [ + 'media_buy.reporting_delivery', + 'media_buy.reporting_subscriptions', + 'protocol.principal', + ]); + assert.deepEqual(result.structuredContent.adcp.principal.supported_sections, [ + 'notification_configs', + 'reporting_subscriptions', + ]); + // standard_dimensions is defined from 3.3.0-beta.2 (adcp#8585), so the offerings are served whole. + assert.deepEqual(result.structuredContent.media_buy.reporting_delivery, structuredClone(service.capabilities)); + + // Callers pinned to 3.2 see neither the 3.3 registration fields nor the 3.3 section. + const legacy = createAdcpServerFromPlatform(platform, { + name: 'reporting-subscriptions-capabilities-3-2', + version: '1.0.0', + adcpVersion: '3.2.1', + protocol: lifecycle.protocol, + validation: { requests: 'strict', responses: 'strict' }, + }); + const legacyResult = await legacy.dispatchTestRequest({ + method: 'tools/call', + params: { name: 'get_adcp_capabilities', arguments: {} }, + }); + assert.notEqual(legacyResult.isError, true, JSON.stringify(legacyResult.structuredContent)); + assert.equal(legacyResult.structuredContent.media_buy.reporting_delivery.configuration_tasks, undefined); + assert.equal(legacyResult.structuredContent.media_buy.reporting_delivery.subscriptions, undefined); + assert.equal( + legacyResult.structuredContent.experimental_features.includes('media_buy.reporting_subscriptions'), + false + ); + assert.deepEqual(legacyResult.structuredContent.adcp.principal.supported_sections, ['notification_configs']); + + // Below 3.2 neither the principal block nor its feature id is advertised. + const pinned31 = await server.dispatchTestRequest({ + method: 'tools/call', + params: { name: 'get_adcp_capabilities', arguments: { adcp_version: '3.1' } }, + }); + assert.notEqual(pinned31.isError, true, JSON.stringify(pinned31.structuredContent)); + assert.equal(pinned31.structuredContent.adcp.principal, undefined); + assert.equal(pinned31.structuredContent.experimental_features?.includes('protocol.principal') ?? false, false); + + // subscription_coverage reaches 3.3 callers only; 3.2 response shapes are closed. + const subscribed = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: 'subscription-coverage-0001', + configuration: { + reporting_subscriptions: [ + { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'official' }, + reconciliation_mode: 'delivery_only', + }, + ], + }, + }, + { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } } + ); + assert.equal(subscribed.result.kind, 'applied', JSON.stringify(subscribed)); + await engine.reconcile({ tenantId: 't', principalId: 'p' }); + const statusFor = async target => + target.dispatchTestRequest( + { + method: 'tools/call', + params: { + name: 'get_reporting_status', + arguments: { account: { account_id: 'account-a' }, view: 'summary' }, + }, + }, + { authInfo: { clientId: 'buyer-a' } } + ); + const current = await statusFor(server); + assert.notEqual(current.isError, true, JSON.stringify(current.structuredContent)); + assert.deepEqual(current.structuredContent.subscription_coverage, [ + { subscription_id: 'analytics-daily', subscription_version: 1, status: 'active', excluded: [] }, + ]); + const older = await statusFor(legacy); + assert.notEqual(older.isError, true, JSON.stringify(older.structuredContent)); + assert.equal(older.structuredContent.subscription_coverage, undefined); + }); + test('derives Core-only capabilities and installs native server handlers', async () => { const { service } = serviceFixture(); assert.equal(service.capabilities.consumer_status_task, undefined); diff --git a/test/lib/reporting-advanced-public-export.test.js b/test/lib/reporting-advanced-public-export.test.js new file mode 100644 index 000000000..2e4989268 --- /dev/null +++ b/test/lib/reporting-advanced-public-export.test.js @@ -0,0 +1,88 @@ +/** + * `@adcp/sdk/reporting/advanced` is the only public home of the low-level reporting-subscription + * building blocks (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): the engine and its + * stores, the coverage function, the calendar, selection, the capability builder and the service's + * subscription hooks. The service, consumer and ledger subpaths and the package root no longer + * re-export them. + */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { describe, test } = require('node:test'); + +const pkg = require('../../package.json'); + +const LOW_LEVEL = [ + 'createReportingSubscriptionEngineV1', + 'createInMemoryReportingSubscriptionEngineStoreV1', + 'createReportingSubscriptionEngineStateStoreV1', + 'DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION', + 'reportingSubscriptionCoverageGrantedV1', + 'expectedSubscriptionPeriods', + 'groupExpectedSubscriptionPeriodsV1', + 'reportingSubscriptionVersionRecordsV1', + 'subscriptionExpectedReportingPeriodsV1', + 'createReportingPeriodCalendarV1', + 'parseReportingPeriodDurationV1', + 'assertReportingIanaTimezoneV1', + 'resolveReportingSubscriptionOfferingsV1', + 'reportingSubscriptionScopeIncludesV1', + 'isReportingSubscriptionCandidateV1', + 'effectiveReportingSubscriptionMatchV1', + 'compareReportingIdsByteOrderV1', + 'reportingConfigurationCapabilitiesV1', + 'withReportingSubscriptionCoverageV1', +]; + +describe('@adcp/sdk/reporting/advanced', () => { + test('publishes runtime and declaration paths like the other reporting subpaths', () => { + assert.deepEqual(pkg.exports['./reporting/advanced'], { + import: { + types: './dist/lib/reporting/advanced/index.d.mts', + default: './dist/lib/reporting/advanced/index.mjs', + }, + require: { + types: './dist/lib/reporting/advanced/index.d.ts', + default: './dist/lib/reporting/advanced/index.js', + }, + }); + assert.deepEqual(pkg.typesVersions['*']['reporting/advanced'], ['dist/lib/reporting/advanced/index.d.ts']); + const distRoot = path.join(__dirname, '..', '..', 'dist', 'lib', 'reporting', 'advanced'); + for (const file of ['index.js', 'index.mjs', 'index.d.ts', 'index.d.mts']) { + assert.ok(fs.existsSync(path.join(distRoot, file)), `dist/lib/reporting/advanced/${file} must exist`); + } + const declarations = fs.readFileSync(path.join(distRoot, 'index.d.ts'), 'utf8'); + assert.match(declarations, /@experimental/); + }); + + test('exports every low-level building block', () => { + const advanced = require('@adcp/sdk/reporting/advanced'); + for (const name of LOW_LEVEL) assert.notEqual(advanced[name], undefined, name); + }); + + test('the service, consumer and ledger subpaths and the package root do not re-export them', () => { + const surfaces = { + root: require('@adcp/sdk'), + service: require('@adcp/sdk/reporting/service'), + consumer: require('@adcp/sdk/reporting/consumer'), + ledger: require('@adcp/sdk/reporting/ledger'), + }; + for (const [surface, exports] of Object.entries(surfaces)) { + for (const name of LOW_LEVEL) assert.equal(exports[name], undefined, `${surface} must not export ${name}`); + } + }); + + test('the service declarations do not name the subscription hook types', () => { + const declarations = fs.readFileSync( + path.join(__dirname, '..', '..', 'dist', 'lib', 'reporting', 'service', 'index.d.ts'), + 'utf8' + ); + for (const name of [ + 'ReliableReportingSubscriptionsOptionsV1', + 'ReliableReportingSubscriptionBindingInputV1', + 'ReportingSubscriptionEngineV1', + ]) { + assert.doesNotMatch(declarations, new RegExp(`export (?:interface|type|\\{[^}]*\\b)${name}\\b`), name); + } + }); +}); diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js new file mode 100644 index 000000000..ce539f182 --- /dev/null +++ b/test/lib/reporting-subscription-consumer.test.js @@ -0,0 +1,670 @@ +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const { InMemoryStateStore } = require('../../dist/lib/server/index.js'); +const { + createReportingSubscriptionEngineStateStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { createReliableReportingConsumerV1 } = require('../../dist/lib/reporting/consumer/index.js'); +const { + expectedSubscriptionPeriods, + groupExpectedSubscriptionPeriodsV1, + reportingSubscriptionVersionRecordsV1, + subscriptionExpectedReportingPeriodsV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { syncReportingSubscriptionsV1 } = require('../../dist/lib/index.js'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); + +const SCOPE = { tenantId: 't', principalId: 'p' }; +const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; +const ACCOUNT_ID = 'acct_1'; + +const OFFERING = { + 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: 'b'.repeat(64), + reporting_profile: { + id: 'adcp.delivery.v1', + version: '1.0.0', + schema_uri: 'https://seller.example/schemas/delivery.json', + schema_sha256: 'a'.repeat(64), + 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'], +}; + +function subscription(extra = {}) { + return { + 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', + ...extra, + }; +} + +const BUY = { + 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'] }], +}; + +/** A PrincipalLifecycleClient over the in-process principal runtime. */ +function principalClient(lifecycle) { + const wrap = data => ({ + success: true, + status: 'completed', + data, + metadata: { status: 'completed', taskName: 'principal' }, + conversation: [], + debug_logs: [], + }); + return { + getPrincipal: async params => wrap(await lifecycle.protocol.getPrincipal(params ?? {}, CTX)), + syncPrincipal: async params => wrap(await lifecycle.protocol.syncPrincipal(params, CTX)), + }; +} + +function engineFor(lifecycle, clock, store) { + return createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [OFFERING], + statusRetentionDays: 30, + now: () => clock.now, + ...(store ? { store } : {}), + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: ACCOUNT_ID, authorized_at: '2026-10-01T00:00:00.000Z' }], + }), + listAccountBuys: () => [structuredClone(BUY)], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); +} + +describe('reporting subscription engine state store', () => { + test('persists one CAS document per key, listed per principal and per tenant', async () => { + const backing = new InMemoryStateStore(); + const store = createReportingSubscriptionEngineStateStoreV1({ store: backing }); + assert.equal(store.durability, 'process-local'); + assert.equal(await store.get(SCOPE, 'principal'), null); + const value = { account: { grants: [{ authorized_at: '2026-10-01T00:00:00.000Z' }] }, subscriptions: {} }; + const first = await store.put(SCOPE, 'account:a', value, null); + assert.equal(first.outcome, 'applied'); + assert.deepEqual(await store.put(SCOPE, 'account:a', value, null), { outcome: 'conflict' }); + const read = await store.get(SCOPE, 'account:a'); + assert.deepEqual(read.value, value); + assert.equal((await store.put(SCOPE, 'account:a', value, read.revision)).outcome, 'applied'); + // Each account is its own document, so accounts never contend on one CAS row. + assert.equal((await store.put(SCOPE, 'account:b', value, null)).outcome, 'applied'); + assert.equal((await store.put(SCOPE, 'principal', { served: {} }, null)).outcome, 'applied'); + assert.deepEqual((await store.listKeys(SCOPE)).sort(), ['account:a', 'account:b', 'principal']); + await store.put({ tenantId: 't', principalId: 'q' }, 'principal', { served: {} }, null); + await store.put({ tenantId: 'other', principalId: 'p' }, 'principal', { served: {} }, null); + assert.deepEqual((await store.listPrincipals('t')).map(scope => scope.principalId).sort(), ['p', 'q']); + assert.deepEqual(await store.listPrincipals('other'), [{ tenantId: 'other', principalId: 'p' }]); + assert.deepEqual(await store.listKeys({ tenantId: 'other', principalId: 'p' }), ['principal']); + assert.throws( + () => createReportingSubscriptionEngineStateStoreV1({ store: backing, durability: 'durable' }), + /cannot be declared durable/ + ); + }); + + test('an incomplete principal walk fails closed', async () => { + const backing = new InMemoryStateStore(); + let page = 0; + const endless = Object.assign(Object.create(backing), { + list: async () => ({ items: [], nextCursor: `page-${++page}` }), + getWithVersion: backing.getWithVersion.bind(backing), + putIfMatch: backing.putIfMatch.bind(backing), + }); + await assert.rejects( + createReportingSubscriptionEngineStateStoreV1({ store: endless }).listPrincipals('t'), + /refusing a partial list/ + ); + const repeating = Object.assign(Object.create(backing), { + list: async () => ({ items: [], nextCursor: 'same' }), + getWithVersion: backing.getWithVersion.bind(backing), + putIfMatch: backing.putIfMatch.bind(backing), + }); + await assert.rejects( + createReportingSubscriptionEngineStateStoreV1({ store: repeating }).listPrincipals('t'), + /repeating cursor/ + ); + }); + + test('a fresh engine over the same store reads the same coverage', async () => { + const clock = { now: new Date('2026-11-01T12:00:00.000Z') }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + const store = createReportingSubscriptionEngineStateStoreV1({ store: new InMemoryStateStore() }); + await syncReportingSubscriptionsV1(principalClient(lifecycle), [subscription()], { + createIdempotencyKey: () => 'idempotency-key-0001', + }); + clock.now = new Date('2026-11-02T00:00:00.000Z'); + await engineFor(lifecycle, clock, store).reconcile(SCOPE); + const restarted = engineFor(lifecycle, clock, store); + const owed = await restarted.owedPeriods(SCOPE, ACCOUNT_ID, { now: '2026-11-10T00:00:00.000Z' }); + assert.deepEqual( + owed.map(row => row.period_start), + ['2026-11-03T00:00:00.000Z', '2026-11-04T00:00:00.000Z', '2026-11-05T00:00:00.000Z'] + ); + }); +}); + +describe('buyer helpers', () => { + test('syncReportingSubscriptionsV1 writes and reads back subscriptions, and its records rebuild coverage', async () => { + const clock = { now: new Date('2026-11-01T12:00:00.000Z') }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + let key = 0; + const keys = { createIdempotencyKey: () => `idempotency-key-${String(++key).padStart(4, '0')}` }; + const sent = { 1: subscription() }; + const first = await syncReportingSubscriptionsV1(client, [sent[1]], keys); + assert.equal(first.subscriptions[0].state, 'active'); + assert.equal(first.subscriptions[0].accepted_at, '2026-11-01T12:00:00.000Z'); + clock.now = new Date('2026-11-04T12:00:00.000Z'); + sent[2] = subscription({ subscription_version: 2, exclude_account_ids: ['acct_other'] }); + const second = await syncReportingSubscriptionsV1(client, [sent[2]], keys); + const versions = reportingSubscriptionVersionRecordsV1({ state: second.subscriptions[0], configurations: sent }); + assert.deepEqual( + versions.map(version => [version.subscription_version, version.accepted_at, version.superseded_at]), + [ + [1, '2026-11-01T12:00:00.000Z', '2026-11-04T12:00:00.000Z'], + [2, '2026-11-04T12:00:00.000Z', undefined], + ] + ); + assert.throws( + () => reportingSubscriptionVersionRecordsV1({ state: second.subscriptions[0], configurations: { 2: sent[2] } }), + /keep every version/ + ); + + // The buyer's rebuilt input yields the seller's owed set. + const engine = engineFor(lifecycle, clock); + await engine.reconcile(SCOPE); + const buyer = expectedSubscriptionPeriods({ + subscription_id: 'analytics-daily', + versions, + account: { account_id: ACCOUNT_ID, grants: [{ authorized_at: '2026-10-01T00:00:00.000Z' }] }, + buys: [BUY], + offerings: [OFFERING], + now: '2026-11-10T00:00:00.000Z', + }); + assert.deepEqual(buyer, await engine.owedPeriods(SCOPE, ACCOUNT_ID, { now: '2026-11-10T00:00:00.000Z' })); + assert.deepEqual( + buyer.map(row => [row.subscription_version, row.period_start.slice(0, 10)]), + [ + [1, '2026-11-03'], + [1, '2026-11-04'], + [2, '2026-11-05'], + ] + ); + + // A readback that drops a submitted subscription is refused. + const lying = { + getPrincipal: client.getPrincipal, + syncPrincipal: async params => { + const response = await client.syncPrincipal(params); + response.data.result.configuration.reporting_subscriptions = []; + return response; + }, + }; + await assert.rejects( + syncReportingSubscriptionsV1(lying, [subscription({ subscription_version: 3 })], keys), + /does not carry subscription/ + ); + }); + + test('reportingSubscriptionVersionRecordsV1 orders newest-first prior_versions oldest first', () => { + const configurations = { + 1: subscription(), + 2: subscription({ subscription_version: 2, exclude_account_ids: ['x'] }), + 3: subscription({ subscription_version: 3, exclude_account_ids: ['y'] }), + }; + const state = { + subscription_id: 'analytics-daily', + subscription_version: 3, + state: 'active', + accepted_at: '2026-11-06T00:00:00.000Z', + prior_versions: [ + { subscription_version: 2, accepted_at: '2026-11-04T00:00:00.000Z', superseded_at: '2026-11-06T00:00:00.000Z' }, + { subscription_version: 1, accepted_at: '2026-11-01T00:00:00.000Z', superseded_at: '2026-11-04T00:00:00.000Z' }, + ], + configuration: configurations[3], + }; + const versions = reportingSubscriptionVersionRecordsV1({ state, configurations }); + assert.deepEqual( + versions.map(version => [version.subscription_version, version.accepted_at, version.superseded_at]), + [ + [1, '2026-11-01T00:00:00.000Z', '2026-11-04T00:00:00.000Z'], + [2, '2026-11-04T00:00:00.000Z', '2026-11-06T00:00:00.000Z'], + [3, '2026-11-06T00:00:00.000Z', undefined], + ] + ); + assert.deepEqual(versions[1].configuration.exclude_account_ids, ['x']); + // The records drive cutovers: each period belongs to the version in force when it started. + const owed = expectedSubscriptionPeriods({ + subscription_id: 'analytics-daily', + versions, + account: { account_id: ACCOUNT_ID, grants: [{ authorized_at: '2026-10-01T00:00:00Z' }] }, + buys: [{ ...BUY, end_time: '2026-11-08T00:00:00Z' }], + offerings: [OFFERING], + now: '2026-11-10T00:00:00Z', + }); + assert.deepEqual( + owed.map(row => [row.subscription_version, row.period_start.slice(0, 10)]), + [ + [1, '2026-11-03'], + [2, '2026-11-04'], + [2, '2026-11-05'], + [3, '2026-11-06'], + [3, '2026-11-07'], + ] + ); + }); + + test('subscriptionExpectedReportingPeriodsV1 maps owed obligations onto expected periods', () => { + const obligations = groupExpectedSubscriptionPeriodsV1( + expectedSubscriptionPeriods({ + subscription_id: 'analytics-daily', + versions: [{ subscription_version: 1, accepted_at: '2026-11-01T12:00:00Z', configuration: subscription() }], + account: { account_id: ACCOUNT_ID, grants: [{ authorized_at: '2026-10-01T00:00:00Z' }] }, + buys: [BUY], + offerings: [OFFERING], + now: '2026-11-10T00:00:00Z', + }) + ); + const expected = subscriptionExpectedReportingPeriodsV1(obligations, template); + assert.equal(expected.length, 3); + assert.deepEqual( + { + deliveryConfigId: expected[0].deliveryConfigId, + deliveryConfigVersion: expected[0].deliveryConfigVersion, + reportDefinitionId: expected[0].reportDefinitionId, + mediaBuyIds: expected[0].mediaBuyIds, + periodStart: expected[0].periodStart, + periodEnd: expected[0].periodEnd, + status: expected[0].coverage.status, + covered: expected[0].coverage.fully_covered_media_buy_ids, + }, + { + deliveryConfigId: 'analytics-daily', + deliveryConfigVersion: 1, + reportDefinitionId: 'rd_off_a_pkg', + mediaBuyIds: ['mb_1'], + periodStart: '2026-11-03T00:00:00.000Z', + periodEnd: '2026-11-04T00:00:00.000Z', + status: 'full', + covered: ['mb_1'], + } + ); + assert.deepEqual(expected[0].coverage.package_ids, ['pkg_1']); + // No default coverage: one could never match an obligation reporting packages or partial coverage. + assert.throws(() => { + const { coverage: _coverage, ...noCoverage } = template(obligations[0]); + subscriptionExpectedReportingPeriodsV1(obligations, () => noCoverage); + }, /must supply coverage/); + }); + + test('reportingSubscriptionVersionRecordsV1 falls back to configurations the readback carries', () => { + const configurations = { + 1: subscription(), + 2: subscription({ subscription_version: 2, exclude_account_ids: ['x'] }), + }; + const state = { + subscription_id: 'analytics-daily', + subscription_version: 2, + state: 'active', + accepted_at: '2026-11-04T00:00:00.000Z', + // adcp#8588: sellers on 3.3.0-beta.2 return each prior version's configuration. + prior_versions: [ + { + subscription_version: 1, + accepted_at: '2026-11-01T00:00:00.000Z', + superseded_at: '2026-11-04T00:00:00.000Z', + configuration: configurations[1], + }, + ], + configuration: configurations[2], + }; + const versions = reportingSubscriptionVersionRecordsV1({ state }); + assert.deepEqual( + versions.map(version => [version.subscription_version, version.configuration.exclude_account_ids]), + [ + [1, undefined], + [2, ['x']], + ] + ); + const { configuration: _dropped, ...bare } = state.prior_versions[0]; + assert.throws( + () => reportingSubscriptionVersionRecordsV1({ state: { ...state, prior_versions: [bare] } }), + /keep every version/ + ); + }); + + test('syncReportingSubscriptionsV1 refuses an unintended deactivation and a changed stored configuration', async () => { + const clock = { now: new Date('2026-11-01T12:00:00.000Z') }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + let key = 0; + const keys = { createIdempotencyKey: () => `idempotency-key-${String(++key).padStart(4, '0')}` }; + const other = subscription({ subscription_id: 'analytics-other', scope: { account_ids: ['acct_2'] } }); + await syncReportingSubscriptionsV1(client, [subscription(), other], keys); + let writes = 0; + const counting = { + getPrincipal: client.getPrincipal, + syncPrincipal: async params => { + writes += 1; + return client.syncPrincipal(params); + }, + }; + await assert.rejects( + syncReportingSubscriptionsV1(counting, [subscription()], keys), + /would deactivate active subscriptions it omits \("analytics-other"\)/ + ); + assert.equal(writes, 0, 'nothing is written'); + const explicit = await syncReportingSubscriptionsV1( + counting, + [subscription(), { ...other, subscription_version: 2, active: false }], + keys + ); + assert.equal(explicit.subscriptions.find(item => item.subscription_id === 'analytics-other').state, 'inactive'); + await syncReportingSubscriptionsV1( + counting, + [subscription({ subscription_version: 2, exclude_account_ids: ['z'] })], + { + ...keys, + allowDeactivation: true, + } + ); + + const rewriting = { + getPrincipal: client.getPrincipal, + syncPrincipal: async params => { + const response = await client.syncPrincipal(params); + for (const state of response.data.result.configuration.reporting_subscriptions ?? []) { + state.configuration = { ...state.configuration, exclude_account_ids: ['seller-chosen'] }; + } + return response; + }, + }; + await assert.rejects( + syncReportingSubscriptionsV1( + rewriting, + [subscription({ subscription_version: 3, exclude_account_ids: ['z'] })], + keys + ), + /stores a different configuration at \/exclude_account_ids\/0/ + ); + }); +}); + +function fullCoverage(obligation) { + return { + status: 'full', + media_buy_ids: [...obligation.media_buy_ids], + fully_covered_media_buy_ids: [...obligation.media_buy_ids], + partially_covered_media_buy_ids: [], + unsupported_media_buy_ids: [], + unknown_media_buy_ids: [], + package_ids: ['pkg_1'], + covered_package_ids: ['pkg_1'], + unsupported_package_ids: [], + unknown_package_ids: [], + }; +} + +function template(obligation) { + return { + coverage: fullCoverage(obligation), + feedPurpose: 'analytics', + reportingProfile: 'adcp.delivery.v1', + destinationRef: 'api', + deliveryMethod: 'api', + requiredFinality: 'official', + reconciliationMode: 'delivery_only', + coverageRequirement: 'full', + reportDefinitionUri: OFFERING.report_definition_uri, + reportDefinitionSha256: OFFERING.report_definition_sha256, + schemaVersion: '1.0.0', + schemaUri: OFFERING.reporting_profile.schema_uri, + schemaSha256: OFFERING.reporting_profile.schema_sha256, + schemaDialect: 'https://json-schema.org/draft/2020-12/schema', + schemaRefPolicy: 'local_fragment_only', + verificationProfile: 'native_commit', + }; +} + +function emptyPeriodsPage(view = 'periods') { + return { + status: 'completed', + view, + ledger_snapshot_id: 'snapshot-1', + ledger_as_of: '2026-11-10T00:00:00.000Z', + changes_checkpoint: 'checkpoint-1', + account_id: ACCOUNT_ID, + scope: { + period_start: '2026-01-01T00:00:00.000Z', + period_end: '2027-01-01T00:00:00.000Z', + all_accessible_media_buys: true, + media_buy_ids: [], + delivery_config_generations: [], + feed_purposes: [], + finality: [], + scope_closed: true, + coverage_complete: true, + }, + periods: [], + revisions: [], + materializations: [], + receipts: [], + consumer_statuses: [], + pagination: { total_count: 0, has_more: false }, + }; +} + +function memoryPersistence() { + const cursors = new Map(); + const leases = new Map(); + const id = key => `${key.consumerScope}|${key.accountId}`; + return { + checkpointStore: { async get() {}, async put() {} }, + pendingConsumerStatusStore: { async get() {}, async put() {}, async clear() {} }, + changesCheckpointStore: { + async get(key) { + return cursors.get(id(key)); + }, + async compareAndSet(key, expected, checkpoint) { + const existing = cursors.get(id(key)); + if ((existing?.checkpoint ?? null) !== expected) return 'conflict'; + if (expected === checkpoint) return 'unchanged'; + cursors.set(id(key), { checkpoint, generation: (existing?.generation ?? 0) + 1 }); + return 'applied'; + }, + }, + workLeases: { + async claim({ key, ownerToken }) { + const keyId = id(key); + if (leases.has(keyId)) return null; + const lease = { scopeKey: keyId, ownerToken, generation: 1, expiresAt: '2099-01-01T00:00:00.000Z' }; + leases.set(keyId, lease); + return lease; + }, + async renew(lease) { + return leases.get(lease.scopeKey) === lease ? lease : null; + }, + async release(lease) { + if (leases.get(lease.scopeKey) !== lease) return false; + leases.delete(lease.scopeKey); + return true; + }, + }, + }; +} + +describe('consumer runtime with subscription expectations', () => { + test('reconciles subscription-derived expectations read from subscription_coverage', async () => { + const coverage = [{ subscription_id: 'analytics-daily', subscription_version: 1, status: 'active', excluded: [] }]; + const calls = []; + const client = { + async getReportingStatus(params) { + calls.push(params.view); + return params.view === 'summary' + ? { ...emptyPeriodsPage('summary'), subscription_coverage: coverage } + : emptyPeriodsPage(); + }, + async syncReportingReceipts() { + return { status: 'completed', results: [] }; + }, + }; + const seen = []; + const nows = []; + const consumer = createReliableReportingConsumerV1({ + persistence: memoryPersistence(), + ownerToken: 'buyer-worker-1', + runOnStart: false, + accounts: [ + { + consumerScope: 'seller.example|buyer', + accountId: ACCOUNT_ID, + reconciliation: { + client, + request: { account: { account_id: ACCOUNT_ID } }, + expectedPeriods: [], + inspect: async () => ({ rowCount: 0, controlTotals: [] }), + }, + subscriptionExpectationGraceMs: 3_600_000, + subscriptionExpectations: ({ subscriptionCoverage, now, ledgerAsOf }) => { + seen.push(subscriptionCoverage); + nows.push([now.toISOString(), ledgerAsOf]); + const obligations = groupExpectedSubscriptionPeriodsV1( + expectedSubscriptionPeriods({ + subscription_id: 'analytics-daily', + versions: [ + { subscription_version: 1, accepted_at: '2026-11-01T12:00:00Z', configuration: subscription() }, + ], + account: { account_id: ACCOUNT_ID, grants: [{ authorized_at: '2026-10-01T00:00:00Z' }] }, + buys: [BUY], + offerings: [OFFERING], + coverage: subscriptionCoverage[0], + now: now.toISOString(), + }) + ); + return subscriptionExpectedReportingPeriodsV1(obligations, template); + }, + }, + ], + }); + const result = await consumer.runAccount(ACCOUNT_ID); + assert.equal(result.state, 'reconciled'); + assert.equal(calls[0], 'summary', 'coverage is read before reconciling'); + assert.deepEqual(seen, [coverage]); + assert.deepEqual( + nows, + [['2026-11-09T23:00:00.000Z', '2026-11-10T00:00:00.000Z']], + 'expectations use the snapshot ledger_as_of minus the grace, not the wall clock' + ); + assert.equal( + result.reconciliation.missingExpectedPeriods.length, + 3, + 'owed periods without obligations are missing' + ); + assert.equal(result.reconciliation.definitive, false); + + // principal.changed refreshes through the hook and re-reconciles the scope's accounts. + const refreshed = []; + const scoped = createReliableReportingConsumerV1({ + persistence: memoryPersistence(), + ownerToken: 'buyer-worker-2', + runOnStart: false, + onPrincipalChanged: input => refreshed.push(input.notification.reason), + accounts: [ + { + consumerScope: 'seller.example|buyer', + accountId: ACCOUNT_ID, + reconciliation: { + client, + request: { account: { account_id: ACCOUNT_ID } }, + expectedPeriods: [], + inspect: async () => ({ rowCount: 0, controlTotals: [] }), + }, + }, + ], + }); + const results = await scoped.handleAuthenticatedPrincipalChanged( + { + notification_type: 'principal.changed', + idempotency_key: 'principal-change-0001', + reason: 'reporting_subscription_changed', + agent_url: 'https://seller.example/mcp', + fired_at: '2026-11-05T00:00:00Z', + changed_at: '2026-11-05T00:00:00Z', + }, + { consumerScope: 'seller.example|buyer' } + ); + assert.deepEqual(refreshed, ['reporting_subscription_changed']); + assert.deepEqual( + results.map(item => [item.accountId, item.reason, item.state]), + [[ACCOUNT_ID, 'principal.changed', 'reconciled']] + ); + const change = n => ({ + notification_type: 'principal.changed', + idempotency_key: `principal-change-${String(n).padStart(4, '0')}`, + reason: 'reporting_subscription_changed', + agent_url: 'https://seller.example/mcp', + fired_at: '2026-11-05T00:00:00Z', + changed_at: '2026-11-05T00:00:00Z', + }); + const authentication = { consumerScope: 'seller.example|buyer' }; + // A redelivery with the same idempotency_key is dropped. + assert.deepEqual(await scoped.handleAuthenticatedPrincipalChanged(change(1), authentication), []); + assert.equal(refreshed.length, 1); + // A burst arriving while the scope is reconciled shares one trailing run. + const burst = await Promise.all( + [2, 3, 4, 5].map(n => scoped.handleAuthenticatedPrincipalChanged(change(n), authentication)) + ); + assert.equal(refreshed.length, 3, 'one run for the first notification and one trailing run for the rest'); + assert.equal(burst[1], burst[2]); + assert.equal(burst[2], burst[3]); + assert.equal( + await scoped.handleAuthenticatedPrincipalChanged( + { notification_type: 'reporting.ledger_changed' }, + { + consumerScope: 'seller.example|buyer', + } + ), + null + ); + await assert.rejects( + scoped.handleAuthenticatedPrincipalChanged( + { notification_type: 'principal.changed' }, + { + consumerScope: 'seller.example|buyer', + } + ), + /invalid identity fields/ + ); + await consumer.stop(); + await scoped.stop(); + }); +}); diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js new file mode 100644 index 000000000..cae78975d --- /dev/null +++ b/test/lib/reporting-subscription-coverage.test.js @@ -0,0 +1,556 @@ +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); +const path = require('node:path'); +const advanced = require('@adcp/sdk/reporting/advanced'); +const { CoverageInputError } = advanced; +const consumer = require('@adcp/sdk/reporting/consumer'); +const ledger = require('@adcp/sdk/reporting/ledger'); +const service = require('@adcp/sdk/reporting/service'); +const root = require('@adcp/sdk'); + +const { + createReportingPeriodCalendarV1, + expectedSubscriptionPeriods, + groupExpectedSubscriptionPeriodsV1, + resolveReportingSubscriptionOfferingsV1, + reportingSubscriptionScopeIncludesV1, +} = advanced; + +// The canonical coverage vectors (adcontextprotocol/adcp#8616), vendored byte for byte. The Python SDK runs +// the same file. Refresh with `npm run sync:reporting-subscription-vectors -- --commit `. +const CANONICAL = path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'canonical'); +const PIN = require(path.join(CANONICAL, 'PINNED.json')); +const table = require(path.join(CANONICAL, 'vectors.json')); + +// Expected rows hold for IANA tzdata from table.tzdata's floor through its pinned release. On a runtime whose +// bundled tzdata predates the floor, a case that depends on a non-UTC zone may legitimately disagree: such a +// failure is reported as an explicit skip naming the runtime's release, never hidden. UTC-only cases and +// expected_error cases always run strictly. +const TZDATA_FLOOR = '2026b'; +const runtimeTzdata = process.versions.tz; +const staleTzdata = typeof runtimeTzdata === 'string' && runtimeTzdata < TZDATA_FLOOR; +const UTC_ZONES = new Set(['UTC', 'Etc/UTC']); +const zoneDependent = testCase => (testCase.timezones ?? []).some(zone => !UTC_ZONES.has(zone)); + +const instant = value => Date.parse(value); +const iso = value => new Date(value).toISOString(); + +function comparable(rows) { + return rows.map(row => ({ + subscription_version: row.subscription_version, + media_buy_id: row.media_buy_id, + offering_id: row.offering_id, + period_start: instant(row.period_start), + period_end: instant(row.period_end), + })); +} + +describe('canonical coverage vectors (adcp#8616)', () => { + test('the mirror is byte-identical to the pinned upstream files', () => { + const fs = require('node:fs'); + const { createHash } = require('node:crypto'); + assert.match(PIN.sha, /^[0-9a-f]{40}$/); + const files = fs.readdirSync(CANONICAL).filter(file => file !== 'PINNED.json'); + assert.deepEqual(files.sort(), Object.keys(PIN.sha256).sort(), 'every vendored file is pinned'); + for (const [file, sha256] of Object.entries(PIN.sha256)) { + const actual = createHash('sha256') + .update(fs.readFileSync(path.join(CANONICAL, file))) + .digest('hex'); + assert.equal( + actual, + sha256, + `${file} differs from ${PIN.repository}@${PIN.sha}; run npm run sync:reporting-subscription-vectors` + ); + } + }); + + test('the vectors are non-trivial, uniquely identified and pinned to a tzdata release', () => { + assert.ok(table.cases.length >= 113); + assert.equal(new Set(table.cases.map(c => c.id)).size, table.cases.length); + assert.equal(new Set(table.cases.map(c => c.name)).size, table.cases.length); + assert.match(table.tzdata.version, /^\d{4}[a-z]$/); + assert.ok(table.tzdata.version >= TZDATA_FLOOR); + }); + + test('every case validates against case.schema.json', () => { + const Ajv = require('ajv'); + const addFormats = require('ajv-formats'); + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(require(path.join(CANONICAL, 'case.schema.json'))); + for (const testCase of table.cases) { + assert.equal(validate(testCase), true, `${testCase.id}: ${JSON.stringify(validate.errors)}`); + } + }); + + for (const testCase of table.cases) { + test(testCase.id, t => { + try { + runCase(testCase); + } catch (error) { + if (staleTzdata && !testCase.expected_error && zoneDependent(testCase)) { + t.skip( + `runtime tzdata ${runtimeTzdata} predates ${TZDATA_FLOOR}; ${testCase.timezones.join(', ')} may differ from ${table.tzdata.version}` + ); + return; + } + throw error; + } + }); + } +}); + +function runCase(testCase) { + const input = structuredClone(testCase.input); + if (testCase.expected_error) { + assert.throws( + () => expectedSubscriptionPeriods(input), + error => + error instanceof CoverageInputError && + error.code === testCase.expected_error.code && + error.detail === testCase.expected_error.detail && + error.recovery === 'correctable' && + !('dispatched' in error) + ); + return; + } + const actual = expectedSubscriptionPeriods(input); + assert.deepEqual(comparable(actual), comparable(testCase.expected), testCase.rule); + const offerings = new Map(input.offerings.map(offering => [offering.offering_id, offering])); + for (const row of actual) { + assert.equal(row.subscription_id, input.subscription_id); + assert.equal(row.account_id, input.account.account_id); + assert.equal(row.report_definition_id, offerings.get(row.offering_id).report_definition_id); + assert.equal(row.period_start, iso(instant(row.period_start)), 'emits canonical toISOString form'); + } + assert.ok(testCase.expected_obligations, 'every case states its obligations'); + if (testCase.expected_obligations) { + const grouped = groupExpectedSubscriptionPeriodsV1(actual).map(group => ({ + subscription_version: group.subscription_version, + offering_id: group.offering_id, + period_start: instant(group.period_start), + period_end: instant(group.period_end), + media_buy_ids: group.media_buy_ids, + })); + assert.deepEqual( + grouped, + testCase.expected_obligations.map(group => ({ + ...group, + period_start: instant(group.period_start), + period_end: instant(group.period_end), + })) + ); + } +} + +describe('expectedSubscriptionPeriods over the vectors', () => { + test('every subscription in the table is schema-valid', () => { + // Validate against the bundle the SDK pins (ADCP_VERSION). + const Ajv = require('ajv'); + const addFormats = require('ajv-formats'); + const fs = require('node:fs'); + const pinned = fs.readFileSync(path.join(__dirname, '..', '..', 'ADCP_VERSION'), 'utf8').trim(); + const root = path.join(__dirname, '..', '..', 'schemas', 'cache', pinned); + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + for (const dir of ['core', 'enums']) { + for (const file of fs.readdirSync(path.join(root, dir))) { + if (!file.endsWith('.json')) continue; + const schema = JSON.parse(fs.readFileSync(path.join(root, dir, file), 'utf8')); + // The dimension vocabulary is extensible (receivers MUST accept unknown values), as the SDK validates it. + if (file === 'reporting-dimension.json') { + delete schema.enum; + schema.pattern = schema['x-pattern']; + } + ajv.addSchema(schema); + } + } + const validate = ajv.getSchema(`https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json`); + for (const testCase of table.cases.filter(c => c.expected_error?.wire_valid !== false)) { + for (const version of testCase.input.versions) { + assert.equal(validate(version.configuration), true, `${testCase.name}: ${JSON.stringify(validate.errors)}`); + } + } + }); + + test('the function is pure: inputs are not mutated and repeated calls agree', () => { + for (const testCase of table.cases.filter(c => !c.expected_error)) { + const input = structuredClone(testCase.input); + const snapshot = structuredClone(input); + const first = expectedSubscriptionPeriods(input); + assert.deepEqual(input, snapshot, testCase.name); + assert.deepEqual(expectedSubscriptionPeriods(input), first, testCase.name); + } + }); + + test('only the advanced entry point exports the rule', () => { + assert.equal(typeof expectedSubscriptionPeriods, 'function'); + for (const [name, surface] of Object.entries({ consumer, ledger, service, root })) { + assert.equal(surface.expectedSubscriptionPeriods, undefined, name); + assert.equal(surface.createReportingPeriodCalendarV1, undefined, name); + } + }); +}); + +describe('expectedSubscriptionPeriods invariants', () => { + const baseline = table.cases.find(c => c.name === 'baseline').input; + + test('exceptions only ever remove periods', () => { + const owed = expectedSubscriptionPeriods(structuredClone(baseline)); + const windows = [ + { reason: 'capacity', from: '2026-11-03T00:00:00Z' }, + { reason: 'capacity', from: '2026-10-01T00:00:00Z', until: '2026-11-04T00:00:00Z' }, + { reason: 'overridden', from: '2026-11-04T12:00:00Z', overridden_by: { subscription_id: 'x' } }, + { reason: 'offering_unsupported', media_buy_id: 'mb_other', from: '2026-11-01T00:00:00Z' }, + ]; + for (const entry of windows) { + const reduced = expectedSubscriptionPeriods({ ...structuredClone(baseline), coverage: { excluded: [entry] } }); + const keys = new Set(owed.map(row => JSON.stringify(row))); + for (const row of reduced) assert.ok(keys.has(JSON.stringify(row)), 'an exception never adds a period'); + const removed = owed.filter(row => !reduced.some(other => JSON.stringify(other) === JSON.stringify(row))); + for (const row of removed) { + const start = instant(row.period_start); + assert.ok(start >= instant(entry.from) && (entry.until === undefined || start < instant(entry.until))); + } + } + }); + + test('periods are contiguous, complete and never clipped across a long flight', () => { + const input = structuredClone(baseline); + input.buys[0].end_time = '2027-06-30T07:00:00Z'; + input.now = '2027-08-01T00:00:00Z'; + const owed = expectedSubscriptionPeriods(input); + assert.equal(owed[0].period_start, '2026-11-03T00:00:00.000Z'); + assert.equal(owed.at(-1).period_start, '2027-06-30T00:00:00.000Z', 'the period containing the stop is owed'); + for (let index = 1; index < owed.length; index += 1) { + assert.equal(owed[index].period_start, owed[index - 1].period_end); + assert.equal(instant(owed[index].period_end) - instant(owed[index].period_start), 86_400_000); + } + }); + + test('version cutovers never owe a period twice and leave no gap on aligned schedules', () => { + const input = structuredClone( + table.cases.find(c => c.name === 'new subscription version, aligned schedules').input + ); + input.buys[0].end_time = '2026-11-20T00:00:00Z'; + const owed = expectedSubscriptionPeriods(input); + const starts = owed.map(row => row.period_start); + assert.equal(new Set(starts).size, starts.length); + for (let index = 1; index < owed.length; index += 1) + assert.equal(owed[index].period_start, owed[index - 1].period_end); + }); + + test('fails closed above max_periods', () => { + const input = structuredClone(baseline); + input.max_periods = 2; + assert.throws(() => expectedSubscriptionPeriods(input), { + name: 'CoverageInputError', + code: 'COVERAGE_BUDGET_EXCEEDED', + recovery: 'correctable', + }); + }); + + test('rejects malformed instants and mismatched version records', () => { + assert.throws(() => expectedSubscriptionPeriods({ ...structuredClone(baseline), now: '2026-12-01' }), { + code: 'COVERAGE_INPUT_INVALID', + detail: 'invalid_instant', + }); + const input = structuredClone(baseline); + input.versions[0].configuration.subscription_version = 9; + assert.throws(() => expectedSubscriptionPeriods(input), { + code: 'COVERAGE_INPUT_INVALID', + detail: 'invalid_input', + }); + const inactive = structuredClone(baseline); + inactive.versions[0].configuration.active = false; + assert.throws(() => expectedSubscriptionPeriods(inactive), /deactivated_at/); + const destination = structuredClone(baseline); + destination.versions[0].configuration.destination = { destination_id: 'd' }; + assert.throws(() => expectedSubscriptionPeriods(destination), { + code: 'COVERAGE_INPUT_INCOMPLETE', + detail: 'missing_destination_method', + }); + }); +}); + +describe('createReportingPeriodCalendarV1 (period_generation)', () => { + const boundaries = (calendar, from, count) => { + const first = calendar.ceilOrdinal(instant(from)); + return Array.from({ length: count }, (_, index) => iso(calendar.boundary(first + index))); + }; + + test('utc origin is 1970-01-01T00:00:00Z', () => { + const calendar = createReportingPeriodCalendarV1({ period_duration: 'P1D', alignment: 'utc' }); + assert.equal(calendar.boundary(0), 0); + assert.equal(iso(calendar.boundary(1)), '1970-01-02T00:00:00.000Z'); + // P7D weeks count from the origin, a Thursday. + const weeks = createReportingPeriodCalendarV1({ period_duration: 'P7D', alignment: 'utc' }); + assert.equal(iso(weeks.ceil(instant('2026-11-04T00:00:00Z'))), '2026-11-05T00:00:00.000Z'); + }); + + test('account_timezone days follow civil midnights through DST', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'P1D', + alignment: 'account_timezone', + timezone: 'America/New_York', + }); + assert.deepEqual(boundaries(calendar, '2026-03-07T05:00:00Z', 4), [ + '2026-03-07T05:00:00.000Z', + '2026-03-08T05:00:00.000Z', + '2026-03-09T04:00:00.000Z', + '2026-03-10T04:00:00.000Z', + ]); + assert.deepEqual(boundaries(calendar, '2026-10-31T04:00:00Z', 3), [ + '2026-10-31T04:00:00.000Z', + '2026-11-01T04:00:00.000Z', + '2026-11-02T05:00:00.000Z', + ]); + }); + + const walk = (calendar, from, count) => { + let at = calendar.ceil(instant(from)); + const out = [iso(at)]; + for (let index = 0; index < count; index += 1) { + at = calendar.next(at); + out.push(iso(at)); + } + return out; + }; + + test('hours are exact elapsed time under utc', () => { + const calendar = createReportingPeriodCalendarV1({ period_duration: 'PT36H', alignment: 'utc' }); + assert.equal( + calendar.next(calendar.ceil(instant('2026-03-07T05:00:00Z'))) - calendar.ceil(instant('2026-03-07T05:00:00Z')), + 129_600_000 + ); + }); + + test('sub-daily local periods restart at each local midnight (adcp#8588)', () => { + const local = (zone, duration = 'PT1H') => + createReportingPeriodCalendarV1({ period_duration: duration, alignment: 'account_timezone', timezone: zone }); + assert.deepEqual(walk(local('Asia/Singapore'), '2026-11-03T00:10:00Z', 1), [ + '2026-11-03T01:00:00.000Z', + '2026-11-03T02:00:00.000Z', + ]); + assert.deepEqual(walk(local('Asia/Kathmandu'), '2026-11-03T00:00:00Z', 1), [ + '2026-11-03T00:15:00.000Z', + '2026-11-03T01:15:00.000Z', + ]); + const fallBack = walk(local('America/New_York'), '2026-11-01T04:00:00Z', 25); + assert.equal(fallBack.at(-1), '2026-11-02T05:00:00.000Z', 'a 25-hour day has 25 hourly periods'); + const springForward = walk(local('America/New_York'), '2026-03-08T05:00:00Z', 23); + assert.equal(springForward.at(-1), '2026-03-09T04:00:00.000Z', 'a 23-hour day has 23 hourly periods'); + assert.deepEqual(walk(local('America/New_York', 'PT6H'), '2026-03-08T05:00:00Z', 4), [ + '2026-03-08T05:00:00.000Z', + '2026-03-08T10:00:00.000Z', + '2026-03-08T16:00:00.000Z', + '2026-03-08T22:00:00.000Z', + '2026-03-09T04:00:00.000Z', + ]); + assert.throws(() => local('UTC', 'PT7H'), /must divide 24 hours/); + assert.throws(() => local('UTC', 'P1DT1H'), /mixes date and time/); + assert.throws(() => local('UTC').boundary(0), /no ordinal addressing/); + }); + + test('a billing anchor inside a DST fold is interval zero itself', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'P1M', + alignment: 'billing_cycle', + timezone: 'America/New_York', + period_anchor: '2026-11-01T06:30:00Z', + }); + assert.equal(iso(calendar.boundary(0)), '2026-11-01T06:30:00.000Z'); + assert.equal(iso(calendar.ceil(instant('2026-11-01T06:30:00Z'))), '2026-11-01T06:30:00.000Z'); + }); + + test('next skips a zero-length period', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'P1D', + alignment: 'account_timezone', + timezone: 'Pacific/Apia', + }); + assert.deepEqual(walk(calendar, '2011-12-29T10:00:00Z', 2), [ + '2011-12-29T10:00:00.000Z', + '2011-12-30T10:00:00.000Z', + '2011-12-31T10:00:00.000Z', + ]); + }); + + test('billing cycles clamp to month end without drifting the anchor day', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'P1M', + alignment: 'billing_cycle', + timezone: 'America/New_York', + period_anchor: '2026-01-31T05:00:00Z', + }); + assert.deepEqual( + [0, 1, 2, 3].map(n => iso(calendar.boundary(n))), + ['2026-01-31T05:00:00.000Z', '2026-02-28T05:00:00.000Z', '2026-03-31T04:00:00.000Z', '2026-04-30T04:00:00.000Z'] + ); + assert.equal(iso(calendar.boundary(-1)), '2025-12-31T05:00:00.000Z'); + }); + + test('quarters and years', () => { + const quarters = createReportingPeriodCalendarV1({ period_duration: 'P3M', alignment: 'utc' }); + assert.equal(iso(quarters.ceil(instant('2026-02-10T00:00:00Z'))), '2026-04-01T00:00:00.000Z'); + assert.equal(iso(quarters.floor(instant('2026-02-10T00:00:00Z'))), '2026-01-01T00:00:00.000Z'); + const years = createReportingPeriodCalendarV1({ period_duration: 'P1Y', alignment: 'utc' }); + assert.equal(iso(years.ceil(instant('2026-02-10T00:00:00Z'))), '2027-01-01T00:00:00.000Z'); + }); + + test('a skipped civil date yields a zero-length period, which coverage never owes', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'P1D', + alignment: 'account_timezone', + timezone: 'Pacific/Apia', + }); + const first = calendar.ceilOrdinal(instant('2011-12-29T00:00:00Z')); + assert.deepEqual( + [0, 1, 2, 3].map(n => iso(calendar.boundary(first + n))), + ['2011-12-29T10:00:00.000Z', '2011-12-30T10:00:00.000Z', '2011-12-30T10:00:00.000Z', '2011-12-31T10:00:00.000Z'] + ); + }); + + test('ceil and floor agree with boundaries', () => { + const calendar = createReportingPeriodCalendarV1({ period_duration: 'PT15M', alignment: 'utc' }); + const on = instant('2026-11-03T10:15:00Z'); + assert.equal(calendar.ceil(on), on); + assert.equal(calendar.floor(on), on); + assert.equal(iso(calendar.ceil(on + 1)), '2026-11-03T10:30:00.000Z'); + assert.equal(iso(calendar.floor(on - 1)), '2026-11-03T10:00:00.000Z'); + }); + + test('refuses schedules the protocol does not define', () => { + assert.throws(() => createReportingPeriodCalendarV1({ period_duration: 'P0D', alignment: 'utc' }), { + code: 'COVERAGE_INPUT_INVALID', + detail: 'invalid_period_duration', + }); + assert.throws( + () => + createReportingPeriodCalendarV1({ period_duration: 'P1D', alignment: 'account_timezone', timezone: '+05:00' }), + { code: 'COVERAGE_INPUT_INVALID', detail: 'invalid_timezone' } + ); + assert.throws( + () => + createReportingPeriodCalendarV1({ + period_duration: 'P1D', + alignment: 'account_timezone', + timezone: 'Mars/Base', + }), + { code: 'COVERAGE_INPUT_INVALID', detail: 'invalid_timezone' } + ); + assert.throws( + () => createReportingPeriodCalendarV1({ period_duration: 'P1M', alignment: 'billing_cycle', timezone: 'UTC' }), + /period_anchor/ + ); + // adcp#8622: a sub-daily billing cycle is invalid; it is refused, never aligned to local midnight. + assert.throws( + () => + createReportingPeriodCalendarV1({ + period_duration: 'PT6H', + alignment: 'billing_cycle', + timezone: 'UTC', + period_anchor: '2026-11-01T09:00:00Z', + }), + { code: 'COVERAGE_INPUT_INVALID', detail: 'invalid_period_duration' } + ); + }); +}); + +describe('selector resolution and scope', () => { + const offerings = table.cases.find(c => c.name === 'match all').input.offerings; + const ids = offerings.map(offering => offering.offering_id); + const selector = extra => ({ + feed_purpose: 'analytics', + period_duration: 'P1D', + required_finality: 'official', + ...extra, + }); + + test('ties go to the lowest offering_id in byte order', () => { + const twins = [ + { ...offerings[1], offering_id: 'b' }, + { ...offerings[1], offering_id: 'B' }, + { ...offerings[1], offering_id: 'a' }, + ]; + // Byte order puts uppercase before lowercase. + assert.deepEqual( + resolveReportingSubscriptionOfferingsV1({ + selector: selector({ dimensions: ['package', 'creative'] }), + reconciliation_mode: 'delivery_only', + offerings: twins, + eligible_offering_ids: ['a', 'b', 'B'], + }), + ['B'] + ); + }); + + test('an unknown vocabulary value matches nothing', () => { + assert.deepEqual( + resolveReportingSubscriptionOfferingsV1({ + selector: selector({ dimensions: ['package', 'audience_segment'], match: 'all' }), + reconciliation_mode: 'delivery_only', + offerings, + eligible_offering_ids: ids, + }), + [] + ); + }); + + test('a configurable billing cycle is never resolvable by a subscription', () => { + const configurable = { + ...offerings[0], + offering_id: 'cfg', + schedule: { + period_duration: 'P1D', + alignment: 'billing_cycle', + period_anchor_policy: 'configurable', + delivery_sla: 'P1D', + }, + }; + assert.deepEqual( + resolveReportingSubscriptionOfferingsV1({ + selector: selector({ dimensions: ['package'] }), + reconciliation_mode: 'delivery_only', + offerings: [configurable], + eligible_offering_ids: ['cfg'], + }), + [] + ); + }); + + test('a utc schedule matches a period_timezone override only for UTC or Etc/UTC', () => { + const resolve = zone => + resolveReportingSubscriptionOfferingsV1({ + selector: selector({ dimensions: ['package'], period_timezone: zone }), + reconciliation_mode: 'delivery_only', + offerings, + eligible_offering_ids: ids, + }); + assert.deepEqual(resolve('Etc/UTC'), ['off_a_pkg']); + assert.deepEqual(resolve('UTC'), ['off_a_pkg']); + assert.deepEqual(resolve('GMT'), [], 'only UTC and Etc/UTC are UTC aliases for matching'); + assert.deepEqual(resolve('Europe/London'), []); + }); + + test('scope membership', () => { + const all = { scope: { all_authorized_accounts: true } }; + assert.equal(reportingSubscriptionScopeIncludesV1(all, { account_id: 'a' }), true); + assert.equal( + reportingSubscriptionScopeIncludesV1({ ...all, exclude_account_ids: ['a'] }, { account_id: 'a' }), + false + ); + assert.equal(reportingSubscriptionScopeIncludesV1({ scope: { account_ids: ['b'] } }, { account_id: 'a' }), false); + assert.equal( + reportingSubscriptionScopeIncludesV1({ ...all, operator_domains: ['x.example'] }, { account_id: 'a' }), + false + ); + assert.equal( + reportingSubscriptionScopeIncludesV1( + { ...all, operator_domains: ['x.example'] }, + { account_id: 'a', operator: 'x.example' } + ), + true + ); + }); +}); diff --git a/test/lib/reporting-subscription-delivery.test.js b/test/lib/reporting-subscription-delivery.test.js new file mode 100644 index 000000000..e953b3195 --- /dev/null +++ b/test/lib/reporting-subscription-delivery.test.js @@ -0,0 +1,295 @@ +/** + * Managed delivery of subscription obligations in the Core service (experimental + * `media_buy.reporting_subscriptions`, AdCP 3.3): a managed generation is bound before its first + * obligation, every delivered row carries account_id, and status subscribers hear only their own + * principal's subscription transitions. The PostgreSQL end-to-end delivery lives in + * packages/reporting-storage/test/reporting-subscription-delivery-pg.test.js. + */ +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const { + REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, + createReliableReportingService, +} = require('../../dist/lib/reporting/service/index.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { + redactedReportingSourceOfferingV1, + redactedReportingSourceRequestV1, +} = require('../../dist/lib/reporting/source/index.js'); +const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); + +const ACCOUNT = 'account-a'; +const TENANT = 'tenant-1'; +const A = { tenantId: TENANT, principalId: 'principal-a' }; +const B = { tenantId: TENANT, principalId: 'principal-b' }; +const day = n => new Date(Date.parse('2026-09-01T00:00:00.000Z') + n * 86_400_000).toISOString(); +const offeringId = redactedReportingSourceOfferingV1.offeringId; + +function deliveryOffering() { + const source = redactedReportingSourceOfferingV1; + return { + offering_id: source.offeringId, + feed_purpose: 'analytics', + report_definition_id: source.contract.report_definition_id, + report_definition_uri: source.contract.reportDefinitionUri, + report_definition_sha256: source.contract.reportDefinitionSha256, + reporting_profile: { + id: source.contract.reportingProfile, + version: source.contract.schemaVersion, + schema_uri: source.contract.schemaUri, + schema_sha256: source.contract.schemaSha256, + schema_dialect: source.contract.schemaDialect, + schema_ref_policy: source.contract.schemaRefPolicy, + grain: source.grain, + primary_keys: ['media_buy_id'], + }, + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT0S' }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + method: { + pattern: 'file_transfer', + transport: 's3', + orchestration: 'producer_managed', + destination_modes: ['existing'], + provider: { domain: 'storage.example' }, + format: 'jsonl', + }, + standard_dimensions: [], + }; +} + +function destination(id) { + return { + pattern: 'file_transfer', + destination_id: id, + active: true, + provider: { domain: 'storage.example' }, + transport: 's3', + location: `s3://bucket/${id}`, + accepted_formats: ['jsonl'], + accepted_verification_profiles: ['manifest_checksums'], + }; +} + +function subscription(destinationId) { + return { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'snapshot' }, + reconciliation_mode: 'delivery_only', + destination: { destination_id: destinationId, path_template: '{account_id}/{offering_id}/{period_start}' }, + }; +} + +function buy(mediaBuyId, start, end) { + return { + media_buy_id: mediaBuyId, + confirmed_at: day(1.5), + start_time: day(start), + end_time: day(end), + packages: [{ package_id: `${mediaBuyId}-pkg`, reporting_delivery_offering_ids: [offeringId] }], + }; +} +const BUYS = { 'principal-a': [buy('mb-a', 3, 5)], 'principal-b': [buy('mb-b', 4, 5)] }; + +async function world({ bind, subscribers = [] } = {}) { + const clock = { now: new Date(day(0.5)) }; + const { lifecycle } = createTestPrincipalLifecycle({ + clock, + reportingSubscriptions: {}, + prepareReportingDestination: ({ destination: value }) => ({ + state: 'validating', + configuration: structuredClone(value), + }), + }); + let key = 0; + for (const [principal, destinationId] of [ + [A, 'dest-a'], + [B, 'dest-b'], + ]) { + const response = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: `subscription-delivery-${String(++key).padStart(4, '0')}`, + configuration: { + reporting_destinations: [destination(destinationId)], + reporting_subscriptions: [subscription(destinationId)], + }, + }, + { + callerMutationScope: { + tenant_id: principal.tenantId, + principal_id: principal.principalId, + principal_kind: 'buyer_agent', + }, + } + ); + assert.equal(response.result.kind, 'applied', JSON.stringify(response)); + } + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [deliveryOffering()], + store: createInMemoryReportingSubscriptionEngineStoreV1(), + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: ACCOUNT, authorized_at: '2026-08-01T00:00:00.000Z' }], + }), + listAccountBuys: principal => structuredClone(BUYS[principal.principalId] ?? []), + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + const request = redactedReportingSourceRequestV1(); + const [template] = request.coverage.constituents; + const constituent = mediaBuyId => ({ + ...structuredClone(template), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(template.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }); + const sourceOffering = structuredClone(redactedReportingSourceOfferingV1); + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const store = new MemoryLedgerStore(); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const service = createReliableReportingService({ + store, + // The composition token a managed-delivery composition passes; the memory store stands in. + [REPORTING_MANAGED_DELIVERY_COMPOSITION_V1]: store, + subscribers, + adapters: { + fixture: { + sourceOffering, + deliveryOffering: deliveryOffering(), + fetchSlice: slice => ({ + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: 'USD', + reporting_rows: slice.media_buy_ids.map(mediaBuyId => ({ + media_buy_id: mediaBuyId, + impressions: 1, + spend: '1.00', + })), + }), + }, + }, + contact: { name: 'Reporting operations', email: 'reporting@example.com' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + resolveSource: account => ({ + adapterId: 'fixture', + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + resolveCoverage: () => ({ constituents: ['mb-a', 'mb-b'].map(constituent) }), + reportingSubscriptions: { + engine, + resolvePrincipal: context => context.principalScope, + listSubscriptionPrincipals: () => engine.listPrincipals(TENANT), + resolveSubscriptionConfiguration: () => ({ + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceSettings, + }), + ...(bind ? { bindManagedGeneration: input => bind(input, store) } : {}), + }, + }); + clock.now = new Date(day(6)); + await engine.reconcile(A); + await engine.reconcile(B); + return { clock, engine, service, store }; +} + +describe('managed subscription delivery in the Core service', () => { + test('binds each managed generation before its first obligation, with its destination and template', async () => { + const binds = []; + const { service, store } = await world({ + bind: async (input, ledger) => { + // No obligation of the generation exists yet: a managed binding must come first. + const written = [...ledger.obligations.values()].filter( + value => value.configurationId === input.configuration.configurationId + ); + assert.equal(written.length, 0); + binds.push(input); + }, + }); + const cycle = await service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }); + assert.equal(cycle.planned, 3, JSON.stringify(cycle)); + assert.deepEqual(binds.map(value => value.principal.principalId).sort(), ['principal-a', 'principal-b']); + for (const input of binds) { + const owner = input.principal.principalId === 'principal-a' ? 'dest-a' : 'dest-b'; + assert.equal(input.subscription.destination.destination_id, owner); + assert.equal(input.subscription.destination.path_template, '{account_id}/{offering_id}/{period_start}'); + assert.deepEqual(input.destinationMethod, { pattern: 'file_transfer', transport: 's3' }); + assert.equal(input.offering.method.pattern, 'file_transfer'); + assert.equal(input.configuration.subscription.principalId, input.principal.principalId); + } + // Binding happens once: a later cycle writes no new obligation and binds nothing again. + await service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }); + assert.equal(binds.length, 2); + // Every delivered row carries account_id, inside the committed digest. + assert.equal(store.revisions.size, 3); + for (const revision of store.revisions.values()) { + assert.ok(revision.rows.length > 0); + for (const row of revision.rows) assert.equal(row.account_id, ACCOUNT); + } + }); + + test('a failing bind writes nothing for that generation and retries next cycle', async () => { + let fail = true; + const { service, store } = await world({ + bind: async input => { + if (fail && input.principal.principalId === 'principal-a') throw new Error('destination is not ready'); + }, + }); + await assert.rejects(service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }), AggregateError); + const owners = () => [...store.obligations.values()].map(value => value.subscription.principalId).sort(); + assert.deepEqual(owners(), ['principal-b'], 'A wrote no obligation it could not deliver'); + fail = false; + await service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }); + assert.deepEqual(owners(), ['principal-a', 'principal-a', 'principal-b']); + }); + + test('a managed offering without a binding composition fails to plan, not silently', async () => { + const { service, store } = await world(); + await assert.rejects(service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }), error => { + assert.ok(error instanceof AggregateError); + assert.match(error.errors[0].message, /binds managed delivery/); + return true; + }); + assert.equal(store.obligations.size, 0); + }); + + test('status subscribers hear only their own principal’s subscription transitions', async () => { + const heard = { a: [], b: [], account: [] }; + const subscriber = (id, principal) => ({ + subscriberId: id, + account_id: ACCOUNT, + ...(principal ? { principal } : {}), + notify: transition => heard[id].push(transition.reporting_obligation_id), + }); + const { service, store } = await world({ + bind: async () => {}, + subscribers: [subscriber('a', A), subscriber('b', B), subscriber('account')], + }); + await service.runCycle({ accountId: ACCOUNT, now: new Date(day(6)) }); + const ownerOf = id => store.obligations.get(id).subscription.principalId; + assert.ok(heard.a.length > 0 && heard.b.length > 0); + assert.ok(heard.a.every(id => ownerOf(id) === 'principal-a')); + assert.ok(heard.b.every(id => ownerOf(id) === 'principal-b')); + assert.deepEqual(heard.account, [], 'an account subscriber learns of no principal’s subscription obligation'); + }); +}); diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js new file mode 100644 index 000000000..35fe3b024 --- /dev/null +++ b/test/lib/reporting-subscription-engine.test.js @@ -0,0 +1,950 @@ +const { describe, test } = require('node:test'); +const PINNED = require('node:fs') + .readFileSync(require('node:path').join(__dirname, '..', '..', 'ADCP_VERSION'), 'utf8') + .trim(); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { expectedSubscriptionPeriods } = require('../../dist/lib/reporting/advanced/index.js'); +const { validateResponse } = require('../../dist/lib/validation/schema-validator.js'); + +const SCOPE = { tenantId: 't', principalId: 'p' }; +const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; + +const profile = { + id: 'adcp.delivery.v1', + version: '1.0.0', + schema_uri: 'https://seller.example/schemas/delivery.json', + schema_sha256: 'a'.repeat(64), + schema_dialect: 'https://json-schema.org/draft/2020-12/schema', + schema_ref_policy: 'local_fragment_only', + grain: 'daily', + primary_keys: ['account_id', 'package_id'], +}; +function offering(id, dimensions) { + return { + offering_id: id, + feed_purpose: 'analytics', + report_definition_id: `rd_${id}`, + report_definition_uri: `https://seller.example/report-definitions/${id}.json`, + report_definition_sha256: 'b'.repeat(64), + reporting_profile: profile, + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT6H' }, + supported_finality: ['official'], + reconciliation_mode: 'delivery_only', + standard_dimensions: dimensions, + }; +} +const OFFERINGS = [ + offering('off_a_pkg', ['package']), + offering('off_b_pkg_creative', ['package', 'creative']), + offering('off_c_pkg_country', ['package', 'country']), +]; +const ALL_IDS = OFFERINGS.map(item => item.offering_id); +const ACCOUNT_TZ_OFFERING = { + ...offering('off_z_pkg_acct_tz', ['package']), + schedule: { period_duration: 'P1D', alignment: 'account_timezone', delivery_sla: 'PT6H' }, +}; +OFFERINGS.push(ACCOUNT_TZ_OFFERING); + +function subscription(extra = {}) { + return { + 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', + ...extra, + }; +} + +function buy(id, extra = {}) { + return { + media_buy_id: id, + confirmed_at: '2026-11-02T15:00:00Z', + start_time: '2026-11-03T00:00:00Z', + end_time: '2026-11-10T00:00:00Z', + packages: [{ package_id: `${id}_pkg`, reporting_delivery_offering_ids: ALL_IDS }], + ...extra, + }; +} + +/** + * One seller (principal runtime + engine + host facts) and one buyer that keeps only what + * it can observe: its own requests, get_principal readback, list_accounts authorized_at, + * get_media_buys, and get_reporting_status subscription_coverage. + */ +function world({ maxAccounts, backfillOnRegrant = false } = {}) { + const clock = { now: new Date('2026-11-01T12:00:00.000Z') }; + const { lifecycle, emitted } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + const host = { + accounts: new Map(), + buys: new Map(), + configs: new Map(), + revokedAt: new Map(), + delivered: () => false, + }; + const engineStore = createInMemoryReportingSubscriptionEngineStoreV1(); + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: OFFERINGS, + store: engineStore, + statusRetentionDays: 30, + ...(maxAccounts !== undefined ? { maxAccounts } : {}), + backfillOnRegrant, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: () => ({ accounts: [...host.accounts.values()].map(item => structuredClone(item)) }), + listAccountBuys: (_principal, accountId) => structuredClone(host.buys.get(accountId) ?? []), + listAccountDeliveryConfigs: (_principal, accountId) => structuredClone(host.configs.get(accountId) ?? []), + // The host records when it revoked authority; the engine never stamps reconcile time. + resolveRevocationInstant: (_principal, accountId, input) => host.revokedAt.get(accountId) ?? input.observedAt, + isPeriodDelivered: (_principal, period) => host.delivered(period), + }, + }); + + // Buyer-side records. + const buyer = { sent: new Map(), grants: new Map(), timezones: new Map() }; + let key = 0; + async function sync(subscriptions) { + key += 1; + const response = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: `buyer-key-${String(key).padStart(6, '0')}`, + configuration: { + notification_configs: [ + { subscriber_id: 'events', url: 'https://buyer.example/events', event_types: ['principal.changed'] }, + ], + reporting_subscriptions: subscriptions, + }, + }, + CTX + ); + assert.equal(response.result.kind, 'applied', JSON.stringify(response)); + for (const item of subscriptions) + buyer.sent.set(`${item.subscription_id}@${item.subscription_version}`, structuredClone(item)); + return response; + } + function grant(accountId, at, extra = {}) { + host.accounts.set(accountId, { account_id: accountId, authorized_at: at, timezone: 'America/New_York', ...extra }); + } + function revoke(accountId, at) { + host.accounts.delete(accountId); + if (at !== undefined) host.revokedAt.set(accountId, at); + } + /** The buyer observes list_accounts and get_principal after each step. */ + async function observe() { + // authorized_at is the start of the authorization chain (adcontextprotocol/adcp#8585): a + // re-grant inside retention leaves it unchanged and continues the chain, and the seller's + // gaps[] then says which periods the interruption removed. A different authorized_at is a + // new chain. + for (const account of host.accounts.values()) { + buyer.timezones.set(account.account_id, account.timezone); + const grants = buyer.grants.get(account.account_id) ?? []; + const last = grants.at(-1); + const chainStart = grants.filter(grant => grant.chain).at(-1)?.authorized_at; + if (!last) grants.push({ authorized_at: account.authorized_at, chain: true }); + else if (last.unauthorized_at !== undefined) { + if (account.authorized_at === chainStart) delete last.unauthorized_at; + else grants.push({ authorized_at: account.authorized_at, chain: true }); + } + buyer.grants.set(account.account_id, grants); + } + // Served at the pinned release the readback carries only fields that release defines and + // is schema-valid. + const served = await lifecycle.protocol.getPrincipal({}, { ...CTX, servedAdcpVersion: PINNED }); + const outcome = validateResponse('get_principal', served, PINNED); + assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); + const read = await lifecycle.protocol.getPrincipal({}, CTX); + for (const state of (await lifecycle.listReportingSubscriptions(SCOPE)).states) { + for (const lost of state.accounts_unauthorized ?? []) { + const grants = buyer.grants.get(lost.account_id) ?? []; + const last = grants.at(-1); + if (last && last.unauthorized_at === undefined) last.unauthorized_at = lost.unauthorized_at; + } + } + return read; + } + async function step(at) { + clock.now = new Date(at); + await engine.reconcile(SCOPE); + return observe(); + } + /** What the buyer expects, from its observations only. */ + async function buyerExpected(accountId, now, subscriptionId = 'analytics-daily') { + // The complete readback, from the internal state. + const { states } = await lifecycle.listReportingSubscriptions(SCOPE); + const state = states.find(item => item.subscription_id === subscriptionId); + const versions = [...(state.prior_versions ?? [])].reverse().map(prior => ({ + subscription_version: prior.subscription_version, + accepted_at: prior.accepted_at, + superseded_at: prior.superseded_at, + configuration: { ...buyer.sent.get(`${subscriptionId}@${prior.subscription_version}`), active: true }, + })); + versions.push({ + subscription_version: state.subscription_version, + accepted_at: state.accepted_at, + ...(state.deactivated_at ? { deactivated_at: state.deactivated_at } : {}), + configuration: buyer.sent.get(`${subscriptionId}@${state.subscription_version}`), + }); + const coverage = (await engine.subscriptionCoverage(SCOPE, accountId)).find( + item => item.subscription_id === subscriptionId + ); + return expectedSubscriptionPeriods({ + subscription_id: subscriptionId, + versions, + ...(state.inactive_intervals ? { inactive_intervals: state.inactive_intervals } : {}), + account: { + account_id: accountId, + timezone: buyer.timezones.get(accountId) ?? 'America/New_York', + grants: (buyer.grants.get(accountId) ?? []).map(({ chain: _chain, ...grant }) => grant), + }, + buys: host.buys.get(accountId) ?? [], + offerings: OFFERINGS, + ...(coverage ? { coverage } : {}), + backfill_on_regrant: backfillOnRegrant, + status_retention_days: 30, + now, + }); + } + async function assertAgree(accountId, now, subscriptionId) { + const seller = (await engine.owedPeriods(SCOPE, accountId, { now })).filter( + item => subscriptionId === undefined || item.subscription_id === subscriptionId + ); + const expected = await buyerExpected(accountId, now, subscriptionId); + assert.deepEqual(seller, expected, `seller and buyer disagree for ${accountId} at ${now}`); + return expected; + } + return { + buyer, + clock, + lifecycle, + engine, + engineStore, + host, + sync, + grant, + revoke, + step, + observe, + buyerExpected, + assertAgree, + emitted, + }; +} + +const starts = rows => + rows.map( + row => `${row.subscription_version}:${row.media_buy_id}:${row.offering_id}:${row.period_start.slice(0, 10)}` + ); + +describe('reporting subscription engine conformance (buyer periods equal seller periods)', () => { + test('a mid-period confirmed_at, cancellation mid-flight, and early completion', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [ + buy('mb_mid', { confirmed_at: '2026-11-03T10:00:00Z' }), + buy('mb_cancel', { canceled_at: '2026-11-05T06:00:00Z' }), + buy('mb_done', { completed_at: '2026-11-04T00:00:00Z' }), + ]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed) + .filter(item => item.includes('mb_mid')) + .map(item => item.slice(-10)), + // adcp#8593: the period in which the buy is confirmed is owed in full. + ['2026-11-03', '2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09'] + ); + assert.deepEqual( + starts(owed) + .filter(item => item.includes('mb_cancel')) + .map(item => item.slice(-10)), + ['2026-11-03', '2026-11-04', '2026-11-05'] + ); + assert.deepEqual( + starts(owed) + .filter(item => item.includes('mb_done')) + .map(item => item.slice(-10)), + ['2026-11-03'] + ); + const grouped = await w.engine.owedObligations(SCOPE, 'acct_1', { now: '2026-11-12T00:00:00.000Z' }); + assert.deepEqual(grouped[0].media_buy_ids, ['mb_cancel', 'mb_done', 'mb_mid']); + }); + + test('a flight extension adds only unresolved periods', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [ + buy('mb_1', { + end_time: '2026-11-08T00:00:00Z', + prior_flights: [ + { replaced_at: '2026-11-07T12:00:00Z', start_time: '2026-11-03T00:00:00Z', end_time: '2026-11-06T00:00:00Z' }, + ], + }), + ]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed).map(item => item.slice(-10)), + ['2026-11-03', '2026-11-04', '2026-11-05', '2026-11-07'] + ); + }); + + test('a new subscription version is a cutover', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + w.clock.now = new Date('2026-11-05T12:00:00.000Z'); + await w.sync([subscription({ subscription_version: 2, selector: { ...subscription().selector, match: 'all' } })]); + await w.step('2026-11-06T00:00:00.000Z'); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed) + .filter(item => item.startsWith('1:')) + .map(item => item.slice(-10)), + ['2026-11-03', '2026-11-04', '2026-11-05'] + ); + assert.equal( + starts(owed).filter(item => item.startsWith('2:') && item.endsWith('2026-11-05')).length, + 0, + 'nothing owed twice' + ); + assert.equal( + starts(owed).filter(item => item.startsWith('2:')).length, + 3 * 4, + 'v2 match all: three offerings from 11-06' + ); + }); + + for (const backfillOnRegrant of [false, true]) { + test(`revocation and re-grant ${backfillOnRegrant ? 'with' : 'without'} backfill_on_regrant`, async () => { + const w = world({ backfillOnRegrant }); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + w.revoke('acct_1', '2026-11-05T05:00:00.000Z'); + // Delivered on time: due by period end + PT6H before the revocation. + w.host.delivered = period => Date.parse(period.period_end) + 6 * 3_600_000 <= Date.parse('2026-11-05T05:00:00Z'); + const revoked = await w.step('2026-11-05T05:00:00.000Z'); + const lost = revoked.result.configuration.reporting_subscriptions[0].accounts_unauthorized; + assert.equal(lost[0].account_id, 'acct_1'); + assert.equal(lost[0].unauthorized_at, '2026-11-05T05:00:00.000Z'); + assert.equal(lost[0].last_owed_period_end, '2026-11-05T00:00:00.000Z'); + assert.equal(lost[0].withheld_period_count, 1, 'the 11-04 period (due 11-05T06) was not yet due at revocation'); + assert.deepEqual(await w.engine.subscriptionCoverage(SCOPE, 'acct_1'), [], 'caller isolation is unchanged'); + // adcp#8607: revocation means no access, so there is no post-loss receipt window. + assert.equal('acceptsReceipt' in w.engine, false); + assert.ok(w.emitted.some(event => event.payload.reason === 'reporting_subscription_changed')); + + // The host keeps reporting the chain's authorized_at and reports when it re-granted. + w.grant('acct_1', '2026-10-01T00:00:00.000Z', { granted_at: '2026-11-06T18:00:00.000Z' }); + const regranted = await w.step('2026-11-06T18:00:00.000Z'); + assert.equal(regranted.result.configuration.reporting_subscriptions[0].accounts_unauthorized, undefined); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + if (backfillOnRegrant) assert.equal(coverage[0].gaps, undefined); + else + assert.deepEqual(coverage[0].gaps, [ + { from: '2026-11-05T00:00:00.000Z', until: '2026-11-07T00:00:00.000Z', reason: 'unauthorized' }, + ]); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed).map(item => item.slice(-10)), + backfillOnRegrant + ? ['2026-11-03', '2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09'] + : ['2026-11-03', '2026-11-04', '2026-11-07', '2026-11-08', '2026-11-09'] + ); + }); + } + + test('max_accounts excludes deterministically and reports capacity', async () => { + const w = world({ maxAccounts: 2 }); + for (const id of ['acct_c', 'acct_a', 'acct_b']) { + w.grant(id, '2026-10-01T00:00:00.000Z'); + w.host.buys.set(id, [buy(`mb_${id}`)]); + } + await w.sync([subscription()]); + const read = await w.step('2026-11-02T00:00:00.000Z'); + assert.deepEqual(read.result.configuration.reporting_subscriptions[0].accounts_with_exceptions, [ + { account_id: 'acct_c', reasons: ['capacity'] }, + ]); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_c'); + assert.equal(coverage[0].status, 'excluded'); + assert.deepEqual(coverage[0].excluded, [{ reason: 'capacity', from: '2026-11-02T00:00:00.000Z' }]); + for (const id of ['acct_a', 'acct_b', 'acct_c']) await w.assertAgree(id, '2026-11-12T00:00:00.000Z'); + assert.equal((await w.engine.owedPeriods(SCOPE, 'acct_c', { now: '2026-11-12T00:00:00.000Z' })).length, 0); + // A served account keeps its slot when a lower id appears later. + w.grant('acct_0', '2026-11-03T00:00:00.000Z'); + w.host.buys.set('acct_0', [buy('mb_acct_0')]); + await w.step('2026-11-03T00:00:00.000Z'); + const zero = await w.engine.subscriptionCoverage(SCOPE, 'acct_0'); + assert.equal(zero[0].excluded[0].reason, 'capacity'); + assert.equal((await w.engine.receiptRoster('t', 'acct_a')).length, 1); + assert.equal((await w.engine.receiptRoster('t', 'acct_c')).length, 0); + }); + + test("precedence: the caller's account-level configuration overrides the subscription", async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + w.host.configs.set('acct_1', [ + { delivery_config_id: 'acme-analytics', feed_purpose: 'analytics', active_from: '2026-11-04T12:00:00.000Z' }, + ]); + await w.step('2026-11-04T12:00:00.000Z'); + let coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.deepEqual(coverage[0].excluded, [ + { + reason: 'overridden', + overridden_by: { delivery_config_id: 'acme-analytics' }, + from: '2026-11-05T00:00:00.000Z', + }, + ]); + w.host.configs.set('acct_1', []); + await w.step('2026-11-06T12:00:00.000Z'); + coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(coverage[0].excluded[0].until, '2026-11-07T00:00:00.000Z'); + assert.equal(coverage[0].status, 'active'); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed).map(item => item.slice(-10)), + ['2026-11-03', '2026-11-04', '2026-11-07', '2026-11-08', '2026-11-09'] + ); + }); + + test('precedence: an account_ids subscription overrides an all-accounts one', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.grant('acct_2', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + w.host.buys.set('acct_2', [buy('mb_2')]); + await w.sync([ + subscription(), + subscription({ subscription_id: 'analytics-acct1', scope: { account_ids: ['acct_1'] } }), + ]); + await w.step('2026-11-02T00:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + const all = coverage.find(item => item.subscription_id === 'analytics-daily'); + assert.deepEqual(all.excluded[0].overridden_by, { subscription_id: 'analytics-acct1' }); + for (const id of ['analytics-daily', 'analytics-acct1']) + await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z', id); + const owed = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-12T00:00:00.000Z' }); + assert.ok( + owed.every(item => item.subscription_id === 'analytics-acct1'), + 'one feed per account and purpose' + ); + await w.assertAgree('acct_2', '2026-11-12T00:00:00.000Z', 'analytics-daily'); + }); + + for (const [match, dimensions, expected] of [ + ['exact', ['package'], ['off_a_pkg']], + ['finest', ['package'], ['off_b_pkg_creative']], + ['all', ['package'], ['off_a_pkg', 'off_b_pkg_creative', 'off_c_pkg_country']], + ['exact', ['package', 'country'], ['off_c_pkg_country']], + ]) { + test(`selector ${match} ${dimensions.join(' x ')}`, async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-11-04T00:00:00Z' })]); + await w.sync([subscription({ selector: { ...subscription().selector, dimensions, match } })]); + await w.step('2026-11-02T00:00:00.000Z'); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + owed.map(item => item.offering_id), + expected + ); + }); + } + + test('offering_unsupported and excluded_by_buyer are reported without removing owed periods elsewhere', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.grant('acct_x', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [ + buy('mb_1'), + buy('mb_dooh', { packages: [{ package_id: 'dooh_pkg', reporting_delivery_offering_ids: [] }] }), + buy('mb_mixed', { + packages: [ + { package_id: 'ok_pkg', reporting_delivery_offering_ids: ['off_a_pkg'] }, + { package_id: 'bad_pkg', reporting_delivery_offering_ids: [] }, + ], + }), + ]); + await w.sync([subscription({ exclude_account_ids: ['acct_x'] })]); + const read = await w.step('2026-11-02T00:00:00.000Z'); + assert.deepEqual(read.result.configuration.reporting_subscriptions[0].accounts_with_exceptions, [ + { account_id: 'acct_1', reasons: ['offering_unsupported'] }, + { account_id: 'acct_x', reasons: ['excluded_by_buyer'] }, + ]); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(coverage[0].status, 'active'); + assert.deepEqual( + coverage[0].excluded.map(item => [item.media_buy_id, item.package_ids]), + [ + ['mb_dooh', undefined], + ['mb_mixed', ['bad_pkg']], + ] + ); + const outcome = validateResponse( + 'get_reporting_status', + { status: 'completed', view: 'summary', subscription_coverage: coverage }, + PINNED + ); + assert.equal( + outcome.issues?.some(issue => /subscription_coverage/.test(issue.pointer ?? issue.path ?? '')) ?? false, + false, + JSON.stringify(outcome.issues) + ); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual([...new Set(owed.map(item => item.media_buy_id))], ['mb_1', 'mb_mixed']); + assert.equal((await w.engine.subscriptionCoverage(SCOPE, 'acct_x'))[0].status, 'excluded'); + }); + + test('reactivation is a fresh start the buyer recomputes from inactive_intervals', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + w.clock.now = new Date('2026-11-04T12:00:00.000Z'); + await w.sync([subscription({ active: false })]); + w.clock.now = new Date('2026-11-06T12:00:00.000Z'); + await w.sync([subscription()]); + await w.step('2026-11-06T12:00:00.000Z'); + const { states } = await w.lifecycle.listReportingSubscriptions(SCOPE); + assert.deepEqual(states[0].inactive_intervals, [ + { deactivated_at: '2026-11-04T12:00:00.000Z', reactivated_at: '2026-11-06T12:00:00.000Z' }, + ]); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + starts(owed).map(item => item.slice(-10)), + ['2026-11-03', '2026-11-04', '2026-11-07', '2026-11-08', '2026-11-09'] + ); + }); + + test('a deactivated subscription reads inactive while it has retained obligations, then drops out', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + await w.step('2026-11-04T00:00:00.000Z'); + w.clock.now = new Date('2026-11-04T12:00:00.000Z'); + await w.sync([subscription({ active: false })]); + await w.step('2026-11-05T00:00:00.000Z'); + // adcp#8613: never active once deactivated; deactivated_at is published with it. + const [entry] = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(entry.status, 'inactive'); + assert.equal(entry.deactivated_at, '2026-11-04T12:00:00.000Z'); + const { + reportingSubscriptionCoverageForReleaseV1, + reportingSubscriptionReleaseFieldsV1, + } = require('../../dist/lib/server/reporting-subscription-release.js'); + const beta1 = reportingSubscriptionCoverageForReleaseV1( + entry, + reportingSubscriptionReleaseFieldsV1('3.3.0-beta.2') + ); + assert.equal(beta1.status, 'active', 'a release without the inactive value sees the account-level state'); + assert.equal('deactivated_at' in beta1, false); + assert.equal( + reportingSubscriptionCoverageForReleaseV1(entry, { + ...reportingSubscriptionReleaseFieldsV1(undefined), + inactiveCoverageStatus: true, + }).status, + 'inactive' + ); + // The 11-04 period started before the deactivation and is retained for 30 days after it ends. + w.clock.now = new Date('2026-12-04T23:00:00.000Z'); + assert.equal((await w.engine.subscriptionCoverage(SCOPE, 'acct_1')).length, 1); + w.clock.now = new Date('2026-12-05T00:00:01.000Z'); + assert.deepEqual(await w.engine.subscriptionCoverage(SCOPE, 'acct_1'), [], 'omitted once nothing is retained'); + }); + + test('an account timezone change publishes previous_timezone and the buyer agrees', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [ + buy('mb_1', { + start_time: '2026-11-03T05:00:00Z', + end_time: '2026-11-08T08:00:00Z', + packages: [{ package_id: 'pkg_1', reporting_delivery_offering_ids: ['off_z_pkg_acct_tz'] }], + }), + ]); + await w.sync([subscription({ selector: { ...subscription().selector, alignment: 'account_timezone' } })]); + await w.step('2026-11-02T00:00:00.000Z'); + w.grant('acct_1', '2026-10-01T00:00:00.000Z', { + timezone: 'America/Los_Angeles', + timezone_changed_at: '2026-11-04T12:00:00.000Z', + }); + await w.step('2026-11-04T12:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.deepEqual(coverage[0].timezone_cutovers, [ + { + effective_at: '2026-11-05T05:00:00.000Z', + timezone: 'America/Los_Angeles', + previous_timezone: 'America/New_York', + }, + ]); + const owed = await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + assert.deepEqual( + owed.map(row => row.period_start), + [ + '2026-11-03T05:00:00.000Z', + '2026-11-04T05:00:00.000Z', + '2026-11-05T08:00:00.000Z', + '2026-11-06T08:00:00.000Z', + '2026-11-07T08:00:00.000Z', + ] + ); + }); + + test('withdrawing an offering keeps the elapsed owed history', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-12-31T00:00:00Z' })]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + await w.step('2026-11-15T00:00:00.000Z'); + const before = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-15T00:00:00.000Z' }); + assert.equal(before.length, 12, '11-03 through 11-14'); + // The seller withdraws the offering at 11-20 (kept, with withdrawn_at). + const withdrawn = OFFERINGS.find(item => item.offering_id === 'off_a_pkg'); + withdrawn.withdrawn_at = '2026-11-20T00:00:00Z'; + try { + await w.step('2026-11-21T00:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + const unsupported = coverage[0].excluded.find(item => item.reason === 'offering_unsupported'); + assert.equal(unsupported.from, '2026-11-20T00:00:00.000Z', 'opened at the E of the withdrawal, not accepted_at'); + const after = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-25T00:00:00.000Z' }); + assert.deepEqual( + after.map(item => item.period_start.slice(0, 10)), + before + .map(item => item.period_start.slice(0, 10)) + .concat(['2026-11-15', '2026-11-16', '2026-11-17', '2026-11-18', '2026-11-19']) + ); + } finally { + delete withdrawn.withdrawn_at; + } + }); + + test('a product dropping the offering mid-flight keeps the elapsed owed history', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-12-31T00:00:00Z' })]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + await w.step('2026-11-10T12:00:00.000Z'); + w.host.buys.set('acct_1', [ + buy('mb_1', { + end_time: '2026-12-31T00:00:00Z', + packages: [{ package_id: 'mb_1_pkg', reporting_delivery_offering_ids: [] }], + }), + ]); + await w.step('2026-11-12T12:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal( + coverage[0].excluded.find(item => item.reason === 'offering_unsupported').from, + '2026-11-13T00:00:00.000Z', + 'the product edit is observed at 11-12T12; E is the next boundary' + ); + const owed = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-20T00:00:00.000Z' }); + assert.deepEqual( + owed.map(item => item.period_start.slice(0, 10)), + [ + '2026-11-03', + '2026-11-04', + '2026-11-05', + '2026-11-06', + '2026-11-07', + '2026-11-08', + '2026-11-09', + '2026-11-10', + '2026-11-11', + '2026-11-12', + ] + ); + }); + + test('a product gaining the offering mid-flight owes periods from the edit, not retroactively', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + const noOffering = [{ package_id: 'mb_1_pkg', reporting_delivery_offering_ids: [] }]; + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-12-31T00:00:00Z', packages: noOffering })]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + await w.step('2026-11-10T12:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-12-31T00:00:00Z' })]); + await w.step('2026-11-12T12:00:00.000Z'); + const owed = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-16T00:00:00.000Z' }); + assert.deepEqual( + owed.map(item => item.period_start.slice(0, 10)), + ['2026-11-13', '2026-11-14', '2026-11-15'] + ); + }); + + test('a subscription with more than 1000 readback accounts is isolated, not fatal', async () => { + const w = world({ maxAccounts: 1 }); + for (let index = 0; index < 1002; index += 1) { + w.grant(`acct_${String(index).padStart(4, '0')}`, '2026-10-01T00:00:00.000Z'); + } + w.grant('acct_x', '2026-10-01T00:00:00.000Z'); + await w.sync([ + subscription(), + subscription({ subscription_id: 'analytics-x', scope: { account_ids: ['acct_x'] } }), + ]); + { + const result = await w.engine.reconcile(SCOPE); + assert.deepEqual( + result.readback_overflow.map(item => item.subscription_id), + ['analytics-daily'], + 'every account past max_accounts carries capacity, more than the readback can list' + ); + assert.ok(result.readback_overflow[0].accounts_with_exceptions > 1000); + } + // Other subscriptions keep updating. + w.revoke('acct_x', '2026-11-03T00:00:00.000Z'); + w.clock.now = new Date('2026-11-03T12:00:00.000Z'); + const result = await w.engine.reconcile(SCOPE); + assert.deepEqual( + result.readback_overflow.map(item => item.subscription_id), + ['analytics-daily'] + ); + const { states } = await w.lifecycle.listReportingSubscriptions(SCOPE); + const x = states.find(item => item.subscription_id === 'analytics-x'); + assert.deepEqual( + x.accounts_unauthorized.map(item => item.account_id), + ['acct_x'] + ); + const daily = states.find(item => item.subscription_id === 'analytics-daily'); + assert.equal(daily.accounts_with_exceptions, undefined, 'the oversized list is never published cut'); + }); + + test('pruning retained exceptions after status_retention_days never re-owes elapsed periods', async () => { + const w = world({ maxAccounts: 1 }); + for (const id of ['acct_a', 'acct_b']) { + w.grant(id, '2026-10-01T00:00:00.000Z'); + w.host.buys.set(id, [buy(`mb_${id}`, { end_time: '2026-12-31T00:00:00Z' })]); + } + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + // acct_a loses its slot's need: it leaves the roster, so acct_b takes a slot from 11-11. + w.revoke('acct_a', '2026-11-10T12:00:00.000Z'); + await w.step('2026-11-11T00:00:00.000Z'); + const owedBefore = await w.engine.owedPeriods(SCOPE, 'acct_b', { now: '2026-11-12T00:00:00.000Z' }); + assert.deepEqual( + owedBefore.map(item => item.period_start.slice(0, 10)), + ['2026-11-11'] + ); + // Long after status_retention_days (30), the closed capacity exception is pruned. + for (const day of ['2026-11-20', '2026-12-05', '2026-12-20']) await w.step(`${day}T00:00:00.000Z`); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_b'); + assert.equal(coverage[0].excluded.length, 0, 'the closed capacity entry left the wire'); + const owedAfter = await w.engine.owedPeriods(SCOPE, 'acct_b', { now: '2026-11-12T00:00:00.000Z' }); + assert.deepEqual( + owedAfter, + owedBefore.filter(item => Date.parse(item.period_end) + 30 * 86_400_000 > Date.parse('2026-12-20T00:00:00Z')) + ); + const later = await w.engine.owedPeriods(SCOPE, 'acct_b', { now: '2026-12-20T00:00:00.000Z' }); + assert.ok( + later.every(item => item.period_start >= '2026-11-11'), + 'periods refused under capacity never become owed' + ); + }); + + test("an override closes at the configuration's own active_until, not when the engine notices", async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-11-20T00:00:00Z' })]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + w.host.configs.set('acct_1', [ + { delivery_config_id: 'acme-analytics', feed_purpose: 'analytics', active_from: '2026-11-04T12:00:00.000Z' }, + ]); + await w.step('2026-11-04T12:00:00.000Z'); + // replace_account_configs ended it at 11-10T12; the engine reconciles only at 11-12T03. + w.host.configs.set('acct_1', [ + { + delivery_config_id: 'acme-analytics', + feed_purpose: 'analytics', + active_from: '2026-11-04T12:00:00.000Z', + active_until: '2026-11-10T12:00:00.000Z', + schedule: { period_duration: 'P1D', alignment: 'utc' }, + }, + ]); + await w.step('2026-11-12T03:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(coverage[0].excluded[0].until, '2026-11-11T00:00:00.000Z'); + const owed = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-14T00:00:00.000Z' }); + assert.deepEqual( + owed.map(item => item.period_start.slice(0, 10)), + ['2026-11-03', '2026-11-04', '2026-11-11', '2026-11-12', '2026-11-13'] + ); + }); + + test('capacity first observed after coverage began opens at the coverage start', async () => { + const w = world({ maxAccounts: 1 }); + for (const id of ['acct_a', 'acct_b']) { + w.grant(id, '2026-10-01T00:00:00.000Z'); + w.host.buys.set(id, [buy(`mb_${id}`)]); + } + await w.sync([subscription()]); + // The first reconcile runs days after acceptance. + await w.step('2026-11-05T00:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_b'); + assert.deepEqual(coverage[0].excluded, [{ reason: 'capacity', from: '2026-11-02T00:00:00.000Z' }]); + assert.deepEqual(await w.engine.owedPeriods(SCOPE, 'acct_b', { now: '2026-11-10T00:00:00.000Z' }), []); + }); + + test('an unobserved revocation followed by a new chain closes the old chain when it ended', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1', { end_time: '2026-11-20T00:00:00Z' })]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + // Between reconciles: revoked 11-04T06, re-granted after retention as a new chain at 11-08. + w.host.revokedAt.set('acct_1', '2026-11-04T06:00:00.000Z'); + w.grant('acct_1', '2026-11-08T00:00:00.000Z'); + await w.step('2026-11-09T00:00:00.000Z'); + const state = await w.engine.inspect(SCOPE); + assert.deepEqual(state.accounts.acct_1.account.grants, [ + { authorized_at: '2026-10-01T00:00:00.000Z', unauthorized_at: '2026-11-04T06:00:00.000Z' }, + { authorized_at: '2026-11-08T00:00:00.000Z' }, + ]); + const owed = await w.engine.owedPeriods(SCOPE, 'acct_1', { now: '2026-11-11T00:00:00.000Z' }); + assert.ok( + owed.every(item => item.period_start < '2026-11-05' || item.period_start >= '2026-11-08'), + 'the revoked interval is not owed' + ); + }); + + test('every hook the engine needs is required', () => { + const w = world(); + assert.throws( + () => + createReportingSubscriptionEngineV1({ + principal: w.lifecycle, + offerings: OFFERINGS, + statusRetentionDays: 30, + hooks: { listAuthorizedAccounts: () => ({ accounts: [] }), listAccountBuys: () => [] }, + }), + /resolveRevocationInstant/ + ); + }); + + test('an unfinished account roster fails closed instead of reading as revocations', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + const before = await w.engine.inspect(SCOPE); + let page = 0; + const endless = createReportingSubscriptionEngineV1({ + principal: w.lifecycle, + offerings: OFFERINGS, + statusRetentionDays: 30, + now: () => w.clock.now, + store: w.engineStore, + hooks: { + listAuthorizedAccounts: () => ({ accounts: [], nextCursor: `page-${++page}` }), + listAccountBuys: () => [], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + await assert.rejects(endless.reconcile(SCOPE), /partial roster/); + assert.deepEqual(await w.engine.inspect(SCOPE), before, 'no account was marked unauthorized'); + }); + + test('last_owed_period_end is the latest frozen period end, whatever the storage order', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + await w.step('2026-11-05T01:00:00.000Z'); + // Frozen periods are appended across reconciles and offerings, so storage order is not end + // order. Read them back reversed to prove the readback never trusts the last entry. + const get = w.engineStore.get.bind(w.engineStore); + w.engineStore.get = async (scope, key) => { + const row = await get(scope, key); + for (const target of Object.values(row?.value?.subscriptions ?? {})) target.frozen?.periods?.reverse(); + return row; + }; + // Revoked, and its buys are no longer visible, so no calendar resolves at the revocation. + w.revoke('acct_1', '2026-11-05T05:00:00.000Z'); + w.host.buys.set('acct_1', []); + const revoked = await w.step('2026-11-05T05:00:00.000Z'); + const [lost] = revoked.result.configuration.reporting_subscriptions[0].accounts_unauthorized; + assert.equal(lost.last_owed_period_end, '2026-11-05T00:00:00.000Z'); + }); + + test('a changed pending action refreshes the open action_required entry and notifies', async () => { + const w = world(); + const action = extra => ({ + waiting_on: 'buyer', + kind: 'accept_terms', + message: 'Accept reporting terms', + url: 'https://seller.example/terms', + ...extra, + }); + w.grant('acct_1', '2026-10-01T00:00:00.000Z', { pending_action: action() }); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + const [before] = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + const opened = before.excluded.find(entry => entry.reason === 'action_required'); + const notified = w.emitted.length; + // The seller moves the required step to a new page with a new message. + w.grant('acct_1', '2026-10-01T00:00:00.000Z', { + pending_action: action({ message: 'Accept the revised terms', url: 'https://seller.example/terms/v2' }), + }); + await w.step('2026-11-03T00:00:00.000Z'); + const [after] = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(after.status, 'action_required'); + assert.equal(after.pending_action.url, 'https://seller.example/terms/v2'); + assert.equal(after.pending_action.message, 'Accept the revised terms'); + const refreshed = after.excluded.find(entry => entry.reason === 'action_required'); + assert.equal(refreshed.from, opened.from, 'the open entry keeps its from'); + assert.equal(refreshed.until, undefined); + assert.ok(w.emitted.length > notified, 'the change fires principal.changed'); + assert.equal(w.emitted.at(-1).payload.reason, 'reporting_subscription_changed'); + }); + + test('a pending account action is an account-level action_required exception', async () => { + const w = world(); + w.grant('acct_1', '2026-10-01T00:00:00.000Z', { + pending_action: { + waiting_on: 'buyer', + kind: 'accept_terms', + message: 'Accept reporting terms', + url: 'https://seller.example/terms', + }, + }); + w.host.buys.set('acct_1', [buy('mb_1')]); + await w.sync([subscription()]); + await w.step('2026-11-02T00:00:00.000Z'); + const coverage = await w.engine.subscriptionCoverage(SCOPE, 'acct_1'); + assert.equal(coverage[0].status, 'action_required'); + assert.equal(coverage[0].pending_action.kind, 'accept_terms'); + await w.assertAgree('acct_1', '2026-11-12T00:00:00.000Z'); + }); +}); diff --git a/test/lib/reporting-subscription-errors.test.js b/test/lib/reporting-subscription-errors.test.js new file mode 100644 index 000000000..ba19e6d9e --- /dev/null +++ b/test/lib/reporting-subscription-errors.test.js @@ -0,0 +1,393 @@ +/** + * Typed reporting-subscription errors (experimental `media_buy.reporting_subscriptions`, AdCP 3.3): + * `ReportingSubscriptionError` (buyer), `CoverageInputError` (coverage rule), + * `ReportingSubscriptionEngineError` and `PlatformConfigError` codes (seller engine), and the + * `code`, `recovery` and `dispatched` fields on `PrincipalLifecycleError`. + */ +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const sdk = require('@adcp/sdk'); +const { + ADCPError, + CoverageInputError, + PrincipalLifecycleError, + PrincipalLifecycleTimeoutError, + ReportingSubscriptionEngineError, + ReportingSubscriptionError, + RequestValidationError, + isADCPError, + isPreDispatchError, + syncPrincipalLifecycle, + syncReportingSubscriptionsV1, +} = sdk; +const { PlatformConfigError } = require('@adcp/sdk/server'); +const advanced = require('@adcp/sdk/reporting/advanced'); +const { InMemoryStateStore } = require('@adcp/sdk/server'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); + +const SCOPE = { tenantId: 't', principalId: 'p' }; +const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; + +// The canonical error vocabulary (adcontextprotocol/adcp#8616), vendored byte for byte. +const VOCABULARY = require('../fixtures/reporting-subscriptions/canonical/error-codes.json'); +const BUYER_CODES = Object.fromEntries( + VOCABULARY.codes + .filter(entry => entry.sdk_classes.typescript === 'ReportingSubscriptionError') + .map(entry => [entry.code, entry]) +); + +function subscription(extra = {}) { + return { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'official' }, + reconciliation_mode: 'delivery_only', + ...extra, + }; +} + +function wrap(data) { + return { + success: true, + status: 'completed', + data, + metadata: { status: 'completed', taskName: 'principal' }, + conversation: [], + debug_logs: [], + }; +} + +function principalClient(lifecycle) { + return { + getPrincipal: async params => wrap(await lifecycle.protocol.getPrincipal(params ?? {}, CTX)), + syncPrincipal: async params => wrap(await lifecycle.protocol.syncPrincipal(params, CTX)), + }; +} + +function keys() { + let key = 0; + return { createIdempotencyKey: () => `idempotency-key-${String(++key).padStart(4, '0')}` }; +} + +describe('error-codes.json', () => { + test('every code maps to the TS class it names, with its recovery and dispatched rule', () => { + const classes = { ReportingSubscriptionError, CoverageInputError, PlatformConfigError }; + assert.equal(VOCABULARY.codes.length, 14); + for (const entry of VOCABULARY.codes) { + const Class = classes[entry.sdk_classes.typescript]; + assert.ok(Class, `${entry.code}: unknown TS class ${entry.sdk_classes.typescript}`); + const error = + Class === PlatformConfigError + ? new PlatformConfigError('message. Fix it.', { code: entry.code }) + : new Class(entry.code, 'message. Fix it.'); + assert.equal(error.name, entry.sdk_classes.typescript, entry.code); + assert.equal(error.code, entry.code); + assert.equal(error.recovery, entry.recovery, entry.code); + if (entry.dispatched === 'false') assert.equal(error.dispatched, false, entry.code); + else assert.equal('dispatched' in error, false, entry.code); + } + }); +}); + +describe('ReportingSubscriptionError', () => { + test('carries the shared code, recovery and dispatched vocabulary', () => { + for (const [code, expected] of Object.entries(BUYER_CODES)) { + const error = new ReportingSubscriptionError(code, 'message. Fix it.'); + assert.ok(error instanceof ADCPError && isADCPError(error), code); + assert.equal(error.name, 'ReportingSubscriptionError'); + assert.equal(error.code, code); + assert.equal(error.recovery, expected.recovery, code); + assert.equal(isPreDispatchError(error), expected.dispatched === 'false', code); + if (expected.dispatched === 'absent') assert.equal('dispatched' in error, false, code); + } + }); + + test('syncReportingSubscriptionsV1 refuses an omission before sending, with the subscriptions it would deactivate', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + const options = keys(); + const other = subscription({ subscription_id: 'analytics-other', scope: { account_ids: ['acct_2'] } }); + await syncReportingSubscriptionsV1(client, [subscription(), other], options); + let writes = 0; + const counting = { ...client, syncPrincipal: async params => (writes++, client.syncPrincipal(params)) }; + await assert.rejects(syncReportingSubscriptionsV1(counting, [subscription()], options), error => { + assert.ok(error instanceof ReportingSubscriptionError); + assert.equal(error.code, 'SUBSCRIPTION_WOULD_DEACTIVATE'); + assert.equal(error.recovery, 'correctable'); + assert.equal(error.dispatched, false); + assert.deepEqual(error.wouldDeactivate, ['analytics-other']); + assert.match(error.message, /pass allowDeactivation: true\.$/); + return true; + }); + assert.equal(writes, 0); + }); + + test('an omission found on the re-read after a conflict is still pre-dispatch: the conflicted write applied nothing', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + const options = keys(); + await syncReportingSubscriptionsV1(client, [subscription()], options); + // A concurrent writer adds a subscription between our read and our write. + let raced = false; + const racing = { + ...client, + syncPrincipal: async params => { + if (!raced) { + raced = true; + await client.syncPrincipal({ + idempotency_key: 'concurrent-writer-000001', + configuration: { + reporting_subscriptions: [ + subscription(), + subscription({ subscription_id: 'concurrent', scope: { account_ids: ['acct_9'] } }), + ], + }, + }); + } + return client.syncPrincipal(params); + }, + }; + await assert.rejects( + syncReportingSubscriptionsV1( + racing, + [subscription({ subscription_version: 2, exclude_account_ids: ['z'] })], + options + ), + error => { + assert.equal(error.code, 'SUBSCRIPTION_WOULD_DEACTIVATE'); + assert.deepEqual(error.wouldDeactivate, ['concurrent']); + assert.equal(error.dispatched, false, 'the refused write was never sent'); + return true; + } + ); + }); + + test('conflict exhaustion and a changed readback have their own codes', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + // A conflict retry needs the identity fence of a configured principal. + await syncReportingSubscriptionsV1(client, [subscription()], keys()); + const conflicting = { + ...client, + syncPrincipal: async () => + wrap({ result: { kind: 'failed', errors: [{ code: 'CONFLICT', message: 'stale', recovery: 'transient' }] } }), + }; + await assert.rejects( + syncReportingSubscriptionsV1(conflicting, [subscription()], { ...keys(), maxAttempts: 2 }), + error => { + assert.equal(error.code, 'SUBSCRIPTION_CONFLICT_EXHAUSTED'); + assert.equal(error.recovery, 'transient'); + assert.equal('dispatched' in error, false); + assert.ok(error.cause instanceof PrincipalLifecycleError); + assert.equal(error.cause.code, 'PRINCIPAL_CONFLICT_EXHAUSTED'); + assert.match(error.message, /retry later\.$/); + return true; + } + ); + const rewriting = { + ...client, + syncPrincipal: async params => { + const response = await client.syncPrincipal(params); + for (const state of response.data.result.configuration.reporting_subscriptions ?? []) { + state.configuration = { ...state.configuration, exclude_account_ids: ['seller-chosen'] }; + } + return response; + }, + }; + await assert.rejects( + syncReportingSubscriptionsV1( + rewriting, + [subscription({ subscription_version: 2, exclude_account_ids: ['z'] })], + keys() + ), + error => { + assert.equal(error.code, 'SUBSCRIPTION_READBACK_MISMATCH'); + assert.equal(error.recovery, 'terminal'); + assert.equal(error.readback.subscription_id, 'analytics-daily'); + assert.equal(error.appliedResponse.kind, 'applied'); + assert.match(error.message, /contact the seller\.$/); + return true; + } + ); + }); +}); + +describe('PrincipalLifecycleError', () => { + test('is an ADCPError with code and recovery; applied is a deprecated alias of appliedResponse', () => { + const applied = { kind: 'applied', principal_id: 'p', principal_kind: 'buyer_agent' }; + const error = new PrincipalLifecycleError('boom', { applied }); + assert.ok(isADCPError(error)); + assert.equal(error.code, 'PRINCIPAL_LIFECYCLE_FAILED'); + assert.equal(error.recovery, 'terminal'); + assert.equal(error.appliedResponse, applied); + assert.equal(error.applied, applied); + assert.equal('dispatched' in error, false); + const timeout = new PrincipalLifecycleTimeoutError(undefined, { appliedResponse: applied }); + assert.equal(timeout.code, 'PRINCIPAL_SETUP_TIMEOUT'); + assert.equal(timeout.recovery, 'transient'); + assert.equal(timeout.applied, applied); + const fromWire = new PrincipalLifecycleError('failed', { + code: 'PRINCIPAL_WRITE_FAILED', + protocolErrors: [{ code: 'INVALID_REQUEST', recovery: 'correctable' }], + }); + assert.equal(fromWire.recovery, 'correctable', "the seller's recovery wins"); + }); + + test('a principal-kind refusal on the first read is pre-dispatch', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + await syncPrincipalLifecycle(client, { reporting_subscriptions: [subscription()] }, keys()); + await assert.rejects( + syncPrincipalLifecycle( + client, + { reporting_subscriptions: [subscription()] }, + { + ...keys(), + expectedPrincipalKind: 'governance_agent', + } + ), + error => { + assert.equal(error.code, 'PRINCIPAL_IDENTITY_CHANGED'); + assert.equal(error.dispatched, false); + assert.ok(isPreDispatchError(error)); + return true; + } + ); + }); + + test('a write the client refused locally is pre-dispatch; a transport failure is not', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const client = principalClient(lifecycle); + const refusing = { + ...client, + syncPrincipal: async () => { + throw new RequestValidationError('sync_principal', ['/configuration'], 'invalid'); + }, + }; + await assert.rejects(syncPrincipalLifecycle(refusing, { reporting_subscriptions: [] }, keys()), error => { + assert.equal(error.code, 'PRINCIPAL_TRANSPORT_FAILED'); + assert.equal(error.dispatched, false); + assert.equal(error.recovery, 'correctable'); + return true; + }); + const broken = { + ...client, + syncPrincipal: async () => { + throw new Error('socket hang up'); + }, + }; + await assert.rejects(syncPrincipalLifecycle(broken, { reporting_subscriptions: [] }, keys()), error => { + assert.equal(error.code, 'PRINCIPAL_TRANSPORT_FAILED'); + assert.equal('dispatched' in error, false); + assert.equal(error.recovery, 'transient'); + assert.ok(error.attemptedRequest); + return true; + }); + }); +}); + +describe('coverage and engine errors', () => { + test('the coverage function throws CoverageInputError from the advanced subpath and the root', () => { + assert.equal(advanced.CoverageInputError, CoverageInputError); + assert.equal(advanced.ReportingSubscriptionEngineError, ReportingSubscriptionEngineError); + assert.throws(() => advanced.parseReportingPeriodDurationV1('P1W'), { + name: 'CoverageInputError', + code: 'COVERAGE_INPUT_INVALID', + detail: 'invalid_period_duration', + recovery: 'correctable', + }); + }); + + test('engine configuration mistakes are PlatformConfigError with a code and the option', () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + const hooks = { + listAuthorizedAccounts: () => ({ accounts: [] }), + listAccountBuys: () => [], + resolveRevocationInstant: (_p, _a, input) => input.observedAt, + isPeriodDelivered: () => false, + }; + assert.throws( + () => + advanced.createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [], + hooks, + statusRetentionDays: 0, + }), + error => + error instanceof PlatformConfigError && + error.code === 'PLATFORM_CONFIG_INVALID' && + error.configField === 'statusRetentionDays' && + error.recovery === 'correctable' + ); + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + assert.throws( + () => + advanced.createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [], + hooks, + statusRetentionDays: 30, + }), + { name: 'PlatformConfigError', code: 'REPORTING_STORAGE_NOT_DURABLE', configField: 'store' } + ); + } finally { + process.env.NODE_ENV = previous; + } + assert.throws( + () => + advanced.createReportingSubscriptionEngineStateStoreV1({ + store: new InMemoryStateStore(), + durability: 'durable', + }), + { name: 'PlatformConfigError', code: 'REPORTING_STORAGE_NOT_DURABLE' } + ); + }); + + test('runtime engine failures are ReportingSubscriptionEngineError', async () => { + const { lifecycle } = createTestPrincipalLifecycle({ reportingSubscriptions: {} }); + await syncReportingSubscriptionsV1(principalClient(lifecycle), [subscription()], keys()); + const base = { + principal: lifecycle, + offerings: [], + statusRetentionDays: 30, + hooks: { + listAuthorizedAccounts: () => ({ accounts: [], nextCursor: 'same' }), + listAccountBuys: () => [], + resolveRevocationInstant: (_p, _a, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }; + const looping = advanced.createReportingSubscriptionEngineV1({ + ...base, + hooks: { + ...base.hooks, + listAuthorizedAccounts: ({}, { cursor }) => ({ accounts: [], nextCursor: cursor ?? 'same' }), + }, + }); + await assert.rejects(looping.reconcile(SCOPE), { + name: 'ReportingSubscriptionEngineError', + code: 'SUBSCRIPTION_ROSTER_INCOMPLETE', + recovery: 'correctable', + }); + const store = advanced.createInMemoryReportingSubscriptionEngineStoreV1(); + const contended = advanced.createReportingSubscriptionEngineV1({ + ...base, + hooks: { ...base.hooks, listAuthorizedAccounts: () => ({ accounts: [] }) }, + maxCasAttempts: 2, + store: { ...store, put: async () => ({ outcome: 'conflict' }) }, + }); + await assert.rejects(contended.reconcile(SCOPE), { + name: 'ReportingSubscriptionEngineError', + code: 'SUBSCRIPTION_STATE_CONFLICT', + recovery: 'transient', + }); + }); +}); diff --git a/test/lib/reporting-subscription-grants.test.js b/test/lib/reporting-subscription-grants.test.js new file mode 100644 index 000000000..ead51a294 --- /dev/null +++ b/test/lib/reporting-subscription-grants.test.js @@ -0,0 +1,370 @@ +/** + * Grants and reporting subscriptions (experimental `media_buy.reporting_subscriptions`, AdCP 3.3), + * through the real server dispatch. Access means access: a full revocation makes the account + * unknown to every task, receipts and statuses included. A reduced reconciliation grant keeps + * statuses (and receipts) and coverage, because it still lists get_reporting_status; a grant + * without get_reporting_status ends coverage like a loss of authority. + */ +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const { createReliableReportingService } = require('../../dist/lib/reporting/service/index.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, + reportingSubscriptionCoverageGrantedV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { + createSyncReportingReceiptsHandler, + reportingSubscriptionReceiptAdmissibleV1, +} = require('../../dist/lib/reporting/ledger/index.js'); +const { + redactedReportingSourceOfferingV1, + redactedReportingSourceRequestV1, +} = require('../../dist/lib/reporting/source/index.js'); +const { createAdcpServerFromPlatform } = require('../../dist/lib/server/decisioning/runtime/from-platform.js'); +const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); + +const DAY = 86_400_000; +const ACCOUNT = 'account-a'; +const TENANT = 'tenant-1'; +const A = { tenantId: TENANT, principalId: 'principal-a' }; +const B = { tenantId: TENANT, principalId: 'principal-b' }; +const CLIENTS = { 'buyer-a': A, 'buyer-b': B }; +const offeringId = redactedReportingSourceOfferingV1.offeringId; + +function deliveryOffering() { + const source = redactedReportingSourceOfferingV1; + return { + offering_id: source.offeringId, + feed_purpose: 'analytics', + report_definition_id: source.contract.report_definition_id, + report_definition_uri: source.contract.reportDefinitionUri, + report_definition_sha256: source.contract.reportDefinitionSha256, + reporting_profile: { + id: source.contract.reportingProfile, + version: source.contract.schemaVersion, + schema_uri: source.contract.schemaUri, + schema_sha256: source.contract.schemaSha256, + schema_dialect: source.contract.schemaDialect, + schema_ref_policy: source.contract.schemaRefPolicy, + grain: source.grain, + primary_keys: ['media_buy_id'], + }, + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT0S' }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + standard_dimensions: [], + }; +} + +async function world() { + const origin = Math.floor(Date.now() / DAY) * DAY - 10 * DAY; + const day = n => new Date(origin + n * DAY).toISOString(); + const clock = { now: new Date(day(0.5)) }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + let key = 0; + for (const principal of [A, B]) { + const response = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: `subscription-post-loss-${String(++key).padStart(4, '0')}`, + configuration: { + reporting_subscriptions: [ + { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'snapshot' }, + reconciliation_mode: 'delivery_only', + }, + ], + }, + }, + { + callerMutationScope: { tenant_id: TENANT, principal_id: principal.principalId, principal_kind: 'buyer_agent' }, + } + ); + assert.equal(response.result.kind, 'applied', JSON.stringify(response)); + } + // The seller's grants: absent from the map means revoked; `undefined` tasks means unrestricted. + const grants = new Map([ + ['principal-a', undefined], + ['principal-b', undefined], + ]); + const permits = (principalId, task) => + grants.has(principalId) && (grants.get(principalId) === undefined || grants.get(principalId).includes(task)); + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [deliveryOffering()], + store: createInMemoryReportingSubscriptionEngineStoreV1(), + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: principal => ({ + accounts: grants.has(principal.principalId) + ? [ + { + account_id: ACCOUNT, + authorized_at: new Date(origin - 30 * DAY).toISOString(), + ...(grants.get(principal.principalId) ? { allowed_tasks: grants.get(principal.principalId) } : {}), + }, + ] + : [], + }), + listAccountBuys: principal => [ + { + media_buy_id: `mb-${principal.principalId}`, + confirmed_at: day(1.5), + start_time: day(3), + end_time: day(4), + packages: [{ package_id: 'pkg', reporting_delivery_offering_ids: [offeringId] }], + }, + ], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + const request = redactedReportingSourceRequestV1(); + const [template] = request.coverage.constituents; + const constituent = mediaBuyId => ({ + ...structuredClone(template), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(template.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }); + const sourceOffering = structuredClone(redactedReportingSourceOfferingV1); + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const store = new MemoryLedgerStore(); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const principalOf = context => CLIENTS[context.authInfo?.clientId]; + const service = createReliableReportingService({ + store, + adapters: { + fixture: { + sourceOffering, + deliveryOffering: deliveryOffering(), + fetchSlice: slice => ({ + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: 'USD', + reporting_rows: slice.media_buy_ids.map(mediaBuyId => ({ + media_buy_id: mediaBuyId, + impressions: 1, + spend: '1.00', + })), + }), + }, + }, + contact: { name: 'Reporting operations', email: 'reporting@example.com' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + resolveSource: account => ({ + adapterId: 'fixture', + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + resolveCoverage: () => ({ constituents: ['mb-principal-a', 'mb-principal-b'].map(constituent) }), + resolveConsumerId: context => context.authInfo.clientId, + reportingSubscriptions: { + engine, + resolvePrincipal: principalOf, + listSubscriptionPrincipals: () => [A, B], + resolveSubscriptionConfiguration: () => ({ + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceSettings, + }), + }, + }); + const platform = service.install({ + capabilities: { specialisms: [], config: {}, principal: lifecycle.capabilities }, + accounts: { + resolution: 'explicit', + // The seller's own resolver enforces its grants per task: no grant for the task, no account. + resolve: async (ref, ctx) => + ref?.account_id === ACCOUNT && permits(principalOf(ctx)?.principalId, ctx.toolName) + ? { id: ACCOUNT, name: 'Account A', status: 'active', ctx_metadata: {} } + : null, + upsert: async () => [], + }, + }); + const server = createAdcpServerFromPlatform(platform, { + name: 'reporting-subscription-post-loss', + version: '1.0.0', + protocol: lifecycle.protocol, + validation: { requests: 'strict', responses: 'strict' }, + }); + const call = (clientId, name, args) => + server.dispatchTestRequest( + { method: 'tools/call', params: { name, arguments: { account: { account_id: ACCOUNT }, ...args } } }, + { authInfo: { clientId } } + ); + clock.now = new Date(); + await engine.reconcile(A); + await engine.reconcile(B); + const cycle = await service.runCycle({ accountId: ACCOUNT }); + assert.equal(cycle.revisionsCommitted, 2, JSON.stringify(cycle)); + const revisionOf = principal => + [...store.revisions.values()].find( + value => store.obligations.get(value.reporting_obligation_id).subscription.principalId === principal.principalId + ); + const statusFor = (principal, id) => { + const revision = revisionOf(principal); + const obligation = store.obligations.get(revision.reporting_obligation_id); + return { + reporting_status_id: `post-loss-status-${id}`, + delivery_config_id: 'analytics-daily', + delivery_config_version: 1, + report_definition_id: obligation.report_definition_id, + reporting_obligation_id: obligation.reporting_obligation_id, + reporting_revision_id: revision.reporting_revision_id, + observed_revision_content_sha256: revision.wireRevision.revision_content_sha256, + period: { + start: obligation.period.start, + end: obligation.period.end, + source_timezone: obligation.period.sourceTimezone, + }, + consumer_status: 'received', + status_as_of: new Date().toISOString(), + }; + }; + const syncStatus = (clientId, status) => + call(clientId, 'sync_reporting_status', { + idempotency_key: `post-loss-batch-${status.reporting_status_id}`, + statuses: [status], + }); + const reconcileAt = async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + clock.now = new Date(); + await engine.reconcile(A); + }; + return { + clock, + engine, + lifecycle, + service, + store, + server, + call, + syncStatus, + statusFor, + revisionOf, + grants, + reconcileAt, + }; +} + +const RECONCILIATION = ['get_reporting_status', 'sync_reporting_receipts', 'sync_reporting_status']; +const unauthorizedOf = async (lifecycle, principal) => + (await lifecycle.listReportingSubscriptions(principal)).states[0].accounts_unauthorized ?? []; + +describe('grants and reporting subscriptions', () => { + test('a reduced reconciliation grant keeps statuses and coverage', async () => { + const { engine, lifecycle, syncStatus, statusFor, grants, reconcileAt, call } = await world(); + grants.set('principal-a', RECONCILIATION); + await reconcileAt(); + const accepted = await syncStatus('buyer-a', statusFor(A, 'reduced-grant-status-1')); + assert.notEqual(accepted.isError, true, JSON.stringify(accepted.structuredContent)); + assert.equal(accepted.structuredContent.results[0].result, 'recorded', JSON.stringify(accepted.structuredContent)); + assert.equal((await engine.subscriptionCoverage(A, ACCOUNT)).length, 1, 'coverage continues'); + assert.equal(await engine.isAuthorized(A, ACCOUNT), true); + assert.deepEqual(await unauthorizedOf(lifecycle, A), []); + const read = await call('buyer-a', 'get_reporting_status', { view: 'summary' }); + assert.notEqual(read.isError, true, JSON.stringify(read.structuredContent)); + // A task the grant leaves out is refused by the seller's own resolver. + const refused = await call('buyer-a', 'get_media_buy_delivery', { reporting_revision_id: 'unused' }); + assert.equal( + refused.structuredContent.adcp_error?.code ?? refused.structuredContent.errors?.[0]?.code, + 'ACCOUNT_NOT_FOUND' + ); + }); + + test('a full revocation makes the account unknown to statuses too', async () => { + const { engine, lifecycle, syncStatus, statusFor, grants, reconcileAt } = await world(); + grants.delete('principal-a'); + await reconcileAt(); + const refused = await syncStatus('buyer-a', statusFor(A, 'revoked-grant-status1')); + assert.equal( + refused.structuredContent.adcp_error?.code ?? refused.structuredContent.errors?.[0]?.code, + 'ACCOUNT_NOT_FOUND', + JSON.stringify(refused.structuredContent) + ); + assert.equal(await engine.isAuthorized(A, ACCOUNT), false); + assert.equal((await unauthorizedOf(lifecycle, A)).length, 1); + }); + + test('a grant without get_reporting_status ends coverage like a loss of authority', async () => { + const { engine, lifecycle, syncStatus, statusFor, grants, reconcileAt } = await world(); + grants.set('principal-a', ['sync_reporting_status']); + await reconcileAt(); + assert.equal(await engine.isAuthorized(A, ACCOUNT), false); + assert.deepEqual(await engine.subscriptionCoverage(A, ACCOUNT), []); + const [lost] = await unauthorizedOf(lifecycle, A); + assert.equal(lost.account_id, ACCOUNT); + // The seller still lets the status through by its grant; the obligation stays the owner's. + const accepted = await syncStatus('buyer-a', statusFor(A, 'status-only-grant-001')); + assert.equal(accepted.structuredContent.results[0].result, 'recorded', JSON.stringify(accepted.structuredContent)); + // B, with an unrestricted grant, is unaffected; and B cannot report on A's obligation. + assert.equal(await engine.isAuthorized(B, ACCOUNT), true); + const foreign = await syncStatus('buyer-b', statusFor(A, 'foreign-obligation-01')); + assert.equal(foreign.structuredContent.results[0].result, 'failed', JSON.stringify(foreign.structuredContent)); + }); + + test('the coverage grant rule and the receipt admission rule', async () => { + assert.equal(reportingSubscriptionCoverageGrantedV1({}), true, 'no task restriction is every task'); + assert.equal(reportingSubscriptionCoverageGrantedV1({ allowed_tasks: RECONCILIATION }), true); + assert.equal(reportingSubscriptionCoverageGrantedV1({ allowed_tasks: ['sync_reporting_receipts'] }), false); + const owned = { subscription: { ...A, subscriptionId: 's', offeringId: 'o', ownership: 'owed_set' } }; + assert.equal(reportingSubscriptionReceiptAdmissibleV1({ obligation: owned }), true, 'subscriptions off: unscoped'); + assert.equal(reportingSubscriptionReceiptAdmissibleV1({ obligation: {}, admission: { principal: B } }), true); + assert.equal(reportingSubscriptionReceiptAdmissibleV1({ obligation: owned, admission: { principal: B } }), false); + assert.equal(reportingSubscriptionReceiptAdmissibleV1({ obligation: owned, admission: { principal: A } }), true); + // The receipt handler sends the caller's principal to a store that enforces it. + const seen = []; + const store = { receiptAdmission: true, syncReceiptBatch: async input => (seen.push(input.admission), []) }; + assert.throws(() => + createSyncReportingReceiptsHandler({ syncReceiptBatch: async () => [] }, () => 'consumer', undefined, { + resolveSubscriptionPrincipal: () => A, + }) + ); + const handler = createSyncReportingReceiptsHandler(store, () => 'buyer-a', undefined, { + resolveSubscriptionPrincipal: () => A, + }); + await handler( + { + idempotency_key: 'receipt-grant-batch-0001', + receipts: [ + { + reporting_receipt_id: 'receipt-grant-00000001', + reporting_obligation_id: 'obligation-1', + reporting_revision_id: 'revision-1', + reporting_materialization_id: 'materialization-1', + status: 'accepted', + verification_profile: 'canonical_digest', + observed_row_count: 1, + observed_control_totals: [{ name: 'impressions', value: '4', value_type: 'integer', unit: 'impressions' }], + observed_canonical_content_digest: { + algorithm: 'sha256', + value: '6'.repeat(64), + canonicalization_id: 'analytics-rows-v1', + canonicalization_uri: 'https://schemas.fixture.example/canonicalization.json', + canonicalization_sha256: 'c'.repeat(64), + }, + observed_manifest_sha256: 'f'.repeat(64), + observed_at: '2026-09-30T00:00:00.000Z', + }, + ], + }, + { account: { id: ACCOUNT } } + ); + assert.deepEqual(seen, [{ principal: A }]); + }); +}); diff --git a/test/lib/reporting-subscription-ledger.test.js b/test/lib/reporting-subscription-ledger.test.js new file mode 100644 index 000000000..eaadc4b41 --- /dev/null +++ b/test/lib/reporting-subscription-ledger.test.js @@ -0,0 +1,490 @@ +/** + * Subscription obligations in the reporting ledger (experimental `media_buy.reporting_subscriptions`, + * AdCP 3.3): owed-set generations keyed by owner and offering, sparse periods, the owedThrough + * horizon, per-period frozen media buys, status ingest against owed periods only, and caller + * isolation between principals that share a subscription_id on one account. + */ +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { describe, test } = require('node:test'); + +const { + ReportingConfigurationImmutableError, + createReportingDeliveryHandler, + createReportingProducer, + createReportingStatusHandler, + createSyncReportingStatusHandler, + reportingLedgerSubscriptionKeyV1, +} = require('../../dist/lib/reporting/ledger/index.js'); +const { + canonicalJsonV1, + createInlineReportingSourceExecutor, + redactedReportingSourceOfferingV1, + redactedReportingSourceRequestV1, +} = require('../../dist/lib/reporting/source/index.js'); +const { GetReportingStatusResponseSchema } = require('../../dist/lib/schemas/index.js'); + +const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); + +const DAY = 86_400_000; +const ACCOUNT = 'subscription-ledger-account'; +const ORIGIN = Date.parse('2026-09-01T00:00:00Z'); +const day = n => new Date(ORIGIN + n * DAY).toISOString(); +const request = redactedReportingSourceRequestV1(); +const offering = redactedReportingSourceOfferingV1; +const secondOffering = { + ...structuredClone(offering), + offeringId: 'fixture-second-offering', + publicationNamespace: 'reporting-source:fixture-second', +}; +const PRINCIPAL_A = { tenantId: 'tenant-1', principalId: 'principal-a' }; +const PRINCIPAL_B = { tenantId: 'tenant-1', principalId: 'principal-b' }; + +function sha(value) { + return createHash('sha256').update(canonicalJsonV1(value)).digest('hex'); +} + +function constituent(mediaBuyId) { + const base = request.coverage.constituents[0]; + return { + ...structuredClone(base), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(base.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }; +} + +function generation( + principal, + { version = 1, offeringId = offering.offeringId, subscriptionId = 'analytics-daily' } = {} +) { + return { + account: { account_id: ACCOUNT }, + sourceScope: request.sourceScope, + delivery_config_id: subscriptionId, + delivery_config_version: version, + offeringId, + report_definition_id: request.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'snapshot', + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceTimezone: 'UTC', + schedule: { + anchor: '1970-01-01T00:00:00.000Z', + periodMilliseconds: DAY, + deliverySlaMilliseconds: 3_600_000, + recoveryWindowMilliseconds: DAY, + periodDuration: 'P1D', + alignment: 'utc', + deliverySlaDuration: 'PT1H', + }, + sourceSettings: request.sourceSettings, + contract: request.contract, + subscription: { ...principal, subscriptionId, offeringId, ownership: 'owed_set' }, + }; +} + +function owed(start, mediaBuyIds, { version = 1, offeringId = offering.offeringId } = {}) { + return { + delivery_config_version: version, + offeringId, + period: { start: day(start), end: day(start + 1) }, + mediaBuyIds, + constituents: mediaBuyIds.map(constituent), + }; +} + +function plan(principal, obligations, owedThrough, configurations = [generation(principal)]) { + return { + account_id: ACCOUNT, + principal, + subscriptionId: configurations[0]?.delivery_config_id ?? 'analytics-daily', + configurations, + obligations, + owedThrough: day(owedThrough), + now: day(owedThrough), + }; +} + +function setup({ rows = mediaBuyId => [{ media_buy_id: mediaBuyId, impressions: 1, spend: '1.00' }] } = {}) { + const store = new MemoryLedgerStore(); + const source = createInlineReportingSourceExecutor( + slice => slice.media_buy_ids.flatMap(mediaBuyId => rows(mediaBuyId)), + offering + ); + const producer = createReportingProducer({ + store, + source, + offerings: [offering, secondOffering], + contact: { name: 'Reporting operations' }, + }); + return { store, producer }; +} + +const statusHandler = store => + createReportingStatusHandler(store, { resolveSubscriptionPrincipal: context => context.principal }); +const read = (store, principal, body) => + statusHandler(store)( + { account: { account_id: ACCOUNT }, pagination: { max_results: 100 }, ...body }, + { account: { account_id: ACCOUNT }, ...(principal ? { principal } : {}) } + ); + +describe('subscription obligations in the reporting ledger', () => { + test('plans a sparse owed set without grid fill or a continuity error', async () => { + const { store, producer } = setup(); + const result = await producer.planSubscriptionObligations( + plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(3, ['mb-a']), owed(7, ['mb-a'])], 8) + ); + assert.equal(result.created.length, 3); + assert.deepEqual( + result.created.map(value => value.periodOrdinal), + [1, 3, 7].map(n => Math.floor(Date.parse(day(n)) / 1_000)) + ); + const [configuration] = result.configurations; + assert.equal(configuration.delivery_config_id, 'analytics-daily'); + assert.equal(configuration.delivery_config_version, 1); + assert.deepEqual(configuration.mediaBuyIds, []); + assert.deepEqual(configuration.constituents, []); + assert.deepEqual(configuration.owedSet, { + through: day(8), + from: day(1), + until: day(8), + mediaBuyIds: ['mb-a'], + }); + + // The grid planner never fills the gaps of an owed set. + assert.deepEqual(await producer.planObligations(day(9)), []); + // Replanning is idempotent. + const replay = await producer.planSubscriptionObligations( + plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(3, ['mb-a']), owed(7, ['mb-a'])], 8) + ); + assert.deepEqual(replay.created, []); + assert.equal(store.obligations.size, 3); + + store.ledgerAsOf = day(9); + const summary = await read(store, PRINCIPAL_A, { view: 'summary', period: { start: day(0), end: day(8) } }); + assert.equal(summary.status, 'completed', JSON.stringify(summary)); + assert.equal(summary.scope.coverage_complete, true); + assert.equal(summary.scope.scope_closed, true); + assert.equal(summary.obligation_counts.total, 3); + assert.deepEqual(summary.scope.delivery_config_generations, [ + { delivery_config_id: 'analytics-daily', delivery_config_version: 1, feed_purpose: 'analytics' }, + ]); + assert.equal(summary.issues.filter(issue => issue.code === 'HISTORY_UNAVAILABLE').length, 0); + const parsed = GetReportingStatusResponseSchema.safeParse(summary); + assert.equal(parsed.success, true, parsed.success ? undefined : JSON.stringify(parsed.error.issues)); + + const periods = await read(store, PRINCIPAL_A, { view: 'periods', period: { start: day(0), end: day(8) } }); + assert.equal(periods.periods.length, 3); + for (const period of periods.periods) { + assert.equal(period.delivery_config_id, 'analytics-daily'); + assert.equal(period.delivery_config_version, 1); + assert.equal(period.offering_id, offering.offeringId); + assert.equal(period.report_definition_id, request.report_definition_id); + assert.equal(period.scope_resolved_at, period.period.end); + assert.equal(period.expected_at, new Date(Date.parse(period.period.end) + 3_600_000).toISOString()); + assert.deepEqual(period.schedule, { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT1H' }); + } + }); + + test('completeness follows owedThrough rather than ordinal counting', async () => { + const { store, producer } = setup(); + await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 4)); + store.ledgerAsOf = day(9); + const open = await read(store, PRINCIPAL_A, { view: 'summary', period: { start: day(0), end: day(8) } }); + assert.equal(open.status, 'completed', JSON.stringify(open)); + // Owed periods ending after day 4 may still be frozen into this scope: it is open, not + // missing history, so coverage stays complete and no HISTORY_UNAVAILABLE is raised. + assert.equal(open.scope.scope_closed, false); + assert.equal(open.scope.coverage_complete, true); + assert.notEqual(open.health, 'complete'); + assert.equal(open.issues.filter(issue => issue.code === 'HISTORY_UNAVAILABLE').length, 0); + + await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(6, ['mb-a'])], 8)); + const closed = await read(store, PRINCIPAL_A, { view: 'summary', period: { start: day(0), end: day(8) } }); + assert.equal(closed.scope.scope_closed, true); + assert.equal(closed.obligation_counts.total, 2); + // owedThrough never moves backwards. + const [configuration] = await store.listConfigurations(ACCOUNT); + await store.recordOwedSetProgress({ + configurationId: configuration.configurationId, + account_id: ACCOUNT, + through: day(2), + }); + assert.equal((await store.listConfigurations(ACCOUNT))[0].owedSet.through, day(8)); + }); + + test('installs one generation per resolved offering of a version', async () => { + const { store, producer } = setup(); + const result = await producer.planSubscriptionObligations( + plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(1, ['mb-b'], { offeringId: secondOffering.offeringId })], 3, [ + generation(PRINCIPAL_A), + generation(PRINCIPAL_A, { offeringId: secondOffering.offeringId }), + ]) + ); + assert.equal(result.configurations.length, 2); + const ids = new Set(result.configurations.map(value => value.configurationId)); + assert.equal(ids.size, 2); + for (const configuration of result.configurations) { + assert.equal(configuration.delivery_config_id, 'analytics-daily'); + assert.equal(configuration.delivery_config_version, 1); + assert.equal(configuration.subscription.offeringId, configuration.offeringId); + } + store.ledgerAsOf = day(4); + const periods = await read(store, PRINCIPAL_A, { view: 'periods', period: { start: day(0), end: day(3) } }); + assert.deepEqual(periods.periods.map(value => value.offering_id).sort(), [ + offering.offeringId, + secondOffering.offeringId, + ]); + // The wire generation is (delivery_config_id, delivery_config_version, feed_purpose) with + // uniqueItems, so both offerings of one version appear as that one generation. + assert.deepEqual(periods.scope.delivery_config_generations, [ + { delivery_config_id: 'analytics-daily', delivery_config_version: 1, feed_purpose: 'analytics' }, + ]); + // A new version installs its own generation beside the old one. + const next = await producer.planSubscriptionObligations( + plan(PRINCIPAL_A, [owed(3, ['mb-a'], { version: 2 })], 4, [generation(PRINCIPAL_A, { version: 2 })]) + ); + assert.equal(next.configurations.length, 3, 'owedThrough advances on every generation the subscription owns'); + assert.ok(next.configurations.every(value => value.owedSet.through === day(4))); + const summary = await read(store, PRINCIPAL_A, { view: 'summary', period: { start: day(0), end: day(4) } }); + assert.deepEqual( + summary.scope.delivery_config_generations.map(value => value.delivery_config_version).sort(), + [1, 2] + ); + }); + + test('keeps two principals with the same subscription_id apart', async () => { + const { store, producer } = setup(); + const a = await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 3)); + const b = await producer.planSubscriptionObligations(plan(PRINCIPAL_B, [owed(2, ['mb-b'])], 3)); + assert.notEqual(a.configurations[0].configurationId, b.configurations[0].configurationId); + assert.notEqual( + reportingLedgerSubscriptionKeyV1(a.configurations[0]), + reportingLedgerSubscriptionKeyV1(b.configurations[0]) + ); + assert.equal((await store.listConfigurations(ACCOUNT)).length, 2); + + const worked = await producer.runWorker({ now: () => new Date(Date.parse(day(4))), maxIterations: 10 }); + assert.equal(worked.revisionsCommitted, 2, JSON.stringify(worked)); + store.ledgerAsOf = day(5); + const forA = await read(store, PRINCIPAL_A, { view: 'periods', period: { start: day(0), end: day(3) } }); + const forB = await read(store, PRINCIPAL_B, { view: 'periods', period: { start: day(0), end: day(3) } }); + assert.deepEqual( + forA.periods.map(value => value.media_buy_ids), + [['mb-a']] + ); + assert.deepEqual( + forB.periods.map(value => value.media_buy_ids), + [['mb-b']] + ); + // A caller with no principal sees no subscription generation at all. + const anonymous = await createReportingStatusHandler(store)( + { account: { account_id: ACCOUNT }, view: 'periods', period: { start: day(0), end: day(3) } }, + { account: { account_id: ACCOUNT } } + ); + assert.deepEqual(anonymous.periods, []); + assert.deepEqual(anonymous.scope.delivery_config_generations, []); + + // B cannot read A's revision by id. + const revisionOfA = forA.revisions[0].reporting_revision_id; + const delivery = createReportingDeliveryHandler(store, { + resolveSubscriptionPrincipal: context => context.principal, + }); + const own = await delivery( + { reporting_revision_id: revisionOfA }, + { account: { account_id: ACCOUNT }, principal: PRINCIPAL_A } + ); + assert.equal(own.reporting_revision_binding?.reporting_revision_id ?? revisionOfA, revisionOfA); + await assert.rejects( + delivery({ reporting_revision_id: revisionOfA }, { account: { account_id: ACCOUNT }, principal: PRINCIPAL_B }), + error => error.code === 'REFERENCE_NOT_FOUND' + ); + + // A store that leaks another principal's generation is refused, not served. + const leaky = Object.create(store); + leaky.createSnapshot = async query => ({ + ...(await store.createSnapshot({ ...query, subscription_principal: PRINCIPAL_B })), + query, + }); + await assert.rejects( + statusHandler(leaky)( + { account: { account_id: ACCOUNT }, view: 'summary', period: { start: day(0), end: day(3) } }, + { account: { account_id: ACCOUNT }, principal: PRINCIPAL_A } + ), + /subscription generation the caller does not own/ + ); + }); + + test('freezes media_buy_ids per period and matches scope on the written owed set', async () => { + const { store, producer } = setup(); + await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(2, ['mb-b', 'mb-a'])], 3)); + const [first, second] = [...store.obligations.values()].sort((l, r) => l.periodOrdinal - r.periodOrdinal); + assert.deepEqual(first.mediaBuyIds, ['mb-a']); + assert.deepEqual(first.coverage.mediaBuyIds, ['mb-a']); + assert.equal(first.scopeResolvedAt, day(2)); + assert.deepEqual(second.mediaBuyIds, ['mb-a', 'mb-b']); + assert.deepEqual( + second.constituents.map(value => value.mediaBuyId), + ['mb-b', 'mb-a'] + ); + store.ledgerAsOf = day(4); + const filtered = await read(store, PRINCIPAL_A, { + view: 'summary', + media_buy_ids: ['mb-b'], + period: { start: day(0), end: day(3) }, + }); + assert.equal(filtered.status, 'completed', JSON.stringify(filtered)); + assert.deepEqual(filtered.scope.delivery_config_generations, [ + { delivery_config_id: 'analytics-daily', delivery_config_version: 1, feed_purpose: 'analytics' }, + ]); + const unknown = await read(store, PRINCIPAL_A, { + view: 'summary', + media_buy_ids: ['mb-unknown'], + period: { start: day(0), end: day(3) }, + }); + assert.equal(unknown.failure_kind, 'lookup_unavailable'); + // Another principal does not learn that mb-b exists on this subscription. + const other = await read(store, PRINCIPAL_B, { + view: 'summary', + media_buy_ids: ['mb-b'], + period: { start: day(0), end: day(3) }, + }); + assert.equal(other.failure_kind, 'lookup_unavailable'); + }); + + test('status ingest accepts only owed periods of the caller', async () => { + const { store, producer } = setup(); + await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a']), owed(3, ['mb-a'])], 4)); + store.ledgerAsOf = day(5); + const ingest = createSyncReportingStatusHandler(store, { + resolveConsumerId: context => `consumer-${context.principal.principalId}`, + resolveSubscriptionPrincipal: context => context.principal, + now: () => new Date(Date.parse(day(5))), + }); + const status = (n, id) => ({ + reporting_status_id: `subscription-status-${id}`, + delivery_config_id: 'analytics-daily', + delivery_config_version: 1, + report_definition_id: request.report_definition_id, + period: { start: day(n), end: day(n + 1), source_timezone: 'UTC' }, + consumer_status: 'obligation_missing', + status_as_of: day(n + 1.5), + }); + const submit = (principal, statuses, key) => + ingest( + { account: { account_id: ACCOUNT }, idempotency_key: `subscription-batch-${key}`, statuses }, + { account: { id: ACCOUNT }, principal } + ); + const owedResult = await submit(PRINCIPAL_A, [status(1, 'owed-0001')], 'owed-0001'); + assert.equal(owedResult.results[0].result, 'recorded', JSON.stringify(owedResult)); + // Day 2 is a gap in the owed set: not a bare grid slot the caller may report on. + const gap = await submit(PRINCIPAL_A, [status(2, 'gap-00001')], 'gap-00001'); + assert.equal(gap.results[0].result, 'failed', JSON.stringify(gap)); + assert.equal(gap.results[0].errors[0].code, 'VALIDATION_ERROR'); + // Another principal cannot report on A's owed period. + const foreign = await submit(PRINCIPAL_B, [status(3, 'foreign-01')], 'foreign-01'); + assert.equal(foreign.results[0].result, 'failed', JSON.stringify(foreign)); + // Naming A's obligation id does not help either. + const [obligation] = [...store.obligations.values()].filter(value => value.period.start === day(3)); + const named = await submit( + PRINCIPAL_B, + [{ ...status(3, 'foreign-02'), reporting_obligation_id: obligation.reporting_obligation_id }], + 'foreign-02' + ); + assert.equal(named.results[0].result, 'failed', JSON.stringify(named)); + }); + + test('refuses unsafe plans', async () => { + const { producer } = setup(); + await producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 3)); + // A generation is immutable. + const changed = generation(PRINCIPAL_A); + changed.requestedMetrics = ['impressions']; + await assert.rejects( + producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 3, [changed])), + error => error instanceof ReportingConfigurationImmutableError + ); + // Only frozen periods are written. + await assert.rejects( + producer.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(5, ['mb-a'])], 3)), + /owedThrough/ + ); + // A denominator that disagrees with the frozen media buys is refused. + const mismatched = owed(2, ['mb-a', 'mb-b']); + mismatched.constituents = [constituent('mb-a')]; + await assert.rejects(producer.planSubscriptionObligations(plan(PRINCIPAL_A, [mismatched], 3)), /denominator/); + // Subscription generations never go through installConfiguration. + await assert.rejects( + producer.installConfiguration({ ...generation(PRINCIPAL_A), constituents: [], mediaBuyIds: [] }), + /planSubscriptionObligations/ + ); + // A store without owed-set support cannot hold subscription generations. + const legacyStore = new MemoryLedgerStore(); + legacyStore.recordOwedSetProgress = undefined; + const legacy = createReportingProducer({ + store: legacyStore, + source: createInlineReportingSourceExecutor(() => [], offering), + offerings: [offering], + contact: { name: 'Reporting operations' }, + }); + await assert.rejects( + legacy.planSubscriptionObligations(plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 3)), + /subscription generations/ + ); + }); + + test('keeps account generation ids and fingerprints unchanged', async () => { + const { store, producer } = setup(); + const input = { + account: { account_id: ACCOUNT }, + sourceScope: request.sourceScope, + delivery_config_id: request.delivery_config_id, + delivery_config_version: 1, + offeringId: request.offeringId, + report_definition_id: request.report_definition_id, + feedPurpose: 'analytics', + requiredFinality: 'snapshot', + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + constituents: request.coverage.constituents, + mediaBuyIds: request.coverage.mediaBuyIds, + sourceTimezone: 'UTC', + schedule: { + anchor: request.period.start, + periodMilliseconds: DAY, + deliverySlaMilliseconds: 0, + recoveryWindowMilliseconds: DAY, + }, + sourceSettings: request.sourceSettings, + contract: request.contract, + }; + const installed = await producer.installConfiguration(input); + assert.equal(installed.configurationId, `rcfg_${sha([ACCOUNT, request.delivery_config_id, 1]).slice(0, 32)}`); + assert.equal( + installed.semanticFingerprint, + `sha256:${sha({ ...input, schedule: { ...input.schedule, anchor: new Date(input.schedule.anchor).toISOString() } })}` + ); + assert.equal(installed.subscription, undefined); + assert.equal(installed.owedSet, undefined); + assert.equal(reportingLedgerSubscriptionKeyV1(installed), ''); + // An account generation and a subscription generation with the same id coexist. + await producer.planSubscriptionObligations( + plan(PRINCIPAL_A, [owed(1, ['mb-a'])], 3, [ + generation(PRINCIPAL_A, { subscriptionId: request.delivery_config_id }), + ]) + ); + assert.equal((await store.listConfigurations(ACCOUNT)).length, 2); + // A principal without subscriptions sees the account generation exactly as before. + store.ledgerAsOf = day(4); + const plain = await createReportingStatusHandler(store)( + { account: { account_id: ACCOUNT }, view: 'periods', period: { start: day(0), end: day(3) } }, + { account: { account_id: ACCOUNT } } + ); + assert.equal(plain.status, 'completed', JSON.stringify(plain)); + assert.deepEqual(plain.periods, []); + const owner = await read(store, PRINCIPAL_A, { view: 'periods', period: { start: day(0), end: day(3) } }); + assert.equal(owner.periods.length, 1); + }); +}); diff --git a/test/lib/reporting-subscription-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js new file mode 100644 index 000000000..0fb8c0e64 --- /dev/null +++ b/test/lib/reporting-subscription-postgres.test.js @@ -0,0 +1,176 @@ +/** + * Reporting subscriptions on PostgreSQL: the principal row (versions, history, readback) + * and the engine state survive a process restart on PostgresStateStore. + * + * Requires a running PostgreSQL instance. Set DATABASE_URL to run: + * DATABASE_URL=postgres://localhost/test node --test test/lib/reporting-subscription-postgres.test.js + * + * Skipped entirely when DATABASE_URL is not set. + */ +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; +const DATABASE_URL = process.env.DATABASE_URL; +const PRINCIPALS = `adcp_principals_subscriptions_${process.pid}`; +const ENGINE = `adcp_subscription_engine_${process.pid}`; + +describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATABASE_URL not set' }, () => { + let pool; + let lib; + let server; + let advanced; + + before(async () => { + const { Pool } = require('pg'); + pool = new Pool({ connectionString: DATABASE_URL }); + lib = require('../../dist/lib/index.js'); + server = require('../../dist/lib/server/index.js'); + advanced = require('../../dist/lib/reporting/advanced/index.js'); + await pool.query(lib.ADCP_STATE_MIGRATION); + await pool.query('DELETE FROM adcp_state WHERE collection = ANY($1)', [[PRINCIPALS, ENGINE]]); + }); + + after(async () => { + if (!pool) return; + await pool.query('DELETE FROM adcp_state WHERE collection = ANY($1)', [[PRINCIPALS, ENGINE]]); + await pool.end(); + }); + + function boot(clock) { + const backing = new lib.PostgresStateStore(pool); + const store = server.createPrincipalStateStore({ store: backing, collection: PRINCIPALS, durability: 'durable' }); + const notifications = server.createPersistentNotificationRuntime({ + store: server.principalNotificationSubscriptionStore(store), + proofAdapter: { prove: async () => ({ proved: true }) }, + validateDestination: async () => ({ allowed: true }), + authorizeDelivery: async () => ({ authorized: true }), + checkpointDeliveryAttempt: async () => {}, + createEmitter: () => ({ + forTenantScope() { + return this; + }, + async emit(params) { + return { + delivery_id: params.delivery_id, + idempotency_key: params.delivery_id, + attempts: 1, + delivered: true, + terminal: true, + errors: [], + }; + }, + async emitRecovered(params) { + return this.emit(params); + }, + }), + }); + const lifecycle = server.createPrincipalLifecycle({ + store, + notifications, + agentUrl: 'https://seller.example/mcp', + resolvePrincipal: ctx => ctx.callerMutationScope, + now: () => clock.now, + reportingSubscriptions: { listDeliveryConfigIds: () => [] }, + }); + const engineStore = advanced.createReportingSubscriptionEngineStateStoreV1({ + store: backing, + collection: ENGINE, + durability: 'durable', + }); + const engine = advanced.createReportingSubscriptionEngineV1({ + principal: lifecycle, + store: engineStore, + 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: 'b'.repeat(64), + reporting_profile: { + id: 'adcp.delivery.v1', + version: '1.0.0', + schema_uri: 'https://seller.example/schemas/delivery.json', + schema_sha256: 'a'.repeat(64), + 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'], + }, + ], + statusRetentionDays: 30, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: clock.revoked ? [] : [{ account_id: 'acct_1', authorized_at: '2026-10-01T00:00:00.000Z' }], + }), + listAccountBuys: () => [ + { + media_buy_id: 'mb_1', + confirmed_at: '2026-11-02T15:00:00Z', + start_time: '2026-11-03T00:00:00Z', + end_time: '2026-11-08T00:00:00Z', + packages: [{ package_id: 'pkg_1', reporting_delivery_offering_ids: ['off_a_pkg'] }], + }, + ], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + return { lifecycle, engine, engineStore }; + } + + test('versions, lost authority and owed periods survive a restart', async () => { + const scope = { tenantId: 't', principalId: `p-${process.pid}` }; + const ctx = { + callerMutationScope: { tenant_id: 't', principal_id: scope.principalId, principal_kind: 'buyer_agent' }, + }; + const clock = { now: new Date('2026-11-01T12:00:00.000Z'), revoked: false }; + const first = boot(clock); + const subscription = { + 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', + }; + const applied = await first.lifecycle.protocol.syncPrincipal( + { idempotency_key: 'pg-subscription-0001', configuration: { reporting_subscriptions: [subscription] } }, + ctx + ); + assert.equal(applied.result.kind, 'applied', JSON.stringify(applied)); + clock.now = new Date('2026-11-02T00:00:00.000Z'); + await first.engine.reconcile(scope); + clock.now = new Date('2026-11-05T05:00:00.000Z'); + clock.revoked = true; + await first.engine.reconcile(scope); + + const second = boot(clock); + const read = await second.lifecycle.protocol.getPrincipal({}, ctx); + const state = read.result.configuration.reporting_subscriptions[0]; + assert.equal(state.accepted_at, '2026-11-01T12:00:00.000Z'); + assert.equal(state.accounts_unauthorized[0].unauthorized_at, '2026-11-05T05:00:00.000Z'); + const owed = await second.engine.owedPeriods(scope, 'acct_1', { now: '2026-11-10T00:00:00.000Z' }); + assert.deepEqual( + owed.map(row => row.period_start), + ['2026-11-03T00:00:00.000Z', '2026-11-04T00:00:00.000Z'] + ); + // adcp#8607: no post-loss receipt window; the account is unknown to every task after the loss. + // One document per principal and per account, listed per tenant. + assert.deepEqual((await second.engineStore.listKeys(scope)).sort(), ['account:acct_1', 'principal']); + assert.ok((await second.engineStore.listPrincipals('t')).some(item => item.principalId === scope.principalId)); + assert.deepEqual(await second.engineStore.listPrincipals(`other-${process.pid}`), []); + }); +}); diff --git a/test/lib/reporting-subscription-service.test.js b/test/lib/reporting-subscription-service.test.js new file mode 100644 index 000000000..98b43b010 --- /dev/null +++ b/test/lib/reporting-subscription-service.test.js @@ -0,0 +1,347 @@ +/** + * The Reliable Reporting service writes the subscription engine's frozen owed sets into the ledger + * and executes them (experimental `media_buy.reporting_subscriptions`, AdCP 3.3), and a principal + * sees only its own subscription generations. Without subscriptions the service is byte-for-byte + * what it was before. + */ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const { createReliableReportingService } = require('../../dist/lib/reporting/service/index.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/advanced/index.js'); +const { + redactedReportingSourceOfferingV1, + redactedReportingSourceRequestV1, +} = require('../../dist/lib/reporting/source/index.js'); +const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); +const { reportingLedgerOptionOffTranscript } = require('../helpers/reporting-ledger-option-off-transcript.js'); + +const ACCOUNT = 'account-a'; +const TENANT = 'tenant-1'; +const A = { tenantId: TENANT, principalId: 'principal-a' }; +const B = { tenantId: TENANT, principalId: 'principal-b' }; +const C = { tenantId: TENANT, principalId: 'principal-c' }; +const day = n => new Date(Date.parse('2026-09-01T00:00:00.000Z') + n * 86_400_000).toISOString(); + +function deliveryOffering() { + const source = redactedReportingSourceOfferingV1; + return { + offering_id: source.offeringId, + feed_purpose: 'analytics', + report_definition_id: source.contract.report_definition_id, + report_definition_uri: source.contract.reportDefinitionUri, + report_definition_sha256: source.contract.reportDefinitionSha256, + reporting_profile: { + id: source.contract.reportingProfile, + version: source.contract.schemaVersion, + schema_uri: source.contract.schemaUri, + schema_sha256: source.contract.schemaSha256, + schema_dialect: source.contract.schemaDialect, + schema_ref_policy: source.contract.schemaRefPolicy, + grain: source.grain, + primary_keys: ['media_buy_id'], + }, + schedule: { + period_duration: 'P1D', + alignment: 'source_timezone', + period_timezone_policy: 'fixed', + period_timezone: 'UTC', + delivery_sla: 'PT0S', + }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + standard_dimensions: [], + }; +} + +function subscription() { + return { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'snapshot' }, + reconciliation_mode: 'delivery_only', + }; +} + +const offeringId = redactedReportingSourceOfferingV1.offeringId; +function buy(mediaBuyId, start, end) { + return { + media_buy_id: mediaBuyId, + confirmed_at: day(1.5), + start_time: day(start), + end_time: day(end), + packages: [{ package_id: `${mediaBuyId}-pkg`, reporting_delivery_offering_ids: [offeringId] }], + }; +} + +/** Each principal sees its own buys; both subscribe with the same subscription_id. */ +const BUYS = { + 'principal-a': [buy('mb-a', 3, 6), buy('mb-a2', 5, 7)], + 'principal-b': [buy('mb-b', 4, 5)], +}; + +async function world({ failPolicyFor } = {}) { + const clock = { now: new Date(day(0.5)) }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + let key = 0; + for (const principal of [A, B]) { + const response = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: `subscription-service-${String(++key).padStart(4, '0')}`, + configuration: { reporting_subscriptions: [subscription()] }, + }, + { + callerMutationScope: { + tenant_id: principal.tenantId, + principal_id: principal.principalId, + principal_kind: 'buyer_agent', + }, + } + ); + assert.equal(response.result.kind, 'applied', JSON.stringify(response)); + } + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [deliveryOffering()], + store: createInMemoryReportingSubscriptionEngineStoreV1(), + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: ACCOUNT, authorized_at: '2026-08-01T00:00:00.000Z' }], + }), + listAccountBuys: principal => structuredClone(BUYS[principal.principalId] ?? []), + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + const request = redactedReportingSourceRequestV1(); + const [template] = request.coverage.constituents; + const constituent = mediaBuyId => ({ + ...structuredClone(template), + constituentId: `constituent-${mediaBuyId}`, + mediaBuyId, + productBinding: { ...structuredClone(template.productBinding), mediaBuyId, bindingId: `binding-${mediaBuyId}` }, + }); + const sourceOffering = structuredClone(redactedReportingSourceOfferingV1); + sourceOffering.cadence = { + ...sourceOffering.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }; + const fetched = []; + const store = new MemoryLedgerStore(); + const { currency: _currency, ...sourceSettings } = request.sourceSettings; + const service = createReliableReportingService({ + store, + adapters: { + fixture: { + sourceOffering, + deliveryOffering: deliveryOffering(), + fetchSlice: slice => { + fetched.push({ start: slice.start_date, media_buy_ids: [...slice.media_buy_ids] }); + return { + reporting_period: { start: slice.start_date, end: slice.end_date }, + currency: 'USD', + reporting_rows: slice.media_buy_ids.map(mediaBuyId => ({ + media_buy_id: mediaBuyId, + impressions: 1, + spend: '1.00', + })), + }; + }, + }, + }, + contact: { name: 'Reporting operations', email: 'reporting@example.com' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + resolveSource: account => ({ + adapterId: 'fixture', + sourceScope: { network_id: `network-${account.id}` }, + sourceTimezone: 'UTC', + }), + resolveCurrency: () => 'USD', + // The host authorizes the account's buys; each obligation freezes the subset it owes. + resolveCoverage: () => ({ constituents: ['mb-a', 'mb-a2', 'mb-b'].map(constituent) }), + reportingSubscriptions: { + engine, + resolvePrincipal: context => context.principalScope, + listSubscriptionPrincipals: () => engine.listPrincipals(TENANT), + resolveSubscriptionConfiguration: ({ principal }) => { + if (failPolicyFor === principal.principalId) throw new Error(`no policy for ${principal.principalId}`); + return { + requestedMetrics: request.requestedMetrics, + requestedDimensions: request.requestedDimensions, + sourceSettings, + }; + }, + }, + }); + return { clock, engine, service, store, fetched }; +} + +const read = (service, principal, body) => + service.platform.getReportingStatus( + { account: { account_id: ACCOUNT }, pagination: { max_results: 100 }, ...body }, + { account: { id: ACCOUNT, ctx_metadata: {} }, principalScope: principal } + ); + +describe('reliable reporting service with subscription obligations', () => { + test('without subscriptions is byte-for-byte unchanged', async () => { + const golden = require( + path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'ledger-option-off-golden.json') + ); + assert.deepEqual(JSON.parse(JSON.stringify(await reportingLedgerOptionOffTranscript())), golden); + }); + + test('a cycle plans and executes the frozen owed sets, and each principal sees only its own', async () => { + const { clock, engine, service, store, fetched } = await world(); + clock.now = new Date(day(8)); + await engine.reconcile(A); + await engine.reconcile(B); + assert.deepEqual((await engine.listPrincipals(TENANT)).map(value => value.principalId).sort(), [ + 'principal-a', + 'principal-b', + ]); + + const cycle = await service.runCycle({ accountId: ACCOUNT, now: new Date(day(8)) }); + assert.equal(cycle.planned, 5, JSON.stringify(cycle)); + assert.equal(cycle.revisionsCommitted, 5, JSON.stringify(cycle)); + // The source receives each obligation's own frozen media buys, for its source-local date. + assert.deepEqual(fetched.map(value => [value.start, value.media_buy_ids]).sort(), [ + [day(3).slice(0, 10), ['mb-a']], + [day(4).slice(0, 10), ['mb-a']], + [day(4).slice(0, 10), ['mb-b']], + [day(5).slice(0, 10), ['mb-a', 'mb-a2']], + [day(6).slice(0, 10), ['mb-a2']], + ]); + const configurations = await store.listConfigurations(ACCOUNT); + assert.equal(configurations.length, 2, 'one generation per owning principal'); + for (const configuration of configurations) { + assert.equal(configuration.delivery_config_id, 'analytics-daily'); + assert.equal(configuration.delivery_config_version, 1); + assert.equal(configuration.owedSet.through, day(8)); + assert.equal(configuration.subscription.ownership, 'owed_set'); + } + // A second cycle is idempotent. + const again = await service.runCycle({ accountId: ACCOUNT, now: new Date(day(8)) }); + assert.equal(again.planned, 0); + + store.ledgerAsOf = day(8); + const forA = await read(service, A, { view: 'periods', period: { start: day(0), end: day(8) } }); + assert.equal(forA.status, 'completed', JSON.stringify(forA)); + assert.deepEqual( + forA.periods.map(value => [value.period.start, value.media_buy_ids, value.offering_id]), + [ + [day(3), ['mb-a'], offeringId], + [day(4), ['mb-a'], offeringId], + [day(5), ['mb-a', 'mb-a2'], offeringId], + [day(6), ['mb-a2'], offeringId], + ] + ); + assert.deepEqual(forA.scope.delivery_config_generations, [ + { delivery_config_id: 'analytics-daily', delivery_config_version: 1, feed_purpose: 'analytics' }, + ]); + assert.equal(forA.revisions.length, 4); + const summaryA = await read(service, A, { view: 'summary', period: { start: day(0), end: day(8) } }); + assert.equal(summaryA.health, 'complete', JSON.stringify(summaryA)); + assert.equal(summaryA.scope.scope_closed, true); + assert.equal(summaryA.subscription_coverage[0].subscription_id, 'analytics-daily'); + + const forB = await read(service, B, { view: 'periods', period: { start: day(0), end: day(8) } }); + assert.deepEqual( + forB.periods.map(value => [value.period.start, value.media_buy_ids]), + [[day(4), ['mb-b']]] + ); + // A principal on the same account without a subscription sees neither. + const forC = await read(service, C, { view: 'periods', period: { start: day(0), end: day(8) } }); + assert.deepEqual(forC.periods, []); + assert.deepEqual(forC.scope.delivery_config_generations, []); + // Nor can B read A's revision by id. + await assert.rejects( + service.platform.getMediaBuyDelivery( + { account: { account_id: ACCOUNT }, reporting_revision_id: forA.revisions[0].reporting_revision_id }, + { account: { id: ACCOUNT, ctx_metadata: {} }, principalScope: B } + ), + error => error.code === 'REFERENCE_NOT_FOUND' + ); + const own = await service.platform.getMediaBuyDelivery( + { account: { account_id: ACCOUNT }, reporting_revision_id: forA.revisions[0].reporting_revision_id }, + { account: { id: ACCOUNT, ctx_metadata: {} }, principalScope: A } + ); + assert.deepEqual(own.reporting_rows, [ + { account_id: ACCOUNT, media_buy_id: 'mb-a', impressions: 1, spend: '1.00' }, + ]); + }); + + test('one subscription that cannot be planned does not stop the others', async () => { + const { clock, engine, service, store } = await world({ failPolicyFor: 'principal-a' }); + clock.now = new Date(day(8)); + await engine.reconcile(A); + await engine.reconcile(B); + await assert.rejects(service.runCycle({ accountId: ACCOUNT, now: new Date(day(8)) }), error => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors.length, 1); + assert.match(error.errors[0].message, /no policy for principal-a/); + return true; + }); + const configurations = await store.listConfigurations(ACCOUNT); + assert.deepEqual( + configurations.map(value => value.subscription.principalId), + ['principal-b'] + ); + assert.equal(store.revisions.size, 1, 'the account still executed what it could plan'); + }); + + test('requires the planning hooks when subscriptions are advertised', () => { + const base = { + store: new MemoryLedgerStore(), + adapters: { + fixture: { + sourceOffering: { + ...redactedReportingSourceOfferingV1, + cadence: { + ...redactedReportingSourceOfferingV1.cadence, + expectedAvailabilityLag: 'PT0S', + worstCaseAvailabilityLag: 'PT0S', + }, + }, + deliveryOffering: deliveryOffering(), + fetchSlice: () => ({ reporting_rows: [] }), + }, + }, + contact: { name: 'Reporting operations' }, + automatedRecoveryWindowSeconds: 86_400, + statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, + resolveSource: () => ({ adapterId: 'fixture', sourceScope: {}, sourceTimezone: 'UTC' }), + resolveCurrency: () => 'USD', + resolveCoverage: () => ({ constituents: [] }), + }; + const engine = { + settings: { backfillOnRegrant: false, statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays }, + subscriptionCoverage: async () => [], + frozenOwedSets: async () => [], + }; + assert.throws( + () => createReliableReportingService({ ...base, reportingSubscriptions: { engine, resolvePrincipal: () => A } }), + /listSubscriptionPrincipals/ + ); + assert.throws( + () => + createReliableReportingService({ + ...base, + reportingSubscriptions: { engine, resolvePrincipal: () => A, listSubscriptionPrincipals: () => [] }, + }), + /resolveSubscriptionConfiguration/ + ); + }); +}); diff --git a/test/lib/reporting-subscription-wind-down.test.js b/test/lib/reporting-subscription-wind-down.test.js new file mode 100644 index 000000000..91def40d2 --- /dev/null +++ b/test/lib/reporting-subscription-wind-down.test.js @@ -0,0 +1,177 @@ +/** + * Revocation means no access (adcp#8607): winding down with a reduced grant instead of revoking. + * `windDownGrant` and `REPORTING_WIND_DOWN_TASKS` (`@adcp/sdk/server`), and the shared vectors in + * test/fixtures/reporting-subscriptions/grant-cases.json: the coverage grant rule, and grant + * transitions run through the coverage engine. + */ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const server = require('@adcp/sdk/server'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, + reportingSubscriptionCoverageGrantedV1, +} = require('@adcp/sdk/reporting/advanced'); +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); + +const vectors = require(path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'grant-cases.json')); + +const ACCOUNT = 'account-a'; +const OFFERING = 'daily_pkg'; +const PRINCIPAL = { tenantId: 'tenant-1', principalId: 'principal-a' }; + +describe('windDownGrant', () => { + test('is the reconciliation-only grant, writable, as the vectors define it', () => { + const { windDownGrant, REPORTING_WIND_DOWN_TASKS } = server; + assert.deepEqual([...REPORTING_WIND_DOWN_TASKS], vectors.wind_down_grant.allowed_tasks); + assert.ok(Object.isFrozen(REPORTING_WIND_DOWN_TASKS)); + assert.deepEqual(windDownGrant(), vectors.wind_down_grant); + assert.equal(reportingSubscriptionCoverageGrantedV1(windDownGrant()), true, 'a wind-down grant keeps coverage'); + }); + + test('keeps the authorization chain: authorized_at carries over, the old task scoping does not', () => { + const { windDownGrant } = server; + const current = { + allowed_tasks: ['get_media_buys', 'create_media_buy', 'get_reporting_status'], + field_scopes: { create_media_buy: ['packages'] }, + scope_name: 'custom:buyer', + authorized_at: '2026-08-01T00:00:00Z', + read_only: false, + }; + assert.deepEqual(windDownGrant(current), { ...vectors.wind_down_grant, authorized_at: '2026-08-01T00:00:00Z' }); + assert.deepEqual(current.allowed_tasks, ['get_media_buys', 'create_media_buy', 'get_reporting_status']); + const first = windDownGrant(); + first.allowed_tasks.push('create_media_buy'); + assert.deepEqual(windDownGrant().allowed_tasks, vectors.wind_down_grant.allowed_tasks, 'each call is a fresh copy'); + }); +}); + +describe('grant-cases.json: the coverage grant rule', () => { + for (const testCase of vectors.grant_cases) { + test(testCase.name, () => { + const grant = testCase.authorization === null ? {} : testCase.authorization; + assert.equal(reportingSubscriptionCoverageGrantedV1(grant), testCase.covered); + }); + } +}); + +function deliveryOffering() { + return { + offering_id: OFFERING, + feed_purpose: 'analytics', + report_definition_id: 'daily-delivery', + report_definition_uri: 'https://seller.example/reports/daily-delivery.json', + report_definition_sha256: 'a'.repeat(64), + reporting_profile: { + id: 'delivery-v1', + version: '1', + schema_uri: 'https://seller.example/reports/delivery-v1.json', + schema_sha256: 'b'.repeat(64), + schema_dialect: 'https://json-schema.org/draft/2020-12/schema', + schema_ref_policy: 'bundled_only', + grain: ['media_buy_id'], + primary_keys: ['media_buy_id'], + }, + schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT0S' }, + supported_finality: ['snapshot'], + reconciliation_mode: 'delivery_only', + standard_dimensions: [], + }; +} + +async function runTransition(testCase) { + const clock = { now: new Date('2026-09-01T12:00:00Z') }; + const { lifecycle } = createTestPrincipalLifecycle({ clock, reportingSubscriptions: {} }); + const applied = await lifecycle.protocol.syncPrincipal( + { + idempotency_key: 'grant-vector-subscription-0001', + configuration: { + reporting_subscriptions: [ + { + subscription_id: 'analytics-daily', + subscription_version: 1, + active: true, + scope: { all_authorized_accounts: true }, + selector: { feed_purpose: 'analytics', period_duration: 'P1D', required_finality: 'snapshot' }, + reconciliation_mode: 'delivery_only', + }, + ], + }, + }, + { + callerMutationScope: { + tenant_id: PRINCIPAL.tenantId, + principal_id: PRINCIPAL.principalId, + principal_kind: 'buyer_agent', + }, + } + ); + assert.equal(applied.result.kind, 'applied', JSON.stringify(applied)); + let step; + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: [deliveryOffering()], + store: createInMemoryReportingSubscriptionEngineStoreV1(), + statusRetentionDays: 400, + now: () => clock.now, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: + step.allowed_tasks === 'revoked' + ? [] + : [ + { + account_id: ACCOUNT, + authorized_at: '2026-08-01T00:00:00Z', + ...(step.granted_at ? { granted_at: step.granted_at } : {}), + ...(step.allowed_tasks === null ? {} : { allowed_tasks: step.allowed_tasks }), + }, + ], + }), + listAccountBuys: () => [ + { + media_buy_id: 'mb-1', + confirmed_at: '2026-09-01T06:00:00Z', + start_time: '2026-09-02T00:00:00Z', + end_time: '2026-09-11T00:00:00Z', + packages: [{ package_id: 'pkg-1', reporting_delivery_offering_ids: [OFFERING] }], + }, + ], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, + }, + }); + for (step of testCase.steps) { + clock.now = new Date(step.at); + await engine.reconcile(PRINCIPAL); + } + clock.now = new Date(testCase.evaluate_at); + await engine.reconcile(PRINCIPAL); + const [state] = (await lifecycle.listReportingSubscriptions(PRINCIPAL)).states; + return { + authorized: await engine.isAuthorized(PRINCIPAL, ACCOUNT), + covered: (await engine.subscriptionCoverage(PRINCIPAL, ACCOUNT)).length > 0, + unauthorized_at: (state.accounts_unauthorized ?? []) + .filter(entry => entry.account_id === ACCOUNT) + .map(entry => Date.parse(entry.unauthorized_at)), + owed: (await engine.owedPeriods(PRINCIPAL, ACCOUNT)).map(period => Date.parse(period.period_start)), + }; +} + +describe('grant-cases.json: grant transitions through the coverage engine', () => { + for (const testCase of vectors.transition_cases) { + test(testCase.name, async () => { + const actual = await runTransition(testCase); + assert.deepEqual(actual, { + authorized: testCase.expected.authorized, + covered: testCase.expected.covered, + unauthorized_at: testCase.expected.unauthorized_at.map(Date.parse), + owed: testCase.expected.owed.map(Date.parse), + }); + }); + } +}); diff --git a/test/lib/reporting-subscriptions-storyboard.test.js b/test/lib/reporting-subscriptions-storyboard.test.js new file mode 100644 index 000000000..bfe6b7b73 --- /dev/null +++ b/test/lib/reporting-subscriptions-storyboard.test.js @@ -0,0 +1,83 @@ +/** + * The SDK reference seller passes the reporting-subscriptions storyboard over MCP, and a seller that + * drops `prior_versions` from its readback fails it. + */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { describe, test } = require('node:test'); + +process.env.NODE_ENV = 'test'; + +const { serve } = require('../../dist/lib/index.js'); +const { parseStoryboard } = require('../../dist/lib/testing/storyboard/loader.js'); +const { runStoryboard } = require('../../dist/lib/testing/storyboard/runner.js'); +const { MemoryLedgerStore } = require('../helpers/memory-reporting-ledger-store.js'); + +const STORYBOARD = parseStoryboard( + fs.readFileSync(path.join(__dirname, '..', '..', 'examples', 'storyboards', 'reporting-subscriptions.yaml'), 'utf8') +); +const TOOLS = ['get_adcp_capabilities', 'sync_principal', 'get_principal', 'get_reporting_status']; + +async function runAgainst(sellerOptions = {}) { + const { createReportingSubscriptionsSeller } = + await import('../../examples/reporting-subscriptions-seller/seller.mjs'); + const seller = createReportingSubscriptionsSeller({ store: new MemoryLedgerStore(), ...sellerOptions }); + const server = serve(seller.createServer, { + port: 0, + authenticate: () => ({ principal: 'storyboard-buyer' }), + onListening: () => {}, + }); + if (!server.listening) + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + try { + return await runStoryboard(`http://127.0.0.1:${server.address().port}/mcp`, STORYBOARD, { + protocol: 'mcp', + allow_http: true, + agentTools: TOOLS, + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } +} + +function failedSteps(result) { + return result.phases.flatMap(phase => + phase.steps.filter(step => !step.passed && !step.skipped).map(step => step.step_id) + ); +} + +describe('reporting-subscriptions storyboard', () => { + test('the reference seller passes every step', async () => { + const result = await runAgainst(); + assert.equal(result.overall_passed, true, JSON.stringify(result, null, 2)); + assert.equal(result.failed_count, 0, JSON.stringify(failedSteps(result))); + assert.equal(result.skipped_count, 0, JSON.stringify(result, null, 2)); + assert.ok(result.passed_count >= 15, `ran ${result.passed_count} steps`); + }); + + test('fails a seller whose readback drops prior_versions', async () => { + const dropPriorVersions = response => { + for (const state of response?.result?.configuration?.reporting_subscriptions ?? []) delete state.prior_versions; + return response; + }; + const result = await runAgainst({ + wrapProtocol: protocol => ({ + ...protocol, + syncPrincipal: async (params, ctx) => dropPriorVersions(await protocol.syncPrincipal(params, ctx)), + getPrincipal: async (params, ctx) => dropPriorVersions(await protocol.getPrincipal(params, ctx)), + }), + }); + assert.equal(result.overall_passed, false); + assert.deepEqual(failedSteps(result), ['apply_version_2', 'omit_subscription']); + const failures = result.phases + .flatMap(phase => phase.steps) + .flatMap(step => (step.validations ?? []).filter(validation => !validation.passed)); + assert.ok(failures.length > 0); + for (const failure of failures) assert.match(failure.path, /\.prior_versions\[0\]\./, JSON.stringify(failure)); + }); +});