From 3dcbf64956bf51be849973409a0e51bc415631f1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 09:30:41 +0000 Subject: [PATCH 01/49] feat(reporting): buyer-computable coverage for reporting subscriptions Add the pure expectedSubscriptionPeriods rule for AdCP 3.3 principal-level reporting subscriptions (experimental media_buy.reporting_subscriptions), the normative period_generation calendar it uses, selector resolution (exact, finest, all with byte-order ties), scope membership, and a builder for the 3.3 registration capabilities (configuration_tasks, the deprecated configuration_task, and the subscriptions block). createAdcpServerFromPlatform adds media_buy.reporting_subscriptions to experimental_features only beside a subscriptions block. installConfiguration now throws a typed ReportingConfigurationImmutableError with the unchanged message. The cross-SDK parity table is test/fixtures/reporting-subscriptions/coverage-cases.json. Refs #3504 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-coverage.md | 11 + scripts/check-package-size.mjs | 3 +- scripts/check-reporting-consumer-package.mjs | 4 +- src/lib/reporting/consumer/index.ts | 3 + src/lib/reporting/ledger/index.ts | 2 + src/lib/reporting/ledger/producer.ts | 7 +- src/lib/reporting/ledger/types.ts | 32 + src/lib/reporting/service/index.ts | 6 + src/lib/reporting/subscriptions/calendar.ts | 282 + .../reporting/subscriptions/capabilities.ts | 74 + src/lib/reporting/subscriptions/coverage.ts | 737 +++ src/lib/reporting/subscriptions/index.ts | 46 + src/lib/reporting/subscriptions/selection.ts | 212 + .../decisioning/runtime/from-platform.ts | 15 +- .../coverage-cases.json | 5587 +++++++++++++++++ test/lib/reliable-reporting-service.test.js | 64 +- .../reporting-subscription-coverage.test.js | 367 ++ 17 files changed, 7444 insertions(+), 8 deletions(-) create mode 100644 .changeset/reporting-subscription-coverage.md create mode 100644 src/lib/reporting/subscriptions/calendar.ts create mode 100644 src/lib/reporting/subscriptions/capabilities.ts create mode 100644 src/lib/reporting/subscriptions/coverage.ts create mode 100644 src/lib/reporting/subscriptions/index.ts create mode 100644 src/lib/reporting/subscriptions/selection.ts create mode 100644 test/fixtures/reporting-subscriptions/coverage-cases.json create mode 100644 test/lib/reporting-subscription-coverage.test.js diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md new file mode 100644 index 000000000..123aba33b --- /dev/null +++ b/.changeset/reporting-subscription-coverage.md @@ -0,0 +1,11 @@ +--- +'@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 offerings with their standard-dimension mapping, and the published `subscription_coverage` exceptions. It returns every owed (buy, offering, period), and `groupExpectedSubscriptionPeriodsV1` groups them into the obligations that must carry them. It implements the start rule (first boundary at or after the latest of `accepted_at`, `confirmed_at` and `authorized_at`), 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. `createAdcpServerFromPlatform` adds `media_buy.reporting_subscriptions` to `experimental_features` when the reporting capability carries a `subscriptions` block; without one the list is unchanged. + +`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 cross-SDK parity table is `test/fixtures/reporting-subscriptions/coverage-cases.json`. diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 8f13d88bf..1d6949358 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -300,7 +300,8 @@ const MAX_PACKED_FILE_COUNT = 13 + // Property-list identifier, server, and certification modules (three dual-format sets) and adoption guide. 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). - 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). 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..085686e0f 100644 --- a/scripts/check-reporting-consumer-package.mjs +++ b/scripts/check-reporting-consumer-package.mjs @@ -38,7 +38,7 @@ try { ` const assert = require('node:assert/strict'); const buyer = require('@adcp/sdk/reporting/consumer'); - for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1']) { + for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1', 'expectedSubscriptionPeriods']) { assert.equal(typeof buyer[name], 'function', name); } assert.equal(buyer.createReliableReportingServiceV1, undefined); @@ -49,7 +49,7 @@ try { ` import assert from 'node:assert/strict'; import * as buyer from '@adcp/sdk/reporting/consumer'; - for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1']) { + for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1', 'expectedSubscriptionPeriods']) { assert.equal(typeof buyer[name], 'function', name); } assert.equal(buyer.createReliableReportingServiceV1, undefined); diff --git a/src/lib/reporting/consumer/index.ts b/src/lib/reporting/consumer/index.ts index d2263588e..377d49115 100644 --- a/src/lib/reporting/consumer/index.ts +++ b/src/lib/reporting/consumer/index.ts @@ -103,3 +103,6 @@ export type { ReportingResourceReadRole, ReportingResourceReader, } from '../inspection'; + +// Principal-level reporting subscriptions (experimental, AdCP 3.3): buyer-computable coverage. +export * from '../subscriptions'; diff --git a/src/lib/reporting/ledger/index.ts b/src/lib/reporting/ledger/index.ts index bfa6ac2a4..2d9608666 100644 --- a/src/lib/reporting/ledger/index.ts +++ b/src/lib/reporting/ledger/index.ts @@ -27,6 +27,8 @@ export * from './row-encoding'; export * from './row-storage-errors'; export * from './receipts'; export * from './offering-validation'; +// Principal-level reporting subscriptions (experimental, AdCP 3.3): the shared calendar, selection and coverage rule. +export * from '../subscriptions'; // Building blocks a ledger store implementation shares with the protocol layer. They are exported so a // store can live outside the SDK without importing internals. diff --git a/src/lib/reporting/ledger/producer.ts b/src/lib/reporting/ledger/producer.ts index fa943d7ee..70be0a10e 100644 --- a/src/lib/reporting/ledger/producer.ts +++ b/src/lib/reporting/ledger/producer.ts @@ -36,6 +36,7 @@ import { retryReportingStatusNotificationsV1, } from './lifecycle'; import { listAdjustmentMetadataFromStore, listRevisionMetadataFromStore } from './revision-metadata'; +import { ReportingConfigurationImmutableError } from './types'; import type { CreateReportingProducerOptionsV1, ReportingLedgerAdjustmentMetadataV1, @@ -110,7 +111,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); diff --git a/src/lib/reporting/ledger/types.ts b/src/lib/reporting/ledger/types.ts index e8b33162d..1c9928b6c 100644 --- a/src/lib/reporting/ledger/types.ts +++ b/src/lib/reporting/ledger/types.ts @@ -838,6 +838,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'); diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 8ec467bc8..9ff9e6575 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -1561,5 +1561,11 @@ function deepFreeze(value: T): T { return value; } +export { ReportingConfigurationImmutableError, isReportingConfigurationImmutableError } from '../ledger'; +export { + reportingConfigurationCapabilitiesV1, + type ReportingConfigurationCapabilitiesInputV1, + type ReportingConfigurationCapabilitiesV1, +} from '../subscriptions/capabilities'; export * from './conformance'; export * from './scheduler-support'; diff --git a/src/lib/reporting/subscriptions/calendar.ts b/src/lib/reporting/subscriptions/calendar.ts new file mode 100644 index 000000000..fd3f160c0 --- /dev/null +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -0,0 +1,282 @@ +/** + * 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 + */ + +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. */ +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`. */ +export type ReportingPeriodAlignmentV1 = 'utc' | 'account_timezone' | 'source_timezone' | 'billing_cycle'; + +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. */ +export interface ReportingPeriodCalendarV1 { + readonly spec: Readonly; + /** Epoch milliseconds of boundary `ordinal`. Boundary 0 is the schedule origin. */ + 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; + /** 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. */ +export function parseReportingPeriodDurationV1(value: string): ReportingIsoDurationPartsV1 { + if (typeof value !== 'string' || value.length > 64) throw new TypeError('Invalid reporting period_duration'); + const match = DURATION.exec(value); + if (!match) throw new TypeError(`Invalid reporting period_duration ${JSON.stringify(value)}`); + 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 RangeError(`Reporting period_duration ${JSON.stringify(value)} is out of range`); + } + 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. + */ +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 TypeError(`${field} must be an IANA timezone name`); + } + try { + formatterFor(value); + } catch { + throw new TypeError(`${field} ${JSON.stringify(value)} is not a recognized IANA 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 RangeError('Reporting period boundary is outside the supported civil year range'); + } + 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; +} + +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 TypeError(`${field} must be an RFC 3339 date-time`); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) throw new TypeError(`${field} must be an RFC 3339 date-time`); + 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). + */ +export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpecV1): ReportingPeriodCalendarV1 { + const duration = parseReportingPeriodDurationV1(spec.period_duration); + let timeZone: string; + let originWall: number; + let anchorKey = ''; + 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 TypeError('billing_cycle alignment requires period_anchor'); + timeZone = spec.timezone; + const anchor = parseInstant(spec.period_anchor, 'period_anchor'); + originWall = wallTime(anchor, timeZone); + anchorKey = new Date(anchor).toISOString(); + break; + } + default: + throw new TypeError( + `Unknown reporting period alignment ${JSON.stringify((spec as { alignment?: unknown }).alignment)}` + ); + } + 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 RangeError('Reporting period ordinal must be a safe integer'); + 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 RangeError('Reporting period boundary is outside the RFC 3339 instant range'); + } + 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 RangeError('Reporting instant must be finite'); + 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)), + key: JSON.stringify([spec.period_duration, spec.alignment, timeZone, anchorKey]), + }; +} diff --git a/src/lib/reporting/subscriptions/capabilities.ts b/src/lib/reporting/subscriptions/capabilities.ts new file mode 100644 index 000000000..382d79ac4 --- /dev/null +++ b/src/lib/reporting/subscriptions/capabilities.ts @@ -0,0 +1,74 @@ +/** + * `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 type { ReportingDeliveryCapabilities } from '../../types/core.generated'; + +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; +} + +export type ReportingConfigurationCapabilitiesV1 = Pick< + ReportingDeliveryCapabilities, + 'configuration_task' | 'configuration_tasks' | 'subscriptions' +>; + +/** The registration fields of a `reporting_delivery` capability block. */ +export function reportingConfigurationCapabilitiesV1( + input: ReportingConfigurationCapabilitiesInputV1 = {} +): ReportingConfigurationCapabilitiesV1 { + const accountConfigurations = input.accountConfigurations ?? true; + if (input.subscriptions === undefined) { + if (!accountConfigurations) { + throw new TypeError('Reporting registration requires sync_accounts, sync_principal subscriptions, or both'); + } + return { configuration_task: 'sync_accounts' }; + } + const { maxAccounts, backfillOnRegrant, dimensions } = input.subscriptions; + if (maxAccounts !== undefined && (!Number.isSafeInteger(maxAccounts) || maxAccounts < 1)) { + throw new TypeError('subscriptions.maxAccounts must be a positive integer'); + } + 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 TypeError('subscriptions.dimensions must be unique reporting-dimension values'); + } + } + return { + ...(accountConfigurations ? { configuration_task: 'sync_accounts' as const } : {}), + configuration_tasks: accountConfigurations ? ['sync_principal', 'sync_accounts'] : ['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..3717ab86e --- /dev/null +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -0,0 +1,737 @@ +/** + * 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 latest of the version's + * `accepted_at`, the buy's `confirmed_at`, and the account's `authorized_at`; + * 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). 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 type { ReportingSubscription, ReportingSubscriptionCoverage } from '../../types/core.generated'; +import { + assertReportingIanaTimezoneV1, + createReportingPeriodCalendarV1, + 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; + +/** One accepted `subscription_version`, as the buyer recorded it. */ +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`). */ + deactivated_at?: string; + /** + * Earlier in-place deactivations that were later reversed, as the buyer recorded them. + * The readback drops `deactivated_at` once a subscription is active again, so only the + * buyer can supply these. Each interval is a cutover out at `from` (periods that started + * before it stay owed) and a fresh start at `until`. + */ + inactive_intervals?: ReadonlyArray<{ from: string; until?: 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. */ +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`. */ +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. */ + prior_flights?: readonly ReportingSubscriptionPriorFlightV1[]; +} + +/** One grant of reporting authority over the account to the caller. */ +export interface ReportingSubscriptionGrantV1 { + /** `authorization.authorized_at` when this grant began. */ + authorized_at: string; + /** `accounts_unauthorized[].unauthorized_at` when it ended. Absent: still in force. */ + unauthorized_at?: string; +} + +export interface ReportingSubscriptionCoverageAccountV1 { + account_id: string; + /** The account's `operator` domain, needed when a version names `operator_domains`. */ + operator?: string; + /** + * The account's IANA timezone before the first `timezone_cutovers` entry (the current + * one when there are none). Required when an `account_timezone` offering resolves. + */ + timezone?: string; + /** + * The seller-resolved upstream reporting timezone, the obligation's + * `period.source_timezone`. Required when an `account_resolved` `source_timezone` + * offering resolves without a `period_timezone` override. + */ + 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[]; +} + +export interface ExpectedSubscriptionPeriodsInputV1 { + subscription_id: string; + /** Every version whose obligations matter, oldest first. */ + versions: readonly ReportingSubscriptionVersionRecordV1[]; + account: ReportingSubscriptionCoverageAccountV1; + buys: readonly ReportingSubscriptionBuyV1[]; + /** Seller offerings with their standard-dimension mapping. */ + offerings: readonly ReportingSubscriptionOfferingV1[]; + /** 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; +} + +/** One owed period for one (buy, offering) pair. */ +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. */ +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[]; +} + +interface AuthorityChain { + start: number; + /** Revocations followed by a re-grant inside the retention window. */ + 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`. + */ +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 TypeError('max_periods must be a positive integer'); + if (typeof input.subscription_id !== 'string' || input.subscription_id.length === 0) { + throw new TypeError('subscription_id is required'); + } + const versions = normalizeVersions(input.versions, input.subscription_id); + 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 TypeError('offering_id values must be unique'); + offeringsById.set(offering.offering_id, offering); + } + const calendars = new Map(); + const result: ExpectedSubscriptionPeriodV1[] = []; + const seenBuys = new Set(); + + for (const buy of input.buys) { + if (seenBuys.has(buy.media_buy_id)) throw new TypeError('media_buy_id values must be unique'); + 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); + // 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. + const lower = Math.max( + calendar.ceilOrdinal(Math.max(handoff, confirmedAt)), + calendar.floorOrdinal(earliestStart) + ); + for (let ordinal = lower; ; ordinal += 1) { + const start = calendar.boundary(ordinal); + if (start >= regime.until || start >= latestStop) break; + const end = calendar.boundary(ordinal + 1); + if (end > now) break; + if (end === start) continue; // a skipped civil date has no period + const flight = flightAt(flights, end); + if (!(start < flight.stop && end > flight.start)) continue; + if (!authorized(chains, calendar, start, input.backfill_on_regrant === true)) continue; + if (removed(removals, buy.media_buy_id, start)) 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 RangeError(`Expected subscription periods exceed max_periods (${maxPeriods})`); + } + } + } + } + } + return result.sort(comparePeriods); +} + +/** Group owed periods into the obligations that must carry them, with frozen `media_buy_ids`. */ +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 +): NormalizedVersion[] { + if (!Array.isArray(records) || records.length === 0) throw new TypeError('versions must list at least one version'); + 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 TypeError('subscription_version must be a positive integer'); + } + if (record.configuration?.subscription_id !== subscriptionId) { + throw new TypeError(`${label} configuration names a different subscription_id`); + } + if (record.configuration.subscription_version !== record.subscription_version) { + throw new TypeError(`${label} configuration carries a different subscription_version`); + } + if (record.configuration.destination !== undefined && record.destination_method === undefined) { + throw new TypeError(`${label} names a destination; destination_method is required`); + } + 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 TypeError('versions must be in ascending subscription_version and accepted_at order'); + } + 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 TypeError(`${label} superseded_at must equal the next version's accepted_at`); + } + const inactive: Window[] = (record.inactive_intervals ?? []).map((interval: { from: string; until?: string }) => ({ + from: instant(interval.from, `${label} inactive_intervals.from`), + until: + interval.until === undefined + ? Number.POSITIVE_INFINITY + : instant(interval.until, `${label} inactive_intervals.until`), + })); + 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) { + throw new TypeError(`${label} is inactive; deactivated_at is required`); + } + for (const window of inactive) { + if (!(window.until > window.from)) throw new TypeError(`${label} inactive interval must have until after from`); + } + versions.push({ record, acceptedAt, endsAt: supersededAt, inactive }); + } + return versions; +} + +function authorityChains( + grants: readonly ReportingSubscriptionGrantV1[], + retentionDays: number | undefined +): AuthorityChain[] { + if (!Array.isArray(grants)) throw new TypeError('account.grants must be an array'); + 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 TypeError('A grant cannot end before it began'); + } + if (previousEnd === undefined && index > 0) throw new TypeError('Only the last grant may be unrevoked'); + if (previousEnd !== undefined && authorizedAt < previousEnd) throw new TypeError('Grants must not overlap'); + const current = chains.at(-1); + if (current !== undefined && previousEnd !== undefined) { + if (retentionDays === undefined || !Number.isSafeInteger(retentionDays) || retentionDays < 1) { + throw new TypeError('status_retention_days is required to evaluate a re-grant'); + } + 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 (start >= calendar.floor(gap.revokedAt) && start < calendar.ceil(gap.regrantedAt)) return false; + } + } + return true; + }); +} + +interface TimezoneSegment { + from: number; + timezone: string | undefined; +} + +function timezoneTimeline( + initial: string | undefined, + cutovers: ReportingSubscriptionCoverage['timezone_cutovers'] +): TimezoneSegment[] { + if (initial !== undefined) assertReportingIanaTimezoneV1(initial, 'account.timezone'); + const segments: TimezoneSegment[] = [{ from: Number.NEGATIVE_INFINITY, timezone: initial }]; + const sorted = [...(cutovers ?? [])] + .map(entry => { + assertReportingIanaTimezoneV1(entry.timezone, 'timezone_cutovers.timezone'); + return { from: instant(entry.effective_at, 'timezone_cutovers.effective_at'), timezone: entry.timezone }; + }) + .sort((left, right) => left.from - right.from); + if (sorted.length > 0 && initial === undefined) { + throw new TypeError('account.timezone is required with timezone_cutovers'); + } + segments.push(...sorted); + return segments; +} + +function timezoneAt(segments: readonly TimezoneSegment[], at: number): string | undefined { + let current = segments[0]!.timezone; + for (const segment of segments) if (segment.from <= at) current = segment.timezone; + return current; +} + +interface ExceptionWindow extends Window { + mediaBuyId?: 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 } : {}), + }); + } + for (const gap of coverage?.gaps ?? []) { + windows.push({ from: instant(gap.from, 'gaps.from'), until: instant(gap.until, 'gaps.until') }); + } + return windows; +} + +function removed(windows: readonly ExceptionWindow[], mediaBuyId: string, start: number): boolean { + return windows.some( + window => + (window.mediaBuyId === undefined || window.mediaBuyId === mediaBuyId) && + 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: readonly TimezoneSegment[], + 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 segment of timezones) if (Number.isFinite(segment.from)) points.add(segment.from); + 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, + timezoneAt(timezones, from), + 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 }); + } + 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, + accountTimezone: string | undefined, + 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, + accountTimezone, + 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 TypeError( + `Offering ${offering.offering_id} is account_timezone aligned; account.timezone is required` + ); + } + return { ...base, timezone }; + } + case 'source_timezone': { + const timezone = + schedule.period_timezone_policy === 'fixed' ? schedule.period_timezone : (override ?? sourceTimezone); + if (timezone === undefined) { + throw new TypeError( + `Offering ${offering.offering_id} resolves its source timezone per account; account.source_timezone is required` + ); + } + return { ...base, timezone }; + } + case 'billing_cycle': + return { ...base, timezone: schedule.period_timezone!, period_anchor: schedule.period_anchor! }; + default: + throw new TypeError(`Offering ${offering.offering_id} has an unknown alignment`); + } +} + +/** + * 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); + if (same) return same.calendar.ceil(handoff); + return Math.max(...predecessor.offerings.map(entry => entry.calendar.ceil(handoff))); +} + +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 TypeError(`${field} must be an RFC 3339 date-time`); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) throw new TypeError(`${field} must be an RFC 3339 date-time`); + return parsed; +} diff --git a/src/lib/reporting/subscriptions/index.ts b/src/lib/reporting/subscriptions/index.ts new file mode 100644 index 000000000..207c090a4 --- /dev/null +++ b/src/lib/reporting/subscriptions/index.ts @@ -0,0 +1,46 @@ +/** + * 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'; diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts new file mode 100644 index 000000000..1a25c3d13 --- /dev/null +++ b/src/lib/reporting/subscriptions/selection.ts @@ -0,0 +1,212 @@ +/** + * 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 type { ReportingDeliveryOffering, ReportingSubscription } from '../../types/core.generated'; + +/** + * A seller delivery offering plus the dimension mapping selection needs. + * + * The wire offering does not carry its mapped standard dimensions: the seller maps each + * report definition's native dimensions onto `enums/reporting-dimension.json`. A buyer + * reads that mapping from the report definition or from the seller's documentation. + */ +export type ReportingSubscriptionOfferingV1 = ReportingDeliveryOffering & { + /** Standard-vocabulary dimensions this offering's report definition maps onto. */ + dimensions: readonly string[]; + /** + * Native dimensions that map to no standard value. They count as distinct, so a + * `package` × device offering never equals a `package` selector under `exact`. + */ + unmapped_dimensions?: readonly string[]; + /** + * 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. + */ + available_from?: string; + /** When the seller withdrew it. Absent: still available. */ + withdrawn_at?: string; +}; + +export type ReportingSubscriptionSelectorV1 = ReportingSubscription['selector']; +export type ReportingSubscriptionMatchV1 = 'exact' | 'finest' | 'all'; + +/** `pattern` and `transport` of the principal `reporting_destinations` entry a subscription names. */ +export interface ReportingSubscriptionDestinationMethodV1 { + pattern: string; + transport: string; +} + +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. */ +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. */ +export function compareReportingIdsByteOrderV1(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +const UTC_ZONE_NAMES = new Set([ + 'UTC', + 'Etc/UTC', + 'Etc/UCT', + 'UCT', + 'Etc/Universal', + 'Universal', + 'Etc/Zulu', + 'Zulu', + 'GMT', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT-0', + 'Etc/GMT0', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Etc/Greenwich', + 'Greenwich', +]); + +/** + * Whether an offering's published schedule can produce periods in a `period_timezone` + * override. A `utc` schedule only in a UTC alias; a fixed-timezone schedule only in that + * zone; an account-resolved or account-timezone schedule in any zone; a configurable + * billing cycle has no anchor a subscription can supply and so is never resolvable. + */ +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_policy !== 'fixed' || 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. */ +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`). + */ +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 includesWanted = (offering: ReportingSubscriptionOfferingV1) => + wanted === undefined || wanted.every(dimension => offering.dimensions.includes(dimension)); + if (match === 'exact') { + const equal = candidates.filter(offering => { + if (wanted === undefined) return true; + const own = new Set(offering.dimensions); + 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(offering.dimensions).size > new Set(best.dimensions).size) best = offering; + } + return best ? [best.offering_id] : []; +} + +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. + */ +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/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index 5bc039ba3..60fd413d6 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -3160,17 +3160,26 @@ export function createAdcpServerFromPlatform

= 32 + new Set([...adopterExperimentalFeatures, ...reportingFeatureIds]).size > 32 && + reportingFeatureIds.some(id => !adopterExperimentalFeatures.includes(id)) ) { throw new PlatformConfigError( 'Reliable Reporting Core cannot be added because experimental_features already contains 32 entries' ); } const experimentalFeatures = reportingDelivery - ? Array.from(new Set([...adopterExperimentalFeatures, 'media_buy.reporting_delivery'])) + ? Array.from(new Set([...adopterExperimentalFeatures, ...reportingFeatureIds])) : adopterOverrides?.experimental_features; const adopterSupportedVersions = platform.capabilities.supported_versions; const hasOverridesObject = hasOverridesProjection || adopterOverrides !== undefined; diff --git a/test/fixtures/reporting-subscriptions/coverage-cases.json b/test/fixtures/reporting-subscriptions/coverage-cases.json new file mode 100644 index 000000000..aedb8592d --- /dev/null +++ b/test/fixtures/reporting-subscriptions/coverage-cases.json @@ -0,0 +1,5587 @@ +{ + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions). Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "cases": [ + { + "name": "baseline", + "rule": "Owed from the first boundary at or after max(accepted_at 11-01T12, confirmed_at 11-02T15, authorized_at 10-01) = 11-03, through the period containing the stop time.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "mid-period confirmed_at", + "rule": "confirmed_at 11-03T10 falls inside the first flight day: coverage starts at the next boundary, 11-04, though the flight began 11-03.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "confirmed long before the flight", + "rule": "A buy confirmed before accepted_at with a later start owes nothing before start_time.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-20T00:00:00Z", + "start_time": "2026-11-10T00:00:00Z", + "end_time": "2026-11-12T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ] + }, + { + "name": "stop time mid-period is owed in full", + "rule": "end_time 11-05T12 is inside the 11-05 period, which is owed in full: periods are never clipped.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "cancellation mid-flight", + "rule": "canceled_at 11-05T06 is the stop time (earlier than end_time 11-10); its period is owed and none after.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ], + "canceled_at": "2026-11-05T06:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "early completion on a boundary", + "rule": "completed_at 11-04T00 stops the flight on a boundary: the 11-04 period does not overlap [start, stop).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ], + "completed_at": "2026-11-04T00:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + } + ] + }, + { + "name": "flight extension adds only unresolved periods", + "rule": "end_time moved 11-06 -> 11-08 at 11-07T12. The 11-06 period resolved at 11-07T00 under the old end_time and is never owed retroactively; 11-07 resolves after the extension.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ], + "prior_flights": [ + { + "replaced_at": "2026-11-07T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z" + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "reinstatement after cancellation", + "rule": "Canceled at 11-04T12, reinstated at 11-06T12. 11-05 resolved while canceled and stays unowed; 11-06 onward resolve after reinstatement.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ], + "prior_flights": [ + { + "replaced_at": "2026-11-06T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "canceled_at": "2026-11-04T12:00:00Z" + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "only resolved periods are returned", + "rule": "With now at 11-05T12, the 11-05 period has not ended.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-11-05T12:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "unconfirmed buy", + "rule": "A buy with confirmed_at null is not covered.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": null, + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "late authorization (authorized_at refinement)", + "rule": "authorized_at 11-04T10 is the latest of the three start terms: coverage starts at 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-11-04T10:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "new subscription version, aligned schedules", + "rule": "v2 accepted 11-04T12. v1 owes periods starting before T in full (11-03, 11-04); E = 11-05 and v2 owes from there. Contiguous, nothing twice.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "new subscription version, misaligned boundaries", + "rule": "v1 resolves a UTC day offering, v2 (alignment account_timezone) a New York day offering. v1 owes UTC days starting before 11-04T12 (E = 11-05T00Z); v2 owes New York days starting at or after E, the first being 11-05T05Z. 11-05T00Z-05Z is owed under neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ] + }, + { + "name": "deactivation", + "rule": "deactivated_at 11-04T12: the 11-04 period started before it and stays owed in full; nothing later.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": false, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "deactivated_at": "2026-11-04T12:00:00Z" + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "deactivation then reactivation in place", + "rule": "Inactive [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "inactive_intervals": [ + { + "from": "2026-11-04T12:00:00Z", + "until": "2026-11-06T12:00:00Z" + } + ] + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-09T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + } + ] + }, + { + "name": "account new in a later version", + "rule": "v1 names another account; v2 (all accounts, accepted 11-04T12) brings acct_1 in under the ordinary start rule: 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": ["acct_other"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "excluded_by_buyer scope", + "rule": "An all-accounts subscription that lists the account in exclude_account_ids covers nothing for it.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_1"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "operator_domains scope", + "rule": "operator_domains keeps only accounts whose operator is listed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "operator_domains": ["agency.example"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ], + "operator": "other-agency.example" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "revocation", + "rule": "unauthorized_at 11-05T06: periods starting at or after the last boundary at or before it (11-05) are not owed. 11-03 and 11-04 ended before it and stay owed (withheld until re-grant if undelivered).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + } + ] + }, + { + "name": "re-grant without backfill", + "rule": "Re-granted 11-06T18 within status_retention_days: the gap [11-05, 11-07) is not owed; coverage resumes at 11-07 and the earlier periods stay owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "re-grant with backfill_on_regrant", + "rule": "With backfill_on_regrant no gap is recorded and every period in the revoked interval stays owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "re-grant after the unauthorized entry expired", + "rule": "status_retention_days 1: the entry expired at 11-06T06, so the 11-06T18 re-grant is a fresh start from authorized_at (11-07) and backfill does not apply.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ] + }, + { + "name": "seller gaps also remove periods", + "rule": "A gaps[] entry the seller published removes the periods starting in [from, until), like an exception.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "gaps": [ + { + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "reason": "unauthorized" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "exceptions only remove periods", + "rule": "Account-level capacity [11-04, 11-06) removes 11-04 and 11-05 for every buy; a buy-level offering_unsupported removes mb_2 from 11-03; a package_ids entry removes packages, not periods; an exception before coverage adds nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + }, + { + "media_buy_id": "mb_2", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-06T00:00:00Z" + }, + { + "reason": "offering_unsupported", + "media_buy_id": "mb_2", + "from": "2026-11-03T00:00:00Z" + }, + { + "reason": "offering_unsupported", + "media_buy_id": "mb_1", + "package_ids": ["pkg_9"], + "from": "2026-11-03T00:00:00Z" + }, + { + "reason": "excluded_by_buyer", + "from": "2026-10-01T00:00:00Z", + "until": "2026-10-05T00:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "max_accounts capacity exception", + "rule": "Beyond subscriptions.max_accounts the seller records an open account-level capacity exception; nothing is owed from its from.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T00:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + } + ] + }, + { + "name": "precedence over an account-level configuration", + "rule": "The caller's own sync_accounts configuration takes the account from 11-04 to 11-05 (overridden), after which replace_account_configs hands it back.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "overridden", + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-05T00:00:00Z", + "overridden_by": { + "delivery_config_id": "acme-analytics" + } + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match exact (default with dimensions)", + "rule": "exact [package] picks the offering whose dimension set equals it; package x device (unmapped device) is distinct.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match exact with no equal offering", + "rule": "exact [package] against package x creative, package x country and package x device (unmapped) resolves nothing: offering_unsupported, no periods.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "match exact on two dimensions", + "rule": "exact [package, country] picks package x country.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package", "country"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match finest", + "rule": "finest [package] picks the most mapped dimensions among offerings including package; package x creative and package x country tie at two, so the lowest offering_id in byte order wins.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match defaults to finest without dimensions", + "rule": "No dimensions: match defaults to finest over every candidate.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "match all", + "rule": "all [package] picks every offering including package, each owed its own periods (package x device included: it includes package).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_c_pkg_country", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg_device", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "product eligibility gates candidates", + "rule": "Only offerings in the product's reporting_delivery_offering_ids are candidates; a package with none leaves the buy covered by its other packages.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + }, + { + "package_id": "pkg_2", + "reporting_delivery_offering_ids": [] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "finality, purpose and duration must match", + "rule": "An offering advertising only snapshot finality, another feed_purpose, or another period_duration is never a candidate.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg", "off_c_pkg", "off_d_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_b_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_c_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_d_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_d_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "resolution change is a cutover", + "rule": "finest [package]: package x creative becomes available at 11-04T12, changing the resolution. The old offering owes periods starting before it (11-03, 11-04); the new one owes from E = 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package", "creative"], + "available_from": "2026-11-04T12:00:00Z" + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ] + }, + { + "name": "account timezone", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + } + ] + }, + { + "name": "account timezone across a DST change", + "rule": "New York falls back on 2026-11-01: the 11-01 local day is 25 hours, 04:00Z to 05:00Z the next day.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-10-31T12:00:00Z", + "end_time": "2026-11-02T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-10-31T04:00:00Z", + "period_end": "2026-11-01T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + } + ] + }, + { + "name": "account timezone change is a cutover", + "rule": "The account moves to Los Angeles; timezone_cutovers.effective_at 11-05T05Z is E. New York days starting before it are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T05:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ] + }, + { + "name": "period_timezone override", + "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T15:00:00Z", + "period_end": "2026-11-03T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T15:00:00Z", + "period_end": "2026-11-04T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T15:00:00Z", + "period_end": "2026-11-05T15:00:00Z" + } + ] + }, + { + "name": "hourly periods are never clipped", + "rule": "Flight 10:30-13:15, confirmed 10:20: coverage starts at 11:00 (the 10:00 period started before the confirmation boundary) and the 13:00 period containing the stop is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T10:20:00Z", + "start_time": "2026-11-03T10:30:00Z", + "end_time": "2026-11-03T13:15:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_h_pkg_hourly"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_h_pkg_hourly", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_h_pkg_hourly", + "report_definition_uri": "https://seller.example/report-definitions/off_h_pkg_hourly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H" + }, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z" + } + ] + }, + { + "name": "monthly billing cycle", + "rule": "A fixed billing_cycle anchored at New York midnight: month boundaries move between 04:00Z (EDT) and 05:00Z (EST). The month containing the stop is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true, + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-20T00:00:00Z", + "start_time": "2026-11-10T00:00:00Z", + "end_time": "2027-01-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_m_billing_monthly"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m_billing_monthly", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m_billing_monthly", + "report_definition_uri": "https://seller.example/report-definitions/off_m_billing_monthly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ] + }, + { + "name": "destination selects managed offerings only", + "rule": "With a destination only offerings whose method matches its pattern and transport are candidates; without one only Core (method-less) offerings are.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "destination": { + "destination_id": "archive", + "path_template": "a/{account_id}" + } + }, + "destination_method": { + "pattern": "file_transfer", + "transport": "gcs" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_s3_pkg", "off_t_gcs_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + }, + { + "offering_id": "off_s3_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s3_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_s3_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + }, + { + "offering_id": "off_t_gcs_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_t_gcs_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_t_gcs_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "gcs", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_t_gcs_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "multiple buys group into obligations", + "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + }, + { + "media_buy_id": "mb_0", + "confirmed_at": "2026-11-03T12:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_0", "mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "numeric offset timezone is refused", + "rule": "iana_timezone: a numeric offset defines no DST rules.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "+05:00", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "account_timezone offering requires the account timezone", + "rule": "Boundaries cannot be derived without the account timezone.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "re-grant requires status_retention_days", + "rule": "Whether a re-grant continues coverage depends on the retention window.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-05T06:00:00Z" + }, + { + "authorized_at": "2026-11-06T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + } + ] +} diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 7221e18be..c833a02f7 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -5,7 +5,10 @@ process.env.NODE_ENV = 'test'; const { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, + ReportingConfigurationImmutableError, createReliableReportingService, + isReportingConfigurationImmutableError, + reportingConfigurationCapabilitiesV1, runReliableReportingServiceConformanceV1, } = require('../../dist/lib/reporting/service/index.js'); const { @@ -270,7 +273,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 +574,56 @@ describe('ReliableReportingService', () => { assert.equal(wireExact.structuredContent.reporting_revision.reporting_revision_id, revision.reporting_revision_id); }); + test('advertises media_buy.reporting_subscriptions only beside a subscriptions block', 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 }), TypeError); + assert.throws(() => reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 0 } }), TypeError); + + const platform = { + capabilities: { specialisms: [], config: {} }, + accounts: { + resolution: 'explicit', + resolve: async ref => ({ id: ref?.account_id ?? 'account-a', ctx_metadata: {} }), + upsert: async () => [], + }, + reporting: { ...service.platform, capabilities: { ...service.capabilities, ...registration } }, + }; + const server = createAdcpServerFromPlatform(platform, { + name: 'reporting-subscriptions-capabilities-test', + version: '1.0.0', + // The 3.3 schema also requires adcp.principal.supported_sections to list + // reporting_subscriptions; the principal runtime advertises that block. + validation: { requests: 'strict', responses: 'off' }, + }); + 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', + ]); + assert.deepEqual(result.structuredContent.media_buy.reporting_delivery.configuration_tasks, [ + 'sync_principal', + 'sync_accounts', + ]); + }); + 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-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js new file mode 100644 index 000000000..1c9ec76f9 --- /dev/null +++ b/test/lib/reporting-subscription-coverage.test.js @@ -0,0 +1,367 @@ +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); +const path = require('node:path'); +const consumer = require('@adcp/sdk/reporting/consumer'); +const ledger = require('@adcp/sdk/reporting/ledger'); + +const { + createReportingPeriodCalendarV1, + expectedSubscriptionPeriods, + groupExpectedSubscriptionPeriodsV1, + resolveReportingSubscriptionOfferingsV1, + reportingSubscriptionScopeIncludesV1, +} = consumer; + +// The JSON table is the cross-SDK parity contract (adcp-client-python mirrors it). +const table = require(path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'coverage-cases.json')); + +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('expectedSubscriptionPeriods parity table', () => { + test('the table is non-trivial and every case is uniquely named', () => { + assert.ok(table.cases.length >= 40); + assert.equal(new Set(table.cases.map(c => c.name)).size, table.cases.length); + }); + + for (const testCase of table.cases) { + test(testCase.name, () => { + const input = structuredClone(testCase.input); + if (testCase.expected_error) { + assert.throws( + () => expectedSubscriptionPeriods(input), + error => error.name === testCase.expected_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'); + } + 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), + })) + ); + } + }); + } + + 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('ledger and consumer entry points export the same rule', () => { + assert.equal(typeof ledger.expectedSubscriptionPeriods, 'function'); + const input = structuredClone(table.cases[0].input); + assert.deepEqual(ledger.expectedSubscriptionPeriods(input), expectedSubscriptionPeriods(input)); + }); +}); + +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), RangeError); + }); + + test('rejects malformed instants and mismatched version records', () => { + assert.throws(() => expectedSubscriptionPeriods({ ...structuredClone(baseline), now: '2026-12-01' }), TypeError); + const input = structuredClone(baseline); + input.versions[0].configuration.subscription_version = 9; + assert.throws(() => expectedSubscriptionPeriods(input), TypeError); + 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), /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', + ]); + }); + + test('hours are exact elapsed time, not civil time', () => { + const calendar = createReportingPeriodCalendarV1({ + period_duration: 'PT24H', + alignment: 'source_timezone', + timezone: 'America/New_York', + }); + const first = calendar.ceilOrdinal(instant('2026-03-07T05:00:00Z')); + assert.equal(calendar.boundary(first + 1) - calendar.boundary(first), 86_400_000); + assert.equal(calendar.boundary(first + 2) - calendar.boundary(first + 1), 86_400_000); + }); + + 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' }), TypeError); + assert.throws( + () => + createReportingPeriodCalendarV1({ period_duration: 'P1D', alignment: 'account_timezone', timezone: '+05:00' }), + TypeError + ); + assert.throws( + () => + createReportingPeriodCalendarV1({ + period_duration: 'P1D', + alignment: 'account_timezone', + timezone: 'Mars/Base', + }), + TypeError + ); + assert.throws( + () => createReportingPeriodCalendarV1({ period_duration: 'P1M', alignment: 'billing_cycle', timezone: 'UTC' }), + /period_anchor/ + ); + }); +}); + +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 a UTC alias', () => { + 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('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 + ); + }); +}); From 0f98ba1c0e17d05887db61153020f8b790045439 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 09:54:27 +0000 Subject: [PATCH 02/49] fix(reporting): defer the subscriptions feature id until adcp.principal ships Advertising media_buy.reporting_subscriptions also requires adcp.principal.supported_sections to list reporting_subscriptions, which the SDK does not emit yet. Keep the capability builder pure in this slice and add the feature id together with the principal block in the runtime slice, so an adopter can never produce a 3.3-invalid capabilities response. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-coverage.md | 2 +- .../decisioning/runtime/from-platform.ts | 15 ++------- test/lib/reliable-reporting-service.test.js | 32 +------------------ 3 files changed, 5 insertions(+), 44 deletions(-) diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md index 123aba33b..5f1694791 100644 --- a/.changeset/reporting-subscription-coverage.md +++ b/.changeset/reporting-subscription-coverage.md @@ -4,7 +4,7 @@ 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 offerings with their standard-dimension mapping, and the published `subscription_coverage` exceptions. It returns every owed (buy, offering, period), and `groupExpectedSubscriptionPeriodsV1` groups them into the obligations that must carry them. It implements the start rule (first boundary at or after the latest of `accepted_at`, `confirmed_at` and `authorized_at`), 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. `createAdcpServerFromPlatform` adds `media_buy.reporting_subscriptions` to `experimental_features` when the reporting capability carries a `subscriptions` block; without one the list is unchanged. +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. diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index 60fd413d6..5bc039ba3 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -3160,26 +3160,17 @@ export function createAdcpServerFromPlatform

32 && - reportingFeatureIds.some(id => !adopterExperimentalFeatures.includes(id)) + !adopterExperimentalFeatures.includes('media_buy.reporting_delivery') && + adopterExperimentalFeatures.length >= 32 ) { throw new PlatformConfigError( 'Reliable Reporting Core cannot be added because experimental_features already contains 32 entries' ); } const experimentalFeatures = reportingDelivery - ? Array.from(new Set([...adopterExperimentalFeatures, ...reportingFeatureIds])) + ? Array.from(new Set([...adopterExperimentalFeatures, 'media_buy.reporting_delivery'])) : adopterOverrides?.experimental_features; const adopterSupportedVersions = platform.capabilities.supported_versions; const hasOverridesObject = hasOverridesProjection || adopterOverrides !== undefined; diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index c833a02f7..9b96deaed 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -574,7 +574,7 @@ describe('ReliableReportingService', () => { assert.equal(wireExact.structuredContent.reporting_revision.reporting_revision_id, revision.reporting_revision_id); }); - test('advertises media_buy.reporting_subscriptions only beside a subscriptions block', async () => { + 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'); @@ -592,36 +592,6 @@ describe('ReliableReportingService', () => { ); assert.throws(() => reportingConfigurationCapabilitiesV1({ accountConfigurations: false }), TypeError); assert.throws(() => reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 0 } }), TypeError); - - const platform = { - capabilities: { specialisms: [], config: {} }, - accounts: { - resolution: 'explicit', - resolve: async ref => ({ id: ref?.account_id ?? 'account-a', ctx_metadata: {} }), - upsert: async () => [], - }, - reporting: { ...service.platform, capabilities: { ...service.capabilities, ...registration } }, - }; - const server = createAdcpServerFromPlatform(platform, { - name: 'reporting-subscriptions-capabilities-test', - version: '1.0.0', - // The 3.3 schema also requires adcp.principal.supported_sections to list - // reporting_subscriptions; the principal runtime advertises that block. - validation: { requests: 'strict', responses: 'off' }, - }); - 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', - ]); - assert.deepEqual(result.structuredContent.media_buy.reporting_delivery.configuration_tasks, [ - 'sync_principal', - 'sync_accounts', - ]); }); test('derives Core-only capabilities and installs native server handlers', async () => { From f8b2fddd24bc0912356fdfa30197bfb8c6003fbf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 14:22:43 +0000 Subject: [PATCH 03/49] feat(reporting): conform subscription coverage to adcp#8585 Adopt the rules adcontextprotocol/adcp#8585 settled: - Selection reads each offering's published standard_dimensions and unmapped_dimensions, and fails closed on an offering without them. - period_timezone: a utc offering matches only UTC or Etc/UTC; a source_timezone offering only its own published zone (never an account-resolved one). - The coverage function takes the readback's inactive_intervals in wire shape, per-offering timezone_cutovers with previous_timezone (account.timezone is now the current zone), and per-offering gaps. - The parity table fixes the billing-cycle case's scope (both keys, which maxProperties 1 forbids), adds cases for the settled rules, and its subscriptions are now schema-validated. The new wire fields are hand-modeled in subscriptions/wire.ts until 3.3.0-beta.2 ships. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-coverage.md | 4 +- src/lib/reporting/subscriptions/coverage.ts | 175 ++- src/lib/reporting/subscriptions/index.ts | 8 + src/lib/reporting/subscriptions/selection.ts | 70 +- src/lib/reporting/subscriptions/wire.ts | 65 + .../coverage-cases.json | 1131 +++++++++++++++-- .../reporting-subscription-coverage.test.js | 28 +- 7 files changed, 1266 insertions(+), 215 deletions(-) create mode 100644 src/lib/reporting/subscriptions/wire.ts diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md index 5f1694791..f5290237f 100644 --- a/.changeset/reporting-subscription-coverage.md +++ b/.changeset/reporting-subscription-coverage.md @@ -2,10 +2,10 @@ '@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 offerings with their standard-dimension mapping, and the published `subscription_coverage` exceptions. It returns every owed (buy, offering, period), and `groupExpectedSubscriptionPeriodsV1` groups them into the obligations that must carry them. It implements the start rule (first boundary at or after the latest of `accepted_at`, `confirmed_at` and `authorized_at`), 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. +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 (first boundary at or after the latest of `accepted_at`, `confirmed_at` and `authorized_at`), 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 cross-SDK parity table is `test/fixtures/reporting-subscriptions/coverage-cases.json`. +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`. diff --git a/src/lib/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index 3717ab86e..8c9f33e74 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -26,7 +26,12 @@ * * @packageDocumentation */ -import type { ReportingSubscription, ReportingSubscriptionCoverage } from '../../types/core.generated'; +import type { ReportingSubscription } from '../../types/core.generated'; +import type { + ReportingSubscriptionCoverageWithOfferingsV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; import { assertReportingIanaTimezoneV1, createReportingPeriodCalendarV1, @@ -53,15 +58,8 @@ export interface ReportingSubscriptionVersionRecordV1 { * next record when absent. */ superseded_at?: string; - /** When `active: false` was applied (`deactivated_at`). */ + /** When `active: false` was applied (`deactivated_at`), for the current version. */ deactivated_at?: string; - /** - * Earlier in-place deactivations that were later reversed, as the buyer recorded them. - * The readback drops `deactivated_at` once a subscription is active again, so only the - * buyer can supply these. Each interval is a cutover out at `from` (periods that started - * before it stay owed) and a fresh start at `until`. - */ - inactive_intervals?: ReadonlyArray<{ from: string; until?: string }>; /** That version's subscription, as sent (readback `configuration`). */ configuration: ReportingSubscription; /** @@ -110,14 +108,15 @@ export interface ReportingSubscriptionCoverageAccountV1 { /** The account's `operator` domain, needed when a version names `operator_domains`. */ operator?: string; /** - * The account's IANA timezone before the first `timezone_cutovers` entry (the current - * one when there are none). Required when an `account_timezone` offering resolves. + * 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 without a `period_timezone` override. + * offering resolves. */ source_timezone?: string; /** @@ -134,10 +133,16 @@ export interface ExpectedSubscriptionPeriodsInputV1 { versions: readonly ReportingSubscriptionVersionRecordV1[]; account: ReportingSubscriptionCoverageAccountV1; buys: readonly ReportingSubscriptionBuyV1[]; - /** Seller offerings with their standard-dimension mapping. */ + /** 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; + 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. */ @@ -220,7 +225,7 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn if (typeof input.subscription_id !== 'string' || input.subscription_id.length === 0) { throw new TypeError('subscription_id is required'); } - const versions = normalizeVersions(input.versions, input.subscription_id); + 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); @@ -265,7 +270,7 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn const flight = flightAt(flights, end); if (!(start < flight.stop && end > flight.start)) continue; if (!authorized(chains, calendar, start, input.backfill_on_regrant === true)) continue; - if (removed(removals, buy.media_buy_id, start)) continue; + if (removed(removals, buy.media_buy_id, offering.offering_id, start)) continue; result.push({ subscription_id: input.subscription_id, subscription_version: regime.version.subscription_version, @@ -345,7 +350,8 @@ interface NormalizedVersion { function normalizeVersions( records: readonly ReportingSubscriptionVersionRecordV1[], - subscriptionId: string + subscriptionId: string, + globalInactive: readonly Window[] ): NormalizedVersion[] { if (!Array.isArray(records) || records.length === 0) throw new TypeError('versions must list at least one version'); const versions: NormalizedVersion[] = []; @@ -382,29 +388,38 @@ function normalizeVersions( if (next !== undefined && supersededAt !== instant(next.accepted_at, 'accepted_at')) { throw new TypeError(`${label} superseded_at must equal the next version's accepted_at`); } - const inactive: Window[] = (record.inactive_intervals ?? []).map((interval: { from: string; until?: string }) => ({ - from: instant(interval.from, `${label} inactive_intervals.from`), - until: - interval.until === undefined - ? Number.POSITIVE_INFINITY - : instant(interval.until, `${label} inactive_intervals.until`), - })); + 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) { - throw new TypeError(`${label} is inactive; deactivated_at is required`); - } - for (const window of inactive) { - if (!(window.until > window.from)) throw new TypeError(`${label} inactive interval must have until after from`); + } else if ( + record.configuration.active === false && + next === undefined && + !globalInactive.some(window => !Number.isFinite(window.until)) + ) { + throw new TypeError(`${label} is inactive; deactivated_at or an open inactive_intervals entry is required`); } - versions.push({ record, acceptedAt, endsAt: supersededAt, inactive }); + 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 TypeError('inactive_intervals must be an array'); + 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 TypeError('inactive_intervals reactivated_at must be after deactivated_at'); + return { from, until }; + }); +} + function authorityChains( grants: readonly ReportingSubscriptionGrantV1[], retentionDays: number | undefined @@ -467,38 +482,65 @@ function authorized( }); } -interface TimezoneSegment { - from: number; - timezone: string | undefined; +interface TimezoneCutover { + at: number; + timezone: string; + previous?: string; + offeringId?: string; +} + +interface TimezoneTimeline { + current: string | undefined; + cutovers: TimezoneCutover[]; } function timezoneTimeline( - initial: string | undefined, - cutovers: ReportingSubscriptionCoverage['timezone_cutovers'] -): TimezoneSegment[] { - if (initial !== undefined) assertReportingIanaTimezoneV1(initial, 'account.timezone'); - const segments: TimezoneSegment[] = [{ from: Number.NEGATIVE_INFINITY, timezone: initial }]; - const sorted = [...(cutovers ?? [])] + 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'); - return { from: instant(entry.effective_at, 'timezone_cutovers.effective_at'), timezone: entry.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.from - right.from); - if (sorted.length > 0 && initial === undefined) { - throw new TypeError('account.timezone is required with timezone_cutovers'); - } - segments.push(...sorted); - return segments; + .sort((left, right) => left.at - right.at); + return { current, cutovers }; } -function timezoneAt(segments: readonly TimezoneSegment[], at: number): string | undefined { - let current = segments[0]!.timezone; - for (const segment of segments) if (segment.from <= at) current = segment.timezone; - return current; +/** + * 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 TypeError('timezone_cutovers entries require 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; + /** Gaps may name one offering; absent applies to every offering. */ + offeringId?: string; } function exceptionWindows(coverage: ExpectedSubscriptionPeriodsInputV1['coverage']): ExceptionWindow[] { @@ -514,15 +556,20 @@ function exceptionWindows(coverage: ExpectedSubscriptionPeriodsInputV1['coverage }); } for (const gap of coverage?.gaps ?? []) { - windows.push({ from: instant(gap.from, 'gaps.from'), until: instant(gap.until, 'gaps.until') }); + 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 removed(windows: readonly ExceptionWindow[], mediaBuyId: string, start: number): boolean { +function removed(windows: readonly ExceptionWindow[], mediaBuyId: string, offeringId: string, start: number): boolean { return windows.some( window => (window.mediaBuyId === undefined || window.mediaBuyId === mediaBuyId) && + (window.offeringId === undefined || window.offeringId === offeringId) && start >= window.from && start < window.until ); @@ -563,7 +610,7 @@ function buildRegimes( versions: readonly NormalizedVersion[], input: ExpectedSubscriptionPeriodsInputV1, offeringsById: ReadonlyMap, - timezones: readonly TimezoneSegment[], + timezones: TimezoneTimeline, calendars: Map ): Array { const points = new Set(); @@ -579,7 +626,7 @@ function buildRegimes( 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 segment of timezones) if (Number.isFinite(segment.from)) points.add(segment.from); + 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) { @@ -601,15 +648,7 @@ function buildRegimes( if (regimes.at(-1) !== null) regimes.push(null); continue; } - const offerings = resolveForBuy( - buy, - version.record, - input, - offeringsById, - timezoneAt(timezones, from), - from, - calendars - ); + 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; @@ -636,7 +675,7 @@ function resolveForBuy( version: ReportingSubscriptionVersionRecordV1, input: ExpectedSubscriptionPeriodsInputV1, offeringsById: ReadonlyMap, - accountTimezone: string | undefined, + timezones: TimezoneTimeline, at: number, calendars: Map ): ResolvedOffering[] { @@ -658,7 +697,9 @@ function resolveForBuy( const spec = calendarSpec( offering, version.configuration.selector.period_timezone, - accountTimezone, + 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); @@ -693,8 +734,8 @@ function calendarSpec( return { ...base, timezone }; } case 'source_timezone': { - const timezone = - schedule.period_timezone_policy === 'fixed' ? schedule.period_timezone : (override ?? sourceTimezone); + // 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 TypeError( `Offering ${offering.offering_id} resolves its source timezone per account; account.source_timezone is required` diff --git a/src/lib/reporting/subscriptions/index.ts b/src/lib/reporting/subscriptions/index.ts index 207c090a4..6027c59cd 100644 --- a/src/lib/reporting/subscriptions/index.ts +++ b/src/lib/reporting/subscriptions/index.ts @@ -44,3 +44,11 @@ export type { } from './coverage'; export { reportingConfigurationCapabilitiesV1 } from './capabilities'; export type { ReportingConfigurationCapabilitiesInputV1, ReportingConfigurationCapabilitiesV1 } from './capabilities'; +export type { + ReportingDeliveryOfferingWithDimensionsV1, + ReportingSubscriptionCoverageWithOfferingsV1, + ReportingSubscriptionGapV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionStateWithIntervalsV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts index 1a25c3d13..ac5760a59 100644 --- a/src/lib/reporting/subscriptions/selection.ts +++ b/src/lib/reporting/subscriptions/selection.ts @@ -6,22 +6,17 @@ * so both sides resolve the same offering for the same facts. */ import type { ReportingDeliveryOffering, ReportingSubscription } from '../../types/core.generated'; +import type { ReportingDeliveryOfferingWithDimensionsV1 } from './wire'; /** - * A seller delivery offering plus the dimension mapping selection needs. + * A seller delivery offering as published in `media_buy.reporting_delivery.offerings`. * - * The wire offering does not carry its mapped standard dimensions: the seller maps each - * report definition's native dimensions onto `enums/reporting-dimension.json`. A buyer - * reads that mapping from the report definition or from the seller's documentation. + * 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. */ -export type ReportingSubscriptionOfferingV1 = ReportingDeliveryOffering & { - /** Standard-vocabulary dimensions this offering's report definition maps onto. */ - dimensions: readonly string[]; - /** - * Native dimensions that map to no standard value. They count as distinct, so a - * `package` × device offering never equals a `package` selector under `exact`. - */ - unmapped_dimensions?: readonly string[]; +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. @@ -71,32 +66,16 @@ export function compareReportingIdsByteOrderV1(left: string, right: string): num return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); } -const UTC_ZONE_NAMES = new Set([ - 'UTC', - 'Etc/UTC', - 'Etc/UCT', - 'UCT', - 'Etc/Universal', - 'Universal', - 'Etc/Zulu', - 'Zulu', - 'GMT', - 'Etc/GMT', - 'Etc/GMT+0', - 'Etc/GMT-0', - 'Etc/GMT0', - 'GMT+0', - 'GMT-0', - 'GMT0', - 'Etc/Greenwich', - 'Greenwich', -]); +/** The UTC aliases `core/reporting-subscription.json` names for a `utc` offering. */ +const UTC_ZONE_NAMES = new Set(['UTC', 'Etc/UTC']); /** - * Whether an offering's published schedule can produce periods in a `period_timezone` - * override. A `utc` schedule only in a UTC alias; a fixed-timezone schedule only in that - * zone; an account-resolved or account-timezone schedule in any zone; a configurable - * billing cycle has no anchor a subscription can supply and so is never resolvable. + * 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; @@ -108,7 +87,7 @@ function canProduceTimezone(offering: ReportingDeliveryOffering, timezone: strin case 'account_timezone': return true; case 'source_timezone': - return schedule.period_timezone_policy !== 'fixed' || schedule.period_timezone === timezone; + return schedule.period_timezone !== undefined && schedule.period_timezone === timezone; case 'billing_cycle': return schedule.period_timezone === timezone; default: @@ -160,12 +139,23 @@ export function resolveReportingSubscriptionOfferingsV1(input: ResolveReportingS 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 TypeError( + `Offering ${JSON.stringify(offering.offering_id)} does not publish standard_dimensions; ` + + 'a subscription selector cannot be resolved against it' + ); + } + return offering.standard_dimensions; + }; const includesWanted = (offering: ReportingSubscriptionOfferingV1) => - wanted === undefined || wanted.every(dimension => offering.dimensions.includes(dimension)); + 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; - const own = new Set(offering.dimensions); return ( (offering.unmapped_dimensions?.length ?? 0) === 0 && own.size === new Set(wanted).size && @@ -178,7 +168,7 @@ export function resolveReportingSubscriptionOfferingsV1(input: ResolveReportingS if (match === 'all') return including.map(offering => offering.offering_id); let best: ReportingSubscriptionOfferingV1 | undefined; for (const offering of including) { - if (best === undefined || new Set(offering.dimensions).size > new Set(best.dimensions).size) best = offering; + if (best === undefined || new Set(standard(offering)).size > new Set(standard(best)).size) best = offering; } return best ? [best.offering_id] : []; } diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts new file mode 100644 index 000000000..133ed3b47 --- /dev/null +++ b/src/lib/reporting/subscriptions/wire.ts @@ -0,0 +1,65 @@ +/** + * 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. */ +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. */ +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`. */ +export type ReportingSubscriptionStateWithIntervalsV1 = ReportingSubscriptionState & { + inactive_intervals?: ReportingSubscriptionInactiveIntervalV1[]; +}; + +/** One `timezone_cutovers[]` entry with `previous_timezone` and an optional `offering_id`. */ +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`. */ +export interface ReportingSubscriptionGapV1 { + /** Absent: every offering on the account. */ + offering_id?: string; + from: string; + until: string; + reason: 'unauthorized'; +} + +/** `core/reporting-subscription-coverage.json` with per-offering cutovers and gaps. */ +export type ReportingSubscriptionCoverageWithOfferingsV1 = Omit< + ReportingSubscriptionCoverage, + 'timezone_cutovers' | 'gaps' +> & { + timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; + gaps?: ReportingSubscriptionGapV1[]; +}; diff --git a/test/fixtures/reporting-subscriptions/coverage-cases.json b/test/fixtures/reporting-subscriptions/coverage-cases.json index aedb8592d..8a8a00e49 100644 --- a/test/fixtures/reporting-subscriptions/coverage-cases.json +++ b/test/fixtures/reporting-subscriptions/coverage-cases.json @@ -1,5 +1,5 @@ { - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions). Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", @@ -74,7 +74,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -176,7 +176,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -271,7 +271,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -366,7 +366,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -469,7 +469,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -572,7 +572,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -667,7 +667,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -784,7 +784,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -893,7 +893,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-11-05T12:00:00Z" @@ -988,7 +988,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1068,7 +1068,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1184,7 +1184,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1314,7 +1314,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_e_pkg_acct_tz", @@ -1339,7 +1339,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1449,7 +1449,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1473,7 +1473,7 @@ }, { "name": "deactivation then reactivation in place", - "rule": "Inactive [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", + "rule": "Readback inactive_intervals [11-04T12, 11-06T12): a cutover out at 11-04T12 and a fresh start at the first boundary at or after 11-06T12 (11-07).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -1494,13 +1494,7 @@ "dimensions": ["package"] }, "reconciliation_mode": "delivery_only" - }, - "inactive_intervals": [ - { - "from": "2026-11-04T12:00:00Z", - "until": "2026-11-06T12:00:00Z" - } - ] + } } ], "account": { @@ -1550,10 +1544,16 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], - "now": "2026-12-01T00:00:00Z" + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T12:00:00Z" + } + ] }, "expected": [ { @@ -1600,7 +1600,6 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, "account_ids": ["acct_other"] }, "selector": { @@ -1680,7 +1679,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1776,7 +1775,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1858,7 +1857,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -1939,7 +1938,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -2038,7 +2037,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2145,7 +2144,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2267,7 +2266,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2371,7 +2370,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2487,7 +2486,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2607,7 +2606,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2703,7 +2702,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -2815,7 +2814,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -2840,7 +2839,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -2865,7 +2864,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -2890,7 +2889,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -2993,7 +2992,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3018,7 +3017,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3043,7 +3042,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -3068,7 +3067,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -3154,7 +3153,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3179,7 +3178,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3204,7 +3203,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -3229,7 +3228,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -3338,7 +3337,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3363,7 +3362,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3388,7 +3387,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -3413,7 +3412,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -3520,7 +3519,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3545,7 +3544,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3570,7 +3569,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -3595,7 +3594,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -3704,7 +3703,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3729,7 +3728,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3754,7 +3753,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -3779,7 +3778,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -3949,7 +3948,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -3974,7 +3973,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"] + "standard_dimensions": ["package", "creative"] }, { "offering_id": "off_c_pkg_country", @@ -3999,7 +3998,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "country"] + "standard_dimensions": ["package", "country"] }, { "offering_id": "off_d_pkg_device", @@ -4024,7 +4023,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "unmapped_dimensions": ["device"] } ], @@ -4127,7 +4126,7 @@ }, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg", @@ -4152,7 +4151,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_c_pkg", @@ -4177,7 +4176,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_d_pkg", @@ -4202,7 +4201,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -4305,7 +4304,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_b_pkg_creative", @@ -4330,7 +4329,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package", "creative"], + "standard_dimensions": ["package", "creative"], "available_from": "2026-11-04T12:00:00Z" } ], @@ -4440,7 +4439,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -4542,7 +4541,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -4573,7 +4572,7 @@ }, { "name": "account timezone change is a cutover", - "rule": "The account moves to Los Angeles; timezone_cutovers.effective_at 11-05T05Z is E. New York days starting before it are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", + "rule": "The account moves to Los Angeles (account.timezone is the current zone); timezone_cutovers.effective_at 11-05T05Z is E and previous_timezone New York governs before it. New York days starting before E are owed, then Los Angeles days from the first boundary at or after it (11-05T08Z). 05Z-08Z is owed under neither.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -4599,7 +4598,7 @@ ], "account": { "account_id": "acct_1", - "timezone": "America/New_York", + "timezone": "America/Los_Angeles", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -4644,7 +4643,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z", @@ -4652,7 +4651,8 @@ "timezone_cutovers": [ { "effective_at": "2026-11-05T05:00:00Z", - "timezone": "America/Los_Angeles" + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" } ] } @@ -4709,7 +4709,6 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, "account_ids": ["acct_1"] }, "selector": { @@ -4771,7 +4770,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -4873,7 +4872,7 @@ }, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -4920,7 +4919,6 @@ "subscription_version": 1, "active": true, "scope": { - "all_authorized_accounts": true, "account_ids": ["acct_1"] }, "selector": { @@ -4986,7 +4984,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "consumer_receipt", - "dimensions": ["package"], + "standard_dimensions": ["package"], "method": { "pattern": "file_transfer", "transport": "s3", @@ -5103,7 +5101,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] }, { "offering_id": "off_s3_pkg", @@ -5128,7 +5126,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "method": { "pattern": "file_transfer", "transport": "s3", @@ -5160,7 +5158,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"], + "standard_dimensions": ["package"], "method": { "pattern": "file_transfer", "transport": "gcs", @@ -5281,7 +5279,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" @@ -5341,8 +5339,8 @@ ] }, { - "name": "numeric offset timezone is refused", - "rule": "iana_timezone: a numeric offset defines no DST rules.", + "name": "timezone cutover scoped to one offering", + "rule": "A timezone_cutovers entry with offering_id applies to that offering only; an offering with no applicable entry uses account.timezone (Los Angeles) throughout.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5360,7 +5358,8 @@ "feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", - "dimensions": ["package"] + "dimensions": ["package"], + "match": "all" }, "reconciliation_mode": "delivery_only" } @@ -5368,7 +5367,166 @@ ], "account": { "account_id": "acct_1", - "timezone": "+05:00", + "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" + } + ] + }, + { + "name": "gap scoped to one offering", + "rule": "A gaps[] entry with offering_id removes only that offering's periods; an entry without offering_id applies to every offering.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -5384,7 +5542,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg"] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"] } ] } @@ -5413,16 +5571,87 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "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" + "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_error": "TypeError" + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] }, { - "name": "account_timezone offering requires the account timezone", - "rule": "Boundaries cannot be derived without the account timezone.", + "name": "match exact without dimensions", + "rule": "exact with no dimensions: every candidate qualifies and the lowest offering_id in byte order wins.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5440,7 +5669,7 @@ "feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", - "dimensions": ["package"] + "match": "exact" }, "reconciliation_mode": "delivery_only" } @@ -5448,6 +5677,7 @@ ], "account": { "account_id": "acct_1", + "timezone": "America/New_York", "grants": [ { "authorized_at": "2026-10-01T00:00:00Z" @@ -5463,17 +5693,22 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] } ] } ], "offerings": [ { - "offering_id": "off_e_pkg_acct_tz", + "offering_id": "off_a_pkg", "feed_purpose": "analytics", - "report_definition_id": "rd_off_e_pkg_acct_tz", - "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "reporting_profile": { "id": "adcp.delivery.v1", @@ -5487,12 +5722,698 @@ }, "schedule": { "period_duration": "P1D", - "alignment": "account_timezone", + "alignment": "utc", "delivery_sla": "PT6H" }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ] + }, + { + "name": "period_timezone never matches an account-resolved source_timezone offering", + "rule": "A source_timezone offering matches a period_timezone override only when its published period_timezone equals it; an account_resolved offering publishes none.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ], + "source_timezone": "America/New_York" + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_s_resolved"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_resolved", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_resolved", + "report_definition_uri": "https://seller.example/report-definitions/off_s_resolved.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "delivery_sla": "PT6H", + "period_timezone_policy": "account_resolved" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "period_timezone matches a fixed source_timezone offering in the same zone", + "rule": "A fixed source_timezone offering publishing America/New_York matches a New York override and keeps its own boundaries (05:00Z in November).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "America/New_York" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-05T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_s_fixed"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_fixed", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_fixed", + "report_definition_uri": "https://seller.example/report-definitions/off_s_fixed.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "source_timezone", + "delivery_sla": "PT6H", + "period_timezone_policy": "fixed", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + } + ] + }, + { + "name": "a utc offering matches only UTC or Etc/UTC", + "rule": "period_timezone GMT is not one of the UTC aliases core/reporting-subscription.json names, so the utc offering does not match.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "period_timezone": "GMT" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [] + }, + { + "name": "numeric offset timezone is refused", + "rule": "iana_timezone: a numeric offset defines no DST rules.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "+05:00", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "account_timezone offering requires the account timezone", + "rule": "Boundaries cannot be derived without the account timezone.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected_error": "TypeError" + }, + { + "name": "a timezone cutover without previous_timezone is refused", + "rule": "Sellers MUST include previous_timezone: account.timezone already shows the new zone, so the old schedule cannot be recovered without it.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles" + } + ] + } + }, + "expected_error": "TypeError" + }, + { + "name": "an offering without standard_dimensions cannot be resolved", + "rule": "Sellers advertising subscriptions MUST publish standard_dimensions on every offering; matching never guesses.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only" } ], "now": "2026-12-01T00:00:00Z" @@ -5576,7 +6497,7 @@ }, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", - "dimensions": ["package"] + "standard_dimensions": ["package"] } ], "now": "2026-12-01T00:00:00Z" diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index 1c9ec76f9..3d89e1328 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -73,6 +73,30 @@ describe('expectedSubscriptionPeriods parity table', () => { }); } + test('every subscription in the table is schema-valid', () => { + // The subscription schema is unchanged by adcontextprotocol/adcp#8585 apart from + // descriptions, so the cached 3.3.0-beta.1 copy validates the table's configurations. + const Ajv = require('ajv'); + const addFormats = require('ajv-formats'); + const fs = require('node:fs'); + const root = path.join(__dirname, '..', '..', 'schemas', 'cache', '3.3.0-beta.1'); + 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')) ajv.addSchema(JSON.parse(fs.readFileSync(path.join(root, dir, file), 'utf8'))); + } + } + const validate = ajv.getSchema( + 'https://adcontextprotocol.org/schemas/3.3.0-beta.1/core/reporting-subscription.json' + ); + for (const testCase of table.cases) { + 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); @@ -332,7 +356,7 @@ describe('selector resolution and scope', () => { ); }); - test('a utc schedule matches a period_timezone override only for a UTC alias', () => { + 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 }), @@ -341,6 +365,8 @@ describe('selector resolution and scope', () => { 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'), []); }); From a99597a46551c467ec58c68cfdb70d994e88a1a1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 18:48:55 +0000 Subject: [PATCH 04/49] fix(reporting): settle coverage edge cases and adopt adcp#8588 calendars - An empty flight (stop at or before start) overlaps nothing. - A cutover carries the outgoing stretch's own owing start into E, so two cutovers inside one period never owe a period twice. - Sub-daily periods under a local alignment restart at each local midnight on wall-clock multiples (adcp#8588); calendars gain next() and the walk iterates by instant with an iteration budget and skip-ahead. - A billing anchor in a DST fold is interval zero itself. - A wire authorized_at equal to the chain start continues the chain. - The start rule lives in one function for the pending owner decision. - Schema validation accepts unknown reporting-dimension values. - The parity table grows to 79 cases with obligations on every case and non-wire inputs labelled. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-coverage.md | 2 + src/lib/reporting/subscriptions/calendar.ts | 124 +- src/lib/reporting/subscriptions/coverage.ts | 118 +- src/lib/reporting/subscriptions/selection.ts | 4 +- src/lib/validation/schema-loader.ts | 37 +- .../coverage-cases.json | 5103 ++++++++++++++++- .../reporting-subscription-coverage.test.js | 82 +- 7 files changed, 5356 insertions(+), 114 deletions(-) diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md index f5290237f..b80348ee9 100644 --- a/.changeset/reporting-subscription-coverage.md +++ b/.changeset/reporting-subscription-coverage.md @@ -9,3 +9,5 @@ Also exported: `createReportingPeriodCalendarV1`, the normative `period_generati `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/src/lib/reporting/subscriptions/calendar.ts b/src/lib/reporting/subscriptions/calendar.ts index fd3f160c0..2dea29659 100644 --- a/src/lib/reporting/subscriptions/calendar.ts +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -60,7 +60,10 @@ export interface ReportingPeriodCalendarSpecV1 { /** Ordered, half-open reporting periods derived from one published schedule. */ export interface ReportingPeriodCalendarV1 { readonly spec: Readonly; - /** Epoch milliseconds of boundary `ordinal`. Boundary 0 is the schedule origin. */ + /** + * 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; @@ -70,6 +73,11 @@ export interface ReportingPeriodCalendarV1 { 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; } @@ -162,6 +170,21 @@ function instantForWall(wall: number, timeZone: string): number { 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' || @@ -180,9 +203,22 @@ function parseInstant(value: string, field: string): number { */ 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 TypeError( + `Reporting period_duration ${JSON.stringify(spec.period_duration)} mixes date and time components under ${spec.alignment} alignment` + ); + } + 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'; @@ -201,6 +237,7 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe const anchor = parseInstant(spec.period_anchor, 'period_anchor'); originWall = wallTime(anchor, timeZone); anchorKey = new Date(anchor).toISOString(); + anchorInstant = anchor; break; } default: @@ -221,6 +258,9 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe function boundary(ordinal: number): number { if (!Number.isSafeInteger(ordinal)) throw new RangeError('Reporting period ordinal must be a safe integer'); + // 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; @@ -277,6 +317,88 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe 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 TypeError( + `Sub-daily period_duration ${JSON.stringify(spec.period_duration)} must divide 24 hours under ${spec.alignment} alignment` + ); + } + 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 RangeError('Reporting period boundary is outside the RFC 3339 instant range'); + } + 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 RangeError('Reporting instant must be finite'); + 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 RangeError('Reporting instant must be finite'); + 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/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index 8c9f33e74..49b8d7aae 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -47,6 +47,7 @@ import { 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. */ export interface ReportingSubscriptionVersionRecordV1 { @@ -91,13 +92,24 @@ export interface ReportingSubscriptionBuyV1 { 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. */ + /** + * 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. */ export interface ReportingSubscriptionGrantV1 { - /** `authorization.authorized_at` when this grant began. */ + /** + * 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; @@ -151,6 +163,8 @@ export interface ExpectedSubscriptionPeriodsInputV1 { 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. */ @@ -200,12 +214,17 @@ interface Regime { 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. */ - interruptions: Array<{ revokedAt: number; regrantedAt: 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; } @@ -237,6 +256,11 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn 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 TypeError('max_iterations must be a positive integer'); + } + let iterations = 0; for (const buy of input.buys) { if (seenBuys.has(buy.media_buy_id)) throw new TypeError('media_buy_id values must be unique'); @@ -255,22 +279,34 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn 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. - const lower = Math.max( - calendar.ceilOrdinal(Math.max(handoff, confirmedAt)), - calendar.floorOrdinal(earliestStart) - ); - for (let ordinal = lower; ; ordinal += 1) { - const start = calendar.boundary(ordinal); - if (start >= regime.until || start >= latestStop) break; - const end = calendar.boundary(ordinal + 1); + let start = Math.max(firstOwedBoundary(calendar, handoff, confirmedAt), calendar.floor(earliestStart)); + while (start < regime.until && start < latestStop) { + if (++iterations > maxIterations) { + throw new RangeError(`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; - if (end === start) continue; // a skipped civil date has no period + 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); - if (!(start < flight.stop && end > flight.start)) continue; - if (!authorized(chains, calendar, start, input.backfill_on_regrant === true)) continue; - if (removed(removals, buy.media_buy_id, offering.offering_id, start)) continue; + 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, @@ -284,6 +320,7 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn if (result.length > maxPeriods) { throw new RangeError(`Expected subscription periods exceed max_periods (${maxPeriods})`); } + start = end; } } } @@ -291,6 +328,18 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn return result.sort(comparePeriods); } +/** + * The start rule, kept in one place: the first boundary at or after the latest of the + * cutover handoff (the version's `accepted_at`, or E after a cutover) and the buy's + * `confirmed_at`. The account's `authorized_at` term is applied per authorization chain in + * `authorized`; ceil is monotone, so the combination is the first boundary at or after the + * latest of the three. Owner decision pending on whether the period containing + * `confirmed_at` is owed: if so, the confirmed term becomes `calendar.floor(confirmedAt)`. + */ +function firstOwedBoundary(calendar: ReportingPeriodCalendarV1, handoff: number, confirmedAt: number): number { + return Math.max(calendar.ceil(handoff), calendar.ceil(confirmedAt)); +} + /** Group owed periods into the obligations that must carry them, with frozen `media_buy_ids`. */ export function groupExpectedSubscriptionPeriodsV1( periods: readonly ExpectedSubscriptionPeriodV1[] @@ -436,13 +485,17 @@ function authorityChains( throw new TypeError('A grant cannot end before it began'); } if (previousEnd === undefined && index > 0) throw new TypeError('Only the last grant may be unrevoked'); - if (previousEnd !== undefined && authorizedAt < previousEnd) throw new TypeError('Grants must not overlap'); const current = chains.at(-1); if (current !== undefined && previousEnd !== undefined) { - if (retentionDays === undefined || !Number.isSafeInteger(retentionDays) || retentionDays < 1) { + 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 TypeError('status_retention_days is required to evaluate a re-grant'); - } - if (authorizedAt < previousEnd + retentionDays * DAY_MS) { + } else if (authorizedAt < previousEnd + retentionDays * DAY_MS) { current.interruptions.push({ revokedAt: previousEnd, regrantedAt: authorizedAt }); delete current.end; } else { @@ -475,6 +528,7 @@ function authorized( 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; } } @@ -565,8 +619,13 @@ function exceptionWindows(coverage: ExpectedSubscriptionPeriodsInputV1['coverage return windows; } -function removed(windows: readonly ExceptionWindow[], mediaBuyId: string, offeringId: string, start: number): boolean { - return windows.some( +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) && @@ -654,7 +713,7 @@ function buildRegimes( previous.until = until; continue; } - regimes.push({ from, until, version: version.record, offerings }); + regimes.push({ from, until, version: version.record, offerings, handoffs: new Map() }); } return regimes; } @@ -761,8 +820,17 @@ function calendarSpec( 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); - if (same) return same.calendar.ceil(handoff); - return Math.max(...predecessor.offerings.map(entry => entry.calendar.ceil(handoff))); + 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 { diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts index ac5760a59..fbb04961c 100644 --- a/src/lib/reporting/subscriptions/selection.ts +++ b/src/lib/reporting/subscriptions/selection.ts @@ -19,7 +19,9 @@ import type { ReportingDeliveryOfferingWithDimensionsV1 } from './wire'; 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. + * 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. */ diff --git a/src/lib/validation/schema-loader.ts b/src/lib/validation/schema-loader.ts index e9c74868f..9094d3524 100644 --- a/src/lib/validation/schema-loader.ts +++ b/src/lib/validation/schema-loader.ts @@ -679,9 +679,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/coverage-cases.json b/test/fixtures/reporting-subscriptions/coverage-cases.json index 8a8a00e49..2fd4134b1 100644 --- a/test/fixtures/reporting-subscriptions/coverage-cases.json +++ b/test/fixtures/reporting-subscriptions/coverage-cases.json @@ -1,5 +1,5 @@ { - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585 and #8588 (sub-daily local periods). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", @@ -101,6 +101,29 @@ "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"] + } ] }, { @@ -196,6 +219,22 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } ] }, { @@ -291,6 +330,22 @@ "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"] + } ] }, { @@ -393,6 +448,29 @@ "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"] + } ] }, { @@ -496,6 +574,29 @@ "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"] + } ] }, { @@ -585,6 +686,15 @@ "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"] + } ] }, { @@ -701,7 +811,38 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } - ] + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ], + "non_wire_inputs": ["buys[].prior_flights"] }, { "name": "reinstatement after cancellation", @@ -818,7 +959,38 @@ "period_start": "2026-11-07T00:00:00Z", "period_end": "2026-11-08T00:00:00Z" } - ] + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ], + "non_wire_inputs": ["buys[].prior_flights"] }, { "name": "only resolved periods are returned", @@ -913,6 +1085,22 @@ "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"] + } ] }, { @@ -993,7 +1181,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "late authorization (authorized_at refinement)", @@ -1088,6 +1277,22 @@ "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"] + } ] }, { @@ -1218,6 +1423,36 @@ "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"] + } ] }, { @@ -1373,33 +1608,63 @@ "period_start": "2026-11-06T05:00:00Z", "period_end": "2026-11-07T05:00:00Z" } - ] - }, - { - "name": "deactivation", - "rule": "deactivated_at 11-04T12: the 11-04 period started before it and stays owed in full; nothing later.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { - "subscription_version": 1, - "accepted_at": "2026-11-01T12:00:00Z", - "configuration": { - "subscription_id": "analytics-daily", - "subscription_version": 1, - "active": false, - "scope": { - "all_authorized_accounts": true - }, - "selector": { - "feed_purpose": "analytics", - "period_duration": "P1D", - "required_finality": "official", - "dimensions": ["package"] - }, - "reconciliation_mode": "delivery_only" - }, - "deactivated_at": "2026-11-04T12:00:00Z" + ], + "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"] + } + ] + }, + { + "name": "deactivation", + "rule": "deactivated_at 11-04T12: the 11-04 period started before it and stays owed in full; nothing later.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": false, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "deactivated_at": "2026-11-04T12:00:00Z" } ], "account": { @@ -1469,6 +1734,22 @@ "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"] + } ] }, { @@ -1584,6 +1865,36 @@ "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"] + } ] }, { @@ -1699,6 +2010,22 @@ "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"] + } ] }, { @@ -1780,7 +2107,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "operator_domains scope", @@ -1862,7 +2190,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "revocation", @@ -1958,6 +2287,22 @@ "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"] + } ] }, { @@ -2065,6 +2410,29 @@ "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"] + } ] }, { @@ -2187,6 +2555,43 @@ "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"] + } ] }, { @@ -2295,6 +2700,29 @@ "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"] + } ] }, { @@ -2399,6 +2827,22 @@ "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"] + } ] }, { @@ -2531,6 +2975,22 @@ "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"] + } ] }, { @@ -2627,6 +3087,15 @@ "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"] + } ] }, { @@ -2734,13 +3203,29 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } - ] - }, - { - "name": "match exact (default with dimensions)", - "rule": "exact [package] picks the offering whose dimension set equals it; package x device (unmapped device) is distinct.", - "input": { - "subscription_id": "analytics-daily", + ], + "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"] + } + ] + }, + { + "name": "match exact (default with dimensions)", + "rule": "exact [package] picks the offering whose dimension set equals it; package x device (unmapped device) is distinct.", + "input": { + "subscription_id": "analytics-daily", "versions": [ { "subscription_version": 1, @@ -2917,6 +3402,29 @@ "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"] + } ] }, { @@ -3073,7 +3581,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "match exact on two dimensions", @@ -3256,6 +3765,29 @@ "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"] + } ] }, { @@ -3440,6 +3972,29 @@ "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"] + } ] }, { @@ -3622,6 +4177,29 @@ "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"] + } ] }, { @@ -3869,6 +4447,92 @@ "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"] + } ] }, { @@ -4051,6 +4715,29 @@ "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"] + } ] }, { @@ -4228,6 +4915,29 @@ "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"] + } ] }, { @@ -4364,15 +5074,46 @@ "period_start": "2026-11-06T00:00:00Z", "period_end": "2026-11-07T00:00:00Z" } - ] - }, - { - "name": "account timezone", - "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_b_pkg_creative", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ], + "non_wire_inputs": ["offerings[].available_from/withdrawn_at"] + }, + { + "name": "account timezone", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { "subscription_version": 1, "accepted_at": "2026-11-01T12:00:00Z", "configuration": { @@ -4466,6 +5207,29 @@ "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-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"] + } ] }, { @@ -4568,6 +5332,29 @@ "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"] + } ] }, { @@ -4693,6 +5480,43 @@ "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"] + } ] }, { @@ -4797,6 +5621,29 @@ "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"] + } ] }, { @@ -4899,6 +5746,29 @@ "period_start": "2026-11-03T13:00:00Z", "period_end": "2026-11-03T14:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T11:00:00Z", + "period_end": "2026-11-03T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T12:00:00Z", + "period_end": "2026-11-03T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T13:00:00Z", + "period_end": "2026-11-03T14:00:00Z", + "media_buy_ids": ["mb_1"] + } ] }, { @@ -5018,6 +5888,29 @@ "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"] + } ] }, { @@ -5192,6 +6085,29 @@ "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"] + } ] }, { @@ -5495,6 +6411,50 @@ "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"] + } ] }, { @@ -5647,31 +6607,68 @@ "period_start": "2026-11-05T00:00:00Z", "period_end": "2026-11-06T00:00:00Z" } - ] - }, - { - "name": "match exact without dimensions", - "rule": "exact with no dimensions: every candidate qualifies and the lowest offering_id in byte order wins.", - "input": { - "subscription_id": "analytics-daily", - "versions": [ - { - "subscription_version": 1, - "accepted_at": "2026-11-01T12:00:00Z", - "configuration": { - "subscription_id": "analytics-daily", - "subscription_version": 1, - "active": true, - "scope": { - "all_authorized_accounts": true - }, - "selector": { - "feed_purpose": "analytics", - "period_duration": "P1D", - "required_finality": "official", - "match": "exact" - }, - "reconciliation_mode": "delivery_only" + ], + "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"] + } + ] + }, + { + "name": "match exact without dimensions", + "rule": "exact with no dimensions: every candidate qualifies and the lowest offering_id in byte order wins.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "match": "exact" + }, + "reconciliation_mode": "delivery_only" } } ], @@ -5830,6 +6827,29 @@ "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"] + } ] }, { @@ -5913,7 +6933,8 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] }, { "name": "period_timezone matches a fixed source_timezone offering in the same zone", @@ -6011,6 +7032,22 @@ "period_start": "2026-11-04T05:00:00Z", "period_end": "2026-11-05T05:00:00Z" } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_fixed", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + } ] }, { @@ -6092,7 +7129,3919 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected": [] + "expected": [], + "expected_obligations": [] + }, + { + "name": "empty flight: canceled before start_time", + "rule": "canceled_at 11-05T02 is before start_time 11-05T06: the flight [start, stop) is empty and overlaps nothing, though the 11-05 period contains both instants.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-05T06:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ], + "canceled_at": "2026-11-05T02:00:00Z" + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "name": "empty flight: stop equal to start", + "rule": "A zero-length flight (end_time equal to start_time at 11-03T12) is empty and overlaps nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T12:00:00Z", + "end_time": "2026-11-03T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] + }, + { + "name": "two cutovers inside one period", + "rule": "v2 (11-04T12Z) switches to a Tokyo period_timezone and v3 (11-04T13Z) changes nothing else. v1 owes its New York day starting 11-04T05Z, through 11-05T05Z. v2 owes from that E and starts nothing before 13Z. v3 must carry v2's own start forward: it owes Tokyo days from the first boundary at or after 11-05T05Z (11-05T15Z), never the overlapping 11-04T15Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "superseded_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "superseded_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo" + }, + "reconciliation_mode": "delivery_only" + } + }, + { + "subscription_version": 3, + "accepted_at": "2026-11-04T13:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 3, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone", + "period_timezone": "Asia/Tokyo", + "match": "exact" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z" + }, + { + "subscription_version": 3, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T15:00:00Z", + "period_end": "2026-11-06T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T15:00:00Z", + "period_end": "2026-11-07T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 3, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T15:00:00Z", + "period_end": "2026-11-08T15:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "simultaneous version and timezone change at the same T", + "rule": "v2 is accepted at 11-05T05Z, exactly the timezone cutover effective_at (a New York boundary). v1 owes New York days starting before it; v2 owes Los Angeles days from the first boundary at or after it (11-05T08Z).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T05:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Los_Angeles", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T08:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "timezone_cutovers": [ + { + "effective_at": "2026-11-05T05:00:00Z", + "timezone": "America/Los_Angeles", + "previous_timezone": "America/New_York" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-03T05:00:00Z", + "period_end": "2026-11-04T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-04T05:00:00Z", + "period_end": "2026-11-05T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T08:00:00Z", + "period_end": "2026-11-06T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T08:00:00Z", + "period_end": "2026-11-07T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-07T08:00:00Z", + "period_end": "2026-11-08T08:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "billing cycle anchored on the 31st clamps to month end", + "rule": "P1M from 2026-01-31 New York midnight: the 31st clamps to Feb 28 and returns to the 31st in March (a clamped February boundary does not shift the anchor day).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-01-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2025-12-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-01-20T00:00:00Z", + "start_time": "2026-02-10T00:00:00Z", + "end_time": "2026-04-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_m31"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m31", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m31", + "report_definition_uri": "https://seller.example/report-definitions/off_m31.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-31T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + } + ], + "now": "2026-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-01-31T05:00:00Z", + "period_end": "2026-02-28T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-02-28T05:00:00Z", + "period_end": "2026-03-31T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m31", + "period_start": "2026-03-31T04:00:00Z", + "period_end": "2026-04-30T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "billing cycle anchored on February 29", + "rule": "P1Y from 2024-02-29T00:00Z: non-leap years clamp to Feb 28 and 2028 returns to Feb 29.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2025-12-01T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1Y", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2025-11-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2025-12-15T00:00:00Z", + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2027-06-01T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_y29"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_y29", + "feed_purpose": "billing", + "report_definition_id": "rd_off_y29", + "report_definition_uri": "https://seller.example/report-definitions/off_y29.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1Y", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2024-02-29T00:00:00Z", + "period_timezone": "UTC" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + } + ], + "now": "2028-06-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2026-02-28T00:00:00Z", + "period_end": "2027-02-28T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_y29", + "period_start": "2027-02-28T00:00:00Z", + "period_end": "2028-02-29T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "DST fold at midnight (America/Santiago)", + "rule": "Chile falls back at 24:00 on 2026-04-04 to 23:00, so April 4 is a 25-hour local day; local midnight April 5 occurs once, at 04:00Z.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Santiago", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-04-03T00:00:00Z", + "end_time": "2026-04-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-02T03:00:00Z", + "period_end": "2026-04-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-03T03:00:00Z", + "period_end": "2026-04-04T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-04T03:00:00Z", + "period_end": "2026-04-05T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-05T04:00:00Z", + "period_end": "2026-04-06T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-04-06T04:00:00Z", + "period_end": "2026-04-07T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "DST gap at midnight (America/Santiago)", + "rule": "Chile springs forward at 24:00 on 2026-09-05 to 01:00: local midnight September 6 does not exist and the boundary advances by the gap to 04:00Z, so September 6 is a 23-hour local day.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/Santiago", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-15T00:00:00Z", + "start_time": "2026-09-05T12:00:00Z", + "end_time": "2026-09-07T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_s_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_s_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_s_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-10-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-05T04:00:00Z", + "period_end": "2026-09-06T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-06T04:00:00Z", + "period_end": "2026-09-07T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_s_pkg_acct_tz", + "period_start": "2026-09-07T03:00:00Z", + "period_end": "2026-09-08T03:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "zero-length period on a skipped civil date (Pacific/Apia)", + "rule": "Samoa skipped 2011-12-30: that local day has zero length (its midnight and the next coincide at 10:00Z) and is never owed; the surrounding days are.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2011-12-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "Pacific/Apia", + "grants": [ + { + "authorized_at": "2011-11-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2011-12-01T00:00:00Z", + "start_time": "2011-12-28T12:00:00Z", + "end_time": "2011-12-31T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2012-02-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-28T10:00:00Z", + "period_end": "2011-12-29T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-29T10:00:00Z", + "period_end": "2011-12-30T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-30T10:00:00Z", + "period_end": "2011-12-31T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2011-12-31T10:00:00Z", + "period_end": "2012-01-01T10:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "PT24H under a local alignment restarts at local midnight (#8588)", + "rule": "Under account_timezone a duration of only hours restarts at each local midnight (adcp#8588), so PT24H in New York across the 2026-03-08 spring-forward has a 23-hour period, exactly like P1D.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-03-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "PT24H", + "required_finality": "official", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-02-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-03-01T00:00:00Z", + "start_time": "2026-03-07T12:00:00Z", + "end_time": "2026-03-09T12:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_h24_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_h24_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_h24_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_h24_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT24H", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-04-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-07T05:00:00Z", + "period_end": "2026-03-08T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-08T05:00:00Z", + "period_end": "2026-03-09T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_h24_acct_tz", + "period_start": "2026-03-09T04:00:00Z", + "period_end": "2026-03-10T04:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "PT24H and P1D under utc are the same days", + "rule": "UTC has no DST, so PT24H and P1D yield identical boundaries under utc alignment.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "PT24H", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_u24"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_u24", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_u24", + "report_definition_uri": "https://seller.example/report-definitions/off_u24.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT24H", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_u24", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "sub-daily local periods start on the local hour (Asia/Singapore)", + "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z: first boundary 01:00Z (09:00 local).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "Asia/Singapore", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T00:05:00Z", + "start_time": "2026-11-03T00:10:00Z", + "end_time": "2026-11-03T02:30:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_p_hourly_local"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "account_timezone", + "delivery_sla": "PT1H" + }, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T01:00:00Z", + "period_end": "2026-11-03T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T02:00:00Z", + "period_end": "2026-11-03T03:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "sub-daily local periods on a 25-hour day (America/New_York fall-back)", + "rule": "adcp#8588: the repeated 01:00 yields one boundary per offset, so 2026-11-01 in New York has 25 hourly periods.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-30T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "pacing", + "period_duration": "PT1H", + "required_finality": "snapshot", + "dimensions": ["package"], + "alignment": "account_timezone" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-10-30T00:00:00Z", + "start_time": "2026-11-01T04:00:00Z", + "end_time": "2026-11-02T05:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_p_hourly_local"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_p_hourly_local", + "feed_purpose": "pacing", + "report_definition_id": "rd_off_p_hourly_local", + "report_definition_uri": "https://seller.example/report-definitions/off_p_hourly_local.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "PT1H", + "alignment": "account_timezone", + "delivery_sla": "PT1H" + }, + "supported_finality": ["snapshot"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-11-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T05:00:00Z", + "period_end": "2026-11-01T06:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T06:00:00Z", + "period_end": "2026-11-01T07:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T07:00:00Z", + "period_end": "2026-11-01T08:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T08:00:00Z", + "period_end": "2026-11-01T09:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T09:00:00Z", + "period_end": "2026-11-01T10:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T10:00:00Z", + "period_end": "2026-11-01T11:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T11:00:00Z", + "period_end": "2026-11-01T12:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T12:00:00Z", + "period_end": "2026-11-01T13:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T13:00:00Z", + "period_end": "2026-11-01T14:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T14:00:00Z", + "period_end": "2026-11-01T15:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T15:00:00Z", + "period_end": "2026-11-01T16:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T16:00:00Z", + "period_end": "2026-11-01T17:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T17:00:00Z", + "period_end": "2026-11-01T18:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T18:00:00Z", + "period_end": "2026-11-01T19:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T19:00:00Z", + "period_end": "2026-11-01T20:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T20:00:00Z", + "period_end": "2026-11-01T21:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T21:00:00Z", + "period_end": "2026-11-01T22:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T22:00:00Z", + "period_end": "2026-11-01T23:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-01T23:00:00Z", + "period_end": "2026-11-02T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T00:00:00Z", + "period_end": "2026-11-02T01:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T01:00:00Z", + "period_end": "2026-11-02T02:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T02:00:00Z", + "period_end": "2026-11-02T03:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T03:00:00Z", + "period_end": "2026-11-02T04:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-02T04:00:00Z", + "period_end": "2026-11-02T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "re-grant chain of three grants without backfill", + "rule": "Revoked 11-04T06 and re-granted 11-05T18, revoked 11-07T06 and re-granted 11-08T18, all inside retention: one chain with gaps [11-04, 11-06) and [11-07, 11-09).", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-05T18:00:00Z", + "unauthorized_at": "2026-11-07T06:00:00Z" + }, + { + "authorized_at": "2026-11-08T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "re-grant chain of three grants with backfill_on_regrant", + "rule": "The same chain with backfill_on_regrant: no gap, every period owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-05T18:00:00Z", + "unauthorized_at": "2026-11-07T06:00:00Z" + }, + { + "authorized_at": "2026-11-08T18:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-10T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "backfill_on_regrant": true + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-09T00:00:00Z", + "period_end": "2026-11-10T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "re-grant seen only through the chain-start authorized_at", + "rule": "The wire keeps authorized_at at the chain start after a re-grant inside retention (adcp#8585), so the buyer lists the continuation with that value; the seller's gaps[] says which periods the interruption removed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 30, + "coverage": { + "gaps": [ + { + "from": "2026-11-04T00:00:00Z", + "until": "2026-11-06T00:00:00Z", + "reason": "unauthorized" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "two authorization chains", + "rule": "Revoked 11-04T06; re-granted 11-10 after retention (1 day) expired, a new chain from 11-10; revoked again 11-12T06.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z", + "unauthorized_at": "2026-11-04T06:00:00Z" + }, + { + "authorized_at": "2026-11-10T00:00:00Z", + "unauthorized_at": "2026-11-12T06:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "status_retention_days": 1 + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-10T00:00:00Z", + "period_end": "2026-11-11T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-11T00:00:00Z", + "period_end": "2026-11-12T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "match all adds an offering on a different alignment", + "rule": "v2 (11-04T12Z) moves from exact to all and adds a New York offering. The UTC offering continues (E = 11-05T00Z); the New York offering is new and starts at the latest outgoing end, so its first period is 11-05T05Z and 00Z-05Z is owed by neither.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-04T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-04T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_e_pkg_acct_tz", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "account_timezone", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-05T05:00:00Z", + "period_end": "2026-11-06T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-06T05:00:00Z", + "period_end": "2026-11-07T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "version change exactly on a boundary", + "rule": "v2 accepted at 11-05T00Z, itself a boundary: E is T, v1 owes 11-03 and 11-04, v2 from 11-05.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T00:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-07T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "reactivation exactly on a boundary", + "rule": "Inactive [11-04T12, 11-06T00): 11-04 started before deactivation and stays owed; coverage resumes at 11-06 itself.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T00:00:00Z" + } + ] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-06T00:00:00Z", + "period_end": "2026-11-07T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "inactive interval spanning a version change", + "rule": "Deactivated under v1 at 11-04T12, v2 accepted while inactive at 11-05T12, reactivated 11-06T12: v1 owes 11-03 and 11-04, v2 starts fresh at 11-07.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + }, + "superseded_at": "2026-11-05T12:00:00Z" + }, + { + "subscription_version": 2, + "accepted_at": "2026-11-05T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 2, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only", + "exclude_account_ids": ["acct_other"] + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-09T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "inactive_intervals": [ + { + "deactivated_at": "2026-11-04T12:00:00Z", + "reactivated_at": "2026-11-06T12:00:00Z" + } + ] + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + }, + { + "subscription_version": 2, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 2, + "offering_id": "off_a_pkg", + "period_start": "2026-11-08T00:00:00Z", + "period_end": "2026-11-09T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "exception bounds between boundaries", + "rule": "capacity [11-04T12, 11-06T12) removes the periods whose start is in it, 11-05 and 11-06; 11-04 started before from and stays owed.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-08T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z", + "coverage": { + "excluded": [ + { + "reason": "capacity", + "from": "2026-11-04T12:00:00Z", + "until": "2026-11-06T12:00:00Z" + } + ] + } + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-07T00:00:00Z", + "period_end": "2026-11-08T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "finest tie in byte order (B before a)", + "rule": "Two offerings tie at two mapped dimensions; byte order puts uppercase B (0x42) before lowercase a (0x61), unlike locale order.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"], + "match": "finest" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["a", "B"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "a", + "feed_purpose": "analytics", + "report_definition_id": "rd_a", + "report_definition_uri": "https://seller.example/report-definitions/a.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "B", + "feed_purpose": "analytics", + "report_definition_id": "rd_B", + "report_definition_uri": "https://seller.example/report-definitions/B.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "B", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "unknown dimension values parse and match only where published", + "rule": "The dimension vocabulary is extensible: an offering may publish placement, and a selector naming it matches that offering. A value no offering publishes (audience_segment) matches nothing.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package", "placement"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_q_pkg_placement", "off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_q_pkg_placement", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_q_pkg_placement", + "report_definition_uri": "https://seller.example/report-definitions/off_q_pkg_placement.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "placement"] + }, + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_q_pkg_placement", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "an unknown selector dimension no offering publishes matches nothing", + "rule": "A selector naming a value the seller does not map matches no offering.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package", "audience_segment"], + "match": "all" + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-02T15:00:00Z", + "start_time": "2026-11-03T00:00:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": [ + "off_a_pkg", + "off_b_pkg_creative", + "off_c_pkg_country", + "off_d_pkg_device" + ] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + }, + { + "offering_id": "off_b_pkg_creative", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_b_pkg_creative", + "report_definition_uri": "https://seller.example/report-definitions/off_b_pkg_creative.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "creative"] + }, + { + "offering_id": "off_c_pkg_country", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_c_pkg_country", + "report_definition_uri": "https://seller.example/report-definitions/off_c_pkg_country.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package", "country"] + }, + { + "offering_id": "off_d_pkg_device", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_d_pkg_device", + "report_definition_uri": "https://seller.example/report-definitions/off_d_pkg_device.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"], + "unmapped_dimensions": ["device"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [], + "expected_obligations": [] }, { "name": "numeric offset timezone is refused", diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index 3d89e1328..eeb1bae96 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -30,7 +30,7 @@ function comparable(rows) { describe('expectedSubscriptionPeriods parity table', () => { test('the table is non-trivial and every case is uniquely named', () => { - assert.ok(table.cases.length >= 40); + assert.ok(table.cases.length >= 75); assert.equal(new Set(table.cases.map(c => c.name)).size, table.cases.length); }); @@ -53,6 +53,7 @@ describe('expectedSubscriptionPeriods parity table', () => { 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, @@ -84,7 +85,14 @@ describe('expectedSubscriptionPeriods parity table', () => { addFormats(ajv); for (const dir of ['core', 'enums']) { for (const file of fs.readdirSync(path.join(root, dir))) { - if (file.endsWith('.json')) ajv.addSchema(JSON.parse(fs.readFileSync(path.join(root, dir, file), 'utf8'))); + 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( @@ -216,15 +224,73 @@ describe('createReportingPeriodCalendarV1 (period_generation)', () => { ]); }); - test('hours are exact elapsed time, not civil time', () => { + 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: 'PT24H', - alignment: 'source_timezone', + 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', }); - const first = calendar.ceilOrdinal(instant('2026-03-07T05:00:00Z')); - assert.equal(calendar.boundary(first + 1) - calendar.boundary(first), 86_400_000); - assert.equal(calendar.boundary(first + 2) - calendar.boundary(first + 1), 86_400_000); + 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', () => { From f8b48c0c8594944dd367b774585f685a47380d57 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 19:49:03 +0000 Subject: [PATCH 05/49] feat(reporting): the confirmation period is owed (adcp#8593 start rule) The start bound is the first boundary at or after the later of the version's accepted_at and the account's authorized_at; a buy is owed every period that ends after its confirmed_at and overlaps its flight, so the period in which the buy is confirmed is owed in full. The rule stays in firstOwedBoundary. Parity table: the mid-period confirmed_at and hourly cases change, plus every case whose first period contains confirmed_at; new cases for an ASAP buy confirmed mid-day and a monthly billing buy confirmed on the 12th. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-coverage.md | 2 +- src/lib/reporting/subscriptions/coverage.ts | 23 +- .../coverage-cases.json | 390 +++++++++++++++++- 3 files changed, 395 insertions(+), 20 deletions(-) diff --git a/.changeset/reporting-subscription-coverage.md b/.changeset/reporting-subscription-coverage.md index b80348ee9..c175464ae 100644 --- a/.changeset/reporting-subscription-coverage.md +++ b/.changeset/reporting-subscription-coverage.md @@ -2,7 +2,7 @@ '@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 (first boundary at or after the latest of `accepted_at`, `confirmed_at` and `authorized_at`), 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. +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. diff --git a/src/lib/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index 49b8d7aae..8dbd361f5 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -12,11 +12,13 @@ * * 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 latest of the version's - * `accepted_at`, the buy's `confirmed_at`, and the account's `authorized_at`; + * 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). Periods are never clipped. + * 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 @@ -329,15 +331,16 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn } /** - * The start rule, kept in one place: the first boundary at or after the latest of the - * cutover handoff (the version's `accepted_at`, or E after a cutover) and the buy's - * `confirmed_at`. The account's `authorized_at` term is applied per authorization chain in - * `authorized`; ceil is monotone, so the combination is the first boundary at or after the - * latest of the three. Owner decision pending on whether the period containing - * `confirmed_at` is owed: if so, the confirmed term becomes `calendar.floor(confirmedAt)`. + * 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.ceil(confirmedAt)); + return Math.max(calendar.ceil(handoff), calendar.floor(confirmedAt)); } /** Group owed periods into the obligations that must carry them, with frozen `media_buy_ids`. */ diff --git a/test/fixtures/reporting-subscriptions/coverage-cases.json b/test/fixtures/reporting-subscriptions/coverage-cases.json index 2fd4134b1..31c93d8f9 100644 --- a/test/fixtures/reporting-subscriptions/coverage-cases.json +++ b/test/fixtures/reporting-subscriptions/coverage-cases.json @@ -1,9 +1,9 @@ { - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585 and #8588 (sub-daily local periods). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585, #8588 (sub-daily local periods) and #8593 (the start bound is the later of accepted_at and authorized_at; a buy is owed every period ending after its confirmed_at). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods (or expected_error). Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", "cases": [ { "name": "baseline", - "rule": "Owed from the first boundary at or after max(accepted_at 11-01T12, confirmed_at 11-02T15, authorized_at 10-01) = 11-03, through the period containing the stop time.", + "rule": "adcp#8593: the start bound is the first boundary at or after max(accepted_at 11-01T12, authorized_at 10-01) = 11-02; owed are the periods from it that end after confirmed_at 11-02T15 and overlap the flight (11-03 on), through the period containing the stop time.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -128,7 +128,7 @@ }, { "name": "mid-period confirmed_at", - "rule": "confirmed_at 11-03T10 falls inside the first flight day: coverage starts at the next boundary, 11-04, though the flight began 11-03.", + "rule": "adcp#8593: confirmed_at 11-03T10 falls inside the first flight day. That period ends after confirmed_at, so it is owed in full; confirmed_at no longer moves the start to the next boundary.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -205,6 +205,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -221,6 +228,13 @@ } ], "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", @@ -5109,7 +5123,7 @@ }, { "name": "account timezone", - "rule": "An account_timezone offering uses New York midnights (05:00Z in November). confirmed_at 11-02T15Z: first boundary 11-03T05Z.", + "rule": "An account_timezone offering uses New York midnights (05:00Z in November). accepted_at 11-01T12Z: first boundary 11-02T05Z. The 11-02T05Z day contains confirmed_at 11-02T15Z and overlaps the flight from 11-03T00Z, so it is owed (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5186,6 +5200,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -5209,6 +5230,13 @@ } ], "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", @@ -5521,7 +5549,7 @@ }, { "name": "period_timezone override", - "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary.", + "rule": "A single-account subscription with period_timezone Asia/Tokyo uses Tokyo midnights (15:00Z). confirmed_at 11-02T15Z is itself a boundary, so the period ending at it is not owed and the one starting at it is.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5648,7 +5676,7 @@ }, { "name": "hourly periods are never clipped", - "rule": "Flight 10:30-13:15, confirmed 10:20: coverage starts at 11:00 (the 10:00 period started before the confirmation boundary) and the 13:00 period containing the stop is owed in full.", + "rule": "Flight 10:30-13:15, confirmed 10:20: the 10:00 period ends after confirmed_at and overlaps the flight, so it is owed in full (adcp#8593), and the 13:00 period containing the stop is owed in full.", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -5725,6 +5753,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_h_pkg_hourly", + "period_start": "2026-11-03T10:00:00Z", + "period_end": "2026-11-03T11:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -5748,6 +5783,13 @@ } ], "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", @@ -5913,6 +5955,273 @@ } ] }, + { + "name": "ASAP buy confirmed mid-day owes the confirmation day", + "rule": "adcp#8593: an ASAP buy starts when it is confirmed, 11-03T14:30Z. The 11-03 period ends after confirmed_at and overlaps the flight, so it is owed in full.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-11-01T12:00:00Z", + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "all_authorized_accounts": true + }, + "selector": { + "feed_purpose": "analytics", + "period_duration": "P1D", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "delivery_only" + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-03T14:30:00Z", + "start_time": "2026-11-03T14:30:00Z", + "end_time": "2026-11-06T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_a_pkg"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_a_pkg", + "feed_purpose": "analytics", + "report_definition_id": "rd_off_a_pkg", + "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1D", + "alignment": "utc", + "delivery_sla": "PT6H" + }, + "supported_finality": ["official"], + "reconciliation_mode": "delivery_only", + "standard_dimensions": ["package"] + } + ], + "now": "2026-12-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-04T00:00:00Z", + "period_end": "2026-11-05T00:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_a_pkg", + "period_start": "2026-11-05T00:00:00Z", + "period_end": "2026-11-06T00:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, + { + "name": "monthly billing buy confirmed on the 12th owes that month", + "rule": "adcp#8593: the version was accepted 10-15, so the start bound is the November boundary (11-01T04Z). The buy is confirmed and starts on 11-12; the November month ends after confirmed_at and overlaps the flight, so it is owed in full rather than starting in December.", + "input": { + "subscription_id": "analytics-daily", + "versions": [ + { + "subscription_version": 1, + "accepted_at": "2026-10-15T00:00:00Z", + "destination_method": { + "pattern": "file_transfer", + "transport": "s3" + }, + "configuration": { + "subscription_id": "analytics-daily", + "subscription_version": 1, + "active": true, + "scope": { + "account_ids": ["acct_1"] + }, + "selector": { + "feed_purpose": "billing", + "period_duration": "P1M", + "required_finality": "official", + "dimensions": ["package"] + }, + "reconciliation_mode": "consumer_receipt", + "destination": { + "destination_id": "billing-archive" + } + } + } + ], + "account": { + "account_id": "acct_1", + "timezone": "America/New_York", + "grants": [ + { + "authorized_at": "2026-10-01T00:00:00Z" + } + ] + }, + "buys": [ + { + "media_buy_id": "mb_1", + "confirmed_at": "2026-11-12T15:00:00Z", + "start_time": "2026-11-12T15:00:00Z", + "end_time": "2027-01-15T00:00:00Z", + "packages": [ + { + "package_id": "pkg_1", + "reporting_delivery_offering_ids": ["off_m_billing_monthly"] + } + ] + } + ], + "offerings": [ + { + "offering_id": "off_m_billing_monthly", + "feed_purpose": "billing", + "report_definition_id": "rd_off_m_billing_monthly", + "report_definition_uri": "https://seller.example/report-definitions/off_m_billing_monthly.json", + "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reporting_profile": { + "id": "adcp.delivery.v1", + "version": "1.0.0", + "schema_uri": "https://seller.example/schemas/delivery.json", + "schema_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", + "grain": "daily", + "primary_keys": ["account_id", "package_id"] + }, + "schedule": { + "period_duration": "P1M", + "alignment": "billing_cycle", + "delivery_sla": "P5D", + "period_anchor_policy": "fixed", + "period_anchor": "2026-01-01T05:00:00Z", + "period_timezone": "America/New_York" + }, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "standard_dimensions": ["package"], + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "formats": ["jsonl"] + } + } + ], + "now": "2027-03-01T00:00:00Z" + }, + "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z" + }, + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z" + } + ], + "expected_obligations": [ + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-11-01T04:00:00Z", + "period_end": "2026-12-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2026-12-01T05:00:00Z", + "period_end": "2027-01-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + }, + { + "subscription_version": 1, + "offering_id": "off_m_billing_monthly", + "period_start": "2027-01-01T05:00:00Z", + "period_end": "2027-02-01T05:00:00Z", + "media_buy_ids": ["mb_1"] + } + ] + }, { "name": "destination selects managed offerings only", "rule": "With a destination only offerings whose method matches its pattern and transport are candidates; without one only Core (method-less) offerings are.", @@ -6112,7 +6421,7 @@ }, { "name": "multiple buys group into obligations", - "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids.", + "rule": "Each (buy, offering) pair is owed its own periods; obligations group them by period with frozen media_buy_ids. mb_0, confirmed 11-03T12, is owed the 11-03 period it was confirmed in (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -6201,6 +6510,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_0", + "offering_id": "off_a_pkg", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-04T00:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -6236,7 +6552,7 @@ "offering_id": "off_a_pkg", "period_start": "2026-11-03T00:00:00Z", "period_end": "2026-11-04T00:00:00Z", - "media_buy_ids": ["mb_1"] + "media_buy_ids": ["mb_0", "mb_1"] }, { "subscription_version": 1, @@ -7018,6 +7334,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_s_fixed", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -7034,6 +7357,13 @@ } ], "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", @@ -7420,6 +7750,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -7457,6 +7794,13 @@ } ], "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", @@ -7605,6 +7949,13 @@ } }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_e_pkg_acct_tz", + "period_start": "2026-11-02T05:00:00Z", + "period_end": "2026-11-03T05:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -7642,6 +7993,13 @@ } ], "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", @@ -8622,7 +8980,7 @@ }, { "name": "sub-daily local periods start on the local hour (Asia/Singapore)", - "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z: first boundary 01:00Z (09:00 local).", + "rule": "adcp#8588: hourly periods under account_timezone are local wall-clock hours counted from each local midnight, not 1970 offsets (Singapore was +07:30 in 1970). confirmed_at 00:05Z falls in the 00:00Z (08:00 local) hour, which ends after it and overlaps the flight, so it is owed (adcp#8593).", "input": { "subscription_id": "analytics-daily", "versions": [ @@ -8700,6 +9058,13 @@ "now": "2026-12-01T00:00:00Z" }, "expected": [ + { + "subscription_version": 1, + "media_buy_id": "mb_1", + "offering_id": "off_p_hourly_local", + "period_start": "2026-11-03T00:00:00Z", + "period_end": "2026-11-03T01:00:00Z" + }, { "subscription_version": 1, "media_buy_id": "mb_1", @@ -8716,6 +9081,13 @@ } ], "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", From b6d741d1ee1bed7de2a7a4ead2c8bb18ec471943 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 10:20:00 +0000 Subject: [PATCH 06/49] feat(reporting): serve reporting subscriptions from the principal runtime Opt-in support for the AdCP 3.3 sync_principal reporting_subscriptions section (experimental media_buy.reporting_subscriptions): - createPrincipalLifecycle({ reportingSubscriptions }) validates, versions and reads back subscriptions (core/reporting-subscription-state.json), honours dry_run and expected_configuration_version, applies replace_account_configs once per version through a host hook, derives action_required from the destination, and emits principal.changed with reason reporting_subscription_changed. It also exposes the adcp.principal capability block. - createReportingSubscriptionEngineV1 turns three host hooks into grant history, max_accounts slots, precedence, exceptions at cutover instants, timezone cutovers, re-grant gaps, accounts_unauthorized, the receipt roster and the post-loss receipt window, and computes owed periods with the same expectedSubscriptionPeriods rule buyers run. - createReliableReportingService({ reportingSubscriptions }) advertises configuration_tasks and the subscriptions block and adds subscription_coverage to summary and periods views. - createAdcpServerFromPlatform passes capabilities.principal and capability_changes through, adds protocol.principal and media_buy.reporting_subscriptions to experimental_features, refuses subscriptions without the reporting_subscriptions section, and strips 3.3-only fields for callers pinned below 3.3. Without the options every response is byte-for-byte unchanged; a golden transcript captured from the pre-change runtime proves it. Refs #3504 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 27 + scripts/check-package-size.mjs | 9 +- src/lib/reporting/service/index.ts | 96 +- .../reporting/subscriptions/capabilities.ts | 6 +- src/lib/reporting/subscriptions/engine.ts | 995 ++++++++++++++++++ src/lib/server/create-adcp-server.ts | 45 + src/lib/server/decisioning/capabilities.ts | 15 + .../decisioning/runtime/from-platform.ts | 43 +- src/lib/server/index.ts | 7 + src/lib/server/principal/index.ts | 19 + .../principal/reporting-subscriptions.ts | 503 +++++++++ src/lib/server/principal/runtime.ts | 235 ++++- src/lib/server/principal/types.ts | 67 ++ .../principal-option-off-golden.json | 255 +++++ .../principal-option-off-transcript.js | 124 +++ test/helpers/principal-runtime.js | 58 + .../principal-reporting-subscriptions.test.js | 472 +++++++++ test/lib/reliable-reporting-service.test.js | 97 ++ .../lib/reporting-subscription-engine.test.js | 481 +++++++++ 19 files changed, 3532 insertions(+), 22 deletions(-) create mode 100644 .changeset/reporting-subscription-runtime.md create mode 100644 src/lib/reporting/subscriptions/engine.ts create mode 100644 src/lib/server/principal/reporting-subscriptions.ts create mode 100644 test/fixtures/reporting-subscriptions/principal-option-off-golden.json create mode 100644 test/helpers/principal-option-off-transcript.js create mode 100644 test/helpers/principal-runtime.js create mode 100644 test/lib/principal-reporting-subscriptions.test.js create mode 100644 test/lib/reporting-subscription-engine.test.js diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md new file mode 100644 index 000000000..1e952af83 --- /dev/null +++ b/.changeset/reporting-subscription-runtime.md @@ -0,0 +1,27 @@ +--- +'@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? } })` 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 three host hooks: `listAuthorizedAccounts`, `listAccountBuys` and, optionally, `listAccountDeliveryConfigs`. 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), the receipt roster, and the post-loss receipt window (`acceptsReceipt`). + +**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`. diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 1d6949358..1e5b9ccc6 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -137,7 +137,11 @@ 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. -const MAX_PACKED_TARBALL_BYTES = 26_360_000; +// AdCP 3.3 reporting subscriptions (#3504) add the subscription calendar, +// selection, coverage, capability and engine modules plus the principal +// subscription runtime. Measured 26,382,521 packed bytes; allow about 40 KB +// above that for compressor variance. +const MAX_PACKED_TARBALL_BYTES = 26_420_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 @@ -301,7 +305,8 @@ 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). 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). + 20 + // Reporting subscription calendar, selection, coverage, capabilities and index (five dual-format sets). + 8; // Reporting subscription engine and principal subscription runtime (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/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 9ff9e6575..29b325ecf 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -272,6 +272,25 @@ export interface CreateReliableReportingServiceOptionsV1; +} + +export interface ReliableReportingSubscriptionsOptionsV1> { + engine: Pick; + /** The same authenticated mapping the principal runtime uses. Never derive it from request content. */ + resolvePrincipal(context: RequestContext>): PrincipalStoreScope | 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; } /** @@ -333,6 +352,10 @@ export type ReliableReportingMaintenanceFactoryV1 = ( * * @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 */ +import type { ReportingSubscriptionEngineV1 } from '../subscriptions/engine'; +import type { PrincipalStoreScope } from '../../server/principal/types'; +import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; + export const REPORTING_MANAGED_DELIVERY_COMPOSITION_V1: unique symbol = Symbol.for( '@adcp/sdk/reporting/managed-delivery-composition' ) as never; @@ -523,10 +546,39 @@ export function createReliableReportingService => maintenance!.run({ signal, ...(report ? { report } : {}) }); + const subscriptionOptions = options.reportingSubscriptions; + if (subscriptionOptions !== undefined) { + if (typeof subscriptionOptions.engine?.subscriptionCoverage !== 'function') { + throw new TypeError('reportingSubscriptions requires a reporting subscription engine'); + } + if (typeof subscriptionOptions.resolvePrincipal !== 'function') { + throw new TypeError('reportingSubscriptions requires resolvePrincipal'); + } + } + // 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 TypeError('The reporting subscription engine must use the statusRetentionDays this service advertises'); + } + 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, @@ -558,7 +610,12 @@ export function createReliableReportingService = { capabilities, - getReportingStatus: (request, context) => getReportingStatus(request, context as never), + getReportingStatus: subscriptionOptions + ? async (request, context) => { + const response = await getReportingStatus(request, context as never); + return withSubscriptionCoverage(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), @@ -694,7 +751,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' ); @@ -783,6 +840,25 @@ export function createReliableReportingService( + 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; + const accountId = (context.account as { id?: unknown } | undefined)?.id; + if (typeof accountId !== 'string') return response; + const principal = await subscriptions.resolvePrincipal(context); + const coverage = await subscriptions.engine.subscriptionCoverage(principal, accountId); + if (coverage.length === 0) return response; + return { ...body, subscription_coverage: coverage } as unknown as typeof response; +} + function createRoutedSource( sources: ReadonlyMap, offerings: ReadonlyMap @@ -1567,5 +1643,19 @@ export { type ReportingConfigurationCapabilitiesInputV1, type ReportingConfigurationCapabilitiesV1, } from '../subscriptions/capabilities'; +export { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, + type CreateReportingSubscriptionEngineOptionsV1, + type ReportingSubscriptionAccountConfigV1, + type ReportingSubscriptionAccountFactV1, + type ReportingSubscriptionCoverageEntryV1, + type ReportingSubscriptionEngineHooksV1, + type ReportingSubscriptionEngineStateV1, + type ReportingSubscriptionEngineStoreV1, + type ReportingSubscriptionEngineV1, + type ReportingSubscriptionExclusionReasonV1, + type ReportingSubscriptionExclusionV1, +} from '../subscriptions/engine'; export * from './conformance'; export * from './scheduler-support'; diff --git a/src/lib/reporting/subscriptions/capabilities.ts b/src/lib/reporting/subscriptions/capabilities.ts index 382d79ac4..31ff3bd51 100644 --- a/src/lib/reporting/subscriptions/capabilities.ts +++ b/src/lib/reporting/subscriptions/capabilities.ts @@ -7,7 +7,7 @@ * or is absent. Advertising `subscriptions` requires `sync_principal` in * `configuration_tasks`, and vice versa (`core/reporting-delivery-capabilities.json`). */ -import type { ReportingDeliveryCapabilities } from '../../types/core.generated'; +import type { ReportingDeliveryCapabilities } from '../../types/tools.generated'; export interface ReportingConfigurationCapabilitiesInputV1 { /** @@ -61,7 +61,9 @@ export function reportingConfigurationCapabilitiesV1( } return { ...(accountConfigurations ? { configuration_task: 'sync_accounts' as const } : {}), - configuration_tasks: accountConfigurations ? ['sync_principal', 'sync_accounts'] : ['sync_principal'], + 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 } : {}), diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts new file mode 100644 index 000000000..b26d5f9cb --- /dev/null +++ b/src/lib/reporting/subscriptions/engine.ts @@ -0,0 +1,995 @@ +/** + * Seller-side reporting subscription engine (experimental `media_buy.reporting_subscriptions`, + * AdCP 3.3). + * + * The host supplies only the facts it alone knows, through three hooks: which accounts a + * principal may read (`listAuthorizedAccounts`), which buys an account holds + * (`listAccountBuys`), and the caller's own account-level configurations, for precedence + * (`listAccountDeliveryConfigs`). The engine maintains everything else (grant history, + * coverage exceptions, `max_accounts` slots, timezone cutovers, lost authority) and 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, computed with the same pure + * {@link expectedSubscriptionPeriods} rule a buyer runs, so seller and buyer agree by + * construction when they agree on the facts; + * - the receipt roster and the post-loss receipt window. + */ +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 { + 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; + +export type ReportingSubscriptionExclusionReasonV1 = + | 'offering_unsupported' + | 'capacity' + | 'overridden' + | 'excluded_by_buyer' + | 'action_required'; + +/** One `subscription_coverage[].excluded[]` entry. */ +export interface ReportingSubscriptionExclusionV1 { + media_buy_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. */ +export interface ReportingSubscriptionCoverageEntryV1 { + subscription_id: string; + subscription_version: number; + status: 'active' | 'excluded' | 'action_required'; + pending_action?: PendingAction; + excluded: ReportingSubscriptionExclusionV1[]; + timezone_cutovers?: Array<{ effective_at: string; timezone: string }>; + gaps?: Array<{ from: string; until: string; reason: 'unauthorized' }>; +} + +/** Facts about one account the principal may currently read. */ +export interface ReportingSubscriptionAccountFactV1 { + account_id: string; + /** `authorization.authorized_at`: when the current grant began. */ + authorized_at: string; + operator?: string; + /** The account's IANA timezone as `list_accounts` shows it. */ + timezone?: 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; +} + +/** One of the caller's own `sync_accounts` `reporting_delivery_configs` entries, for precedence. */ +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`). Absent while active. */ + active_until?: string; + /** Its schedule, so a handoff back to the subscription uses its own period end. */ + schedule?: { + period_duration: string; + alignment: 'utc' | 'account_timezone' | 'source_timezone' | 'billing_cycle'; + timezone?: string; + period_anchor?: string; + }; +} + +export interface ReportingSubscriptionEngineHooksV1 { + /** The accounts this principal may read, 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; + /** 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; + /** + * Whether an owed period was delivered, for `withheld_period_count`. Without it the engine + * counts owed periods not yet due (`period_end + delivery_sla`) at `unauthorized_at`. + */ + isPeriodDelivered?(period: ExpectedSubscriptionPeriodV1): MaybePromise; +} + +interface StoredExclusion extends ReportingSubscriptionExclusionV1 { + /** Stable identity of the condition, so reconcile opens and closes it exactly once. */ + key: string; + pending_action?: PendingAction; +} + +interface AccountState { + operator?: string; + timezone?: string; + /** Timezone in effect before the first recorded cutover. */ + initialTimezone?: string; + sourceTimezone?: string; + grants: ReportingSubscriptionGrantV1[]; +} + +interface SubscriptionAccountState { + exclusions: StoredExclusion[]; + gaps: Array<{ from: string; until: string; reason: 'unauthorized' }>; + timezoneCutovers: Array<{ effective_at: string; timezone: string }>; + /** The subscription covered this account at some point (for accounts_unauthorized). */ + covered: boolean; +} + +/** Durable engine state for one principal. */ +export interface ReportingSubscriptionEngineStateV1 { + accounts: Record; + subscriptions: Record; served: string[] }>; +} + +/** Engine persistence. Each principal's state is one atomic document. */ +export interface ReportingSubscriptionEngineStoreV1 { + readonly durability: 'process-local' | 'durable'; + get( + principal: Readonly + ): MaybePromise<{ state: ReportingSubscriptionEngineStateV1; revision: string } | null>; + put( + principal: Readonly, + state: ReportingSubscriptionEngineStateV1, + expectedRevision: string | null + ): MaybePromise<{ outcome: 'applied'; revision: string } | { outcome: 'conflict' }>; + listPrincipals(): MaybePromise; +} + +/** Process-local engine store for tests and single-process sellers. */ +export function createInMemoryReportingSubscriptionEngineStoreV1(): ReportingSubscriptionEngineStoreV1 { + const rows = new Map< + string, + { scope: PrincipalStoreScope; state: ReportingSubscriptionEngineStateV1; revision: number } + >(); + const key = (scope: Readonly) => JSON.stringify([scope.tenantId, scope.principalId]); + return { + durability: 'process-local', + get(scope) { + const row = rows.get(key(scope)); + return row ? { state: structuredClone(row.state), revision: String(row.revision) } : null; + }, + put(scope, state, expectedRevision) { + const row = rows.get(key(scope)); + if ((row ? String(row.revision) : null) !== expectedRevision) return { outcome: 'conflict' }; + const revision = (row?.revision ?? 0) + 1; + rows.set(key(scope), { + scope: { tenantId: scope.tenantId, principalId: scope.principalId }, + state: structuredClone(state), + revision, + }); + return { outcome: 'applied', revision: String(revision) }; + }, + listPrincipals() { + return [...rows.values()].map(row => ({ ...row.scope })); + }, + }; +} + +export interface CreateReportingSubscriptionEngineOptionsV1 { + principal: PrincipalLifecycleRuntimeSubscriptions; + hooks: ReportingSubscriptionEngineHooksV1; + /** The seller's offerings with their standard-dimension mapping. */ + 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; +} + +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, stamp grants and + * revocations, open and close exceptions, and publish the readback. Idempotent. + */ + 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`. 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. */ + 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; + /** Principals whose active subscriptions cover the account: the consumers that owe receipts. */ + receiptRoster(accountId: string): Promise; + /** + * Whether `sync_reporting_receipts`/`sync_reporting_status` from this principal may still be + * accepted for a revision it read at `readAt`: always while authorized; after a loss only + * for revisions read before `unauthorized_at`, for `status_retention_days`. + */ + acceptsReceipt( + principal: Readonly, + accountId: string, + input: { readAt: string; now?: string } + ): Promise; + /** The engine state, for inspection and conformance. */ + inspect(principal: Readonly): Promise; +} + +function emptyState(): ReportingSubscriptionEngineStateV1 { + return { accounts: {}, subscriptions: {} }; +} + +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; +} + +/** 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; + const inactive = history.inactiveIntervals.filter( + interval => + Date.parse(interval.from) >= Date.parse(version.acceptedAt) && + (version.supersededAt === undefined || Date.parse(interval.from) < Date.parse(version.supersededAt)) + ); + return { + subscription_version: version.subscriptionVersion, + accepted_at: version.acceptedAt, + ...(version.supersededAt !== undefined ? { superseded_at: version.supersededAt } : {}), + ...(isCurrent && history.deactivatedAt !== undefined ? { deactivated_at: history.deactivatedAt } : {}), + ...(inactive.length > 0 ? { inactive_intervals: inactive } : {}), + configuration: { ...version.configuration, active: !(isCurrent && history.deactivatedAt !== undefined) }, + ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), + }; + }); +} + +export function createReportingSubscriptionEngineV1( + options: CreateReportingSubscriptionEngineOptionsV1 +): ReportingSubscriptionEngineV1 { + if (!options?.principal?.listReportingSubscriptions) { + throw new TypeError( + 'createReportingSubscriptionEngineV1 requires a principal runtime with reporting subscriptions' + ); + } + if ( + typeof options.hooks?.listAuthorizedAccounts !== 'function' || + typeof options.hooks.listAccountBuys !== 'function' + ) { + throw new TypeError( + 'createReportingSubscriptionEngineV1 requires listAuthorizedAccounts and listAccountBuys hooks' + ); + } + if (!Number.isSafeInteger(options.statusRetentionDays) || options.statusRetentionDays < 1) { + throw new TypeError('statusRetentionDays must be a positive integer'); + } + if (options.maxAccounts !== undefined && (!Number.isSafeInteger(options.maxAccounts) || options.maxAccounts < 1)) { + throw new TypeError('maxAccounts must be a positive integer'); + } + const production = process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development'; + const store = options.store ?? createInMemoryReportingSubscriptionEngineStoreV1(); + if (production && store.durability !== 'durable') { + throw new TypeError('Production reporting subscription engines require a durable 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 TypeError('offering_id values must be unique'); + + async function mutate( + principal: Readonly, + change: (state: ReportingSubscriptionEngineStateV1) => void | Promise + ): Promise { + for (let attempt = 0; attempt < maxCasAttempts; attempt += 1) { + const current = await store.get(principal); + const state = current ? structuredClone(current.state) : emptyState(); + await change(state); + const result = await store.put(principal, state, current?.revision ?? null); + if (result.outcome === 'applied') return state; + } + throw new Error('Reporting subscription engine state conflicted repeatedly'); + } + + /** Calendars an account resolves for a version at `at`, across the given buys. */ + function accountCalendars( + version: StoredReportingSubscriptionVersion, + account: AccountState, + buys: readonly ReportingSubscriptionBuyV1[], + at: number, + timezone = account.timezone + ): ReportingPeriodCalendarV1[] { + const calendars = new Map(); + for (const buy of buys) { + for (const pkg of buy.packages) { + for (const id of resolveReportingSubscriptionOfferingsV1({ + selector: version.configuration.selector, + reconciliation_mode: version.configuration.reconciliation_mode, + ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), + offerings: [...offeringsById.values()], + eligible_offering_ids: pkg.reporting_delivery_offering_ids, + at, + })) { + const calendar = calendarFor(offeringsById.get(id)!, version, account, timezone); + if (calendar) calendars.set(calendar.key, calendar); + } + } + } + return [...calendars.values()]; + } + + 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 + : (override ?? 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. */ + function cutoverInstant(calendars: readonly ReportingPeriodCalendarV1[], at: number): number { + return calendars.length === 0 ? at : Math.max(...calendars.map(calendar => calendar.ceil(at))); + } + + function primaryCalendar(calendars: readonly ReportingPeriodCalendarV1[]): ReportingPeriodCalendarV1 | undefined { + return [...calendars].sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))[0]; + } + + /** Open newly desired conditions and close vanished ones, both at E instants. */ + function settleExclusions( + target: SubscriptionAccountState, + desired: ReadonlyArray & { at: number }>, + calendars: readonly ReportingPeriodCalendarV1[], + nowMs: number + ): boolean { + let changed = false; + 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), cutoverInstant(calendars, nowMs))); + changed = true; + } + for (const item of desired) { + if (open.some(entry => entry.key === item.key)) continue; + const { at, ...rest } = item; + target.exclusions.push({ + ...rest, + from: iso(item.reason === 'offering_unsupported' ? at : cutoverInstant(calendars, at)), + }); + changed = true; + } + // Retain closed entries for status_retention_days after until. + 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; + for (let page = 0; page < 10_000; page += 1) { + signal?.throwIfAborted(); + const result = await options.hooks.listAuthorizedAccounts(principal, { + ...(cursor !== undefined ? { cursor } : {}), + ...(signal ? { signal } : {}), + }); + for (const account of result.accounts) accounts.set(account.account_id, account); + if (result.nextCursor === undefined) break; + if (result.nextCursor === cursor) throw new Error('listAuthorizedAccounts returned a repeating cursor'); + cursor = result.nextCursor; + } + return accounts; + } + + function applyGrant( + state: ReportingSubscriptionEngineStateV1, + accountId: string, + authorized: boolean, + at: number, + fact?: Partial + ) { + const account = (state.accounts[accountId] ??= { grants: [] }); + if (fact?.operator !== undefined) account.operator = fact.operator; + if (fact?.source_timezone !== undefined) account.sourceTimezone = fact.source_timezone; + const last = lastGrant(account); + if (authorized) { + if (last && last.unauthorized_at === undefined) return false; + const start = last?.unauthorized_at !== undefined ? Math.max(at, Date.parse(last.unauthorized_at)) : at; + account.grants.push({ authorized_at: iso(start) }); + return true; + } + if (!last || last.unauthorized_at !== undefined) return false; + last.unauthorized_at = iso(Math.max(at, Date.parse(last.authorized_at))); + return true; + } + + async function reconcile(principal: Readonly, reconcileOptions: { signal?: AbortSignal } = {}) { + const signal = reconcileOptions.signal; + const nowMs = now().getTime(); + const { history } = await options.principal.listReportingSubscriptions(principal); + const facts = await readFacts(principal, signal); + const buysByAccount = new Map(); + const configsByAccount = new Map(); + for (const accountId of facts.keys()) { + signal?.throwIfAborted(); + buysByAccount.set(accountId, await options.hooks.listAccountBuys(principal, accountId, signal ? { signal } : {})); + configsByAccount.set( + accountId, + (await options.hooks.listAccountDeliveryConfigs?.(principal, accountId, signal ? { signal } : {})) ?? [] + ); + } + // Accounts whose authority ended inside retention: their buys, as they stood, give the + // withheld periods. The host answers from its own records; nothing is disclosed to the caller. + const previous = await store.get(principal); + for (const [accountId, account] of Object.entries(previous?.state.accounts ?? {})) { + const grant = lastGrant(account); + if (facts.has(accountId) || grant === undefined) continue; + // Revoked in this pass (still open in the stored state), or inside retention. + if (grant.unauthorized_at !== undefined && Date.parse(grant.unauthorized_at) + retentionMs <= nowMs) continue; + buysByAccount.set(accountId, await options.hooks.listAccountBuys(principal, accountId, signal ? { signal } : {})); + } + let notify = false; + const state = await mutate(principal, current => { + notify = false; + // 1. Authority and account facts. + for (const [accountId, fact] of facts) { + if (applyGrant(current, accountId, true, Date.parse(fact.authorized_at), fact)) notify = true; + const account = current.accounts[accountId]!; + if (fact.timezone !== undefined && fact.timezone !== account.timezone) { + if (account.timezone !== undefined) + recordTimezoneChange( + current, + history, + accountId, + account, + fact.timezone, + nowMs, + buysByAccount.get(accountId) ?? [] + ); + else account.initialTimezone = fact.timezone; + account.timezone = fact.timezone; + } + } + for (const [accountId, account] of Object.entries(current.accounts)) { + if (!facts.has(accountId) && isAuthorized(account)) { + applyGrant(current, accountId, false, nowMs); + notify = true; + } + } + // 2. Exceptions per subscription and account. + for (const entry of history) { + const sub = (current.subscriptions[entry.subscriptionId] ??= { accounts: {}, served: [] }); + const version = entry.versions.at(-1)!; + const active = entry.deactivatedAt === undefined; + const configuration = version.configuration; + const inScope = [...facts.keys()] + .filter(accountId => { + const account = current.accounts[accountId]!; + return reportingSubscriptionScopeIncludesV1( + { + scope: configuration.scope, + ...(configuration.operator_domains ? { operator_domains: configuration.operator_domains } : {}), + }, + { account_id: accountId, ...(account.operator !== undefined ? { operator: account.operator } : {}) } + ); + }) + .sort(compareReportingIdsByteOrderV1); + // Precedence: the caller's own account-level configuration, then its own account_ids subscription. + const overriddenBy = new Map< + string, + { + by: StoredExclusion['overridden_by']; + at: number; + until?: { at: number; calendar?: ReportingPeriodCalendarV1 }; + } + >(); + for (const accountId of inScope) { + const configs = (configsByAccount.get(accountId) ?? []).filter( + item => + item.feed_purpose === configuration.selector.feed_purpose && + (item.active_until === undefined || Date.parse(item.active_until) > nowMs) + ); + const winner = configs.sort((left, right) => + compareReportingIdsByteOrderV1(left.delivery_config_id, right.delivery_config_id) + )[0]; + if (winner) { + overriddenBy.set(accountId, { + by: { delivery_config_id: winner.delivery_config_id }, + at: Date.parse(winner.active_from), + }); + 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) { + overriddenBy.set(accountId, { + by: { subscription_id: specific.subscriptionId }, + at: Date.parse(specific.versions.at(-1)!.acceptedAt), + }); + } + } + } + // max_accounts: previously served accounts keep their slots, then byte order. + const eligible = inScope.filter( + accountId => !overriddenBy.has(accountId) && !facts.get(accountId)!.pending_action + ); + const ordered = [ + ...sub.served.filter(accountId => eligible.includes(accountId)), + ...eligible.filter(accountId => !sub.served.includes(accountId)), + ]; + const served = options.maxAccounts === undefined ? ordered : ordered.slice(0, options.maxAccounts); + const capacity = new Set(ordered.slice(served.length)); + sub.served = active ? served : sub.served; + + const allAccounts = new Set([...inScope, ...Object.keys(sub.accounts)]); + if (configuration.exclude_account_ids) { + for (const accountId of configuration.exclude_account_ids) + if (facts.has(accountId)) allAccounts.add(accountId); + } + for (const accountId of allAccounts) { + const account = current.accounts[accountId]; + if (!account) continue; + const target = (sub.accounts[accountId] ??= { + exclusions: [], + gaps: [], + timezoneCutovers: [], + covered: false, + }); + const buys = buysByAccount.get(accountId) ?? []; + const calendars = accountCalendars(version, account, buys, nowMs); + const desired: Array & { at: number }> = []; + const accepted = Date.parse(version.acceptedAt); + if (facts.has(accountId) && active) { + if (configuration.exclude_account_ids?.includes(accountId)) { + desired.push({ key: 'excluded_by_buyer', reason: 'excluded_by_buyer', at: accepted }); + } else if (inScope.includes(accountId)) { + const pending = facts.get(accountId)!.pending_action; + const override = overriddenBy.get(accountId); + if (pending) { + desired.push({ + key: 'action_required', + reason: 'action_required', + at: nowMs, + pending_action: structuredClone(pending), + }); + } else if (override) { + desired.push({ + key: `overridden:${JSON.stringify(override.by)}`, + reason: 'overridden', + overridden_by: override.by!, + at: Math.max(override.at, accepted), + }); + } else if (capacity.has(accountId)) { + desired.push({ key: 'capacity', reason: 'capacity', at: nowMs }); + } + for (const buy of buys) { + if (buy.confirmed_at === undefined || buy.confirmed_at === null) continue; + const unsupported = buy.packages + .filter( + pkg => + resolveReportingSubscriptionOfferingsV1({ + selector: configuration.selector, + reconciliation_mode: configuration.reconciliation_mode, + ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), + offerings: [...offeringsById.values()], + eligible_offering_ids: pkg.reporting_delivery_offering_ids, + at: nowMs, + }).length === 0 + ) + .map(pkg => pkg.package_id) + .sort(compareReportingIdsByteOrderV1); + if (unsupported.length === 0) continue; + const 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, + } + : { + key: `offering_unsupported:${buy.media_buy_id}:${unsupported.join(',')}`, + reason: 'offering_unsupported', + media_buy_id: buy.media_buy_id, + package_ids: unsupported, + at, + } + ); + } + } + } + if (!facts.has(accountId)) { + // No current authority: keep the exception history frozen; lost authority is + // reported in accounts_unauthorized instead. + continue; + } + // 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; + } + // Lost-authority gaps on re-grant inside retention, without backfill. + if (!options.backfillOnRegrant) recordRegrantGaps(target, account, calendars); + if (settleExclusions(target, desired, calendars, nowMs)) notify = true; + } + } + }); + + // 3. Readback: accounts_with_exceptions and accounts_unauthorized. An exception that + // opened or closed fires principal.changed even when the summary is unchanged. + const updates = []; + for (const entry of history) updates.push(await readbackFor(entry, state, nowMs, buysByAccount)); + await options.principal.recordReportingSubscriptionCoverage(principal, updates, { forceNotification: notify }); + } + + function recordTimezoneChange( + state: ReportingSubscriptionEngineStateV1, + history: readonly StoredReportingSubscriptionHistory[], + accountId: string, + account: AccountState, + timezone: string, + nowMs: 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 = accountCalendars(version, account, buys, nowMs).filter( + calendar => calendar.spec.alignment === 'account_timezone' + ); + if (calendars.length === 0) continue; + const sub = (state.subscriptions[entry.subscriptionId] ??= { accounts: {}, served: [] }); + const target = (sub.accounts[accountId] ??= { exclusions: [], gaps: [], timezoneCutovers: [], covered: false }); + target.timezoneCutovers.push({ effective_at: iso(cutoverInstant(calendars, nowMs)), timezone }); + } + } + + function recordRegrantGaps( + target: SubscriptionAccountState, + account: AccountState, + calendars: readonly ReportingPeriodCalendarV1[] + ) { + const calendar = primaryCalendar(calendars); + 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 + const from = calendar ? calendar.floor(revokedAt) : revokedAt; + const until = calendar ? calendar.ceil(regrantedAt) : regrantedAt; + if (until <= from) continue; + if (target.gaps.some(gap => Date.parse(gap.from) === from && Date.parse(gap.until) === until)) continue; + if (target.gaps.some(gap => Date.parse(gap.from) <= revokedAt && Date.parse(gap.until) >= regrantedAt)) continue; + target.gaps.push({ from: iso(from), until: iso(until), reason: 'unauthorized' }); + } + } + + async function readbackFor( + entry: StoredReportingSubscriptionHistory, + state: ReportingSubscriptionEngineStateV1, + nowMs: number, + buysByAccount: ReadonlyMap + ) { + const sub = state.subscriptions[entry.subscriptionId]; + const accountsWithExceptions: NonNullable = []; + const accountsUnauthorized: NonNullable = []; + for (const [accountId, target] of Object.entries(sub?.accounts ?? {})) { + const account = state.accounts[accountId]; + 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(accountCalendars(version, account!, buys, unauthorizedAt)); + const owed = await withheldPeriods(entry, state, accountId, unauthorizedAt, buys); + accountsUnauthorized.push({ + account_id: accountId, + unauthorized_at: grant.unauthorized_at, + ...(calendar + ? { last_owed_period_end: iso(calendar.floor(unauthorizedAt)) } + : owed.lastEnd + ? { last_owed_period_end: owed.lastEnd } + : {}), + withheld_period_count: owed.count, + }); + } + return { subscriptionId: entry.subscriptionId, accountsWithExceptions, accountsUnauthorized }; + } + + /** Owed periods that ended at or before `unauthorizedAt` and were not delivered by then. */ + async function withheldPeriods( + entry: StoredReportingSubscriptionHistory, + state: ReportingSubscriptionEngineStateV1, + accountId: string, + unauthorizedAt: number, + buys: readonly ReportingSubscriptionBuyV1[] + ): Promise<{ count: number; lastEnd?: string }> { + const periods = computeOwed(entry, state, accountId, buys, iso(unauthorizedAt)); + let count = 0; + for (const period of periods) { + const delivered = options.hooks.isPeriodDelivered + ? await options.hooks.isPeriodDelivered(period) + : Date.parse(period.period_end) + slaMs(period.offering_id) <= unauthorizedAt; + if (!delivered) count += 1; + } + return { count, ...(periods.length ? { lastEnd: periods.at(-1)!.period_end } : {}) }; + } + + function slaMs(offeringId: string): number { + const sla = offeringsById.get(offeringId)?.schedule.delivery_sla; + const match = sla ? /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(sla) : null; + if (!match) return 0; + return ( + ((Number(match[1] ?? 0) * 24 + Number(match[2] ?? 0)) * 3_600 + + Number(match[3] ?? 0) * 60 + + Number(match[4] ?? 0)) * + 1_000 + ); + } + + function computeOwed( + entry: StoredReportingSubscriptionHistory, + state: ReportingSubscriptionEngineStateV1, + accountId: string, + buys: readonly ReportingSubscriptionBuyV1[], + nowIso: string + ): ExpectedSubscriptionPeriodV1[] { + const account = state.accounts[accountId]; + if (!account) return []; + const target = state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + const cutovers = target?.timezoneCutovers ?? []; + return expectedSubscriptionPeriods({ + subscription_id: entry.subscriptionId, + versions: versionRecords(entry), + account: { + account_id: accountId, + ...(account.operator !== undefined ? { operator: account.operator } : {}), + ...((cutovers.length > 0 ? account.initialTimezone : account.timezone) !== undefined + ? { timezone: (cutovers.length > 0 ? account.initialTimezone : account.timezone)! } + : {}), + ...(account.sourceTimezone !== undefined ? { source_timezone: account.sourceTimezone } : {}), + grants: account.grants, + }, + buys, + offerings: [...offeringsById.values()], + 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 current = await store.get(principal); + if (!current) return []; + const { history } = await options.principal.listReportingSubscriptions(principal); + const buys = await options.hooks.listAccountBuys( + principal, + accountId, + owedOptions.signal ? { signal: owedOptions.signal } : {} + ); + const nowIso = owedOptions.now ?? now().toISOString(); + return history.flatMap(entry => computeOwed(entry, current.state, accountId, buys, nowIso)); + } + + return { + 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, change) { + const at = Date.parse(change.at); + if (!Number.isFinite(at)) throw new TypeError('authorizationChanged requires an RFC 3339 instant'); + let changed = false; + await mutate(principal, state => { + changed = applyGrant(state, accountId, change.authorized, at, change.account); + }); + if (changed) await reconcile(principal, {}); + }, + async subscriptionCoverage(principal, accountId) { + const current = await store.get(principal); + if (!current || !isAuthorized(current.state.accounts[accountId])) return []; + const { history } = await options.principal.listReportingSubscriptions(principal); + const entries: ReportingSubscriptionCoverageEntryV1[] = []; + for (const entry of history) { + const target = current.state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + if (!target) 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: pending ? 'action_required' : open.length > 0 ? 'excluded' : 'active', + ...(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 receiptRoster(accountId) { + const roster: PrincipalStoreScope[] = []; + for (const principal of await store.listPrincipals()) { + const current = await store.get(principal); + if (!current || !isAuthorized(current.state.accounts[accountId])) continue; + const { history } = await options.principal.listReportingSubscriptions(principal); + const covers = history.some(entry => { + if (entry.deactivatedAt !== undefined) return false; + const target = current.state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + 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.tenantId}\u0000${left.principalId}`, + `${right.tenantId}\u0000${right.principalId}` + ) + ); + }, + async acceptsReceipt(principal, accountId, input) { + const current = await store.get(principal); + const account = current?.state.accounts[accountId]; + if (isAuthorized(account)) return true; + const grant = lastGrant(account); + if (grant?.unauthorized_at === undefined) return false; + const unauthorizedAt = Date.parse(grant.unauthorized_at); + const nowMs = input.now !== undefined ? Date.parse(input.now) : now().getTime(); + return Date.parse(input.readAt) < unauthorizedAt && nowMs < unauthorizedAt + retentionMs; + }, + async inspect(principal) { + return structuredClone((await store.get(principal))?.state ?? emptyState()); + }, + }; +} + +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/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index c11eecd49..d67822428 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -1386,6 +1386,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[]; @@ -9515,6 +9522,7 @@ export function createAdcpServer(config: AdcpServerConfig(config: AdcpServerConfig section !== 'reporting_subscriptions' + ); + if (principalBlock.supported_sections.length === 0) delete (data.adcp as { principal?: unknown }).principal; + } + } + 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/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/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index 5bc039ba3..353422454 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -3160,20 +3160,43 @@ 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 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 +3215,10 @@ export function createAdcpServerFromPlatform

; + /** Last version whose `replace_account_configs` was applied. */ + replaceAppliedVersion?: number; +} + +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; + }>; +} + +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_]*$/; + +/** 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'); + } + for (const placeholder of template.match(/\{[^}]*\}/g) ?? []) { + if (!PLACEHOLDERS.has(placeholder)) fail(`destination.path_template has unknown placeholder ${placeholder}`); + } + if (/[{}]/.test(template.replace(/\{[a-z_]+\}/g, ''))) fail('destination.path_template has an unbalanced brace'); + } + 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 ( + 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. + const currentVersion = history.versions.at(-1)!; + if ( + subscription.active !== false && + subscription.replace_account_configs === true && + history.replaceAppliedVersion !== currentVersion.subscriptionVersion + ) { + replaceAccountConfigs.push({ + subscriptionId: subscription.subscription_id, + subscriptionVersion: currentVersion.subscriptionVersion, + feedPurpose: subscription.selector.feed_purpose, + at: now, + }); + history.replaceAppliedVersion = currentVersion.subscriptionVersion; + } else if (subscription.replace_account_configs !== true && history.replaceAppliedVersion !== undefined) { + // A flag turned off and on again applies again. + delete history.replaceAppliedVersion; + } + } + // 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) 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'; + const prior = history.versions + .slice(0, -1) + .reverse() + .map(version => ({ + subscription_version: version.subscriptionVersion, + accepted_at: version.acceptedAt, + superseded_at: version.supersededAt!, + })); + 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 } : {}), + ...(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 distinct `{account_id}` templates. */ +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}` + ); + } + if (new Set(templates).size !== templates.length) { + throw new ReportingSubscriptionValidationError( + `Active subscriptions sharing destination ${JSON.stringify(destinationId)} must use different path_template values` + ); + } + } +} + +/** 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..8db300aee 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,13 @@ import { projectStoredNotificationSubscriptionReadback, } from '../notification-subscriptions/runtime'; import { PRINCIPAL_NOTIFICATION_STORE_OWNER } from './store'; +import { + ReportingSubscriptionValidationError, + applyReportingSubscriptions, + projectReportingSubscriptionState, + type ApplyReportingSubscriptionsResult, + type StoredReportingSubscriptionHistory, +} from './reporting-subscriptions'; import type { PendingPrincipalNotification, PrepareReportingDestination, @@ -31,8 +39,11 @@ import type { PrincipalDeclarationsState, PrincipalDestinationTransition, PrincipalKind, + PrincipalLifecycleRuntimeSubscriptions, PrincipalNotificationRecoveryResult, PrincipalReportingDestination, + PrincipalReportingSubscriptionState, + PrincipalReportingSubscriptionSupport, PrincipalReportingDestinationInput, ResolvedReportingDestination, PrincipalStateStore, @@ -56,6 +67,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 +87,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 PrincipalLifecycleRuntimeSubscriptions { + /** + * 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: { @@ -149,6 +181,7 @@ 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'); async function resolve(ctx: HandlerContext): Promise { const cached = ctx.callerMutationScope as (ResolvedPrincipalScope & Record) | undefined; @@ -237,13 +270,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 ?? [], @@ -297,6 +339,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 +352,9 @@ export function createPrincipalLifecycle( return syncFailure('CONFLICT', 'The principal configuration changed; read current state and retry'); } await notificationPlan?.commitCredentials(); + for (const replacement of subscriptionPlan.result?.replaceAccountConfigs ?? []) { + await options.reportingSubscriptions!.replaceAccountConfigs!({ scope: structuredClone(scope), ...replacement }); + } return { result: { ...currentResult(persisted.value.record), @@ -320,6 +368,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); } @@ -335,12 +386,15 @@ export function createPrincipalLifecycle( 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 +418,40 @@ 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; + if (!(next.reporting_destinations ?? []).some(item => item.destination_id === id)) { + throw new ReportingSubscriptionValidationError( + `Active reporting subscription ${JSON.stringify(state.subscription_id)} names destination ${JSON.stringify(id)}, which this request removes` + ); + } + } + next.reporting_subscriptions = reprojectSubscriptionStates( + subscriptionHistory, + next.reporting_destinations ?? [], + current?.reporting_subscriptions ?? [] + ); } if (input.declarations !== undefined) { if (!options.declarations) { @@ -635,6 +719,7 @@ export function createPrincipalLifecycle( storedGeneration.state = transition.state; storedGeneration.updatedAt = now().toISOString(); const timestamp = now().toISOString(); + const subscriptionMovedToActionRequired = reprojectStoredSubscriptions(record); enqueuePrincipalNotification( record, { @@ -648,6 +733,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 +923,76 @@ 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 ?? []]); + const withExceptions = [...update.accountsWithExceptions] + .sort((left, right) => compareBytes(left.account_id, right.account_id)) + .slice(0, 1000); + const unauthorized = [...update.accountsUnauthorized] + .sort((left, right) => compareBytes(left.account_id, right.account_id)) + .slice(0, 1000); + 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-maintained readback is not a caller write: the configuration version + // still changes so readers can tell, but no account revision is involved. + if (changed) record.configurationVersion = issueConfigurationVersion(); + 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 +1010,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 +1033,46 @@ 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; +} + 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..9e2c84050 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,63 @@ 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; +} + +/** 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/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/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..6ef621652 --- /dev/null +++ b/test/helpers/principal-runtime.js @@ -0,0 +1,58 @@ +// 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, + }); + return { lifecycle, store, emitted, clock }; +} + +module.exports = { createTestPrincipalLifecycle }; diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js new file mode 100644 index 000000000..d4bd88f04 --- /dev/null +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -0,0 +1,472 @@ +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 { validateResponse } = require('../../dist/lib/validation/schema-validator.js'); +const { principalOptionOffTranscript } = require('../helpers/principal-option-off-transcript.js'); + +const SCOPE = { tenantId: 't', principalId: 'p' }; +const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; + +function assertSchemaValid(toolName, response) { + const outcome = validateResponse(toolName, response, '3.3.0-beta.1'); + 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, 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, [ + { subscription_version: 1, accepted_at: '2026-11-01T12:00:00.000Z', superseded_at: '2026-11-04T12:00:00.000Z' }, + ]); + + // 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' }, + ]); + + 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); + }); + + 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({ 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, /different path_template/); + }); + + 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('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 9b96deaed..74e440ddb 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -7,6 +7,7 @@ const { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, ReportingConfigurationImmutableError, createReliableReportingService, + createReportingSubscriptionEngineV1, isReportingConfigurationImmutableError, reportingConfigurationCapabilitiesV1, runReliableReportingServiceConformanceV1, @@ -19,6 +20,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'); @@ -594,6 +596,101 @@ describe('ReliableReportingService', () => { assert.throws(() => reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 0 } }), TypeError); }); + 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: [] }), listAccountBuys: () => [] }, + }); + const { service } = serviceFixture({ + reportingSubscriptions: { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }, + }); + 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 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', + ]); + assert.deepEqual(result.structuredContent.media_buy.reporting_delivery, 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']); + }); + 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-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js new file mode 100644 index 000000000..b7ed08332 --- /dev/null +++ b/test/lib/reporting-subscription-engine.test.js @@ -0,0 +1,481 @@ +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); +const { createReportingSubscriptionEngineV1 } = require('../../dist/lib/reporting/service/index.js'); +const { expectedSubscriptionPeriods } = require('../../dist/lib/reporting/consumer/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 DAY = 86_400_000; + +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', + 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); + +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() }; + const engine = createReportingSubscriptionEngineV1({ + principal: lifecycle, + offerings: OFFERINGS, + 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) ?? []), + }, + }); + + // Buyer-side records. + const buyer = { sent: new Map(), grants: 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) { + host.accounts.delete(accountId); + } + /** The buyer observes list_accounts and get_principal after each step. */ + async function observe() { + for (const account of host.accounts.values()) { + const grants = buyer.grants.get(account.account_id) ?? []; + const last = grants.at(-1); + if (!last || last.unauthorized_at !== undefined) grants.push({ authorized_at: account.authorized_at }); + buyer.grants.set(account.account_id, grants); + } + const read = await lifecycle.protocol.getPrincipal({}, CTX); + const outcome = validateResponse('get_principal', read, '3.3.0-beta.1'); + assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); + for (const state of read.result.configuration.reporting_subscriptions ?? []) { + 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') { + const read = await lifecycle.protocol.getPrincipal({}, CTX); + const state = read.result.configuration.reporting_subscriptions.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, + account: { account_id: accountId, timezone: 'America/New_York', grants: buyer.grants.get(accountId) ?? [] }, + 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 { clock, lifecycle, engine, 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)), + ['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']); + }); + + 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'); + 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'); + assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T01:00:00.000Z' }), true); + assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T06:00:00.000Z' }), false); + assert.ok(w.emitted.some(event => event.payload.reason === 'reporting_subscription_changed')); + + w.grant('acct_1', '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('acct_a')).length, 1); + assert.equal((await w.engine.receiptRoster('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 }, + '3.3.0-beta.1' + ); + 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('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'); + }); +}); From 08291bfc738f7ece6ba93dbad89a510e1af73b31 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 10:37:44 +0000 Subject: [PATCH 07/49] fix(reporting): fail closed on partial rosters and project 3.3 fields by release - readFacts refuses a roster that does not finish within the page cap, as it already did for a repeating cursor: a partial roster would read as revocations and fabricate accounts_unauthorized entries. - Callers pinned below 3.2 no longer see protocol.principal once the adcp.principal block is removed for them. - subscription_coverage is added only for callers served 3.3 or later; earlier get_reporting_status response shapes are closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/reporting/service/index.ts | 4 ++ src/lib/reporting/subscriptions/engine.ts | 10 +++- src/lib/server/create-adcp-server.ts | 6 ++ test/lib/reliable-reporting-service.test.js | 57 ++++++++++++++++++- .../lib/reporting-subscription-engine.test.js | 46 ++++++++++++++- 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 29b325ecf..a0c6e7c41 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -355,6 +355,7 @@ export type ReliableReportingMaintenanceFactoryV1 = ( import type { ReportingSubscriptionEngineV1 } from '../subscriptions/engine'; import type { PrincipalStoreScope } from '../../server/principal/types'; import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; +import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; export const REPORTING_MANAGED_DELIVERY_COMPOSITION_V1: unique symbol = Symbol.for( '@adcp/sdk/reporting/managed-delivery-composition' @@ -851,6 +852,9 @@ async function withSubscriptionCoverage( ) { 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); diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index b26d5f9cb..46040c549 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -45,6 +45,7 @@ import { type MaybePromise = T | Promise; const DAY_MS = 86_400_000; +const MAX_AUTHORIZED_ACCOUNT_PAGES = 10_000; export type ReportingSubscriptionExclusionReasonV1 = | 'offering_unsupported' @@ -477,18 +478,21 @@ export function createReportingSubscriptionEngineV1( async function readFacts(principal: Readonly, signal?: AbortSignal) { const accounts = new Map(); let cursor: string | undefined; - for (let page = 0; page < 10_000; page += 1) { + // 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 } : {}), }); for (const account of result.accounts) accounts.set(account.account_id, account); - if (result.nextCursor === undefined) break; + if (result.nextCursor === undefined) return accounts; if (result.nextCursor === cursor) throw new Error('listAuthorizedAccounts returned a repeating cursor'); cursor = result.nextCursor; } - return accounts; + throw new Error( + `listAuthorizedAccounts did not finish within ${MAX_AUTHORIZED_ACCOUNT_PAGES} pages; refusing a partial roster` + ); } function applyGrant( diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index d67822428..96efd2a76 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -9841,7 +9841,13 @@ export function createAdcpServer(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( diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 74e440ddb..d0d48954b 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -611,7 +611,12 @@ describe('ReliableReportingService', () => { offerings: [], statusRetentionDays: redactedReportingSourceOfferingV1.retentionDays, maxAccounts: 100, - hooks: { listAuthorizedAccounts: () => ({ accounts: [] }), listAccountBuys: () => [] }, + hooks: { + listAuthorizedAccounts: () => ({ + accounts: [{ account_id: 'account-a', authorized_at: '2026-10-01T00:00:00.000Z' }], + }), + listAccountBuys: () => [], + }, }); const { service } = serviceFixture({ reportingSubscriptions: { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }, @@ -689,6 +694,56 @@ describe('ReliableReportingService', () => { 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 () => { diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index b7ed08332..acc0f1e54 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -4,7 +4,10 @@ const assert = require('node:assert/strict'); process.env.NODE_ENV = 'test'; const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); -const { createReportingSubscriptionEngineV1 } = require('../../dist/lib/reporting/service/index.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/service/index.js'); const { expectedSubscriptionPeriods } = require('../../dist/lib/reporting/consumer/index.js'); const { validateResponse } = require('../../dist/lib/validation/schema-validator.js'); @@ -80,9 +83,11 @@ 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() }; + const engineStore = createInMemoryReportingSubscriptionEngineStoreV1(); const engine = createReportingSubscriptionEngineV1({ principal: lifecycle, offerings: OFFERINGS, + store: engineStore, statusRetentionDays: 30, ...(maxAccounts !== undefined ? { maxAccounts } : {}), backfillOnRegrant, @@ -188,7 +193,21 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { assert.deepEqual(seller, expected, `seller and buyer disagree for ${accountId} at ${now}`); return expected; } - return { clock, lifecycle, engine, host, sync, grant, revoke, step, observe, buyerExpected, assertAgree, emitted }; + return { + clock, + lifecycle, + engine, + engineStore, + host, + sync, + grant, + revoke, + step, + observe, + buyerExpected, + assertAgree, + emitted, + }; } const starts = rows => @@ -460,6 +479,29 @@ describe('reporting subscription engine conformance (buyer periods equal seller assert.equal((await w.engine.subscriptionCoverage(SCOPE, 'acct_x'))[0].status, 'excluded'); }); + 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: () => [], + }, + }); + await assert.rejects(endless.reconcile(SCOPE), /partial roster/); + assert.deepEqual(await w.engine.inspect(SCOPE), before, 'no account was marked unauthorized'); + }); + 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', { From ed79ca3da8265bdfacc44b4fe045113aea348a0a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 10:42:08 +0000 Subject: [PATCH 08/49] fix(reporting): scan path_template placeholders in one linear pass The placeholder regex backtracked polynomially on runs of '{' (CodeQL). Tokenize braces in a single pass instead, which also rejects nested and unbalanced braces explicitly. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../principal/reporting-subscriptions.ts | 23 +++++++++++++++++-- .../principal-reporting-subscriptions.test.js | 12 ++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/lib/server/principal/reporting-subscriptions.ts b/src/lib/server/principal/reporting-subscriptions.ts index d12c98476..fcd856331 100644 --- a/src/lib/server/principal/reporting-subscriptions.ts +++ b/src/lib/server/principal/reporting-subscriptions.ts @@ -77,6 +77,24 @@ 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; @@ -188,10 +206,11 @@ export function validateReportingSubscription( if (typeof template !== 'string' || template.length > 512 || !PATH_TEMPLATE.test(template)) { fail('destination.path_template must be a relative path without . or .. segments'); } - for (const placeholder of template.match(/\{[^}]*\}/g) ?? []) { + 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 (/[{}]/.test(template.replace(/\{[a-z_]+\}/g, ''))) fail('destination.path_template has an unbalanced brace'); } if (isMultiAccount(subscription)) { if (template === undefined && destinationPattern(destination!).pattern === 'file_transfer') { diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index d4bd88f04..55efd21d2 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -256,6 +256,18 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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/], ]; From c3316b0f3e2b2e9efe5d0625e6b6eacbe7100adc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 11:25:18 +0000 Subject: [PATCH 09/49] test(reporting): drop an unused constant from the subscription engine test Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-engine.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index acc0f1e54..4f8796b60 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -13,7 +13,6 @@ const { validateResponse } = require('../../dist/lib/validation/schema-validator const SCOPE = { tenantId: 't', principalId: 'p' }; const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; -const DAY = 86_400_000; const profile = { id: 'adcp.delivery.v1', From 8843fabad9ae97bcd4a39be409e03b4e14bca46e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 14:33:19 +0000 Subject: [PATCH 10/49] feat(reporting): conform the subscription runtime to adcp#8585 - The principal readback lists inactive_intervals; the engine feeds them to the coverage function in wire shape. - Timezone cutovers carry previous_timezone and gaps are recorded per offering_id when covered offerings' boundaries differ. - authorized_at is the start of the authorization chain: a re-grant inside retention keeps it, so the engine stamps the re-grant when authority is observed again. - The service requires standard_dimensions on every offering when subscriptions are advertised. - Responses served at 3.3.0-beta.1 omit the fields its closed schemas do not define; they reach callers served 3.3.0-beta.2 or later. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 9 ++ src/lib/reporting/service/index.ts | 20 ++- src/lib/reporting/subscriptions/engine.ts | 121 +++++++++++++----- src/lib/reporting/subscriptions/wire.ts | 33 +++++ src/lib/server/create-adcp-server.ts | 14 ++ .../principal/reporting-subscriptions.ts | 30 +++++ src/lib/server/principal/runtime.ts | 20 ++- .../principal-reporting-subscriptions.test.js | 19 ++- test/lib/reliable-reporting-service.test.js | 19 ++- .../lib/reporting-subscription-engine.test.js | 94 +++++++++++++- 10 files changed, 333 insertions(+), 46 deletions(-) diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md index 1e952af83..bdc1938fe 100644 --- a/.changeset/reporting-subscription-runtime.md +++ b/.changeset/reporting-subscription-runtime.md @@ -25,3 +25,12 @@ Versioning follows the spec: a version changes only for semantic changes; each v 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), the receipt roster, and the post-loss receipt window (`acceptsReceipt`). **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. + +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`). They are emitted to callers served 3.3.0-beta.2 or later. diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index a0c6e7c41..411aa0dd6 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -356,6 +356,11 @@ import type { ReportingSubscriptionEngineV1 } from '../subscriptions/engine'; import type { PrincipalStoreScope } from '../../server/principal/types'; import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; +import { + servesSettledReportingSubscriptionFieldsV1, + withoutSettledCoverageFieldsV1, + type ReportingDeliveryOfferingWithDimensionsV1, +} from '../subscriptions/wire'; export const REPORTING_MANAGED_DELIVERY_COMPOSITION_V1: unique symbol = Symbol.for( '@adcp/sdk/reporting/managed-delivery-composition' @@ -555,6 +560,16 @@ export function createReliableReportingService( const accountId = (context.account as { id?: unknown } | undefined)?.id; if (typeof accountId !== 'string') return response; const principal = await subscriptions.resolvePrincipal(context); - const coverage = await subscriptions.engine.subscriptionCoverage(principal, accountId); + const settled = servesSettledReportingSubscriptionFieldsV1(served, isAdcpVersionAtLeast); + const coverage = (await subscriptions.engine.subscriptionCoverage(principal, accountId)).map(entry => + settled ? entry : withoutSettledCoverageFieldsV1(entry as unknown as Record) + ); if (coverage.length === 0) return response; return { ...body, subscription_coverage: coverage } as unknown as typeof response; } diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 46040c549..b6734f4b2 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -27,6 +27,11 @@ import type { StoredReportingSubscriptionVersion, } from '../../server/principal/reporting-subscriptions'; import { createReportingPeriodCalendarV1, type ReportingPeriodCalendarV1 } from './calendar'; +import type { + ReportingSubscriptionGapV1, + ReportingSubscriptionInactiveIntervalV1, + ReportingSubscriptionTimezoneCutoverV1, +} from './wire'; import { expectedSubscriptionPeriods, groupExpectedSubscriptionPeriodsV1, @@ -71,8 +76,8 @@ export interface ReportingSubscriptionCoverageEntryV1 { status: 'active' | 'excluded' | 'action_required'; pending_action?: PendingAction; excluded: ReportingSubscriptionExclusionV1[]; - timezone_cutovers?: Array<{ effective_at: string; timezone: string }>; - gaps?: Array<{ from: string; until: string; reason: 'unauthorized' }>; + timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; + gaps?: ReportingSubscriptionGapV1[]; } /** Facts about one account the principal may currently read. */ @@ -143,17 +148,16 @@ interface StoredExclusion extends ReportingSubscriptionExclusionV1 { interface AccountState { operator?: string; + /** The account's current timezone. Earlier zones are in each cutover's previous_timezone. */ timezone?: string; - /** Timezone in effect before the first recorded cutover. */ - initialTimezone?: string; sourceTimezone?: string; grants: ReportingSubscriptionGrantV1[]; } interface SubscriptionAccountState { exclusions: StoredExclusion[]; - gaps: Array<{ from: string; until: string; reason: 'unauthorized' }>; - timezoneCutovers: Array<{ effective_at: string; timezone: string }>; + gaps: ReportingSubscriptionGapV1[]; + timezoneCutovers: ReportingSubscriptionTimezoneCutoverV1[]; /** The subscription covered this account at some point (for accounts_unauthorized). */ covered: boolean; } @@ -300,23 +304,22 @@ function isAuthorized(account: AccountState | undefined): boolean { function versionRecords(history: StoredReportingSubscriptionHistory): ReportingSubscriptionVersionRecordV1[] { return history.versions.map((version, index) => { const isCurrent = index === history.versions.length - 1; - const inactive = history.inactiveIntervals.filter( - interval => - Date.parse(interval.from) >= Date.parse(version.acceptedAt) && - (version.supersededAt === undefined || Date.parse(interval.from) < Date.parse(version.supersededAt)) - ); return { subscription_version: version.subscriptionVersion, accepted_at: version.acceptedAt, ...(version.supersededAt !== undefined ? { superseded_at: version.supersededAt } : {}), ...(isCurrent && history.deactivatedAt !== undefined ? { deactivated_at: history.deactivatedAt } : {}), - ...(inactive.length > 0 ? { inactive_intervals: inactive } : {}), 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 })); +} + export function createReportingSubscriptionEngineV1( options: CreateReportingSubscriptionEngineOptionsV1 ): ReportingSubscriptionEngineV1 { @@ -372,6 +375,17 @@ export function createReportingSubscriptionEngineV1( at: number, timezone = account.timezone ): ReportingPeriodCalendarV1[] { + return [...offeringCalendars(version, account, buys, at, timezone).values()]; + } + + /** 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 pkg of buy.packages) { @@ -384,11 +398,11 @@ export function createReportingSubscriptionEngineV1( at, })) { const calendar = calendarFor(offeringsById.get(id)!, version, account, timezone); - if (calendar) calendars.set(calendar.key, calendar); + if (calendar) calendars.set(id, calendar); } } } - return [...calendars.values()]; + return new Map([...calendars].sort(([left], [right]) => compareReportingIdsByteOrderV1(left, right))); } function calendarFor( @@ -500,7 +514,8 @@ export function createReportingSubscriptionEngineV1( accountId: string, authorized: boolean, at: number, - fact?: Partial + fact?: Partial, + observedAt = at ) { const account = (state.accounts[accountId] ??= { grants: [] }); if (fact?.operator !== undefined) account.operator = fact.operator; @@ -508,7 +523,11 @@ export function createReportingSubscriptionEngineV1( const last = lastGrant(account); if (authorized) { if (last && last.unauthorized_at === undefined) return false; - const start = last?.unauthorized_at !== undefined ? Math.max(at, Date.parse(last.unauthorized_at)) : at; + // authorized_at is the start of the authorization chain (adcontextprotocol/adcp#8585): + // a re-grant inside retention leaves it unchanged, so the re-grant instant is when + // authority was observed again. A later authorized_at is itself the re-grant. + const revokedAt = last?.unauthorized_at !== undefined ? Date.parse(last.unauthorized_at) : undefined; + const start = revokedAt === undefined ? at : at > revokedAt ? at : Math.max(observedAt, revokedAt); account.grants.push({ authorized_at: iso(start) }); return true; } @@ -547,10 +566,10 @@ export function createReportingSubscriptionEngineV1( notify = false; // 1. Authority and account facts. for (const [accountId, fact] of facts) { - if (applyGrant(current, accountId, true, Date.parse(fact.authorized_at), fact)) notify = true; + if (applyGrant(current, accountId, true, Date.parse(fact.authorized_at), fact, nowMs)) notify = true; const account = current.accounts[accountId]!; if (fact.timezone !== undefined && fact.timezone !== account.timezone) { - if (account.timezone !== undefined) + if (account.timezone !== undefined) { recordTimezoneChange( current, history, @@ -560,7 +579,7 @@ export function createReportingSubscriptionEngineV1( nowMs, buysByAccount.get(accountId) ?? [] ); - else account.initialTimezone = fact.timezone; + } account.timezone = fact.timezone; } } @@ -729,7 +748,9 @@ export function createReportingSubscriptionEngineV1( target.covered = true; } // Lost-authority gaps on re-grant inside retention, without backfill. - if (!options.backfillOnRegrant) recordRegrantGaps(target, account, calendars); + if (!options.backfillOnRegrant) { + recordRegrantGaps(target, account, offeringCalendars(version, account, buys, nowMs)); + } if (settleExclusions(target, desired, calendars, nowMs)) notify = true; } } @@ -754,22 +775,32 @@ export function createReportingSubscriptionEngineV1( for (const entry of history) { const version = entry.versions.at(-1)!; if (version.configuration.selector.period_timezone !== undefined) continue; - const calendars = accountCalendars(version, account, buys, nowMs).filter( - calendar => calendar.spec.alignment === 'account_timezone' + const calendars = [...offeringCalendars(version, account, buys, nowMs)].filter( + ([, calendar]) => calendar.spec.alignment === 'account_timezone' ); if (calendars.length === 0) continue; const sub = (state.subscriptions[entry.subscriptionId] ??= { accounts: {}, served: [] }); const target = (sub.accounts[accountId] ??= { exclusions: [], gaps: [], timezoneCutovers: [], covered: false }); - target.timezoneCutovers.push({ effective_at: iso(cutoverInstant(calendars, nowMs)), timezone }); + // 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(nowMs) })); + 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: readonly ReportingPeriodCalendarV1[] + calendars: ReadonlyMap ) { - const calendar = primaryCalendar(calendars); for (let index = 1; index < account.grants.length; index += 1) { const previous = account.grants[index - 1]!; const grant = account.grants[index]!; @@ -777,12 +808,35 @@ export function createReportingSubscriptionEngineV1( 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 - const from = calendar ? calendar.floor(revokedAt) : revokedAt; - const until = calendar ? calendar.ceil(regrantedAt) : regrantedAt; - if (until <= from) continue; - if (target.gaps.some(gap => Date.parse(gap.from) === from && Date.parse(gap.until) === until)) continue; - if (target.gaps.some(gap => Date.parse(gap.from) <= revokedAt && Date.parse(gap.until) >= regrantedAt)) continue; - target.gaps.push({ from: iso(from), until: iso(until), reason: 'unauthorized' }); + // 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', + }); + } } } @@ -874,14 +928,13 @@ export function createReportingSubscriptionEngineV1( account: { account_id: accountId, ...(account.operator !== undefined ? { operator: account.operator } : {}), - ...((cutovers.length > 0 ? account.initialTimezone : account.timezone) !== undefined - ? { timezone: (cutovers.length > 0 ? account.initialTimezone : account.timezone)! } - : {}), + ...(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 } : {}), diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts index 133ed3b47..0368013ba 100644 --- a/src/lib/reporting/subscriptions/wire.ts +++ b/src/lib/reporting/subscriptions/wire.ts @@ -63,3 +63,36 @@ export type ReportingSubscriptionCoverageWithOfferingsV1 = Omit< timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; gaps?: ReportingSubscriptionGapV1[]; }; + +/** + * First release whose schemas define the fields above. Earlier response shapes are closed + * (`additionalProperties: false`), so sellers serving an earlier release must omit them. + * + * TODO(3.3.0-beta.2): confirm the published release includes adcontextprotocol/adcp#8585. + */ +export const REPORTING_SUBSCRIPTION_SETTLED_FIELDS_RELEASE = '3.3.0-beta.2'; + +/** + * Whether a response served at `servedAdcpVersion` may carry the fields settled after + * 3.3.0-beta.1. A context without a served release (direct handler calls) gets them. + */ +export function servesSettledReportingSubscriptionFieldsV1( + servedAdcpVersion: string | undefined, + isAtLeast: (version: string, minimum: string) => boolean +): boolean { + return servedAdcpVersion === undefined || isAtLeast(servedAdcpVersion, REPORTING_SUBSCRIPTION_SETTLED_FIELDS_RELEASE); +} + +/** Remove the settled fields from a `subscription_coverage[]` entry for an earlier release. */ +export function withoutSettledCoverageFieldsV1>(entry: T): T { + const copy = structuredClone(entry) as Record; + if (Array.isArray(copy.timezone_cutovers)) { + copy.timezone_cutovers = (copy.timezone_cutovers as Array>).map( + ({ previous_timezone: _previous, offering_id: _offering, ...rest }) => rest + ); + } + if (Array.isArray(copy.gaps)) { + copy.gaps = (copy.gaps as Array>).map(({ offering_id: _offering, ...rest }) => rest); + } + return copy as T; +} diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 96efd2a76..2582fc4e0 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 { servesSettledReportingSubscriptionFieldsV1 } from '../reporting/subscriptions/wire'; import { ConfigurationError } from '../errors'; import { getValidator, @@ -9856,6 +9857,19 @@ export function createAdcpServer(config: AdcpServerConfig>) { + 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. diff --git a/src/lib/server/principal/reporting-subscriptions.ts b/src/lib/server/principal/reporting-subscriptions.ts index fcd856331..35cf77f6e 100644 --- a/src/lib/server/principal/reporting-subscriptions.ts +++ b/src/lib/server/principal/reporting-subscriptions.ts @@ -10,6 +10,8 @@ import { canonicalJsonSha256 } from '../../utils/jcs'; import type { ReportingSubscription, ReportingSubscriptionState } from '../../types/core.generated'; import { assertReportingIanaTimezoneV1, parseReportingPeriodDurationV1 } from '../../reporting/subscriptions/calendar'; import type { ReportingSubscriptionDestinationMethodV1 } from '../../reporting/subscriptions/selection'; +import { servesSettledReportingSubscriptionFieldsV1 } from '../../reporting/subscriptions/wire'; +import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; import type { PrincipalReportingDestination } from './types'; export type { ReportingSubscription, ReportingSubscriptionState }; @@ -375,6 +377,28 @@ function pickInPlace(subscription: ReportingSubscription): Partial( + configuration: T, + servedAdcpVersion: string | undefined +): T { + if ( + !Array.isArray(configuration.reporting_subscriptions) || + servesSettledReportingSubscriptionFieldsV1(servedAdcpVersion, isAdcpVersionAtLeast) + ) { + return configuration; + } + return { + ...configuration, + reporting_subscriptions: (configuration.reporting_subscriptions as Array>).map( + ({ inactive_intervals: _intervals, ...state }) => state + ), + }; +} + export function projectReportingSubscriptionState( history: StoredReportingSubscriptionHistory, destinations: readonly PrincipalReportingDestination[], @@ -413,6 +437,11 @@ export function projectReportingSubscriptionState( } 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 }] : []), + ].slice(-64); const prior = history.versions .slice(0, -1) .reverse() @@ -428,6 +457,7 @@ export function projectReportingSubscriptionState( 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) } : {}), diff --git a/src/lib/server/principal/runtime.ts b/src/lib/server/principal/runtime.ts index 8db300aee..a6fc95117 100644 --- a/src/lib/server/principal/runtime.ts +++ b/src/lib/server/principal/runtime.ts @@ -26,6 +26,7 @@ import { ReportingSubscriptionValidationError, applyReportingSubscriptions, projectReportingSubscriptionState, + reportingSubscriptionStatesForRelease, type ApplyReportingSubscriptionsResult, type StoredReportingSubscriptionHistory, } from './reporting-subscriptions'; @@ -194,6 +195,10 @@ export function createPrincipalLifecycle( params: GetPrincipalRequest, ctx: HandlerContext ): Promise> { + return forRelease(await readPrincipal(ctx), ctx.servedAdcpVersion); + } + + async function readPrincipal(ctx: HandlerContext): Promise> { const resolved = await resolve(ctx); const scope = storeScope(resolved); const current = await options.store.get(scope); @@ -227,7 +232,7 @@ export function createPrincipalLifecycle( ctx: HandlerContext ): Promise> { const resolved = await resolve(ctx); - return syncConfiguration(resolved, params); + return forRelease(await syncConfiguration(resolved, params), ctx.servedAdcpVersion); } async function syncConfiguration( @@ -1073,6 +1078,19 @@ function reprojectStoredSubscriptions(record: StoredPrincipalRecord): boolean { return moved; } +/** Project a principal response onto the fields the served release defines. */ +function forRelease(response: T, servedAdcpVersion: string | undefined): T { + const result = (response as { result?: { configuration?: { reporting_subscriptions?: unknown } } }).result; + if (!result?.configuration?.reporting_subscriptions) return response; + return { + ...response, + result: { + ...result, + configuration: reportingSubscriptionStatesForRelease(result.configuration, servedAdcpVersion), + }, + } as T; +} + function storeScope(resolved: Readonly): PrincipalStoreScope { return { tenantId: resolved.tenant_id, principalId: resolved.principal_id }; } diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index 55efd21d2..4f72d043a 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -16,7 +16,12 @@ const { validateResponse } = require('../../dist/lib/validation/schema-validator const { principalOptionOffTranscript } = require('../helpers/principal-option-off-transcript.js'); const SCOPE = { tenantId: 't', principalId: 'p' }; -const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; +// 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: '3.3.0-beta.1', +}; +const CTX_UNPINNED = { callerMutationScope: CTX.callerMutationScope }; function assertSchemaValid(toolName, response) { const outcome = validateResponse(toolName, response, '3.3.0-beta.1'); @@ -212,6 +217,13 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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 that defines it; 3.3.0-beta.1's closed state schema does not. + const unpinned = await lifecycle.protocol.getPrincipal({}, CTX_UNPINNED); + assert.deepEqual(unpinned.result.configuration.reporting_subscriptions[0].inactive_intervals, [ + { deactivated_at: '2026-11-04T12:00:00.000Z', reactivated_at: '2026-11-06T12:00:00.000Z' }, + ]); + assert.equal(on.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: [] }); @@ -219,6 +231,11 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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.protocol.getPrincipal({}, CTX_UNPINNED); + assert.deepEqual(closed.result.configuration.reporting_subscriptions[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('validates the cross-field rules of reporting-subscription.json', async () => { diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index d0d48954b..47fd63807 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -618,9 +618,19 @@ describe('ReliableReportingService', () => { listAccountBuys: () => [], }, }); + const subscriptionOptions = { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }; + 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({ - reportingSubscriptions: { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }, + 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, { @@ -672,7 +682,12 @@ describe('ReliableReportingService', () => { 'notification_configs', 'reporting_subscriptions', ]); - assert.deepEqual(result.structuredContent.media_buy.reporting_delivery, service.capabilities); + // standard_dimensions is defined after 3.3.0-beta.1, whose offering schema is closed. + const { offerings: advertised, ...rest } = structuredClone(service.capabilities); + assert.deepEqual(result.structuredContent.media_buy.reporting_delivery, { + ...rest, + offerings: advertised.map(({ standard_dimensions: _dimensions, ...offering }) => offering), + }); // Callers pinned to 3.2 see neither the 3.3 registration fields nor the 3.3 section. const legacy = createAdcpServerFromPlatform(platform, { diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index 4f8796b60..ebbb55071 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -35,7 +35,7 @@ function offering(id, dimensions) { schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT6H' }, supported_finality: ['official'], reconciliation_mode: 'delivery_only', - dimensions, + standard_dimensions: dimensions, }; } const OFFERINGS = [ @@ -44,6 +44,11 @@ const OFFERINGS = [ 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 { @@ -99,7 +104,7 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { }); // Buyer-side records. - const buyer = { sent: new Map(), grants: new Map() }; + const buyer = { sent: new Map(), grants: new Map(), timezones: new Map() }; let key = 0; async function sync(subscriptions) { key += 1; @@ -128,15 +133,28 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { } /** 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); - if (!last || last.unauthorized_at !== undefined) grants.push({ authorized_at: account.authorized_at }); + 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); } - const read = await lifecycle.protocol.getPrincipal({}, CTX); - const outcome = validateResponse('get_principal', read, '3.3.0-beta.1'); + // Served at 3.3.0-beta.1 the readback omits fields that release does not define and is + // schema-valid; read without a served release, it carries them all. + const served = await lifecycle.protocol.getPrincipal({}, { ...CTX, servedAdcpVersion: '3.3.0-beta.1' }); + const outcome = validateResponse('get_principal', served, '3.3.0-beta.1'); assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); + const read = await lifecycle.protocol.getPrincipal({}, CTX); for (const state of read.result.configuration.reporting_subscriptions ?? []) { for (const lost of state.accounts_unauthorized ?? []) { const grants = buyer.grants.get(lost.account_id) ?? []; @@ -175,7 +193,12 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { return expectedSubscriptionPeriods({ subscription_id: subscriptionId, versions, - account: { account_id: accountId, timezone: 'America/New_York', grants: buyer.grants.get(accountId) ?? [] }, + ...(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 } : {}), @@ -315,7 +338,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T06:00:00.000Z' }), false); assert.ok(w.emitted.some(event => event.payload.reason === 'reporting_subscription_changed')); - w.grant('acct_1', '2026-11-06T18:00:00.000Z'); + // The host keeps reporting the chain's authorized_at; the engine stamps the re-grant. + w.grant('acct_1', '2026-10-01T00: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'); @@ -478,6 +502,62 @@ describe('reporting subscription engine conformance (buyer periods equal seller 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()]); + const read = await w.step('2026-11-06T12:00:00.000Z'); + assert.deepEqual(read.result.configuration.reporting_subscriptions[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('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' }); + 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('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'); From 4323ee42bb9b83b589db97c53898b9c95794a9c5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 19:35:37 +0000 Subject: [PATCH 11/49] fix(reporting): immutable subscription history, sourced change instants, sharded engine state Addresses the expert review of the subscription runtime: - Owed periods are frozen as they elapse and never recomputed; pruning past status_retention_days never re-owes or drops an elapsed period. A product, package or offering change applies from the E of its observation, and periods of that buy starting earlier keep resolving from the buy as it was. - Change instants come from source data (granted_at, timezone_changed_at, resolveRevocationInstant, the overriding configuration's active_until). - The engine store is sharded per principal and per account; the receipt roster is tenant-scoped; an unobserved revocation followed by a new chain opens a gap. - replace_account_configs runs after commit and is retried on failure; a destination transport change needs a version; inactive_intervals over 64 is rejected; shared path_templates that can expand to the same path are rejected; the delivery-config clash check is required unless opted out; coverage recording rejects oversized lists and no longer bumps configuration_version. - Post-beta.1 fields are gated on the served bundle's schema, and an unknown served version emits none of them. prior_versions[] carries configuration (adcp#8588). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 23 +- scripts/check-package-size.mjs | 3 +- src/lib/reporting/service/index.ts | 13 +- src/lib/reporting/subscriptions/engine.ts | 1289 +++++++++++------ src/lib/reporting/subscriptions/wire.ts | 33 - src/lib/server/create-adcp-server.ts | 8 +- .../decisioning/runtime/from-platform.ts | 10 + .../principal/reporting-subscriptions.ts | 127 +- src/lib/server/principal/runtime.ts | 118 +- src/lib/server/principal/types.ts | 6 + .../server/reporting-subscription-release.ts | 113 ++ test/helpers/principal-runtime.js | 3 + .../principal-reporting-subscriptions.test.js | 195 ++- test/lib/reliable-reporting-service.test.js | 12 + .../lib/reporting-subscription-engine.test.js | 244 +++- 15 files changed, 1661 insertions(+), 536 deletions(-) create mode 100644 src/lib/server/reporting-subscription-release.ts diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md index bdc1938fe..1c4f0d6f8 100644 --- a/.changeset/reporting-subscription-runtime.md +++ b/.changeset/reporting-subscription-runtime.md @@ -4,7 +4,7 @@ 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? } })` accepts the `sync_principal` `reporting_subscriptions` section. It validates the cross-field rules of `core/reporting-subscription.json`: +**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` @@ -14,7 +14,7 @@ Serve AdCP 3.3 principal-level reporting subscriptions (experimental `media_buy. 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 three host hooks: `listAuthorizedAccounts`, `listAccountBuys` and, optionally, `listAccountDeliveryConfigs`. From them it maintains: +**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) @@ -33,4 +33,21 @@ Conforms to adcontextprotocol/adcp#8585: - 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. -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`). They are emitted to callers served 3.3.0-beta.2 or later. +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 oversized lists and does not bump `configuration_version`. +- The receipt roster is scoped by tenant. + +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/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 1e5b9ccc6..6d99ccc50 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -306,7 +306,8 @@ const MAX_PACKED_FILE_COUNT = 5 + // Creative build probe: one dual-format runtime/declaration module (+4) and one guide (+1). 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). - 8; // Reporting subscription engine and principal subscription runtime (two dual-format sets). + 8 + // Reporting subscription engine and principal subscription runtime (two dual-format sets). + 4; // Reporting subscription release gate (one dual-format set). 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/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 411aa0dd6..5bb477ab8 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -356,11 +356,11 @@ import type { ReportingSubscriptionEngineV1 } from '../subscriptions/engine'; import type { PrincipalStoreScope } from '../../server/principal/types'; import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; +import type { ReportingDeliveryOfferingWithDimensionsV1 } from '../subscriptions/wire'; import { - servesSettledReportingSubscriptionFieldsV1, - withoutSettledCoverageFieldsV1, - type ReportingDeliveryOfferingWithDimensionsV1, -} from '../subscriptions/wire'; + reportingSubscriptionCoverageForReleaseV1, + reportingSubscriptionReleaseFieldsV1, +} from '../../server/reporting-subscription-release'; export const REPORTING_MANAGED_DELIVERY_COMPOSITION_V1: unique symbol = Symbol.for( '@adcp/sdk/reporting/managed-delivery-composition' @@ -873,9 +873,10 @@ async function withSubscriptionCoverage( const accountId = (context.account as { id?: unknown } | undefined)?.id; if (typeof accountId !== 'string') return response; const principal = await subscriptions.resolvePrincipal(context); - const settled = servesSettledReportingSubscriptionFieldsV1(served, isAdcpVersionAtLeast); + // 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 => - settled ? entry : withoutSettledCoverageFieldsV1(entry as unknown as Record) + 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/engine.ts b/src/lib/reporting/subscriptions/engine.ts index b6734f4b2..bba9c939c 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -2,19 +2,33 @@ * Seller-side reporting subscription engine (experimental `media_buy.reporting_subscriptions`, * AdCP 3.3). * - * The host supplies only the facts it alone knows, through three hooks: which accounts a - * principal may read (`listAuthorizedAccounts`), which buys an account holds - * (`listAccountBuys`), and the caller's own account-level configurations, for precedence - * (`listAccountDeliveryConfigs`). The engine maintains everything else (grant history, - * coverage exceptions, `max_accounts` slots, timezone cutovers, lost authority) and derives: + * 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, computed with the same pure - * {@link expectedSubscriptionPeriods} rule a buyer runs, so seller and buyer agree by - * construction when they agree on the facts; - * - the receipt roster and the post-loss receipt window. + * - 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 and the post-loss receipt window. + * + * State is sharded: one document per principal for `max_accounts` slots, and one per + * (principal, account), each updated with compare-and-set. */ import type { PendingAction } from '../../types/core.generated'; import type { @@ -51,6 +65,8 @@ import { 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:'; export type ReportingSubscriptionExclusionReasonV1 = | 'offering_unsupported' @@ -83,15 +99,27 @@ export interface ReportingSubscriptionCoverageEntryV1 { /** Facts about one account the principal may currently read. */ export interface ReportingSubscriptionAccountFactV1 { account_id: string; - /** `authorization.authorized_at`: when the current grant began. */ + /** + * `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; } /** One of the caller's own `sync_accounts` `reporting_delivery_configs` entries, for precedence. */ @@ -100,9 +128,13 @@ export interface ReportingSubscriptionAccountConfigV1 { feed_purpose: 'pacing' | 'analytics' | 'billing'; /** When it became active. */ active_from: string; - /** When it ended (for example through `replace_account_configs`). Absent while active. */ + /** + * 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 its own period end. */ + /** 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'; @@ -112,7 +144,7 @@ export interface ReportingSubscriptionAccountConfigV1 { } export interface ReportingSubscriptionEngineHooksV1 { - /** The accounts this principal may read, paged. The host owns the answer; the SDK never infers it. */ + /** 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 } @@ -127,17 +159,27 @@ export interface ReportingSubscriptionEngineHooksV1 { 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; - /** - * Whether an owed period was delivered, for `withheld_period_count`. Without it the engine - * counts owed periods not yet due (`period_end + delivery_sla`) at `unauthorized_at`. - */ - isPeriodDelivered?(period: ExpectedSubscriptionPeriodV1): MaybePromise; } interface StoredExclusion extends ReportingSubscriptionExclusionV1 { @@ -146,68 +188,124 @@ interface StoredExclusion extends ReportingSubscriptionExclusionV1 { 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 covered this account at some point (for accounts_unauthorized). */ + /** 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). */ +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. */ +/** Durable engine state for one principal: `max_accounts` slots per subscription. */ +export interface ReportingSubscriptionEnginePrincipalDocumentV1 { + served: Record; +} + +/** Everything the engine holds for one principal, for inspection and conformance. */ export interface ReportingSubscriptionEngineStateV1 { - accounts: Record; - subscriptions: Record; served: string[] }>; + principal: ReportingSubscriptionEnginePrincipalDocumentV1; + accounts: Record; } -/** Engine persistence. Each principal's state is one atomic document. */ +/** + * Engine persistence: small documents keyed per principal (`principal`) and per account + * (`account:`), each with its own compare-and-set revision. + */ export interface ReportingSubscriptionEngineStoreV1 { readonly durability: 'process-local' | 'durable'; get( - principal: Readonly - ): MaybePromise<{ state: ReportingSubscriptionEngineStateV1; revision: string } | null>; + principal: Readonly, + key: string + ): MaybePromise<{ value: Record; revision: string } | null>; put( principal: Readonly, - state: ReportingSubscriptionEngineStateV1, + key: string, + value: Record, expectedRevision: string | null ): MaybePromise<{ outcome: 'applied'; revision: string } | { outcome: 'conflict' }>; - listPrincipals(): MaybePromise; + /** 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. */ export function createInMemoryReportingSubscriptionEngineStoreV1(): ReportingSubscriptionEngineStoreV1 { const rows = new Map< string, - { scope: PrincipalStoreScope; state: ReportingSubscriptionEngineStateV1; revision: number } + { scope: PrincipalStoreScope; key: string; value: Record; revision: number } >(); - const key = (scope: Readonly) => JSON.stringify([scope.tenantId, scope.principalId]); + const id = (scope: Readonly, key: string) => + JSON.stringify([scope.tenantId, scope.principalId, key]); return { durability: 'process-local', - get(scope) { - const row = rows.get(key(scope)); - return row ? { state: structuredClone(row.state), revision: String(row.revision) } : null; + get(scope, key) { + const row = rows.get(id(scope, key)); + return row ? { value: structuredClone(row.value), revision: String(row.revision) } : null; }, - put(scope, state, expectedRevision) { - const row = rows.get(key(scope)); + 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(key(scope), { + rows.set(id(scope, key), { scope: { tenantId: scope.tenantId, principalId: scope.principalId }, - state: structuredClone(state), + key, + value: structuredClone(value), revision, }); return { outcome: 'applied', revision: String(revision) }; }, - listPrincipals() { - return [...rows.values()].map(row => ({ ...row.scope })); + 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()]; }, }; } @@ -215,7 +313,7 @@ export function createInMemoryReportingSubscriptionEngineStoreV1(): ReportingSub export interface CreateReportingSubscriptionEngineOptionsV1 { principal: PrincipalLifecycleRuntimeSubscriptions; hooks: ReportingSubscriptionEngineHooksV1; - /** The seller's offerings with their standard-dimension mapping. */ + /** 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; @@ -233,8 +331,9 @@ 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, stamp grants and - * revocations, open and close exceptions, and publish the readback. Idempotent. + * 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. */ @@ -248,14 +347,18 @@ export interface ReportingSubscriptionEngineV1 { } ): Promise; /** - * `subscription_coverage[]` for `get_reporting_status`. Empty for an account the principal - * is not currently authorized for: caller isolation is unchanged. + * `subscription_coverage[]` for `get_reporting_status`: subscriptions whose current scope + * includes the account, plus inactive ones with retained obligations. 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. */ + /** + * 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, @@ -267,8 +370,8 @@ export interface ReportingSubscriptionEngineV1 { accountId: string, options?: { now?: string; signal?: AbortSignal } ): Promise; - /** Principals whose active subscriptions cover the account: the consumers that owe receipts. */ - receiptRoster(accountId: string): Promise; + /** Principals in the tenant whose active subscriptions cover the account: the consumers that owe receipts. */ + receiptRoster(tenantId: string, accountId: string): Promise; /** * Whether `sync_reporting_receipts`/`sync_reporting_status` from this principal may still be * accepted for a revision it read at `readAt`: always while authorized; after a loss only @@ -279,14 +382,16 @@ export interface ReportingSubscriptionEngineV1 { accountId: string, input: { readAt: string; now?: 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; } -function emptyState(): ReportingSubscriptionEngineStateV1 { - return { accounts: {}, subscriptions: {} }; -} - function iso(value: number): string { return new Date(value).toISOString(); } @@ -300,6 +405,20 @@ function isAuthorized(account: AccountState | undefined): boolean { 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 TypeError(`${field} must be an RFC 3339 date-time`); + return parsed; +} + /** Versions in the coverage function's input shape. */ function versionRecords(history: StoredReportingSubscriptionHistory): ReportingSubscriptionVersionRecordV1[] { return history.versions.map((version, index) => { @@ -328,13 +447,15 @@ export function createReportingSubscriptionEngineV1( 'createReportingSubscriptionEngineV1 requires a principal runtime with reporting subscriptions' ); } - if ( - typeof options.hooks?.listAuthorizedAccounts !== 'function' || - typeof options.hooks.listAccountBuys !== 'function' - ) { - throw new TypeError( - 'createReportingSubscriptionEngineV1 requires listAuthorizedAccounts and listAccountBuys hooks' - ); + for (const hook of [ + 'listAuthorizedAccounts', + 'listAccountBuys', + 'resolveRevocationInstant', + 'isPeriodDelivered', + ] as const) { + if (typeof options.hooks?.[hook] !== 'function') { + throw new TypeError(`createReportingSubscriptionEngineV1 requires the ${hook} hook`); + } } if (!Number.isSafeInteger(options.statusRetentionDays) || options.statusRetentionDays < 1) { throw new TypeError('statusRetentionDays must be a positive integer'); @@ -353,29 +474,35 @@ export function createReportingSubscriptionEngineV1( const offeringsById = new Map(options.offerings.map(offering => [offering.offering_id, offering])); if (offeringsById.size !== options.offerings.length) throw new TypeError('offering_id values must be unique'); - async function mutate( + async function mutate>( principal: Readonly, - change: (state: ReportingSubscriptionEngineStateV1) => void | Promise - ): Promise { + key: string, + empty: () => T, + change: (value: T) => void | Promise + ): Promise { for (let attempt = 0; attempt < maxCasAttempts; attempt += 1) { - const current = await store.get(principal); - const state = current ? structuredClone(current.state) : emptyState(); - await change(state); - const result = await store.put(principal, state, current?.revision ?? null); - if (result.outcome === 'applied') return state; + 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 Error('Reporting subscription engine state conflicted repeatedly'); } - /** Calendars an account resolves for a version at `at`, across the given buys. */ - function accountCalendars( - version: StoredReportingSubscriptionVersion, - account: AccountState, - buys: readonly ReportingSubscriptionBuyV1[], - at: number, - timezone = account.timezone - ): ReportingPeriodCalendarV1[] { - return [...offeringCalendars(version, account, buys, at, timezone).values()]; + 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. */ @@ -388,23 +515,33 @@ export function createReportingSubscriptionEngineV1( ): Map { const calendars = new Map(); for (const buy of buys) { - for (const pkg of buy.packages) { - for (const id of resolveReportingSubscriptionOfferingsV1({ - selector: version.configuration.selector, - reconciliation_mode: version.configuration.reconciliation_mode, - ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), - offerings: [...offeringsById.values()], - eligible_offering_ids: pkg.reporting_delivery_offering_ids, - at, - })) { - const calendar = calendarFor(offeringsById.get(id)!, version, account, timezone); - if (calendar) calendars.set(id, calendar); - } + 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, @@ -429,7 +566,7 @@ export function createReportingSubscriptionEngineV1( alignment: 'source_timezone', timezone: (schedule.period_timezone_policy === 'fixed' ? schedule.period_timezone - : (override ?? account.sourceTimezone))!, + : account.sourceTimezone)!, }); case 'billing_cycle': return createReportingPeriodCalendarV1({ @@ -446,39 +583,55 @@ export function createReportingSubscriptionEngineV1( } } - /** E for a change at T: the latest end of an outgoing period that started before T. */ - function cutoverInstant(calendars: readonly ReportingPeriodCalendarV1[], at: number): number { - return calendars.length === 0 ? at : Math.max(...calendars.map(calendar => calendar.ceil(at))); + /** + * 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: readonly ReportingPeriodCalendarV1[]): ReportingPeriodCalendarV1 | undefined { + function primaryCalendar(calendars: Iterable): ReportingPeriodCalendarV1 | undefined { return [...calendars].sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))[0]; } - /** Open newly desired conditions and close vanished ones, both at E instants. */ + 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: ReadonlyArray & { at: number }>, - calendars: readonly ReportingPeriodCalendarV1[], - nowMs: number + 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), cutoverInstant(calendars, nowMs))); + entry.until = iso(Math.max(Date.parse(entry.from), closeAt(entry) ?? cutoverInstant(calendarList, nowMs))); changed = true; } for (const item of desired) { if (open.some(entry => entry.key === item.key)) continue; - const { at, ...rest } = item; - target.exclusions.push({ - ...rest, - from: iso(item.reason === 'offering_unsupported' ? at : cutoverInstant(calendars, at)), - }); + const { at, openCalendars, ...rest } = item; + target.exclusions.push({ ...rest, from: iso(openCalendars ? cutoverInstant(openCalendars, at) : at) }); changed = true; } - // Retain closed entries for status_retention_days after until. + // 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 ); @@ -509,281 +662,444 @@ export function createReportingSubscriptionEngineV1( ); } - function applyGrant( - state: ReportingSubscriptionEngineStateV1, + /** When authority ended, from the host, clamped to what the engine observed. */ + async function revocationInstant( + principal: Readonly, accountId: string, - authorized: boolean, - at: number, - fact?: Partial, - observedAt = at - ) { - const account = (state.accounts[accountId] ??= { grants: [] }); - if (fact?.operator !== undefined) account.operator = fact.operator; - if (fact?.source_timezone !== undefined) account.sourceTimezone = fact.source_timezone; + 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 (authorized) { - if (last && last.unauthorized_at === undefined) return false; - // authorized_at is the start of the authorization chain (adcontextprotocol/adcp#8585): - // a re-grant inside retention leaves it unchanged, so the re-grant instant is when - // authority was observed again. A later authorized_at is itself the re-grant. - const revokedAt = last?.unauthorized_at !== undefined ? Date.parse(last.unauthorized_at) : undefined; - const start = revokedAt === undefined ? at : at > revokedAt ? at : Math.max(observedAt, revokedAt); - account.grants.push({ authorized_at: iso(start) }); + if (!fact) { + if (!last || last.unauthorized_at !== undefined) return false; + last.unauthorized_at = iso(await revocationInstant(principal, accountId, account, nowMs)); return true; } - if (!last || last.unauthorized_at !== undefined) return false; - last.unauthorized_at = iso(Math.max(at, Date.parse(last.authorized_at))); - 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 TypeError( + `Account ${JSON.stringify(accountId)} was re-granted inside its authorization chain; the host must report granted_at` + ); + } + 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 } = {}) { 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 facts.keys()) { + for (const accountId of [...known].sort(compareReportingIdsByteOrderV1)) { signal?.throwIfAborted(); - buysByAccount.set(accountId, await options.hooks.listAccountBuys(principal, accountId, signal ? { signal } : {})); - configsByAccount.set( - accountId, - (await options.hooks.listAccountDeliveryConfigs?.(principal, accountId, signal ? { signal } : {})) ?? [] - ); - } - // Accounts whose authority ended inside retention: their buys, as they stood, give the - // withheld periods. The host answers from its own records; nothing is disclosed to the caller. - const previous = await store.get(principal); - for (const [accountId, account] of Object.entries(previous?.state.accounts ?? {})) { - const grant = lastGrant(account); - if (facts.has(accountId) || grant === undefined) continue; - // Revoked in this pass (still open in the stored state), or inside retention. - if (grant.unauthorized_at !== undefined && Date.parse(grant.unauthorized_at) + retentionMs <= nowMs) continue; - buysByAccount.set(accountId, await options.hooks.listAccountBuys(principal, accountId, signal ? { signal } : {})); - } - let notify = false; - const state = await mutate(principal, current => { - notify = false; - // 1. Authority and account facts. - for (const [accountId, fact] of facts) { - if (applyGrant(current, accountId, true, Date.parse(fact.authorized_at), fact, nowMs)) notify = true; - const account = current.accounts[accountId]!; - if (fact.timezone !== undefined && fact.timezone !== account.timezone) { - if (account.timezone !== undefined) { - recordTimezoneChange( - current, - history, - accountId, - account, - fact.timezone, - nowMs, - buysByAccount.get(accountId) ?? [] - ); - } - account.timezone = fact.timezone; - } - } - for (const [accountId, account] of Object.entries(current.accounts)) { - if (!facts.has(accountId) && isAuthorized(account)) { - applyGrant(current, accountId, false, nowMs); - notify = true; - } + 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)) ?? [] + ); } - // 2. Exceptions per subscription and account. - for (const entry of history) { - const sub = (current.subscriptions[entry.subscriptionId] ??= { accounts: {}, served: [] }); - const version = entry.versions.at(-1)!; - const active = entry.deactivatedAt === undefined; - const configuration = version.configuration; - const inScope = [...facts.keys()] - .filter(accountId => { - const account = current.accounts[accountId]!; - return reportingSubscriptionScopeIncludesV1( - { - scope: configuration.scope, - ...(configuration.operator_domains ? { operator_domains: configuration.operator_domains } : {}), - }, - { account_id: accountId, ...(account.operator !== undefined ? { operator: account.operator } : {}) } - ); - }) - .sort(compareReportingIdsByteOrderV1); - // Precedence: the caller's own account-level configuration, then its own account_ids subscription. - const overriddenBy = new Map< - string, - { - by: StoredExclusion['overridden_by']; - at: number; - until?: { at: number; calendar?: ReportingPeriodCalendarV1 }; - } - >(); - for (const accountId of inScope) { - const configs = (configsByAccount.get(accountId) ?? []).filter( - item => - item.feed_purpose === configuration.selector.feed_purpose && - (item.active_until === undefined || Date.parse(item.active_until) > nowMs) - ); - const winner = configs.sort((left, right) => - compareReportingIdsByteOrderV1(left.delivery_config_id, right.delivery_config_id) - )[0]; - if (winner) { - overriddenBy.set(accountId, { - by: { delivery_config_id: winner.delivery_config_id }, - at: Date.parse(winner.active_from), - }); - 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) { - overriddenBy.set(accountId, { - by: { subscription_id: specific.subscriptionId }, - at: Date.parse(specific.versions.at(-1)!.acceptedAt), + } + + // 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; } - // max_accounts: previously served accounts keep their slots, then byte order. - const eligible = inScope.filter( - accountId => !overriddenBy.has(accountId) && !facts.get(accountId)!.pending_action - ); - const ordered = [ - ...sub.served.filter(accountId => eligible.includes(accountId)), - ...eligible.filter(accountId => !sub.served.includes(accountId)), - ]; - const served = options.maxAccounts === undefined ? ordered : ordered.slice(0, options.maxAccounts); - const capacity = new Set(ordered.slice(served.length)); - sub.served = active ? served : sub.served; + } + ); - const allAccounts = new Set([...inScope, ...Object.keys(sub.accounts)]); - if (configuration.exclude_account_ids) { - for (const accountId of configuration.exclude_account_ids) - if (facts.has(accountId)) allAccounts.add(accountId); - } - for (const accountId of allAccounts) { - const account = current.accounts[accountId]; - if (!account) continue; - const target = (sub.accounts[accountId] ??= { - exclusions: [], - gaps: [], - timezoneCutovers: [], - covered: false, - }); - const buys = buysByAccount.get(accountId) ?? []; - const calendars = accountCalendars(version, account, buys, nowMs); - const desired: Array & { at: number }> = []; - const accepted = Date.parse(version.acceptedAt); - if (facts.has(accountId) && active) { - if (configuration.exclude_account_ids?.includes(accountId)) { - desired.push({ key: 'excluded_by_buyer', reason: 'excluded_by_buyer', at: accepted }); - } else if (inScope.includes(accountId)) { - const pending = facts.get(accountId)!.pending_action; - const override = overriddenBy.get(accountId); - if (pending) { - desired.push({ - key: 'action_required', - reason: 'action_required', - at: nowMs, - pending_action: structuredClone(pending), - }); - } else if (override) { - desired.push({ - key: `overridden:${JSON.stringify(override.by)}`, - reason: 'overridden', - overridden_by: override.by!, - at: Math.max(override.at, accepted), - }); - } else if (capacity.has(accountId)) { - desired.push({ key: 'capacity', reason: 'capacity', at: nowMs }); - } - for (const buy of buys) { - if (buy.confirmed_at === undefined || buy.confirmed_at === null) continue; - const unsupported = buy.packages - .filter( - pkg => - resolveReportingSubscriptionOfferingsV1({ - selector: configuration.selector, - reconciliation_mode: configuration.reconciliation_mode, - ...(version.destinationMethod ? { destination_method: version.destinationMethod } : {}), - offerings: [...offeringsById.values()], - eligible_offering_ids: pkg.reporting_delivery_offering_ids, - at: nowMs, - }).length === 0 - ) - .map(pkg => pkg.package_id) - .sort(compareReportingIdsByteOrderV1); - if (unsupported.length === 0) continue; - const 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, - } - : { - key: `offering_unsupported:${buy.media_buy_id}:${unsupported.join(',')}`, - reason: 'offering_unsupported', - media_buy_id: buy.media_buy_id, - package_ids: unsupported, - at, - } + 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 TypeError( + `Account ${JSON.stringify(accountId)} changed timezone; the host must report timezone_changed_at` ); } + 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; } - if (!facts.has(accountId)) { - // No current authority: keep the exception history frozen; lost authority is - // reported in accounts_unauthorized instead. - continue; - } - // 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; + for (const entry of history) { + if (settleSubscription(current, entry, accountId, fact, buys, nowMs)) changed = true; } - // Lost-authority gaps on re-grant inside retention, without backfill. - if (!options.backfillOnRegrant) { - recordRegrantGaps(target, account, offeringCalendars(version, account, buys, nowMs)); + 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); } - if (settleExclusions(target, desired, calendars, nowMs)) notify = true; + current.lastBuys = structuredClone(buys); } - } - }); + ); + } - // 3. Readback: accounts_with_exceptions and accounts_unauthorized. An exception that + // 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 = []; - for (const entry of history) updates.push(await readbackFor(entry, state, nowMs, buysByAccount)); + for (const entry of history) updates.push(await readbackFor(principal, entry, state, nowMs, buysByAccount)); await options.principal.recordReportingSubscriptionCoverage(principal, updates, { forceNotification: notify }); + + 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( - state: ReportingSubscriptionEngineStateV1, + current: ReportingSubscriptionEngineAccountDocumentV1, history: readonly StoredReportingSubscriptionHistory[], - accountId: string, account: AccountState, timezone: string, - nowMs: number, + 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, nowMs)].filter( + const calendars = [...offeringCalendars(version, account, buys, changedAt)].filter( ([, calendar]) => calendar.spec.alignment === 'account_timezone' ); if (calendars.length === 0) continue; - const sub = (state.subscriptions[entry.subscriptionId] ??= { accounts: {}, served: [] }); - const target = (sub.accounts[accountId] ??= { exclusions: [], gaps: [], timezoneCutovers: [], covered: false }); + 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(nowMs) })); + 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({ @@ -840,17 +1156,116 @@ export function createReportingSubscriptionEngineV1( } } + /** + * 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; + } + + /** 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 sub = state.subscriptions[entry.subscriptionId]; const accountsWithExceptions: NonNullable = []; const accountsUnauthorized: NonNullable = []; - for (const [accountId, target] of Object.entries(sub?.accounts ?? {})) { - const account = state.accounts[accountId]; + 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)), @@ -864,63 +1279,36 @@ export function createReportingSubscriptionEngineV1( if (unauthorizedAt + retentionMs <= nowMs) continue; const version = entry.versions.at(-1)!; const buys = buysByAccount.get(accountId) ?? []; - const calendar = primaryCalendar(accountCalendars(version, account!, buys, unauthorizedAt)); - const owed = await withheldPeriods(entry, state, accountId, unauthorizedAt, buys); + 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)) } - : owed.lastEnd - ? { last_owed_period_end: owed.lastEnd } + : withheld.length + ? { last_owed_period_end: withheld.at(-1)!.period_end } : {}), - withheld_period_count: owed.count, + withheld_period_count: count, }); } return { subscriptionId: entry.subscriptionId, accountsWithExceptions, accountsUnauthorized }; } - /** Owed periods that ended at or before `unauthorizedAt` and were not delivered by then. */ - async function withheldPeriods( - entry: StoredReportingSubscriptionHistory, - state: ReportingSubscriptionEngineStateV1, - accountId: string, - unauthorizedAt: number, - buys: readonly ReportingSubscriptionBuyV1[] - ): Promise<{ count: number; lastEnd?: string }> { - const periods = computeOwed(entry, state, accountId, buys, iso(unauthorizedAt)); - let count = 0; - for (const period of periods) { - const delivered = options.hooks.isPeriodDelivered - ? await options.hooks.isPeriodDelivered(period) - : Date.parse(period.period_end) + slaMs(period.offering_id) <= unauthorizedAt; - if (!delivered) count += 1; - } - return { count, ...(periods.length ? { lastEnd: periods.at(-1)!.period_end } : {}) }; - } - - function slaMs(offeringId: string): number { - const sla = offeringsById.get(offeringId)?.schedule.delivery_sla; - const match = sla ? /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(sla) : null; - if (!match) return 0; - return ( - ((Number(match[1] ?? 0) * 24 + Number(match[2] ?? 0)) * 3_600 + - Number(match[3] ?? 0) * 60 + - Number(match[4] ?? 0)) * - 1_000 - ); - } - function computeOwed( entry: StoredReportingSubscriptionHistory, - state: ReportingSubscriptionEngineStateV1, + current: ReportingSubscriptionEngineAccountDocumentV1, accountId: string, buys: readonly ReportingSubscriptionBuyV1[], nowIso: string ): ExpectedSubscriptionPeriodV1[] { - const account = state.accounts[accountId]; - if (!account) return []; - const target = state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + 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, @@ -951,19 +1339,60 @@ export function createReportingSubscriptionEngineV1( accountId: string, owedOptions: { now?: string; signal?: AbortSignal } = {} ): Promise { - const current = await store.get(principal); - if (!current) return []; + const document = await readAccount(principal, accountId); + if (!document) return []; const { history } = await options.principal.listReportingSubscriptions(principal); - const buys = await options.hooks.listAccountBuys( - principal, - accountId, - owedOptions.signal ? { signal: owedOptions.signal } : {} + 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) ); - const nowIso = owedOptions.now ?? now().toISOString(); - return history.flatMap(entry => computeOwed(entry, current.state, accountId, buys, nowIso)); } - return { + 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, @@ -971,22 +1400,58 @@ export function createReportingSubscriptionEngineV1( }), reconcile: (principal, reconcileOptions) => reconcile(principal, reconcileOptions ?? {}), async authorizationChanged(principal, accountId, change) { - const at = Date.parse(change.at); - if (!Number.isFinite(at)) throw new TypeError('authorizationChanged requires an RFC 3339 instant'); + const at = parseInstant(change.at, 'authorizationChanged at'); let changed = false; - await mutate(principal, state => { - changed = applyGrant(state, accountId, change.authorized, at, change.account); - }); - if (changed) await reconcile(principal, {}); + 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 current = await store.get(principal); - if (!current || !isAuthorized(current.state.accounts[accountId])) return []; + 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 = current.state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + 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; + // Subscriptions whose scope includes the account, plus inactive ones with retained obligations. + const retainedInactive = entry.deactivatedAt !== undefined && target.frozen.periods.length > 0; + if (!inScope && !retainedInactive) 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({ @@ -1005,15 +1470,16 @@ export function createReportingSubscriptionEngineV1( async owedObligations(principal, accountId, owedOptions) { return groupExpectedSubscriptionPeriodsV1(await owedPeriods(principal, accountId, owedOptions)); }, - async receiptRoster(accountId) { + async receiptRoster(tenantId, accountId) { const roster: PrincipalStoreScope[] = []; - for (const principal of await store.listPrincipals()) { - const current = await store.get(principal); - if (!current || !isAuthorized(current.state.accounts[accountId])) continue; + 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 = current.state.subscriptions[entry.subscriptionId]?.accounts[accountId]; + const target = document.subscriptions[entry.subscriptionId]; return ( target?.covered === true && !target.exclusions.some(item => item.until === undefined && item.media_buy_id === undefined) @@ -1021,16 +1487,11 @@ export function createReportingSubscriptionEngineV1( }); if (covers) roster.push(principal); } - return roster.sort((left, right) => - compareReportingIdsByteOrderV1( - `${left.tenantId}\u0000${left.principalId}`, - `${right.tenantId}\u0000${right.principalId}` - ) - ); + return roster.sort((left, right) => compareReportingIdsByteOrderV1(left.principalId, right.principalId)); }, async acceptsReceipt(principal, accountId, input) { - const current = await store.get(principal); - const account = current?.state.accounts[accountId]; + const document = await readAccount(principal, accountId); + const account = document?.account; if (isAuthorized(account)) return true; const grant = lastGrant(account); if (grant?.unauthorized_at === undefined) return false; @@ -1038,10 +1499,26 @@ export function createReportingSubscriptionEngineV1( const nowMs = input.now !== undefined ? Date.parse(input.now) : now().getTime(); return Date.parse(input.readAt) < unauthorizedAt && nowMs < unauthorizedAt + retentionMs; }, - async inspect(principal) { - return structuredClone((await store.get(principal))?.state ?? emptyState()); + 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 { diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts index 0368013ba..133ed3b47 100644 --- a/src/lib/reporting/subscriptions/wire.ts +++ b/src/lib/reporting/subscriptions/wire.ts @@ -63,36 +63,3 @@ export type ReportingSubscriptionCoverageWithOfferingsV1 = Omit< timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; gaps?: ReportingSubscriptionGapV1[]; }; - -/** - * First release whose schemas define the fields above. Earlier response shapes are closed - * (`additionalProperties: false`), so sellers serving an earlier release must omit them. - * - * TODO(3.3.0-beta.2): confirm the published release includes adcontextprotocol/adcp#8585. - */ -export const REPORTING_SUBSCRIPTION_SETTLED_FIELDS_RELEASE = '3.3.0-beta.2'; - -/** - * Whether a response served at `servedAdcpVersion` may carry the fields settled after - * 3.3.0-beta.1. A context without a served release (direct handler calls) gets them. - */ -export function servesSettledReportingSubscriptionFieldsV1( - servedAdcpVersion: string | undefined, - isAtLeast: (version: string, minimum: string) => boolean -): boolean { - return servedAdcpVersion === undefined || isAtLeast(servedAdcpVersion, REPORTING_SUBSCRIPTION_SETTLED_FIELDS_RELEASE); -} - -/** Remove the settled fields from a `subscription_coverage[]` entry for an earlier release. */ -export function withoutSettledCoverageFieldsV1>(entry: T): T { - const copy = structuredClone(entry) as Record; - if (Array.isArray(copy.timezone_cutovers)) { - copy.timezone_cutovers = (copy.timezone_cutovers as Array>).map( - ({ previous_timezone: _previous, offering_id: _offering, ...rest }) => rest - ); - } - if (Array.isArray(copy.gaps)) { - copy.gaps = (copy.gaps as Array>).map(({ offering_id: _offering, ...rest }) => rest); - } - return copy as T; -} diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 2582fc4e0..0061ff907 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -93,7 +93,7 @@ import { isMovingAdcpPrereleaseFamilyAlias, resolveAdcpVersion, } from '../utils/adcp-version-config'; -import { servesSettledReportingSubscriptionFieldsV1 } from '../reporting/subscriptions/wire'; +import { reportingSubscriptionReleaseFieldsV1 } from './reporting-subscription-release'; import { ConfigurationError } from '../errors'; import { getValidator, @@ -9861,10 +9861,10 @@ export function createAdcpServer(config: AdcpServerConfig>) { delete offering.standard_dimensions; delete offering.unmapped_dimensions; diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index 353422454..622e488c4 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -3175,6 +3175,16 @@ export function createAdcpServerFromPlatform

; - /** Last version whose `replace_account_configs` was applied. */ + /** 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 { @@ -74,6 +77,8 @@ export interface ApplyReportingSubscriptionsResult { }>; } +/** `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}']); @@ -296,6 +301,16 @@ export function applyReportingSubscriptions( ); } 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) @@ -325,23 +340,34 @@ export function applyReportingSubscriptions( } delete history.deactivatedAt; } - // replace_account_configs applies once per version, or when the flag turns true. + // 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.replaceAppliedVersion !== currentVersion.subscriptionVersion && + history.replacePending?.subscriptionVersion !== currentVersion.subscriptionVersion ) { - replaceAccountConfigs.push({ - subscriptionId: subscription.subscription_id, + history.replacePending = { subscriptionVersion: currentVersion.subscriptionVersion, feedPurpose: subscription.selector.feed_purpose, at: now, - }); - history.replaceAppliedVersion = currentVersion.subscriptionVersion; - } else if (subscription.replace_account_configs !== true && history.replaceAppliedVersion !== undefined) { + }; + } else if (subscription.replace_account_configs !== true) { // A flag turned off and on again applies again. 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. @@ -377,28 +403,6 @@ function pickInPlace(subscription: ReportingSubscription): Partial( - configuration: T, - servedAdcpVersion: string | undefined -): T { - if ( - !Array.isArray(configuration.reporting_subscriptions) || - servesSettledReportingSubscriptionFieldsV1(servedAdcpVersion, isAdcpVersionAtLeast) - ) { - return configuration; - } - return { - ...configuration, - reporting_subscriptions: (configuration.reporting_subscriptions as Array>).map( - ({ inactive_intervals: _intervals, ...state }) => state - ), - }; -} - export function projectReportingSubscriptionState( history: StoredReportingSubscriptionHistory, destinations: readonly PrincipalReportingDestination[], @@ -441,7 +445,7 @@ export function projectReportingSubscriptionState( const intervals = [ ...history.inactiveIntervals.map(interval => ({ deactivated_at: interval.from, reactivated_at: interval.until })), ...(history.deactivatedAt !== undefined ? [{ deactivated_at: history.deactivatedAt }] : []), - ].slice(-64); + ]; const prior = history.versions .slice(0, -1) .reverse() @@ -449,6 +453,8 @@ export function projectReportingSubscriptionState( 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, @@ -501,7 +507,13 @@ function assertNoSameLevelOverlap(desired: readonly ReportingSubscription[]): vo } } -/** Active subscriptions sharing a destination each need distinct `{account_id}` templates. */ +/** + * 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) { @@ -518,14 +530,53 @@ function assertSharedDestinationTemplates(desired: readonly ReportingSubscriptio `Active subscriptions sharing destination ${JSON.stringify(destinationId)} must each set a path_template containing {account_id}` ); } - if (new Set(templates).size !== templates.length) { - throw new ReportingSubscriptionValidationError( - `Active subscriptions sharing destination ${JSON.stringify(destinationId)} must use different path_template values` - ); + 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, diff --git a/src/lib/server/principal/runtime.ts b/src/lib/server/principal/runtime.ts index a6fc95117..4ca6d5eb5 100644 --- a/src/lib/server/principal/runtime.ts +++ b/src/lib/server/principal/runtime.ts @@ -22,11 +22,14 @@ import { projectStoredNotificationSubscriptionReadback, } from '../notification-subscriptions/runtime'; import { PRINCIPAL_NOTIFICATION_STORE_OWNER } from './store'; +import { + reportingSubscriptionReleaseFieldsV1, + reportingSubscriptionStatesForReleaseV1, +} from '../reporting-subscription-release'; import { ReportingSubscriptionValidationError, applyReportingSubscriptions, projectReportingSubscriptionState, - reportingSubscriptionStatesForRelease, type ApplyReportingSubscriptionsResult, type StoredReportingSubscriptionHistory, } from './reporting-subscriptions'; @@ -101,12 +104,12 @@ export interface CreatePrincipalLifecycleOptions { acknowledgeMissingAttemptCheckpoint?: boolean; } -export interface PrincipalLifecycleRuntime extends PrincipalLifecycleRuntimeSubscriptions { +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; + readonly capabilities?: NonNullable; protocol: Pick, 'resolvePrincipalScope' | 'getPrincipal' | 'syncPrincipal'> & Partial, 'resolveScope' | 'syncAgentNotificationConfigs'>>; recognizePrincipal(input: { @@ -144,7 +147,10 @@ export interface PrincipalLifecycleRuntime extends Principal */ 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'); @@ -183,6 +189,15 @@ export function createPrincipalLifecycle( 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; @@ -195,7 +210,7 @@ export function createPrincipalLifecycle( params: GetPrincipalRequest, ctx: HandlerContext ): Promise> { - return forRelease(await readPrincipal(ctx), ctx.servedAdcpVersion); + return forRelease(await readPrincipal(ctx), ctx.servedAdcpVersion, options.reportingSubscriptions !== undefined); } async function readPrincipal(ctx: HandlerContext): Promise> { @@ -232,7 +247,11 @@ export function createPrincipalLifecycle( ctx: HandlerContext ): Promise> { const resolved = await resolve(ctx); - return forRelease(await syncConfiguration(resolved, params), ctx.servedAdcpVersion); + return forRelease( + await syncConfiguration(resolved, params), + ctx.servedAdcpVersion, + options.reportingSubscriptions !== undefined + ); } async function syncConfiguration( @@ -318,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), @@ -357,9 +378,7 @@ export function createPrincipalLifecycle( return syncFailure('CONFLICT', 'The principal configuration changed; read current state and retry'); } await notificationPlan?.commitCredentials(); - for (const replacement of subscriptionPlan.result?.replaceAccountConfigs ?? []) { - await options.reportingSubscriptions!.replaceAccountConfigs!({ scope: structuredClone(scope), ...replacement }); - } + await runPendingReplacements(scope); return { result: { ...currentResult(persisted.value.record), @@ -386,6 +405,34 @@ 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, @@ -446,11 +493,23 @@ export function createPrincipalLifecycle( for (const state of current?.reporting_subscriptions ?? []) { if (state.state === 'inactive' || !state.configuration.destination) continue; const id = state.configuration.destination.destination_id; - if (!(next.reporting_destinations ?? []).some(item => item.destination_id === 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, @@ -959,12 +1018,18 @@ export function createPrincipalLifecycle( const state = nextStates.find(item => item.subscription_id === update.subscriptionId); if (!state) continue; const before = canonicalJsonSha256([state.accounts_with_exceptions ?? [], state.accounts_unauthorized ?? []]); - const withExceptions = [...update.accountsWithExceptions] - .sort((left, right) => compareBytes(left.account_id, right.account_id)) - .slice(0, 1000); - const unauthorized = [...update.accountsUnauthorized] - .sort((left, right) => compareBytes(left.account_id, right.account_id)) - .slice(0, 1000); + 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); @@ -977,7 +1042,8 @@ export function createPrincipalLifecycle( const timestamp = now().toISOString(); // Engine-maintained readback is not a caller write: the configuration version // still changes so readers can tell, but no account revision is involved. - if (changed) record.configurationVersion = issueConfigurationVersion(); + // 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, { @@ -1078,15 +1144,25 @@ function reprojectStoredSubscriptions(record: StoredPrincipalRecord): boolean { return moved; } -/** Project a principal response onto the fields the served release defines. */ -function forRelease(response: T, servedAdcpVersion: string | undefined): T { +/** + * 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?.reporting_subscriptions) return response; + 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: reportingSubscriptionStatesForRelease(result.configuration, servedAdcpVersion), + configuration: reportingSubscriptionStatesForReleaseV1( + result.configuration, + reportingSubscriptionReleaseFieldsV1(servedAdcpVersion) + ), }, } as T; } diff --git a/src/lib/server/principal/types.ts b/src/lib/server/principal/types.ts index 9e2c84050..7b40da691 100644 --- a/src/lib/server/principal/types.ts +++ b/src/lib/server/principal/types.ts @@ -204,6 +204,12 @@ export interface PrincipalReportingSubscriptionSupport { }): 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. */ diff --git a/src/lib/server/reporting-subscription-release.ts b/src/lib/server/reporting-subscription-release.ts new file mode 100644 index 000000000..ac4ce7a5e --- /dev/null +++ b/src/lib/server/reporting-subscription-release.ts @@ -0,0 +1,113 @@ +/** + * 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; +} + +const NONE: ReportingSubscriptionReleaseFieldsV1 = Object.freeze({ + offeringDimensions: false, + inactiveIntervals: false, + priorVersionConfiguration: false, + cutoverPreviousTimezone: false, + perOfferingEntries: 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'), + }); + 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 (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/test/helpers/principal-runtime.js b/test/helpers/principal-runtime.js index 6ef621652..e538869ca 100644 --- a/test/helpers/principal-runtime.js +++ b/test/helpers/principal-runtime.js @@ -51,6 +51,9 @@ function createTestPrincipalLifecycle({ issueDestinationRef: () => `dest-ref-${++ref}`, now: () => clock.now, ...lifecycleOptions, + ...(lifecycleOptions.reportingSubscriptions + ? { reportingSubscriptions: { listDeliveryConfigIds: () => [], ...lifecycleOptions.reportingSubscriptions } } + : {}), }); return { lifecycle, store, emitted, clock }; } diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index 4f72d043a..da30b8b02 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -12,7 +12,7 @@ const { expandReportingSubscriptionPathTemplate, principalNotificationSubscriptionStore, } = require('../../dist/lib/server/index.js'); -const { validateResponse } = require('../../dist/lib/validation/schema-validator.js'); +const { validateRequest, validateResponse } = require('../../dist/lib/validation/schema-validator.js'); const { principalOptionOffTranscript } = require('../helpers/principal-option-off-transcript.js'); const SCOPE = { tenantId: 't', principalId: 'p' }; @@ -82,7 +82,7 @@ function setup({ support = {}, clock } = {}) { ...support, }, }); - return { lifecycle, store, emitted, time, replacements }; + return { lifecycle, store, notifications, emitted, time, replacements }; } function destination(id = 'archive', extra = {}) { @@ -217,13 +217,16 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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 that defines it; 3.3.0-beta.1's closed state schema does not. - const unpinned = await lifecycle.protocol.getPrincipal({}, CTX_UNPINNED); - assert.deepEqual(unpinned.result.configuration.reporting_subscriptions[0].inactive_intervals, [ + // The readback lists inactive_intervals (adcontextprotocol/adcp#8585) only to callers served + // a release whose bundle defines it (3.3.0-beta.1's closed state schema does not), 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.equal(on.result.configuration.reporting_subscriptions[0].inactive_intervals, undefined); + 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: [] }); @@ -231,8 +234,8 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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.protocol.getPrincipal({}, CTX_UNPINNED); - assert.deepEqual(closed.result.configuration.reporting_subscriptions[0].inactive_intervals, [ + 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' }, ]); @@ -348,7 +351,7 @@ describe('sync_principal reporting_subscriptions (option on)', () => { subscription({ subscription_id: 'analytics-2', operator_domains: ['two.example'] }), ], }); - assert.match(errorOf(sameTemplate).message, /different path_template/); + assert.match(errorOf(sameTemplate).message, /can expand to the same path/); }); test('a destination still named by an active subscription cannot be removed', async () => { @@ -419,6 +422,180 @@ describe('sync_principal reporting_subscriptions (option on)', () => { assert.equal(errorOf(refused).code, 'UNSUPPORTED_FEATURE'); }); + 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, '3.3.0-beta.1'); + 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 3.3.0-beta.1, whose prior_versions items are closed, the field is omitted. + const served = await lifecycle.protocol.getPrincipal({}, CTX); + assert.equal(served.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, { diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 47fd63807..01b06057c 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -616,6 +616,8 @@ describe('ReliableReportingService', () => { accounts: [{ account_id: 'account-a', authorized_at: '2026-10-01T00:00:00.000Z' }], }), listAccountBuys: () => [], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, }, }); const subscriptionOptions = { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }; @@ -653,6 +655,16 @@ describe('ReliableReportingService', () => { /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: [], diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index ebbb55071..57c1fe5b4 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -86,7 +86,13 @@ function buy(id, extra = {}) { 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() }; + const host = { + accounts: new Map(), + buys: new Map(), + configs: new Map(), + revokedAt: new Map(), + delivered: () => false, + }; const engineStore = createInMemoryReportingSubscriptionEngineStoreV1(); const engine = createReportingSubscriptionEngineV1({ principal: lifecycle, @@ -100,6 +106,9 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { 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), }, }); @@ -128,8 +137,9 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { function grant(accountId, at, extra = {}) { host.accounts.set(accountId, { account_id: accountId, authorized_at: at, timezone: 'America/New_York', ...extra }); } - function revoke(accountId) { + 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() { @@ -155,7 +165,7 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { const outcome = validateResponse('get_principal', served, '3.3.0-beta.1'); assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); const read = await lifecycle.protocol.getPrincipal({}, CTX); - for (const state of read.result.configuration.reporting_subscriptions ?? []) { + 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); @@ -171,10 +181,9 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { } /** What the buyer expects, from its observations only. */ async function buyerExpected(accountId, now, subscriptionId = 'analytics-daily') { - const read = await lifecycle.protocol.getPrincipal({}, CTX); - const state = read.result.configuration.reporting_subscriptions.find( - item => item.subscription_id === subscriptionId - ); + // The complete readback (served at 3.3.0-beta.1 the wire omits inactive_intervals). + 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, @@ -216,6 +225,7 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { return expected; } return { + buyer, clock, lifecycle, engine, @@ -326,7 +336,9 @@ describe('reporting subscription engine conformance (buyer periods equal seller 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'); + 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'); @@ -338,8 +350,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T06:00:00.000Z' }), false); assert.ok(w.emitted.some(event => event.payload.reason === 'reporting_subscription_changed')); - // The host keeps reporting the chain's authorized_at; the engine stamps the re-grant. - w.grant('acct_1', '2026-10-01T00:00:00.000Z'); + // 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'); @@ -380,8 +392,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller 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('acct_a')).length, 1); - assert.equal((await w.engine.receiptRoster('acct_c')).length, 0); + 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 () => { @@ -512,8 +524,9 @@ describe('reporting subscription engine conformance (buyer periods equal seller await w.sync([subscription({ active: false })]); w.clock.now = new Date('2026-11-06T12:00:00.000Z'); await w.sync([subscription()]); - const read = await w.step('2026-11-06T12:00:00.000Z'); - assert.deepEqual(read.result.configuration.reporting_subscriptions[0].inactive_intervals, [ + 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'); @@ -535,7 +548,10 @@ describe('reporting subscription engine conformance (buyer periods equal seller ]); 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' }); + 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, [ @@ -558,6 +574,202 @@ describe('reporting subscription engine conformance (buyer periods equal seller ); }); + 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('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'); @@ -575,6 +787,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller hooks: { listAuthorizedAccounts: () => ({ accounts: [], nextCursor: `page-${++page}` }), listAccountBuys: () => [], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, }, }); await assert.rejects(endless.reconcile(SCOPE), /partial roster/); From 36e2e4cc9f029ace966dfc1ded70fd283fdeaf50 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 19:57:29 +0000 Subject: [PATCH 12/49] test(reporting): the engine owes the confirmation period (adcp#8593) Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-engine.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index 57c1fe5b4..a48f7e30b 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -263,7 +263,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller starts(owed) .filter(item => item.includes('mb_mid')) .map(item => item.slice(-10)), - ['2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09'] + // 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) @@ -278,7 +279,7 @@ describe('reporting subscription engine conformance (buyer periods equal seller ['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']); + assert.deepEqual(grouped[0].media_buy_ids, ['mb_cancel', 'mb_done', 'mb_mid']); }); test('a flight extension adds only unresolved periods', async () => { From f103466adfbc3e9da9dd413e7a45829dc20e0b4e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 20:30:38 +0000 Subject: [PATCH 13/49] fix(reporting): isolate a subscription whose readback exceeds 1000 accounts A small max_accounts against a large roster gives every account past the cap a capacity exception, which can exceed the readback's 1000-entry limit. The reconcile used to throw for the whole principal on every pass. Now that subscription keeps its last published lists, every other subscription still updates, and the pass reports it in readback_overflow and through onReadbackOverflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 2 +- src/lib/reporting/service/index.ts | 2 + src/lib/reporting/subscriptions/engine.ts | 56 ++++++++++++++++++- src/lib/server/principal/runtime.ts | 2 - .../lib/reporting-subscription-engine.test.js | 37 ++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md index 1c4f0d6f8..473e93a29 100644 --- a/.changeset/reporting-subscription-runtime.md +++ b/.changeset/reporting-subscription-runtime.md @@ -47,7 +47,7 @@ Also in this release: - 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 oversized lists and does not bump `configuration_version`. +- `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. 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/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 5bb477ab8..1f5df57c4 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -1679,6 +1679,8 @@ export { type ReportingSubscriptionEngineV1, type ReportingSubscriptionExclusionReasonV1, type ReportingSubscriptionExclusionV1, + type ReportingSubscriptionReadbackOverflowV1, + type ReportingSubscriptionReconcileResultV1, } from '../subscriptions/engine'; export * from './conformance'; export * from './scheduler-support'; diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index bba9c939c..5d170ff3b 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -325,8 +325,34 @@ export interface CreateReportingSubscriptionEngineOptionsV1 { 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. */ +export interface ReportingSubscriptionReadbackOverflowV1 { + subscription_id: string; + accounts_with_exceptions: number; + accounts_unauthorized: number; +} + +/** What a reconcile pass could not publish. */ +export interface ReportingSubscriptionReconcileResultV1 { + readback_overflow: ReportingSubscriptionReadbackOverflowV1[]; } +/** The readback's `maxItems` for `accounts_with_exceptions` and `accounts_unauthorized`. */ +const MAX_READBACK_ACCOUNTS = 1000; + export interface ReportingSubscriptionEngineV1 { /** The commitments a capability document advertises for this engine. */ readonly settings: { maxAccounts?: number; backfillOnRegrant: boolean; statusRetentionDays: number }; @@ -335,7 +361,10 @@ export interface ReportingSubscriptionEngineV1 { * 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; + reconcile( + principal: Readonly, + options?: { signal?: AbortSignal } + ): Promise; /** Record an authority change the host observed, at the instant it happened. */ authorizationChanged( principal: Readonly, @@ -731,7 +760,10 @@ export function createReportingSubscriptionEngineV1( return changed; } - async function reconcile(principal: Readonly, reconcileOptions: { signal?: AbortSignal } = {}) { + async function reconcile( + principal: Readonly, + reconcileOptions: { signal?: AbortSignal } = {} + ): Promise { const signal = reconcileOptions.signal; const nowMs = now().getTime(); const hookOptions = signal ? { signal } : {}; @@ -900,8 +932,26 @@ export function createReportingSubscriptionEngineV1( // opened or closed fires principal.changed even when the summary is unchanged. const state = await inspect(principal); const updates = []; - for (const entry of history) updates.push(await readbackFor(principal, entry, state, nowMs, buysByAccount)); + 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, diff --git a/src/lib/server/principal/runtime.ts b/src/lib/server/principal/runtime.ts index 4ca6d5eb5..a24385c06 100644 --- a/src/lib/server/principal/runtime.ts +++ b/src/lib/server/principal/runtime.ts @@ -1040,8 +1040,6 @@ export function createPrincipalLifecycle( } if (!changed && !recordOptions.forceNotification) return false; const timestamp = now().toISOString(); - // Engine-maintained readback is not a caller write: the configuration version - // still changes so readers can tell, but no account revision is involved. // 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( diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index a48f7e30b..465bc09e0 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -659,6 +659,43 @@ describe('reporting subscription engine conformance (buyer periods equal seller ); }); + 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']) { From 9d917bccb861f60c8aefa72a43c124a58acdbacf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 10:31:13 +0000 Subject: [PATCH 14/49] feat(reporting): buyer runtime and durable storage for reporting subscriptions - syncReportingSubscriptionsV1 writes and reads back the reporting_subscriptions section through the guarded sync_principal lifecycle. - reportingSubscriptionVersionRecordsV1 rebuilds coverage version records from the readback plus the buyer's own per-version configurations, and subscriptionExpectedReportingPeriodsV1 maps owed obligations onto the consumer runtime's ExpectedReportingPeriod. - The consumer runtime takes a per-account subscriptionExpectations hook fed with the summary view's subscription_coverage, and refreshes on an authenticated principal.changed through handleAuthenticatedPrincipalChanged. - createReportingSubscriptionEngineStateStoreV1 persists engine state as one CAS document per principal on any AdcpStateStore; PostgresStateStore makes it durable with no further migration. A PostgreSQL restart test joins the CI backend-contracts step. Refs #3504 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-consumer.md | 11 + .github/workflows/ci.yml | 1 + scripts/check-package-size.mjs | 3 +- src/lib/principal/index.ts | 1 + src/lib/principal/reporting-subscriptions.ts | 55 +++ src/lib/reporting/consumer-runtime.ts | 99 ++++ src/lib/reporting/consumer/index.ts | 5 + src/lib/reporting/service/index.ts | 5 + .../subscriptions/engine-state-store.ts | 99 ++++ .../reporting/subscriptions/expectations.ts | 116 +++++ .../reporting-subscription-consumer.test.js | 449 ++++++++++++++++++ .../reporting-subscription-postgres.test.js | 169 +++++++ 12 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 .changeset/reporting-subscription-consumer.md create mode 100644 src/lib/principal/reporting-subscriptions.ts create mode 100644 src/lib/reporting/subscriptions/engine-state-store.ts create mode 100644 src/lib/reporting/subscriptions/expectations.ts create mode 100644 test/lib/reporting-subscription-consumer.test.js create mode 100644 test/lib/reporting-subscription-postgres.test.js diff --git a/.changeset/reporting-subscription-consumer.md b/.changeset/reporting-subscription-consumer.md new file mode 100644 index 000000000..27f6594f7 --- /dev/null +++ b/.changeset/reporting-subscription-consumer.md @@ -0,0 +1,11 @@ +--- +'@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 and checks that every submitted subscription reads back at its version and activation state. +- `reportingSubscriptionVersionRecordsV1({ state, configurations })` rebuilds the coverage function's version records from the readback (`accepted_at`, `prior_versions`, `deactivated_at`) and the configurations the buyer kept per version. +- `subscriptionExpectedReportingPeriodsV1(obligations, template)` turns owed obligations into the consumer runtime's `ExpectedReportingPeriod`s. +- 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. `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. +- `createReportingSubscriptionEngineStateStoreV1({ store })` persists engine state as one CAS document per principal on any `AdcpStateStore`. 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 522fee79e..067cb1628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -801,6 +801,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/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 6d99ccc50..5812bde5c 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -307,7 +307,8 @@ const MAX_PACKED_FILE_COUNT = 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). 8 + // Reporting subscription engine and principal subscription runtime (two dual-format sets). - 4; // Reporting subscription release gate (one dual-format set). + 4 + // Reporting subscription release gate (one dual-format set). + 12; // Reporting subscription engine store, buyer expectations and client helper (three 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/src/lib/principal/index.ts b/src/lib/principal/index.ts index 660fe96b6..6703f163b 100644 --- a/src/lib/principal/index.ts +++ b/src/lib/principal/index.ts @@ -6,3 +6,4 @@ export { type PrincipalLifecycleOptions, type PrincipalLifecycleResult, } from './lifecycle'; +export { syncReportingSubscriptionsV1, type SyncReportingSubscriptionsResultV1 } from './reporting-subscriptions'; diff --git a/src/lib/principal/reporting-subscriptions.ts b/src/lib/principal/reporting-subscriptions.ts new file mode 100644 index 000000000..c70b469d0 --- /dev/null +++ b/src/lib/principal/reporting-subscriptions.ts @@ -0,0 +1,55 @@ +/** + * 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 type { ReportingSubscription, ReportingSubscriptionState } from '../types/core.generated'; +import { + PrincipalLifecycleError, + syncPrincipalLifecycle, + 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[]; +} + +/** + * Replace the caller's complete reporting subscription set. Omitted subscriptions are + * deactivated by the seller. Uses the same guarded replacement as + * `syncPrincipalLifecycle` (expected_configuration_version, fresh idempotency keys on + * conflict), then checks that every submitted subscription reads back at the submitted + * version. Keep each submitted configuration by version: the readback carries only the + * current one, and coverage needs every version's selector. + */ +export async function syncReportingSubscriptionsV1( + client: PrincipalLifecycleClient, + subscriptions: readonly ReportingSubscription[], + options: PrincipalLifecycleOptions = {} +): Promise { + const result = await syncPrincipalLifecycle( + client, + { reporting_subscriptions: structuredClone([...subscriptions]) } as never, + options + ); + 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); + if (!state || state.subscription_version !== subscription.subscription_version) { + throw new PrincipalLifecycleError( + `Seller readback does not carry subscription ${JSON.stringify(subscription.subscription_id)} at version ${subscription.subscription_version}`, + { applied: result.applied, current: result.current } + ); + } + if ((subscription.active === false) !== (state.state === 'inactive')) { + throw new PrincipalLifecycleError( + `Seller readback for subscription ${JSON.stringify(subscription.subscription_id)} does not reflect active: ${subscription.active}`, + { applied: result.applied, current: result.current } + ); + } + } + return { ...result, subscriptions: structuredClone(states) }; +} diff --git a/src/lib/reporting/consumer-runtime.ts b/src/lib/reporting/consumer-runtime.ts index ee3421470..590b8ae41 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,6 +23,7 @@ import { type ReportingCheckpointStore, type ReportingReconciliationClient, type ReportingReconciliationResult, + type ExpectedReportingPeriod, } from './reconciliation'; const DEFAULT_POLL_INTERVAL_MS = 60_000; @@ -45,6 +48,18 @@ 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`. + */ + subscriptionExpectations?: (input: { + subscriptionCoverage: readonly ReportingSubscriptionCoverage[]; + now: Date; + signal: AbortSignal; + }) => readonly ExpectedReportingPeriod[] | Promise; } export interface ReliableReportingConsumerPersistenceV1 { @@ -66,6 +81,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 +98,7 @@ export type ReliableReportingConsumerRunReasonV1 = | 'startup' | 'poll' | 'manual' + | 'principal.changed' | ReportingNotificationV1['notification_type']; export interface ReliableReportingConsumerErrorContextV1 { @@ -114,6 +135,16 @@ 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. 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 +557,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 +662,34 @@ export function createReliableReportingConsumerV1( return work; }; + /** 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; subscription_coverage?: unknown }; + if (response.status !== 'completed' || response.account_id !== account.accountId) { + throw consumerError('INCOMPLETE_SUMMARY', 'get_reporting_status returned an incomplete subscription summary'); + } + const coverage = Array.isArray(response.subscription_coverage) + ? (response.subscription_coverage as ReportingSubscriptionCoverage[]) + : []; + const expected = await account.subscriptionExpectations!({ + subscriptionCoverage: coverage, + now: new Date(), + signal, + }); + if (!Array.isArray(expected)) throw new TypeError('subscriptionExpectations must return an array'); + return [...expected]; + }; + const runAccount = ( accountId: string, reason: ReliableReportingConsumerRunReasonV1 = 'manual', @@ -676,6 +741,27 @@ 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); + await options.onPrincipalChanged?.({ consumerScope: authentication.consumerScope, notification }); + const scoped = [...accounts.values()].filter(account => account.consumerScope === authentication.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; + }, async handleAuthenticatedNotification(payload, authentication) { const notification = reportingNotification(payload); if (!notification) return null; @@ -912,6 +998,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/consumer/index.ts b/src/lib/reporting/consumer/index.ts index 377d49115..53dedc9ab 100644 --- a/src/lib/reporting/consumer/index.ts +++ b/src/lib/reporting/consumer/index.ts @@ -106,3 +106,8 @@ export type { // Principal-level reporting subscriptions (experimental, AdCP 3.3): buyer-computable coverage. export * from '../subscriptions'; +export { + reportingSubscriptionVersionRecordsV1, + subscriptionExpectedReportingPeriodsV1, + type SubscriptionExpectationTemplateV1, +} from '../subscriptions/expectations'; diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 1f5df57c4..4ca93f13b 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -1682,5 +1682,10 @@ export { type ReportingSubscriptionReadbackOverflowV1, type ReportingSubscriptionReconcileResultV1, } from '../subscriptions/engine'; +export { + DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION, + createReportingSubscriptionEngineStateStoreV1, + type CreateReportingSubscriptionEngineStateStoreOptionsV1, +} from '../subscriptions/engine-state-store'; export * from './conformance'; export * from './scheduler-support'; 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..755c1069c --- /dev/null +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -0,0 +1,99 @@ +/** + * Durable reporting subscription engine store over the SDK state-store port. + * + * Each principal's engine state is one CAS document, so a PostgreSQL deployment uses the + * `PostgresStateStore` it already runs for the principal row; no further migration is + * needed. The document holds grant history, exceptions, timezone cutovers, gaps and + * `max_accounts` slots; the subscription version history lives in the principal row and is + * never pruned, so `prior_versions` can always be answered for retained obligations. + */ +import { createHash } from 'node:crypto'; +import { InMemoryStateStore, type AdcpStateStore } from '../../server/state-store'; +import type { PrincipalStoreScope } from '../../server/principal/types'; +import type { ReportingSubscriptionEngineStateV1, ReportingSubscriptionEngineStoreV1 } from './engine'; + +export const DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION = 'adcp_reporting_subscription_engine'; + +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: 1; + tenantId: string; + principalId: string; + state: ReportingSubscriptionEngineStateV1; +} + +function documentId(scope: Readonly): string { + return `subscription_engine_${createHash('sha256') + .update(JSON.stringify([scope.tenantId, scope.principalId])) + .digest('hex')}`; +} + +/** {@link ReportingSubscriptionEngineStoreV1} over any `AdcpStateStore` with `getWithVersion` and `putIfMatch`. */ +export function createReportingSubscriptionEngineStateStoreV1( + options: CreateReportingSubscriptionEngineStateStoreOptionsV1 +): ReportingSubscriptionEngineStoreV1 { + if (!options?.store?.getWithVersion || !options.store.putIfMatch) { + throw new TypeError('The reporting subscription engine store requires getWithVersion and putIfMatch'); + } + if (options.durability === 'durable' && options.store instanceof InMemoryStateStore) { + throw new TypeError('InMemoryStateStore cannot be declared durable'); + } + const collection = options.collection ?? DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION; + const store = options.store; + return { + durability: options.durability ?? 'process-local', + async get(scope) { + const row = await store.getWithVersion!(collection, documentId(scope)); + if (!row) return null; + const document = row.data; + if ( + document.schemaVersion !== 1 || + document.tenantId !== scope.tenantId || + document.principalId !== scope.principalId || + !document.state || + typeof document.state !== 'object' + ) { + throw new Error('Reporting subscription engine document is malformed or belongs to another principal'); + } + return { state: structuredClone(document.state), revision: String(row.version) }; + }, + async put(scope, state, expectedRevision) { + const expected = expectedRevision === null ? null : Number(expectedRevision); + if (expected !== null && (!Number.isSafeInteger(expected) || expected < 1)) { + throw new Error('Reporting subscription engine store returned an invalid revision'); + } + const document: EngineDocument = { + schemaVersion: 1, + tenantId: scope.tenantId, + principalId: scope.principalId, + state: structuredClone(state), + }; + const result = await store.putIfMatch!(collection, documentId(scope), document, expected); + return result.ok ? { outcome: 'applied', revision: String(result.version) } : { outcome: 'conflict' }; + }, + async listPrincipals() { + const scopes: PrincipalStoreScope[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10_000; page += 1) { + const result = await store.list(collection, { + limit: 500, + ...(cursor === undefined ? {} : { cursor }), + }); + for (const document of result.items) { + if (typeof document.tenantId === 'string' && typeof document.principalId === 'string') { + scopes.push({ tenantId: document.tenantId, principalId: document.principalId }); + } + } + if (result.nextCursor === undefined) break; + cursor = result.nextCursor; + } + return scopes; + }, + }; +} diff --git a/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts new file mode 100644 index 000000000..787b9847d --- /dev/null +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -0,0 +1,116 @@ +/** + * Bridge from subscription coverage to the consumer runtime's `ExpectedReportingPeriod` + * (experimental `media_buy.reporting_subscriptions`). + */ +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 plus the buyer's own + * records of each version's configuration (the readback carries only the current one). + */ +export function reportingSubscriptionVersionRecordsV1(input: { + state: ReportingSubscriptionState; + /** Every version the buyer sent, keyed by `subscription_version`. */ + configurations: Readonly>; + /** Pattern and transport of the destination each version named. */ + destinationMethods?: Readonly>; + /** In-place deactivations the buyer observed and later reversed. */ + inactiveIntervals?: ReadonlyArray<{ subscription_version: number; from: string; until?: string }>; +}): ReportingSubscriptionVersionRecordV1[] { + const { state } = input; + const entries = [ + ...[...(state.prior_versions ?? [])].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, + })), + { + version: state.subscription_version, + accepted_at: state.accepted_at, + superseded_at: undefined as string | undefined, + deactivated_at: state.deactivated_at, + }, + ]; + return entries.map(entry => { + const configuration = input.configurations[entry.version]; + if (!configuration) { + throw new TypeError( + `No recorded configuration for ${state.subscription_id} version ${entry.version}; keep every version you send` + ); + } + const intervals = (input.inactiveIntervals ?? []) + .filter(item => item.subscription_version === entry.version) + .map(({ from, until }) => ({ from, ...(until !== undefined ? { until } : {}) })); + 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 } : {}), + ...(intervals.length > 0 ? { inactive_intervals: intervals } : {}), + 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. */ +export type SubscriptionExpectationTemplateV1 = Omit< + ExpectedReportingPeriod, + | 'deliveryConfigId' + | 'deliveryConfigVersion' + | 'reportDefinitionId' + | 'mediaBuyIds' + | 'periodStart' + | 'periodEnd' + | 'coverage' +> & { + /** Defaults to full coverage of exactly the owed buys, with no package-level detail. */ + 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. + */ +export function subscriptionExpectedReportingPeriodsV1( + obligations: readonly ExpectedSubscriptionObligationV1[], + template: (obligation: ExpectedSubscriptionObligationV1) => SubscriptionExpectationTemplateV1 +): ExpectedReportingPeriod[] { + return obligations.map(obligation => { + const pins = template(obligation); + const { coverage, ...rest } = pins; + 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: coverage + ? structuredClone(coverage) + : { + 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: [], + covered_package_ids: [], + unsupported_package_ids: [], + unknown_package_ids: [], + }, + } as ExpectedReportingPeriod; + }); +} diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js new file mode 100644 index 000000000..3698e3673 --- /dev/null +++ b/test/lib/reporting-subscription-consumer.test.js @@ -0,0 +1,449 @@ +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/service/index.js'); +const { + createReliableReportingConsumerV1, + expectedSubscriptionPeriods, + groupExpectedSubscriptionPeriodsV1, + reportingSubscriptionVersionRecordsV1, + subscriptionExpectedReportingPeriodsV1, +} = require('../../dist/lib/reporting/consumer/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', + 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)], + }, + }); +} + +describe('reporting subscription engine state store', () => { + test('persists one CAS document per principal over an AdcpStateStore', async () => { + const backing = new InMemoryStateStore(); + const store = createReportingSubscriptionEngineStateStoreV1({ store: backing }); + assert.equal(store.durability, 'process-local'); + assert.equal(await store.get(SCOPE), null); + const state = { accounts: { a: { grants: [{ authorized_at: '2026-10-01T00:00:00.000Z' }] } }, subscriptions: {} }; + const first = await store.put(SCOPE, state, null); + assert.equal(first.outcome, 'applied'); + assert.deepEqual(await store.put(SCOPE, state, null), { outcome: 'conflict' }); + const read = await store.get(SCOPE); + assert.deepEqual(read.state, state); + assert.equal((await store.put(SCOPE, state, read.revision)).outcome, 'applied'); + await store.put({ tenantId: 't', principalId: 'q' }, state, null); + assert.deepEqual((await store.listPrincipals()).map(scope => scope.principalId).sort(), ['p', 'q']); + assert.throws( + () => createReportingSubscriptionEngineStateStoreV1({ store: backing, durability: 'durable' }), + /cannot be declared durable/ + ); + }); + + 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('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'], + } + ); + }); +}); + +function template() { + return { + 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 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: [] }), + }, + subscriptionExpectations: ({ subscriptionCoverage }) => { + seen.push(subscriptionCoverage); + 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: '2026-11-10T00:00:00Z', + }) + ); + 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.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']] + ); + 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-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js new file mode 100644 index 000000000..d3303e0e8 --- /dev/null +++ b/test/lib/reporting-subscription-postgres.test.js @@ -0,0 +1,169 @@ +/** + * 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 service; + + 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'); + service = require('../../dist/lib/reporting/service/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: {}, + }); + const engine = service.createReportingSubscriptionEngineV1({ + principal: lifecycle, + store: service.createReportingSubscriptionEngineStateStoreV1({ + store: backing, + collection: ENGINE, + durability: 'durable', + }), + 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', + 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'] }], + }, + ], + }, + }); + return { lifecycle, engine }; + } + + 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'] + ); + assert.equal(await second.engine.acceptsReceipt(scope, 'acct_1', { readAt: '2026-11-05T01:00:00.000Z' }), true); + }); +}); From 2d6b4cd6d47e4dcba0316904eaa3861569c9d7b2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 11:54:28 +0000 Subject: [PATCH 15/49] test(reporting): cover multi-version readback ordering in version records Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-consumer.test.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js index 3698e3673..eb663805b 100644 --- a/test/lib/reporting-subscription-consumer.test.js +++ b/test/lib/reporting-subscription-consumer.test.js @@ -203,6 +203,54 @@ describe('buyer helpers', () => { ); }); + 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({ From 60c748a96760f75025444c0f671bd917b0d74829 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 14:37:25 +0000 Subject: [PATCH 16/49] feat(reporting): read inactive_intervals and standard_dimensions on the buyer side reportingSubscriptionVersionRecordsV1 no longer takes buyer-recorded inactive intervals: the readback's inactive_intervals (adcp#8585) pass to expectedSubscriptionPeriods unchanged. Test offerings publish standard_dimensions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-consumer.md | 2 +- src/lib/reporting/subscriptions/expectations.ts | 7 +------ test/lib/reporting-subscription-consumer.test.js | 2 +- test/lib/reporting-subscription-postgres.test.js | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.changeset/reporting-subscription-consumer.md b/.changeset/reporting-subscription-consumer.md index 27f6594f7..b9633d184 100644 --- a/.changeset/reporting-subscription-consumer.md +++ b/.changeset/reporting-subscription-consumer.md @@ -5,7 +5,7 @@ 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 and checks that every submitted subscription reads back at its version and activation state. -- `reportingSubscriptionVersionRecordsV1({ state, configurations })` rebuilds the coverage function's version records from the readback (`accepted_at`, `prior_versions`, `deactivated_at`) and the configurations the buyer kept per version. +- `reportingSubscriptionVersionRecordsV1({ state, configurations })` rebuilds the coverage function's version records from the readback (`accepted_at`, `prior_versions`, `deactivated_at`) and the configurations the buyer kept per version. Pass the readback's `inactive_intervals` to `expectedSubscriptionPeriods` unchanged. - `subscriptionExpectedReportingPeriodsV1(obligations, template)` turns owed obligations into the consumer runtime's `ExpectedReportingPeriod`s. - 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. `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. - `createReportingSubscriptionEngineStateStoreV1({ store })` persists engine state as one CAS document per principal on any `AdcpStateStore`. 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/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts index 787b9847d..d09460e6f 100644 --- a/src/lib/reporting/subscriptions/expectations.ts +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -10,6 +10,7 @@ import type { ReportingSubscriptionDestinationMethodV1 } from './selection'; /** * The coverage function's version records from the seller readback plus the buyer's own * records of each version's configuration (the readback carries only the current one). + * Pass the readback's `inactive_intervals` to `expectedSubscriptionPeriods` unchanged. */ export function reportingSubscriptionVersionRecordsV1(input: { state: ReportingSubscriptionState; @@ -17,8 +18,6 @@ export function reportingSubscriptionVersionRecordsV1(input: { configurations: Readonly>; /** Pattern and transport of the destination each version named. */ destinationMethods?: Readonly>; - /** In-place deactivations the buyer observed and later reversed. */ - inactiveIntervals?: ReadonlyArray<{ subscription_version: number; from: string; until?: string }>; }): ReportingSubscriptionVersionRecordV1[] { const { state } = input; const entries = [ @@ -42,16 +41,12 @@ export function reportingSubscriptionVersionRecordsV1(input: { `No recorded configuration for ${state.subscription_id} version ${entry.version}; keep every version you send` ); } - const intervals = (input.inactiveIntervals ?? []) - .filter(item => item.subscription_version === entry.version) - .map(({ from, until }) => ({ from, ...(until !== undefined ? { until } : {}) })); 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 } : {}), - ...(intervals.length > 0 ? { inactive_intervals: intervals } : {}), configuration: structuredClone({ ...configuration, active: entry.deactivated_at === undefined ? true : false, diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js index eb663805b..7ad17f417 100644 --- a/test/lib/reporting-subscription-consumer.test.js +++ b/test/lib/reporting-subscription-consumer.test.js @@ -41,7 +41,7 @@ const OFFERING = { schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT6H' }, supported_finality: ['official'], reconciliation_mode: 'delivery_only', - dimensions: ['package'], + standard_dimensions: ['package'], }; function subscription(extra = {}) { diff --git a/test/lib/reporting-subscription-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js index d3303e0e8..f38596188 100644 --- a/test/lib/reporting-subscription-postgres.test.js +++ b/test/lib/reporting-subscription-postgres.test.js @@ -100,7 +100,7 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA schedule: { period_duration: 'P1D', alignment: 'utc', delivery_sla: 'PT6H' }, supported_finality: ['official'], reconciliation_mode: 'delivery_only', - dimensions: ['package'], + standard_dimensions: ['package'], }, ], statusRetentionDays: 30, From b35d3ff286ed78dcf664afc1db23fa9bb362a700 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 14:58:19 +0000 Subject: [PATCH 17/49] fix(reporting): fail closed on an incomplete engine-store principal walk listPrincipals feeds the receipt roster, so a list cut short at the page cap, or a repeating cursor, now throws instead of returning a partial list, matching the authorized-account walk. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../subscriptions/engine-state-store.ts | 13 ++++++++--- .../reporting-subscription-consumer.test.js | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/lib/reporting/subscriptions/engine-state-store.ts b/src/lib/reporting/subscriptions/engine-state-store.ts index 755c1069c..7b593acb4 100644 --- a/src/lib/reporting/subscriptions/engine-state-store.ts +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -13,6 +13,7 @@ import type { PrincipalStoreScope } from '../../server/principal/types'; import type { ReportingSubscriptionEngineStateV1, ReportingSubscriptionEngineStoreV1 } from './engine'; export const DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION = 'adcp_reporting_subscription_engine'; +const MAX_PRINCIPAL_PAGES = 10_000; export interface CreateReportingSubscriptionEngineStateStoreOptionsV1 { store: AdcpStateStore; @@ -78,9 +79,10 @@ export function createReportingSubscriptionEngineStateStoreV1( return result.ok ? { outcome: 'applied', revision: String(result.version) } : { outcome: 'conflict' }; }, async listPrincipals() { + // A truncated list would drop consumers that owe receipts, so every incomplete walk fails closed. const scopes: PrincipalStoreScope[] = []; let cursor: string | undefined; - for (let page = 0; page < 10_000; page += 1) { + for (let page = 0; page < MAX_PRINCIPAL_PAGES; page += 1) { const result = await store.list(collection, { limit: 500, ...(cursor === undefined ? {} : { cursor }), @@ -90,10 +92,15 @@ export function createReportingSubscriptionEngineStateStoreV1( scopes.push({ tenantId: document.tenantId, principalId: document.principalId }); } } - if (result.nextCursor === undefined) break; + if (result.nextCursor === undefined) return scopes; + if (result.nextCursor === cursor) { + throw new Error('Reporting subscription engine store returned a repeating cursor'); + } cursor = result.nextCursor; } - return scopes; + throw new Error( + `Reporting subscription engine store did not finish within ${MAX_PRINCIPAL_PAGES} pages; refusing a partial list` + ); }, }; } diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js index 7ad17f417..dfd59ed12 100644 --- a/test/lib/reporting-subscription-consumer.test.js +++ b/test/lib/reporting-subscription-consumer.test.js @@ -122,6 +122,29 @@ describe('reporting subscription engine state store', () => { ); }); + 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(), + /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(), + /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: {} }); From 800adfc5e88120f3e742c51f12cdc289d7cfb51e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 20:18:12 +0000 Subject: [PATCH 18/49] fix(reporting): buyer subscription runtime review fixes - The engine state store follows the engine's sharded interface: one CAS document per principal and per (principal, account), with listKeys and a tenant-scoped listPrincipals. - Subscription expectations get the summary's ledger_as_of (minus an optional grace) as now, and the hook is documented as requiring a seller that materializes subscription obligations. - Expectation templates must supply coverage; the old default could never match an obligation reporting packages or partial coverage. - principal.changed redeliveries are dropped by idempotency_key, and a burst during a run joins one trailing run. - syncReportingSubscriptionsV1 refuses a deactivation by omission unless allowDeactivation, checked against every configuration read it replaces, and verifies the stored configuration field by field. - reportingSubscriptionVersionRecordsV1 falls back to the readback's configuration and prior_versions[].configuration (adcp#8588). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-consumer.md | 17 +- src/lib/principal/index.ts | 6 +- src/lib/principal/lifecycle.ts | 17 ++ src/lib/principal/reporting-subscriptions.ts | 94 +++++++-- src/lib/reporting/consumer-runtime.ts | 120 ++++++++++-- .../subscriptions/engine-state-store.ts | 104 ++++++---- .../reporting/subscriptions/expectations.ts | 55 +++--- .../reporting-subscription-consumer.test.js | 184 ++++++++++++++++-- .../reporting-subscription-postgres.test.js | 21 +- 9 files changed, 486 insertions(+), 132 deletions(-) diff --git a/.changeset/reporting-subscription-consumer.md b/.changeset/reporting-subscription-consumer.md index b9633d184..d1569360e 100644 --- a/.changeset/reporting-subscription-consumer.md +++ b/.changeset/reporting-subscription-consumer.md @@ -4,8 +4,15 @@ 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 and checks that every submitted subscription reads back at its version and activation state. -- `reportingSubscriptionVersionRecordsV1({ state, configurations })` rebuilds the coverage function's version records from the readback (`accepted_at`, `prior_versions`, `deactivated_at`) and the configurations the buyer kept per version. Pass the readback's `inactive_intervals` to `expectedSubscriptionPeriods` unchanged. -- `subscriptionExpectedReportingPeriodsV1(obligations, template)` turns owed obligations into the consumer runtime's `ExpectedReportingPeriod`s. -- 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. `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. -- `createReportingSubscriptionEngineStateStoreV1({ store })` persists engine state as one CAS document per principal on any `AdcpStateStore`. 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. +- `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/src/lib/principal/index.ts b/src/lib/principal/index.ts index 6703f163b..d70526f58 100644 --- a/src/lib/principal/index.ts +++ b/src/lib/principal/index.ts @@ -6,4 +6,8 @@ export { type PrincipalLifecycleOptions, type PrincipalLifecycleResult, } from './lifecycle'; -export { syncReportingSubscriptionsV1, type SyncReportingSubscriptionsResultV1 } from './reporting-subscriptions'; +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..2261ad64b 100644 --- a/src/lib/principal/lifecycle.ts +++ b/src/lib/principal/lifecycle.ts @@ -306,6 +306,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']) => void ): Promise { const maxAttempts = options.maxAttempts ?? 3; const setupTimeoutMs = options.setupTimeoutMs ?? 60_000; @@ -330,6 +345,7 @@ export async function syncPrincipalLifecycle( ); let prior = await readCurrent(client, options); assertExpectedPrincipalKind(prior, options.expectedPrincipalKind); + assertReplaceable?.(prior); let applied: AppliedPrincipal | undefined; const attemptedIdempotencyKeys = new Set(); @@ -382,6 +398,7 @@ export async function syncPrincipalLifecycle( const refreshed = await readCurrent(client, options); assertSamePrincipal(prior, refreshed); assertExpectedPrincipalKind(refreshed, options.expectedPrincipalKind); + assertReplaceable?.(refreshed); prior = refreshed; } diff --git a/src/lib/principal/reporting-subscriptions.ts b/src/lib/principal/reporting-subscriptions.ts index c70b469d0..79f78b4ea 100644 --- a/src/lib/principal/reporting-subscriptions.ts +++ b/src/lib/principal/reporting-subscriptions.ts @@ -6,7 +6,7 @@ import type { ReportingSubscription, ReportingSubscriptionState } from '../types/core.generated'; import { PrincipalLifecycleError, - syncPrincipalLifecycle, + guardedSyncPrincipalLifecycle, type PrincipalLifecycleClient, type PrincipalLifecycleOptions, type PrincipalLifecycleResult, @@ -17,39 +17,101 @@ export interface SyncReportingSubscriptionsResultV1 extends PrincipalLifecycleRe 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. Omitted subscriptions are - * deactivated by the seller. Uses the same guarded replacement as - * `syncPrincipalLifecycle` (expected_configuration_version, fresh idempotency keys on - * conflict), then checks that every submitted subscription reads back at the submitted - * version. Keep each submitted configuration by version: the readback carries only the - * current one, and coverage needs every version's selector. + * Replace the caller's complete reporting subscription set. 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: PrincipalLifecycleOptions = {} + options: SyncReportingSubscriptionsOptionsV1 = {} ): Promise { - const result = await syncPrincipalLifecycle( + const { allowDeactivation = false, ...lifecycleOptions } = options; + const submittedIds = new Set(subscriptions.map(subscription => subscription.subscription_id)); + const result = await guardedSyncPrincipalLifecycle( client, { reporting_subscriptions: structuredClone([...subscriptions]) } as never, - options + 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 PrincipalLifecycleError( + `The write would deactivate active subscriptions it omits (${omitted.map(id => JSON.stringify(id)).join(', ')}); include them, send active: false, or pass allowDeactivation: true` + ); + } + } ); 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 PrincipalLifecycleError(message, { applied: result.applied, current: result.current }); if (!state || state.subscription_version !== subscription.subscription_version) { - throw new PrincipalLifecycleError( - `Seller readback does not carry subscription ${JSON.stringify(subscription.subscription_id)} at version ${subscription.subscription_version}`, - { applied: result.applied, current: result.current } + 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 new PrincipalLifecycleError( - `Seller readback for subscription ${JSON.stringify(subscription.subscription_id)} does not reflect active: ${subscription.active}`, - { applied: result.applied, current: result.current } + 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/consumer-runtime.ts b/src/lib/reporting/consumer-runtime.ts index 590b8ae41..ac1683b63 100644 --- a/src/lib/reporting/consumer-runtime.ts +++ b/src/lib/reporting/consumer-runtime.ts @@ -27,6 +27,7 @@ import { } 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; @@ -53,13 +54,26 @@ export interface ReliableReportingConsumerAccountV1 { * 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`. + * `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 { @@ -139,7 +153,9 @@ export interface ReliableReportingConsumerV1 { * 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. Returns null for any other payload. + * 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, @@ -662,6 +678,62 @@ 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, @@ -674,16 +746,27 @@ export function createReliableReportingConsumerV1( ).getReportingStatus( { ...withoutChangesAfter(account.reconciliation.request), view: 'summary' } as GetReportingStatusRequest, { signal } - )) as unknown as { status?: unknown; account_id?: unknown; subscription_coverage?: unknown }; - if (response.status !== 'completed' || response.account_id !== account.accountId) { + )) 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(), + 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'); @@ -746,21 +829,20 @@ export function createReliableReportingConsumerV1( if (!notification) return null; if (!authentication) throw new TypeError('consumerScope is required for a principal.changed notification'); boundedString(authentication.consumerScope, 'consumerScope', 4_096); - await options.onPrincipalChanged?.({ consumerScope: authentication.consumerScope, notification }); - const scoped = [...accounts.values()].filter(account => account.consumerScope === authentication.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', - }); - } + 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; } - return results; }, async handleAuthenticatedNotification(payload, authentication) { const notification = reportingNotification(payload); diff --git a/src/lib/reporting/subscriptions/engine-state-store.ts b/src/lib/reporting/subscriptions/engine-state-store.ts index 7b593acb4..bb60de400 100644 --- a/src/lib/reporting/subscriptions/engine-state-store.ts +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -1,19 +1,22 @@ /** * Durable reporting subscription engine store over the SDK state-store port. * - * Each principal's engine state is one CAS document, so a PostgreSQL deployment uses the - * `PostgresStateStore` it already runs for the principal row; no further migration is - * needed. The document holds grant history, exceptions, timezone cutovers, gaps and - * `max_accounts` slots; the subscription version history lives in the principal row and is - * never pruned, so `prior_versions` can always be answered for retained obligations. + * 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 { createHash } from 'node:crypto'; import { InMemoryStateStore, type AdcpStateStore } from '../../server/state-store'; import type { PrincipalStoreScope } from '../../server/principal/types'; -import type { ReportingSubscriptionEngineStateV1, ReportingSubscriptionEngineStoreV1 } from './engine'; +import type { ReportingSubscriptionEngineStoreV1 } from './engine'; export const DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION = 'adcp_reporting_subscription_engine'; -const MAX_PRINCIPAL_PAGES = 10_000; +const MAX_PAGES = 10_000; +const PAGE_SIZE = 500; export interface CreateReportingSubscriptionEngineStateStoreOptionsV1 { store: AdcpStateStore; @@ -23,15 +26,16 @@ export interface CreateReportingSubscriptionEngineStateStoreOptionsV1 { } interface EngineDocument extends Record { - schemaVersion: 1; + schemaVersion: 2; tenantId: string; principalId: string; - state: ReportingSubscriptionEngineStateV1; + key: string; + value: Record; } -function documentId(scope: Readonly): string { +function documentId(scope: Readonly, key: string): string { return `subscription_engine_${createHash('sha256') - .update(JSON.stringify([scope.tenantId, scope.principalId])) + .update(JSON.stringify([scope.tenantId, scope.principalId, key])) .digest('hex')}`; } @@ -47,60 +51,76 @@ export function createReportingSubscriptionEngineStateStoreV1( } 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 Error('Reporting subscription engine store returned a repeating cursor'); + cursor = result.nextCursor; + } + throw new Error( + `Reporting subscription engine store did not finish within ${MAX_PAGES} pages; refusing a partial list` + ); + } + return { durability: options.durability ?? 'process-local', - async get(scope) { - const row = await store.getWithVersion!(collection, documentId(scope)); + async get(scope, key) { + const row = await store.getWithVersion!(collection, documentId(scope, key)); if (!row) return null; const document = row.data; if ( - document.schemaVersion !== 1 || + document.schemaVersion !== 2 || document.tenantId !== scope.tenantId || document.principalId !== scope.principalId || - !document.state || - typeof document.state !== 'object' + document.key !== key || + !document.value || + typeof document.value !== 'object' ) { throw new Error('Reporting subscription engine document is malformed or belongs to another principal'); } - return { state: structuredClone(document.state), revision: String(row.version) }; + return { value: structuredClone(document.value), revision: String(row.version) }; }, - async put(scope, state, expectedRevision) { + async put(scope, key, value, expectedRevision) { const expected = expectedRevision === null ? null : Number(expectedRevision); if (expected !== null && (!Number.isSafeInteger(expected) || expected < 1)) { throw new Error('Reporting subscription engine store returned an invalid revision'); } const document: EngineDocument = { - schemaVersion: 1, + schemaVersion: 2, tenantId: scope.tenantId, principalId: scope.principalId, - state: structuredClone(state), + key, + value: structuredClone(value), }; - const result = await store.putIfMatch!(collection, documentId(scope), document, expected); + const result = await store.putIfMatch!(collection, documentId(scope, key), document, expected); return result.ok ? { outcome: 'applied', revision: String(result.version) } : { outcome: 'conflict' }; }, - async listPrincipals() { - // A truncated list would drop consumers that owe receipts, so every incomplete walk fails closed. - const scopes: PrincipalStoreScope[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_PRINCIPAL_PAGES; page += 1) { - const result = await store.list(collection, { - limit: 500, - ...(cursor === undefined ? {} : { cursor }), - }); - for (const document of result.items) { - if (typeof document.tenantId === 'string' && typeof document.principalId === 'string') { - scopes.push({ tenantId: document.tenantId, principalId: document.principalId }); - } - } - if (result.nextCursor === undefined) return scopes; - if (result.nextCursor === cursor) { - throw new Error('Reporting subscription engine store returned a repeating cursor'); + 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 }); } - cursor = result.nextCursor; } - throw new Error( - `Reporting subscription engine store did not finish within ${MAX_PRINCIPAL_PAGES} pages; refusing a partial list` - ); + return [...seen.values()]; }, }; } diff --git a/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts index d09460e6f..0eaf5022c 100644 --- a/src/lib/reporting/subscriptions/expectations.ts +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -8,37 +8,45 @@ import type { ExpectedSubscriptionObligationV1, ReportingSubscriptionVersionReco import type { ReportingSubscriptionDestinationMethodV1 } from './selection'; /** - * The coverage function's version records from the seller readback plus the buyer's own - * records of each version's configuration (the readback carries only the current one). - * Pass the readback's `inactive_intervals` to `expectedSubscriptionPeriods` unchanged. + * 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. */ export function reportingSubscriptionVersionRecordsV1(input: { state: ReportingSubscriptionState; - /** Every version the buyer sent, keyed by `subscription_version`. */ - configurations: Readonly>; + /** 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 = [ - ...[...(state.prior_versions ?? [])].reverse().map(prior => ({ + ...[...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]; + const configuration = input.configurations?.[entry.version] ?? entry.published; if (!configuration) { throw new TypeError( - `No recorded configuration for ${state.subscription_id} version ${entry.version}; keep every version you send` + `No configuration for ${state.subscription_id} version ${entry.version}: the readback does not carry it, so keep every version you send` ); } const method = input.destinationMethods?.[entry.version]; @@ -67,15 +75,20 @@ export type SubscriptionExpectationTemplateV1 = Omit< | 'periodEnd' | 'coverage' > & { - /** Defaults to full coverage of exactly the owed buys, with no package-level detail. */ - coverage?: ExpectedReportingCoverage; + /** + * 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. + * finality and reconciliation mode, and the coverage the obligation must report (its package + * sets, and `partial` where packages carry an `offering_unsupported` exception). */ export function subscriptionExpectedReportingPeriodsV1( obligations: readonly ExpectedSubscriptionObligationV1[], @@ -84,6 +97,11 @@ export function subscriptionExpectedReportingPeriodsV1( 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, @@ -92,20 +110,7 @@ export function subscriptionExpectedReportingPeriodsV1( mediaBuyIds: [...obligation.media_buy_ids], periodStart: obligation.period_start, periodEnd: obligation.period_end, - coverage: coverage - ? structuredClone(coverage) - : { - 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: [], - covered_package_ids: [], - unsupported_package_ids: [], - unknown_package_ids: [], - }, + coverage: structuredClone(coverage), } as ExpectedReportingPeriod; }); } diff --git a/test/lib/reporting-subscription-consumer.test.js b/test/lib/reporting-subscription-consumer.test.js index dfd59ed12..eb30f5cde 100644 --- a/test/lib/reporting-subscription-consumer.test.js +++ b/test/lib/reporting-subscription-consumer.test.js @@ -97,25 +97,34 @@ function engineFor(lifecycle, clock, store) { 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 principal over an AdcpStateStore', async () => { + 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), null); - const state = { accounts: { a: { grants: [{ authorized_at: '2026-10-01T00:00:00.000Z' }] } }, subscriptions: {} }; - const first = await store.put(SCOPE, state, null); + 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, state, null), { outcome: 'conflict' }); - const read = await store.get(SCOPE); - assert.deepEqual(read.state, state); - assert.equal((await store.put(SCOPE, state, read.revision)).outcome, 'applied'); - await store.put({ tenantId: 't', principalId: 'q' }, state, null); - assert.deepEqual((await store.listPrincipals()).map(scope => scope.principalId).sort(), ['p', 'q']); + 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/ @@ -131,7 +140,7 @@ describe('reporting subscription engine state store', () => { putIfMatch: backing.putIfMatch.bind(backing), }); await assert.rejects( - createReportingSubscriptionEngineStateStoreV1({ store: endless }).listPrincipals(), + createReportingSubscriptionEngineStateStoreV1({ store: endless }).listPrincipals('t'), /refusing a partial list/ ); const repeating = Object.assign(Object.create(backing), { @@ -140,7 +149,7 @@ describe('reporting subscription engine state store', () => { putIfMatch: backing.putIfMatch.bind(backing), }); await assert.rejects( - createReportingSubscriptionEngineStateStoreV1({ store: repeating }).listPrincipals(), + createReportingSubscriptionEngineStateStoreV1({ store: repeating }).listPrincipals('t'), /repeating cursor/ ); }); @@ -285,7 +294,7 @@ describe('buyer helpers', () => { now: '2026-11-10T00:00:00Z', }) ); - const expected = subscriptionExpectedReportingPeriodsV1(obligations, () => template()); + const expected = subscriptionExpectedReportingPeriodsV1(obligations, template); assert.equal(expected.length, 3); assert.deepEqual( { @@ -309,11 +318,125 @@ describe('buyer helpers', () => { 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 template() { +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', @@ -415,6 +538,7 @@ describe('consumer runtime with subscription expectations', () => { }, }; const seen = []; + const nows = []; const consumer = createReliableReportingConsumerV1({ persistence: memoryPersistence(), ownerToken: 'buyer-worker-1', @@ -429,8 +553,10 @@ describe('consumer runtime with subscription expectations', () => { expectedPeriods: [], inspect: async () => ({ rowCount: 0, controlTotals: [] }), }, - subscriptionExpectations: ({ subscriptionCoverage }) => { + subscriptionExpectationGraceMs: 3_600_000, + subscriptionExpectations: ({ subscriptionCoverage, now, ledgerAsOf }) => { seen.push(subscriptionCoverage); + nows.push([now.toISOString(), ledgerAsOf]); const obligations = groupExpectedSubscriptionPeriodsV1( expectedSubscriptionPeriods({ subscription_id: 'analytics-daily', @@ -441,10 +567,10 @@ describe('consumer runtime with subscription expectations', () => { buys: [BUY], offerings: [OFFERING], coverage: subscriptionCoverage[0], - now: '2026-11-10T00:00:00Z', + now: now.toISOString(), }) ); - return subscriptionExpectedReportingPeriodsV1(obligations, () => template()); + return subscriptionExpectedReportingPeriodsV1(obligations, template); }, }, ], @@ -453,6 +579,11 @@ describe('consumer runtime with subscription expectations', () => { 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, @@ -496,6 +627,25 @@ describe('consumer runtime with subscription expectations', () => { 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' }, diff --git a/test/lib/reporting-subscription-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js index f38596188..f67be049a 100644 --- a/test/lib/reporting-subscription-postgres.test.js +++ b/test/lib/reporting-subscription-postgres.test.js @@ -71,15 +71,16 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA agentUrl: 'https://seller.example/mcp', resolvePrincipal: ctx => ctx.callerMutationScope, now: () => clock.now, - reportingSubscriptions: {}, + reportingSubscriptions: { listDeliveryConfigIds: () => [] }, + }); + const engineStore = service.createReportingSubscriptionEngineStateStoreV1({ + store: backing, + collection: ENGINE, + durability: 'durable', }); const engine = service.createReportingSubscriptionEngineV1({ principal: lifecycle, - store: service.createReportingSubscriptionEngineStateStoreV1({ - store: backing, - collection: ENGINE, - durability: 'durable', - }), + store: engineStore, offerings: [ { offering_id: 'off_a_pkg', @@ -118,9 +119,11 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA packages: [{ package_id: 'pkg_1', reporting_delivery_offering_ids: ['off_a_pkg'] }], }, ], + resolveRevocationInstant: (_principal, _account, input) => input.observedAt, + isPeriodDelivered: () => false, }, }); - return { lifecycle, engine }; + return { lifecycle, engine, engineStore }; } test('versions, lost authority and owed periods survive a restart', async () => { @@ -165,5 +168,9 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA ['2026-11-03T00:00:00.000Z', '2026-11-04T00:00:00.000Z'] ); assert.equal(await second.engine.acceptsReceipt(scope, 'acct_1', { readAt: '2026-11-05T01:00:00.000Z' }), true); + // 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}`), []); }); }); From a74b552523f74e64c6b661b020d9f9efe06ff662 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 23:11:41 +0000 Subject: [PATCH 19/49] feat(reporting): hold subscription obligations in the reporting ledger Owed-set generations for AdCP 3.3 principal-level reporting subscriptions (#3504): one generation per (account, owning principal, subscription_id, subscription_version, resolved offering), with an immutable `subscription` block and a mutable `owedSet` horizon outside the fingerprint. - producer.planSubscriptionObligations installs each generation once, writes every frozen owed obligation with its own period, media buys and constituents (periodOrdinal = start second), and then advances owedSet.through on every generation the subscription owns. - Coverage treats an owed set as complete by construction: gaps are not owed, so there is no continuity error, and the scope stays open until owedSet.through reaches its end. - get_reporting_status, get_media_buy_delivery and sync_reporting_status scope subscription generations to the caller's principal (resolveSubscriptionPrincipal, snapshot `subscription_principal`). - Status ingest accepts a status without reporting_obligation_id against an owed set only for an existing owed obligation. - PostgreSQL: additive `subscription_key` column and a unique index on (account_id, delivery_config_id, delivery_config_version, subscription_key) replacing the three-column constraint, found and dropped by its columns. Mirrored in the memory test store. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-ledger.md | 17 + packages/reporting-storage/src/postgres.ts | 180 ++++++- .../reporting-subscription-ledger-pg.test.js | 326 ++++++++++++ scripts/check-package-size.mjs | 7 +- src/lib/reporting/ledger/coverage.ts | 161 +++++- src/lib/reporting/ledger/handler.ts | 120 ++++- src/lib/reporting/ledger/producer.ts | 440 +++++++++++++++- src/lib/reporting/ledger/status-ingest.ts | 102 +++- src/lib/reporting/ledger/types.ts | 211 ++++++++ test/helpers/memory-reporting-ledger-store.js | 37 +- .../lib/reporting-subscription-ledger.test.js | 490 ++++++++++++++++++ 11 files changed, 2009 insertions(+), 82 deletions(-) create mode 100644 .changeset/reporting-subscription-ledger.md create mode 100644 packages/reporting-storage/test/reporting-subscription-ledger-pg.test.js create mode 100644 test/lib/reporting-subscription-ledger.test.js 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/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/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/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 5812bde5c..125bf1b11 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -141,7 +141,12 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // selection, coverage, capability and engine modules plus the principal // subscription runtime. Measured 26,382,521 packed bytes; allow about 40 KB // above that for compressor variance. -const MAX_PACKED_TARBALL_BYTES = 26_420_000; +// Writing subscription obligations into the reporting ledger (#3504) extends the +// ledger producer, coverage, handlers and status ingest, and then the service +// and engine, and adds no module file. Measured 26,440,194 packed bytes with +// both; allow about 40 KB above that for compressor variance. Other budgets stay +// fixed. +const MAX_PACKED_TARBALL_BYTES = 26_480_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 diff --git a/src/lib/reporting/ledger/coverage.ts b/src/lib/reporting/ledger/coverage.ts index 19ef43584..61b532542 100644 --- a/src/lib/reporting/ledger/coverage.ts +++ b/src/lib/reporting/ledger/coverage.ts @@ -1,4 +1,14 @@ -import type { ReportingLedgerConfigurationV1, ReportingLedgerSnapshotQueryV1 } from './types'; +import { createHash } from 'node:crypto'; + +import { canonicalJsonV1 } from '../source'; +import type { + ReportingLedgerConfigurationV1, + ReportingLedgerOwedSetProgressV1, + ReportingLedgerOwedSetV1, + ReportingLedgerSnapshotQueryV1, + ReportingLedgerSubscriptionPrincipalV1, + ReportingLedgerSubscriptionV1, +} from './types'; import { frozenCalendarObligationsCoverOwnership, isFrozenCalendarRulesMismatch, @@ -22,13 +32,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 +157,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 +192,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 +228,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 +238,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 +319,7 @@ export function evaluateReportingLedgerCoverageV1( } if (!complete) break; } - return { complete, retainedFrom: retainedFrom ?? period.start }; + return { complete, retainedFrom: retainedFrom ?? period.start, ...(owedSetOpen ? { owedSetOpen: true } : {}) }; } /** @@ -180,7 +330,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, 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/producer.ts b/src/lib/reporting/ledger/producer.ts index 70be0a10e..ecba6cba1 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,8 +41,10 @@ import { retryReportingStatusNotificationsV1, } from './lifecycle'; import { listAdjustmentMetadataFromStore, listRevisionMetadataFromStore } from './revision-metadata'; -import { ReportingConfigurationImmutableError } from './types'; +import { ReportingConfigurationImmutableError, isReportingLedgerErrorNamed } from './types'; import type { + ReportingSubscriptionConfigurationInputV1, + ReportingSubscriptionLedgerObligationInputV1, CreateReportingProducerOptionsV1, ReportingLedgerAdjustmentMetadataV1, ReportingLedgerAdjustmentV1, @@ -76,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: { @@ -91,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); @@ -202,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; @@ -229,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. @@ -349,6 +366,159 @@ 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}` + ) + ); + // 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; @@ -815,6 +985,194 @@ 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!), + }; +} + +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, @@ -1596,7 +1954,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 @@ -1619,17 +1978,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) { @@ -1645,13 +2018,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/status-ingest.ts b/src/lib/reporting/ledger/status-ingest.ts index 9adc26aad..90d6172e8 100644 --- a/src/lib/reporting/ledger/status-ingest.ts +++ b/src/lib/reporting/ledger/status-ingest.ts @@ -10,11 +10,19 @@ import { ADCP_MAJOR_VERSION, ADCP_VERSION, toReleasePrecisionVersion } from '../ import { isWellFormedUnicodeString } from '../../utils/well-formed-unicode'; import { jsonPointerToJsonPathLite } from '../../utils/pointer-utils'; import { + isReportingLedgerOwedSetV1, reportingLedgerConfigurationMatchesScope, reportingLedgerEffectivePeriod, + reportingLedgerOwedSetOrdinalV1, + reportingLedgerSubscriptionVisibleV1, reportingLedgerSuccessor, } from './coverage'; -import { acquireAccountReadSlot, ReportingReadCapacityError } from './handler'; +import { + acquireAccountReadSlot, + ReportingReadCapacityError, + resolveReportingSubscriptionPrincipalV1, + type ReportingSubscriptionPrincipalResolverV1, +} from './handler'; import { isReportingCalendarDay, reportingPeriodSchedule } from './schedule'; import { canonicalReportingInstant, @@ -36,6 +44,7 @@ import { type ReportingLedgerConfigurationV1, type ReportingLedgerConsumerStatementV1, type ReportingLedgerObligationV1, + type ReportingLedgerSubscriptionPrincipalV1, } from './types'; const statusId = z @@ -161,6 +170,12 @@ export interface SyncReportingStatusHandlerOptionsV1 { 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 1c9928b6c..d56f32078 100644 --- a/src/lib/reporting/ledger/types.ts +++ b/src/lib/reporting/ledger/types.ts @@ -140,6 +140,98 @@ 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; } export interface ReportingLedgerObligationV1 { @@ -179,6 +271,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 { @@ -569,6 +663,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[]; @@ -978,10 +1079,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 @@ -1179,6 +1298,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, @@ -1221,6 +1351,66 @@ 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; +} + export interface ReportingProducerV1 { installConfiguration( input: Omit< @@ -1243,6 +1433,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; @@ -1309,6 +1512,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/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/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); + }); +}); From 209ded8ba50170004801d7ff1305e5ad96fddf09 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 23:34:15 +0000 Subject: [PATCH 20/49] feat(reporting): plan and execute subscription obligations in the service createReliableReportingService({ reportingSubscriptions }) now writes the engine's frozen owed sets into the ledger every cycle, before account configurations, and the worker executes them (#3504). - engine.frozenOwedSets(principal, { accountId? }) returns each subscription's frozen obligations, horizon and version configurations; engine.listPrincipals(tenantId) lists principals from the engine store. - Generations go through the same trusted path as installConfiguration (resolveSource, resolveCurrency, resolveCoverage), with seller policy from the new resolveSubscriptionConfiguration hook. Each obligation freezes the authorized constituents of exactly the media buys it owes. - New options: listSubscriptionPrincipals (required), resolveSubscriptionConfiguration (required), resolveSubscriptionAccount and onPlanningError. Planning failures are isolated per subscription. - resolvePrincipal now also scopes get_reporting_status, get_media_buy_delivery and sync_reporting_status to the caller's own subscription generations. - Without reportingSubscriptions the service is byte-for-byte unchanged, proven by a golden transcript captured from the service before the change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-ledger-service.md | 11 + docs/guides/REPORTING-LEDGER.md | 64 ++ src/lib/reporting/ledger/types.ts | 2 + src/lib/reporting/service/index.ts | 522 +++++++-- src/lib/reporting/subscriptions/engine.ts | 56 + .../ledger-option-off-golden.json | 994 ++++++++++++++++++ .../reporting-ledger-option-off-transcript.js | 184 ++++ test/lib/reliable-reporting-service.test.js | 13 +- .../reporting-subscription-service.test.js | 345 ++++++ 9 files changed, 2082 insertions(+), 109 deletions(-) create mode 100644 .changeset/reporting-subscription-ledger-service.md create mode 100644 test/fixtures/reporting-subscriptions/ledger-option-off-golden.json create mode 100644 test/helpers/reporting-ledger-option-off-transcript.js create mode 100644 test/lib/reporting-subscription-service.test.js 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/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 973044f3f..93df35578 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -230,6 +230,70 @@ 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: + +```ts +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 subscription obligations is not supported yet. + ## Advanced: assemble the primitives directly ```ts diff --git a/src/lib/reporting/ledger/types.ts b/src/lib/reporting/ledger/types.ts index d56f32078..882017411 100644 --- a/src/lib/reporting/ledger/types.ts +++ b/src/lib/reporting/ledger/types.ts @@ -1421,6 +1421,8 @@ export interface ReportingProducerV1 { | 'calendarRules' | 'historyStart' | 'historyStartOrdinal' + | 'subscription' + | 'owedSet' >, options?: { historyStart?: string } ): Promise; diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 4ca93f13b..02a7e2e1f 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -38,6 +38,8 @@ import { type ReportingProducerContactV1, type ReportingProducerV1, type ReportingSourceWithReaderV1, + type ReportingSubscriptionConfigurationInputV1, + type ReportingSubscriptionLedgerObligationInputV1, } from '../ledger'; import { reportReportingSchedulerError, @@ -117,6 +119,8 @@ type LedgerInstallInput = Omit< | 'calendarRules' | 'historyStart' | 'historyStartOrdinal' + | 'subscription' + | 'owedSet' | 'account' | 'sourceScope' | 'sourceTimezone' @@ -284,15 +288,70 @@ export interface CreateReliableReportingServiceOptionsV1> { - engine: Pick; - /** The same authenticated mapping the principal runtime uses. Never derive it from request content. */ + engine: 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; /** 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; } +/** What {@link ReliableReportingSubscriptionsOptionsV1.resolveSubscriptionConfiguration} is asked about. */ +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. */ +export interface ReliableReportingSubscriptionPolicyV1 { + requestedMetrics: string[]; + requestedDimensions: string[]; + sourceSettings: Omit; + finalityPolicy?: ReportingLedgerConfigurationV1['finalityPolicy']; + canonicalization?: ReportingLedgerConfigurationV1['canonicalization']; +} + /** * Outcome of one maintenance task. A task that resolved but could not process some items is `completed` * with `partialFailure` set; the pass resumes those items next time. @@ -352,7 +411,7 @@ export type ReliableReportingMaintenanceFactoryV1 = ( * * @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 */ -import type { ReportingSubscriptionEngineV1 } from '../subscriptions/engine'; +import type { ReportingSubscriptionEngineV1, ReportingSubscriptionFrozenOwedSetV1 } from '../subscriptions/engine'; import type { PrincipalStoreScope } from '../../server/principal/types'; import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; @@ -554,12 +613,23 @@ 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; @@ -645,6 +729,304 @@ 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), + 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']; + }> { + 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` + ); + } + if (deliveryOffering.method !== undefined || deliveryOffering.reconciliation_mode !== 'delivery_only') { + throw new TypeError('Managed delivery of subscription obligations is not supported yet'); + } + 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, + }; + } + const service: ReliableReportingServiceV1 = { setup: Object.freeze({ component: 'reliable-reporting-core', @@ -660,105 +1042,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) { @@ -790,6 +1077,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) { @@ -1056,6 +1359,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 ); @@ -1623,6 +1927,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}`); diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 5d170ff3b..e07ca4d56 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -399,6 +399,19 @@ export interface ReportingSubscriptionEngineV1 { 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; /** Principals in the tenant whose active subscriptions cover the account: the consumers that owe receipts. */ receiptRoster(tenantId: string, accountId: string): Promise; /** @@ -421,6 +434,18 @@ export interface ReportingSubscriptionEngineV1 { inspect(principal: Readonly): Promise; } +/** One subscription's frozen owed set on one account. */ +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'] }>; +} + function iso(value: number): string { return new Date(value).toISOString(); } @@ -1520,6 +1545,37 @@ export function createReportingSubscriptionEngineV1( 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), + })), + }); + } + } + return sets; + }, + 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)) { 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..f8c51c75c --- /dev/null +++ b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json @@ -0,0 +1,994 @@ +{ + "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": "source_timezone", + "period_timezone_policy": "fixed", + "period_timezone": "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": "source_timezone", + "periodTimezone": "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"], + "calendarRules": { + "canonicalTimezone": "UTC", + "tzdbVersion": "2026b", + "icuVersion": "78.3", + "firstOwnedOrdinal": 59, + "firstOwnedBoundary": "2026-10-30T00:00:00.000Z" + }, + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "installedAt": "", + "semanticFingerprint": "sha256:a4ae0ea0df85dba8217b5651403245a51011fed9ab4165bad6ee9c00c564a3e5" + }, + "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": "source_timezone", + "periodTimezone": "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"], + "calendarRules": { + "canonicalTimezone": "UTC", + "tzdbVersion": "2026b", + "icuVersion": "78.3", + "firstOwnedOrdinal": 59, + "firstOwnedBoundary": "2026-10-30T00:00:00.000Z" + }, + "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", + "installedAt": "", + "semanticFingerprint": "sha256:a4ae0ea0df85dba8217b5651403245a51011fed9ab4165bad6ee9c00c564a3e5" + } + ], + "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": "source_timezone", + "periodTimezone": "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": "source_timezone", + "periodTimezone": "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": "source_timezone", + "periodTimezone": "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.1", + "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.1", + "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": "source_timezone", + "period_timezone": "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": "source_timezone", + "period_timezone": "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": "source_timezone", + "period_timezone": "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.1", + "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/helpers/reporting-ledger-option-off-transcript.js b/test/helpers/reporting-ledger-option-off-transcript.js new file mode 100644 index 000000000..66fe91cbe --- /dev/null +++ b/test/helpers/reporting-ledger-option-off-transcript.js @@ -0,0 +1,184 @@ +// 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'], + }, + schedule: { + period_duration: 'P1D', + alignment: 'source_timezone', + period_timezone_policy: 'fixed', + period_timezone: '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/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 01b06057c..5646d5326 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -620,7 +620,18 @@ describe('ReliableReportingService', () => { isPeriodDelivered: () => false, }, }); - const subscriptionOptions = { engine, resolvePrincipal: () => ({ tenantId: 't', principalId: 'p' }) }; + 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/, diff --git a/test/lib/reporting-subscription-service.test.js b/test/lib/reporting-subscription-service.test.js new file mode 100644 index 000000000..eb0550b91 --- /dev/null +++ b/test/lib/reporting-subscription-service.test.js @@ -0,0 +1,345 @@ +/** + * 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 { + createInMemoryReportingSubscriptionEngineStoreV1, + createReliableReportingService, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/service/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, [{ 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/ + ); + }); +}); From 5258eb5e418682974a033e9ad27cfc4e88c0a52c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 00:46:42 +0000 Subject: [PATCH 21/49] test(reporting): keep the option-off transcript independent of the runtime timezone data A source-calendar P1D generation freezes the runtime's tzdb and ICU versions and the install day into its identity, so the golden differed on CI runners. The transcript now uses a UTC-aligned offering; the golden is recaptured from #3509's head (800adfc5) and still matches byte for byte. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ledger-option-off-golden.json | 46 +++++-------------- .../reporting-ledger-option-off-transcript.js | 10 ++-- 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json index f8c51c75c..269205dc4 100644 --- a/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json +++ b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json @@ -25,9 +25,7 @@ }, "schedule": { "period_duration": "P1D", - "alignment": "source_timezone", - "period_timezone_policy": "fixed", - "period_timezone": "UTC", + "alignment": "utc", "delivery_sla": "PT0S" }, "supported_finality": ["snapshot"], @@ -53,8 +51,7 @@ "recoveryWindowMilliseconds": 86400000, "periodDuration": "P1D", "deliverySlaDuration": "PT0S", - "alignment": "source_timezone", - "periodTimezone": "UTC" + "alignment": "utc" }, "account": { "account_id": "account-a" @@ -104,16 +101,9 @@ } ], "mediaBuyIds": ["media-buy-account-a"], - "calendarRules": { - "canonicalTimezone": "UTC", - "tzdbVersion": "2026b", - "icuVersion": "78.3", - "firstOwnedOrdinal": 59, - "firstOwnedBoundary": "2026-10-30T00:00:00.000Z" - }, "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", "installedAt": "", - "semanticFingerprint": "sha256:a4ae0ea0df85dba8217b5651403245a51011fed9ab4165bad6ee9c00c564a3e5" + "semanticFingerprint": "sha256:462d031a7e11c7171ec7d3dd7e4235a7d8366431376e0c0e577d8eca7d2762fb" }, "cycle": { "planned": 3, @@ -140,8 +130,7 @@ "recoveryWindowMilliseconds": 86400000, "periodDuration": "P1D", "deliverySlaDuration": "PT0S", - "alignment": "source_timezone", - "periodTimezone": "UTC" + "alignment": "utc" }, "account": { "account_id": "account-a" @@ -191,16 +180,9 @@ } ], "mediaBuyIds": ["media-buy-account-a"], - "calendarRules": { - "canonicalTimezone": "UTC", - "tzdbVersion": "2026b", - "icuVersion": "78.3", - "firstOwnedOrdinal": 59, - "firstOwnedBoundary": "2026-10-30T00:00:00.000Z" - }, "configurationId": "rcfg_e786259e3d4f159969ff5c4a39e71c7a", "installedAt": "", - "semanticFingerprint": "sha256:a4ae0ea0df85dba8217b5651403245a51011fed9ab4165bad6ee9c00c564a3e5" + "semanticFingerprint": "sha256:462d031a7e11c7171ec7d3dd7e4235a7d8366431376e0c0e577d8eca7d2762fb" } ], "obligations": [ @@ -233,8 +215,7 @@ "recoveryWindowMilliseconds": 86400000, "periodDuration": "P1D", "deliverySlaDuration": "PT0S", - "alignment": "source_timezone", - "periodTimezone": "UTC" + "alignment": "utc" }, "scopeResolvedAt": "2026-09-02T00:00:00.000Z", "coverage": { @@ -326,8 +307,7 @@ "recoveryWindowMilliseconds": 86400000, "periodDuration": "P1D", "deliverySlaDuration": "PT0S", - "alignment": "source_timezone", - "periodTimezone": "UTC" + "alignment": "utc" }, "scopeResolvedAt": "2026-09-03T00:00:00.000Z", "coverage": { @@ -419,8 +399,7 @@ "recoveryWindowMilliseconds": 86400000, "periodDuration": "P1D", "deliverySlaDuration": "PT0S", - "alignment": "source_timezone", - "periodTimezone": "UTC" + "alignment": "utc" }, "scopeResolvedAt": "2026-09-04T00:00:00.000Z", "coverage": { @@ -596,8 +575,7 @@ "expected_at": "2026-09-02T00:00:00.000Z", "schedule": { "period_duration": "P1D", - "alignment": "source_timezone", - "period_timezone": "UTC", + "alignment": "utc", "delivery_sla": "PT0S" }, "required_finality": "snapshot", @@ -642,8 +620,7 @@ "expected_at": "2026-09-03T00:00:00.000Z", "schedule": { "period_duration": "P1D", - "alignment": "source_timezone", - "period_timezone": "UTC", + "alignment": "utc", "delivery_sla": "PT0S" }, "required_finality": "snapshot", @@ -688,8 +665,7 @@ "expected_at": "2026-09-04T00:00:00.000Z", "schedule": { "period_duration": "P1D", - "alignment": "source_timezone", - "period_timezone": "UTC", + "alignment": "utc", "delivery_sla": "PT0S" }, "required_finality": "snapshot", diff --git a/test/helpers/reporting-ledger-option-off-transcript.js b/test/helpers/reporting-ledger-option-off-transcript.js index 66fe91cbe..663f19094 100644 --- a/test/helpers/reporting-ledger-option-off-transcript.js +++ b/test/helpers/reporting-ledger-option-off-transcript.js @@ -33,13 +33,9 @@ function deliveryOffering() { 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', - }, + // 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', }; From ff2feb6c79e79f3e483ac7054bca7684fe0d5553 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 03:04:58 +0000 Subject: [PATCH 22/49] feat(reporting): managed delivery of subscription obligations Deliver principal-level reporting subscription obligations (#3504) to the owner's destination through managed delivery. - Destination resolution: the Core service calls a new reportingSubscriptions.bindManagedGeneration hook (through the planner's bindGeneration) before a managed generation's first obligation. The production service supplies createReportingSubscriptionManagedBindingV1: version destination_id -> the owner's current destination generation (delivery-eligible, pattern and transport matching the frozen destination_method) -> a per-account authorization of that destination_ref (ensureDestinationAuthorization) -> an immutable binding that freezes path_template. - Bindings are keyed by their Core configuration: the managed migration drops the published (account, delivery_config_id, delivery_config_version) key, found by its columns. - path_template is expanded per obligation and passed to adapters as delivery_path; object-store names place it inside the collision-free generation prefix. Subscription revision rows carry account_id inside the committed digest. - The managed worker re-checks the owner's account authority before delivering and before settling; a lost grant holds the materialization without spending an attempt, so the obligation stays owed. - Status subscribers and notification activity reach only the owner of a subscription obligation. Production reads are scoped to the caller's principal and carry subscription_coverage. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-delivery.md | 18 + docs/guides/REPORTING-LEDGER.md | 33 +- packages/reporting-storage/src/gcs/adapter.ts | 50 +- packages/reporting-storage/src/gcs/fence.ts | 21 +- .../reporting-storage/src/managed/postgres.ts | 120 ++++- .../reporting-storage/src/managed/runtime.ts | 259 +++++++++- .../notifications/notification-activity.ts | 23 +- .../reporting-storage/src/object-writes.ts | 59 ++- .../reporting-storage/src/service/index.ts | 63 ++- ...reporting-subscription-delivery-pg.test.js | 475 ++++++++++++++++++ src/lib/reporting/ledger/lifecycle.ts | 16 +- src/lib/reporting/ledger/producer.ts | 35 +- src/lib/reporting/ledger/types.ts | 18 + src/lib/reporting/service/index.ts | 80 ++- src/lib/reporting/subscriptions/engine.ts | 16 +- .../reporting-subscription-delivery.test.js | 293 +++++++++++ .../reporting-subscription-service.test.js | 4 +- 17 files changed, 1534 insertions(+), 49 deletions(-) create mode 100644 .changeset/reporting-subscription-delivery.md create mode 100644 packages/reporting-storage/test/reporting-subscription-delivery-pg.test.js create mode 100644 test/lib/reporting-subscription-delivery.test.js 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/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 93df35578..52be5403c 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -292,7 +292,38 @@ 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 subscription obligations is not supported yet. + +**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. ## Advanced: assemble the primitives directly 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..a276ea80a 100644 --- a/packages/reporting-storage/src/managed/postgres.ts +++ b/packages/reporting-storage/src/managed/postgres.ts @@ -107,8 +107,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 +332,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'; @@ -1143,6 +1172,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 +1306,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'); diff --git a/packages/reporting-storage/src/managed/runtime.ts b/packages/reporting-storage/src/managed/runtime.ts index 83e9f249b..a909e0507 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/service'; 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,19 +609,31 @@ 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!) : undefined; @@ -650,6 +725,9 @@ export async function createReportingManagedDeliveryRuntime< }, } : {}), + ...(options.authorizeSubscriptionDelivery + ? { authorizeSubscriptionDelivery: options.authorizeSubscriptionDelivery } + : {}), ...workerOptions, minimumResourceRetentionDays: Math.max( options.resourceRetentionDays, @@ -693,6 +771,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 +840,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 +938,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 +967,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 +978,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 +1013,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/service/index.ts b/packages/reporting-storage/src/service/index.ts index 21bc54845..a230b1e5f 100644 --- a/packages/reporting-storage/src/service/index.ts +++ b/packages/reporting-storage/src/service/index.ts @@ -12,6 +12,7 @@ import { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, createReliableReportingService, + withReportingSubscriptionCoverageV1, rotateReportingRoster, reportingDeploymentWideAccountIds, warnReportingSchedulerFailure, @@ -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/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/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 ecba6cba1..53b1fdc7e 100644 --- a/src/lib/reporting/ledger/producer.ts +++ b/src/lib/reporting/ledger/producer.ts @@ -466,6 +466,15 @@ export function createReportingProducer(options: CreateReportingProducerOptionsV 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. @@ -724,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')!; @@ -1167,6 +1179,27 @@ function planOwedSetObligation( }; } +/** + * `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`); diff --git a/src/lib/reporting/ledger/types.ts b/src/lib/reporting/ledger/types.ts index 882017411..9ce473075 100644 --- a/src/lib/reporting/ledger/types.ts +++ b/src/lib/reporting/ledger/types.ts @@ -656,6 +656,12 @@ export interface ReportingLedgerNotificationActivityPortV1): void | Promise; } @@ -762,6 +768,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; } @@ -1409,6 +1421,12 @@ export interface ReportingSubscriptionLedgerPlanV1 { */ 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 { diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index 02a7e2e1f..a07d24f30 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -288,7 +288,15 @@ export interface CreateReliableReportingServiceOptionsV1> { - engine: Pick; + 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 @@ -327,12 +335,33 @@ export interface ReliableReportingSubscriptionsOptionsV1; + /** + * 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. */ +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. */ export interface ReliableReportingSubscriptionGenerationContextV1> { principal: PrincipalStoreScope; @@ -412,7 +441,8 @@ export type ReliableReportingMaintenanceFactoryV1 = ( * @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 */ import type { ReportingSubscriptionEngineV1, ReportingSubscriptionFrozenOwedSetV1 } from '../subscriptions/engine'; -import type { PrincipalStoreScope } from '../../server/principal/types'; +import type { PrincipalLifecycleRuntimeSubscriptions, PrincipalStoreScope } from '../../server/principal/types'; +import type { PrincipalLifecycleRuntime } from '../../server/principal/runtime'; import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; import type { ReportingDeliveryOfferingWithDimensionsV1 } from '../subscriptions/wire'; @@ -713,7 +743,7 @@ export function createReliableReportingService { const response = await getReportingStatus(request, context as never); - return withSubscriptionCoverage(response, context as never, subscriptionOptions); + return withReportingSubscriptionCoverageV1(response, context as never, subscriptionOptions); } : (request, context) => getReportingStatus(request, context as never), getMediaBuyDelivery: (request, context) => getMediaBuyDelivery(request, context as never), @@ -946,6 +976,23 @@ export function createReliableReportingService 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(), @@ -963,6 +1010,9 @@ export function createReliableReportingService { const deliveryOffering = deliveryOfferings.find(value => value.offering_id === offeringId); const sourceOffering = offerings.get(offeringId); @@ -971,8 +1021,18 @@ export function createReliableReportingService( +export async function withReportingSubscriptionCoverageV1( response: Awaited['getReportingStatus']>>, context: RequestContext>, subscriptions: ReliableReportingSubscriptionsOptionsV1 diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index e07ca4d56..5d70ee8db 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -412,6 +412,11 @@ export interface ReportingSubscriptionEngineV1 { ): 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; /** @@ -443,7 +448,12 @@ export interface ReportingSubscriptionFrozenOwedSetV1 { /** 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'] }>; + versions: Array<{ + subscription_version: number; + configuration: StoredReportingSubscriptionVersion['configuration']; + /** The destination's pattern and transport, frozen when the version was accepted. */ + destination_method?: StoredReportingSubscriptionVersion['destinationMethod']; + }>; } function iso(value: number): string { @@ -1567,12 +1577,16 @@ export function createReportingSubscriptionEngineV1( 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); }, diff --git a/test/lib/reporting-subscription-delivery.test.js b/test/lib/reporting-subscription-delivery.test.js new file mode 100644 index 000000000..6adafb05e --- /dev/null +++ b/test/lib/reporting-subscription-delivery.test.js @@ -0,0 +1,293 @@ +/** + * 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, + createInMemoryReportingSubscriptionEngineStoreV1, + createReliableReportingService, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/service/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-service.test.js b/test/lib/reporting-subscription-service.test.js index eb0550b91..e3c358699 100644 --- a/test/lib/reporting-subscription-service.test.js +++ b/test/lib/reporting-subscription-service.test.js @@ -278,7 +278,9 @@ describe('reliable reporting service with subscription obligations', () => { { 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, [{ media_buy_id: 'mb-a', impressions: 1, spend: '1.00' }]); + 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 () => { From 2ad906d88901b616b9227539a1df51874a5fb40c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 04:07:46 +0000 Subject: [PATCH 23/49] chore(reporting): packed-size budget for the subscription coverage modules after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index b4df85611..7c2b8b7a6 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -137,7 +137,10 @@ 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. -const MAX_PACKED_TARBALL_BYTES = 26_360_000; +// 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. +const MAX_PACKED_TARBALL_BYTES = 26_410_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 From e37f6ab2f7bc1cf8ae11fec48f8459ae64a96051 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 04:14:33 +0000 Subject: [PATCH 24/49] chore(reporting): packed-size budget for the subscription runtime after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 9efc6aa98..aab9570fc 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -139,9 +139,9 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 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,382,521 packed bytes; allow about 40 KB -// above that for compressor variance. -const MAX_PACKED_TARBALL_BYTES = 26_420_000; +// subscription runtime. Measured 26,423,860 packed bytes after merging main; +// allow about 40 KB above that for compressor variance. +const MAX_PACKED_TARBALL_BYTES = 26_465_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 From 73120d5a7dc40ac32a09fdf2501d8be436816d06 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 10:03:06 +0000 Subject: [PATCH 25/49] fix(reporting): bound inactive intervals when omission deactivates a subscription Omitting a subscription from a complete-set sync deactivated it without the MAX_INACTIVE_INTERVALS check the explicit active: false path applies, so 64 closed intervals plus the new open one produced 65 inactive_intervals, one past the readback schema's maxItems. Refuse it with the same error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../principal/reporting-subscriptions.ts | 9 ++++++- .../principal-reporting-subscriptions.test.js | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/server/principal/reporting-subscriptions.ts b/src/lib/server/principal/reporting-subscriptions.ts index eaa84acfa..1a467be7b 100644 --- a/src/lib/server/principal/reporting-subscriptions.ts +++ b/src/lib/server/principal/reporting-subscriptions.ts @@ -373,7 +373,14 @@ export function applyReportingSubscriptions( // 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) history.deactivatedAt = now; + 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[] = []; diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index da30b8b02..8db2946af 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -241,6 +241,33 @@ describe('sync_principal reporting_subscriptions (option on)', () => { ]); }); + 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')] }); From 331d7326a7ec02c0eff5f82eb56342599d1bd255 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 10:27:27 +0000 Subject: [PATCH 26/49] feat(reporting): owner-scoped receipts and statuses, coverage that follows the grant Receipts and consumer statuses for principal-level reporting subscriptions (#3504), under the owner decision that access means access: no post-revocation window. - A subscription obligation accepts sync_reporting_receipts and sync_reporting_status only from its owning principal; account obligations stay open. reportingSubscriptionReceiptAdmissibleV1 is the one rule. createSyncReportingReceiptsHandler passes an admission to stores that declare receiptAdmission; PostgresReportingManagedDeliveryStore enforces it, and a refusal reads like an unknown reference. Wired in the production service. - Once authority ends, the account is unknown to every task, including receipts and statuses. A seller keeps a reduced grant (allowed_tasks limited to the reconciliation tasks) instead of revoking. - The engine covers an account only while its grant lists get_reporting_status (reportingSubscriptionCoverageGrantedV1; no task restriction counts as all tasks). Account facts and authorizationChanged carry allowed_tasks; dropping get_reporting_status is a loss of authority for coverage at that instant. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-receipts.md | 14 + docs/guides/REPORTING-LEDGER.md | 23 ++ .../reporting-storage/src/managed/postgres.ts | 55 ++- .../reporting-storage/src/managed/runtime.ts | 6 +- ...reporting-subscription-receipts-pg.test.js | 319 +++++++++++++++ src/lib/reporting/ledger/coverage.ts | 30 ++ src/lib/reporting/ledger/receipts.ts | 39 +- src/lib/reporting/ledger/types.ts | 12 + src/lib/reporting/service/index.ts | 2 + src/lib/reporting/subscriptions/engine.ts | 34 +- .../lib/reporting-subscription-grants.test.js | 370 ++++++++++++++++++ 11 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 .changeset/reporting-subscription-receipts.md create mode 100644 packages/reporting-storage/test/reporting-subscription-receipts-pg.test.js create mode 100644 test/lib/reporting-subscription-grants.test.js 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/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 52be5403c..540fefe44 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -325,6 +325,29 @@ runtime as `reportingSubscriptions.principal`. The engine must have - 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. + +Access means access: there is no post-revocation window. Once authority for an +account ends, the account is unknown to every task, receipts and statuses +included. 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`. + +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/packages/reporting-storage/src/managed/postgres.ts b/packages/reporting-storage/src/managed/postgres.ts index a276ea80a..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'; @@ -469,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; @@ -2849,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!) @@ -2927,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 @@ -2958,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 @@ -2981,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 a909e0507..ced61d6f8 100644 --- a/packages/reporting-storage/src/managed/runtime.ts +++ b/packages/reporting-storage/src/managed/runtime.ts @@ -635,7 +635,11 @@ export async function createReportingManagedDeliveryRuntime< : {}), }); 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, 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/src/lib/reporting/ledger/coverage.ts b/src/lib/reporting/ledger/coverage.ts index 61b532542..10a8af8a0 100644 --- a/src/lib/reporting/ledger/coverage.ts +++ b/src/lib/reporting/ledger/coverage.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { canonicalJsonV1 } from '../source'; import type { + ReportingSubscriptionReceiptAdmissionV1, ReportingLedgerConfigurationV1, ReportingLedgerOwedSetProgressV1, ReportingLedgerOwedSetV1, @@ -373,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/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 reconcile(principal, reconcileOptions ?? {}), - async authorizationChanged(principal, accountId, change) { + 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>( diff --git a/test/lib/reporting-subscription-grants.test.js b/test/lib/reporting-subscription-grants.test.js new file mode 100644 index 000000000..9a89cc727 --- /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 { + createInMemoryReportingSubscriptionEngineStoreV1, + createReliableReportingService, + createReportingSubscriptionEngineV1, + reportingSubscriptionCoverageGrantedV1, +} = require('../../dist/lib/reporting/service/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 }]); + }); +}); From 4084b81889eec8cf547be6fbf6ba52aaf58e1fa4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 11:11:41 +0000 Subject: [PATCH 27/49] feat(reporting): reporting-subscriptions storyboard and reference seller Add examples/storyboards/reporting-subscriptions.yaml for the experimental sync_principal reporting_subscriptions section: capability discovery, dry run, apply/readback/replay, rejections (duplicate subscription_id, invalid scope, reused version), 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 and passes it. The test runs the storyboard over MCP and fails a seller that drops prior_versions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscriptions-storyboard.md | 5 + examples/README.md | 6 + .../reporting-subscriptions-seller/seller.mjs | 247 +++++++ .../storyboards/reporting-subscriptions.yaml | 607 ++++++++++++++++++ package.json | 2 + ...reporting-subscriptions-storyboard.test.js | 83 +++ 6 files changed, 950 insertions(+) create mode 100644 .changeset/reporting-subscriptions-storyboard.md create mode 100644 examples/reporting-subscriptions-seller/seller.mjs create mode 100644 examples/storyboards/reporting-subscriptions.yaml create mode 100644 test/lib/reporting-subscriptions-storyboard.test.js 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/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..698ebeebf --- /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, + createReportingSubscriptionEngineStateStoreV1, + createReportingSubscriptionEngineV1, +} from '@adcp/sdk/reporting/service'; +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 3ed17ba19..e892bac1d 100644 --- a/package.json +++ b/package.json @@ -590,8 +590,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", 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)); + }); +}); From a1c4fbb3cd4b27bc3c44aca3fb756376f917c107 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:13:46 +0000 Subject: [PATCH 28/49] feat(reporting): deactivated subscriptions read inactive in coverage (adcp#8613) subscription_coverage[] lists a deactivated subscription with status inactive and deactivated_at only while it still has retained obligations for the account (a frozen or still-running owed period ending within status_retention_days), then omits it. The value is gated on the served bundle: a release without it sees the account-level status as before, without deactivated_at. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 2 + src/lib/reporting/subscriptions/engine.ts | 57 ++++++++++++++++--- .../server/reporting-subscription-release.ts | 18 ++++++ .../lib/reporting-subscription-engine.test.js | 38 +++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md index 473e93a29..2558f15c4 100644 --- a/.changeset/reporting-subscription-runtime.md +++ b/.changeset/reporting-subscription-runtime.md @@ -50,4 +50,6 @@ Also in this release: - `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/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 5d170ff3b..58fda8512 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -89,7 +89,13 @@ export interface ReportingSubscriptionExclusionV1 { export interface ReportingSubscriptionCoverageEntryV1 { subscription_id: string; subscription_version: number; - status: 'active' | 'excluded' | 'action_required'; + /** + * `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[]; @@ -376,9 +382,10 @@ export interface ReportingSubscriptionEngineV1 { } ): Promise; /** - * `subscription_coverage[]` for `get_reporting_status`: subscriptions whose current scope - * includes the account, plus inactive ones with retained obligations. Empty for an account - * the principal is not currently authorized for: caller isolation is unchanged. + * `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, @@ -1278,6 +1285,28 @@ export function createReportingSubscriptionEngineV1( 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, @@ -1499,15 +1528,27 @@ export function createReportingSubscriptionEngineV1( ...(document.account.operator !== undefined ? { operator: document.account.operator } : {}), } ) || configuration.exclude_account_ids?.includes(accountId) === true; - // Subscriptions whose scope includes the account, plus inactive ones with retained obligations. - const retainedInactive = entry.deactivatedAt !== undefined && target.frozen.periods.length > 0; - if (!inScope && !retainedInactive) continue; + 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: pending ? 'action_required' : open.length > 0 ? 'excluded' : 'active', + 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) } : {}), diff --git a/src/lib/server/reporting-subscription-release.ts b/src/lib/server/reporting-subscription-release.ts index ac4ce7a5e..0ff9d842b 100644 --- a/src/lib/server/reporting-subscription-release.ts +++ b/src/lib/server/reporting-subscription-release.ts @@ -21,6 +21,8 @@ export interface ReportingSubscriptionReleaseFieldsV1 { 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({ @@ -29,6 +31,7 @@ const NONE: ReportingSubscriptionReleaseFieldsV1 = Object.freeze({ priorVersionConfiguration: false, cutoverPreviousTimezone: false, perOfferingEntries: false, + inactiveCoverageStatus: false, }); const cache = new Map(); @@ -65,6 +68,9 @@ export function reportingSubscriptionReleaseFieldsV1( 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); @@ -77,6 +83,18 @@ export function reportingSubscriptionCoverageForReleaseV1; + 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 }; diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index 465bc09e0..ad1b944bd 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -537,6 +537,44 @@ describe('reporting subscription engine conformance (buyer periods equal seller ); }); + 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.1') + ); + 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'); From c5bc42f82be3a3490f9e5b505a57a3f2bf72e69d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:47:42 +0000 Subject: [PATCH 29/49] fix(reporting): drop the post-loss receipt window; lock replace_account_configs to once per version - Remove engine.acceptsReceipt. adcontextprotocol/adcp#8607 makes revocation mean no access: once authority is lost the account is unknown to every task, receipts and statuses included. - Test that resending replace_account_configs: true (identical resends, dry runs, writes that add another subscription, an active toggle) never reapplies the host hook for the same subscription_version, and that the flag changing to true applies again, as the spec says. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscription-runtime.md | 2 +- src/lib/reporting/subscriptions/engine.ts | 20 ----------- .../principal-reporting-subscriptions.test.js | 34 +++++++++++++++++++ .../lib/reporting-subscription-engine.test.js | 4 +-- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/.changeset/reporting-subscription-runtime.md b/.changeset/reporting-subscription-runtime.md index 2558f15c4..8da4856ab 100644 --- a/.changeset/reporting-subscription-runtime.md +++ b/.changeset/reporting-subscription-runtime.md @@ -22,7 +22,7 @@ Versioning follows the spec: a version changes only for semantic changes; each v - `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), the receipt roster, and the post-loss receipt window (`acceptsReceipt`). +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`. diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 58fda8512..23dd46f39 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -408,16 +408,6 @@ export interface ReportingSubscriptionEngineV1 { ): Promise; /** Principals in the tenant whose active subscriptions cover the account: the consumers that owe receipts. */ receiptRoster(tenantId: string, accountId: string): Promise; - /** - * Whether `sync_reporting_receipts`/`sync_reporting_status` from this principal may still be - * accepted for a revision it read at `readAt`: always while authorized; after a loss only - * for revisions read before `unauthorized_at`, for `status_retention_days`. - */ - acceptsReceipt( - principal: Readonly, - accountId: string, - input: { readAt: string; now?: 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, @@ -1580,16 +1570,6 @@ export function createReportingSubscriptionEngineV1( } return roster.sort((left, right) => compareReportingIdsByteOrderV1(left.principalId, right.principalId)); }, - async acceptsReceipt(principal, accountId, input) { - const document = await readAccount(principal, accountId); - const account = document?.account; - if (isAuthorized(account)) return true; - const grant = lastGrant(account); - if (grant?.unauthorized_at === undefined) return false; - const unauthorizedAt = Date.parse(grant.unauthorized_at); - const nowMs = input.now !== undefined ? Date.parse(input.now) : now().getTime(); - return Date.parse(input.readAt) < unauthorizedAt && nowMs < unauthorizedAt + retentionMs; - }, async replaceAccountConfigTargets(principal, subscriptionId) { const targets: string[] = []; for (const accountId of await accountIds(principal)) { diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index 8db2946af..69a08992d 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -449,6 +449,40 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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'); + // The spec's other trigger: the flag changing to true (an omitted flag is its default, false). + 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, 2, 'a flag change to true applies again'); + }); + test('a failed replace_account_configs hook is retried, and marked applied only after it succeeds', async () => { let failures = 1; const calls = []; diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index ad1b944bd..7287b74d7 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -347,8 +347,8 @@ describe('reporting subscription engine conformance (buyer periods equal seller 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'); - assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T01:00:00.000Z' }), true); - assert.equal(await w.engine.acceptsReceipt(SCOPE, 'acct_1', { readAt: '2026-11-05T06:00:00.000Z' }), false); + // 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. From bcbe0a3cabb7d8d0a5376238757d70b9ddb5a613 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:50:30 +0000 Subject: [PATCH 30/49] test(reporting): no post-loss receipt check in the PostgreSQL restart test (adcp#8607) Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-postgres.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/reporting-subscription-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js index f67be049a..6b04c3584 100644 --- a/test/lib/reporting-subscription-postgres.test.js +++ b/test/lib/reporting-subscription-postgres.test.js @@ -167,7 +167,7 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA owed.map(row => row.period_start), ['2026-11-03T00:00:00.000Z', '2026-11-04T00:00:00.000Z'] ); - assert.equal(await second.engine.acceptsReceipt(scope, 'acct_1', { readAt: '2026-11-05T01:00:00.000Z' }), true); + // 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)); From 9c1ed22a8b520f2127b1bff3411e8f69c8458321 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:50:31 +0000 Subject: [PATCH 31/49] docs(reporting): the engine has no post-loss receipt window Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/reporting/subscriptions/engine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 23dd46f39..78a0f5b02 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -25,7 +25,7 @@ * 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 and the post-loss receipt window. + * - 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. From ce21aebbcd0170f125625435b1127b6c631b1a19 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:53:23 +0000 Subject: [PATCH 32/49] chore(reporting): count the subscription wire-types module in the file budget Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 54123de45..ee5383771 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -306,7 +306,8 @@ const MAX_PACKED_FILE_COUNT = 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. - 20; // Reporting subscription calendar, selection, coverage, capabilities and index (five dual-format sets). + 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). const MAX_BUNDLED_SCHEMA_BYTES = 2000 * 1024; const MAX_CJS_SCHEMA_DECLARATION_BYTES = 45 * 1024 * 1024; const MAX_ESM_SCHEMA_FACADE_BYTES = 1024; From ffe7351a060ed8ad15e6b76bc8ed5fed41b732a4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 16:57:27 +0000 Subject: [PATCH 33/49] chore(reporting): count the example storyboard and reference seller in the file budget Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 8a8bf801f..1722bb1db 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -315,7 +315,8 @@ const MAX_PACKED_FILE_COUNT = 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). + 12 + // Reporting subscription engine store, buyer expectations and client helper (three dual-format sets). + 2; // Reporting subscriptions example storyboard and reference seller. const MAX_BUNDLED_SCHEMA_BYTES = 2000 * 1024; const MAX_CJS_SCHEMA_DECLARATION_BYTES = 45 * 1024 * 1024; const MAX_ESM_SCHEMA_FACADE_BYTES = 1024; From d942a1fd95ea4c18011a2a9a68b74a343d0451ea Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 17:39:21 +0000 Subject: [PATCH 34/49] feat(reporting): move reporting-subscription building blocks to reporting/advanced The coverage engine and its stores, the coverage function, the period calendar, offering selection, the capability builder, the consumer bridge and the Reliable Reporting service's subscription hooks now publish only from @adcp/sdk/reporting/advanced, tagged @experimental. The service, consumer and ledger subpaths stop re-exporting them, so nothing low-level is reachable from the facade surfaces. The service's subscription option types and the coverage hook move to their own module so the service declarations no longer name them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-dx-advanced.md | 10 + docs/guides/REPORTING-LEDGER.md | 14 +- docs/guides/REPORTING-STABILITY.md | 2 + .../reporting-subscriptions-seller/seller.mjs | 4 +- package.json | 13 ++ .../reporting-storage/src/managed/runtime.ts | 2 +- .../reporting-storage/src/service/index.ts | 2 +- .../reporting-storage/test/helpers/service.js | 4 +- scripts/check-package-size.mjs | 3 +- scripts/check-reporting-consumer-package.mjs | 10 +- src/lib/reporting/advanced/index.ts | 61 ++++++ src/lib/reporting/consumer/index.ts | 8 - src/lib/reporting/ledger/index.ts | 2 - src/lib/reporting/service/index.ts | 174 +----------------- src/lib/reporting/service/subscriptions.ts | 158 ++++++++++++++++ src/lib/reporting/subscriptions/calendar.ts | 29 ++- .../reporting/subscriptions/capabilities.ts | 8 +- src/lib/reporting/subscriptions/coverage.ts | 46 ++++- .../subscriptions/engine-state-store.ts | 8 +- src/lib/reporting/subscriptions/engine.ts | 75 ++++++-- .../reporting/subscriptions/expectations.ts | 10 +- src/lib/reporting/subscriptions/selection.ts | 34 +++- src/lib/reporting/subscriptions/wire.ts | 36 +++- test/lib/reliable-reporting-service.test.js | 6 +- .../reporting-advanced-public-export.test.js | 88 +++++++++ .../reporting-subscription-consumer.test.js | 6 +- .../reporting-subscription-coverage.test.js | 15 +- .../reporting-subscription-delivery.test.js | 6 +- .../lib/reporting-subscription-engine.test.js | 4 +- .../lib/reporting-subscription-grants.test.js | 4 +- .../reporting-subscription-postgres.test.js | 8 +- .../reporting-subscription-service.test.js | 4 +- 32 files changed, 613 insertions(+), 241 deletions(-) create mode 100644 .changeset/reporting-subscription-dx-advanced.md create mode 100644 src/lib/reporting/advanced/index.ts create mode 100644 src/lib/reporting/service/subscriptions.ts create mode 100644 test/lib/reporting-advanced-public-export.test.js 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/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 540fefe44..5287fe7f4 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -234,9 +234,21 @@ capability truthfulness. 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: +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: { 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/reporting-subscriptions-seller/seller.mjs b/examples/reporting-subscriptions-seller/seller.mjs index 698ebeebf..12556b249 100644 --- a/examples/reporting-subscriptions-seller/seller.mjs +++ b/examples/reporting-subscriptions-seller/seller.mjs @@ -23,11 +23,11 @@ import { createPrincipalStateStore, principalNotificationSubscriptionStore, } from '@adcp/sdk/server'; +import { createReliableReportingService } from '@adcp/sdk/reporting/service'; import { - createReliableReportingService, createReportingSubscriptionEngineStateStoreV1, createReportingSubscriptionEngineV1, -} from '@adcp/sdk/reporting/service'; +} from '@adcp/sdk/reporting/advanced'; import { redactedReportingSourceOfferingV1, redactedReportingSourceRequestV1 } from '@adcp/sdk/reporting/source'; export const REFERENCE_TENANT = 'reference-seller'; diff --git a/package.json b/package.json index e41197ae3..d1f2cfea3 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" ], diff --git a/packages/reporting-storage/src/managed/runtime.ts b/packages/reporting-storage/src/managed/runtime.ts index ced61d6f8..372e3eee4 100644 --- a/packages/reporting-storage/src/managed/runtime.ts +++ b/packages/reporting-storage/src/managed/runtime.ts @@ -12,7 +12,7 @@ import type { 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/service'; +import type { ReliableReportingSubscriptionBindingInputV1 } from '@adcp/sdk/reporting/advanced'; import { isReportingAdjustmentReceiptEvidence, isReportingReceiptEvidence, diff --git a/packages/reporting-storage/src/service/index.ts b/packages/reporting-storage/src/service/index.ts index a230b1e5f..ef1813cf2 100644 --- a/packages/reporting-storage/src/service/index.ts +++ b/packages/reporting-storage/src/service/index.ts @@ -12,7 +12,6 @@ import { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, createReliableReportingService, - withReportingSubscriptionCoverageV1, rotateReportingRoster, reportingDeploymentWideAccountIds, warnReportingSchedulerFailure, @@ -23,6 +22,7 @@ import { type ReliableReportingSchedulerOptionsV1, type ReliableReportingServiceV1, } from '@adcp/sdk/reporting/service'; +import { withReportingSubscriptionCoverageV1 } from '@adcp/sdk/reporting/advanced'; import { composeNotificationDeliveryAttemptCheckpoints, composeWebhookAttemptResultObservers, 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/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 1722bb1db..0ae1896a2 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -316,7 +316,8 @@ const MAX_PACKED_FILE_COUNT = 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. + 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 085686e0f..c2f7ef184 100644 --- a/scripts/check-reporting-consumer-package.mjs +++ b/scripts/check-reporting-consumer-package.mjs @@ -38,10 +38,13 @@ try { ` const assert = require('node:assert/strict'); const buyer = require('@adcp/sdk/reporting/consumer'); - for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1', 'expectedSubscriptionPeriods']) { + for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1']) { 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( @@ -49,10 +52,13 @@ try { ` import assert from 'node:assert/strict'; import * as buyer from '@adcp/sdk/reporting/consumer'; - for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1', 'expectedSubscriptionPeriods']) { + for (const name of ['reconcileReporting', 'reconcileReportingCoreV1', 'detectReportingContentMismatch', 'isReportingCoverageEvidence', 'createPostgresReportingConsumerRuntimeV1', 'createReliableReportingConsumerV1']) { 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/src/lib/reporting/advanced/index.ts b/src/lib/reporting/advanced/index.ts new file mode 100644 index 000000000..7e2de5300 --- /dev/null +++ b/src/lib/reporting/advanced/index.ts @@ -0,0 +1,61 @@ +/** + * 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'; diff --git a/src/lib/reporting/consumer/index.ts b/src/lib/reporting/consumer/index.ts index 53dedc9ab..d2263588e 100644 --- a/src/lib/reporting/consumer/index.ts +++ b/src/lib/reporting/consumer/index.ts @@ -103,11 +103,3 @@ export type { ReportingResourceReadRole, ReportingResourceReader, } from '../inspection'; - -// Principal-level reporting subscriptions (experimental, AdCP 3.3): buyer-computable coverage. -export * from '../subscriptions'; -export { - reportingSubscriptionVersionRecordsV1, - subscriptionExpectedReportingPeriodsV1, - type SubscriptionExpectationTemplateV1, -} from '../subscriptions/expectations'; diff --git a/src/lib/reporting/ledger/index.ts b/src/lib/reporting/ledger/index.ts index 2d9608666..bfa6ac2a4 100644 --- a/src/lib/reporting/ledger/index.ts +++ b/src/lib/reporting/ledger/index.ts @@ -27,8 +27,6 @@ export * from './row-encoding'; export * from './row-storage-errors'; export * from './receipts'; export * from './offering-validation'; -// Principal-level reporting subscriptions (experimental, AdCP 3.3): the shared calendar, selection and coverage rule. -export * from '../subscriptions'; // Building blocks a ledger store implementation shares with the protocol layer. They are exported so a // store can live outside the SDK without importing internals. diff --git a/src/lib/reporting/service/index.ts b/src/lib/reporting/service/index.ts index ee62ed4f9..1abe36e83 100644 --- a/src/lib/reporting/service/index.ts +++ b/src/lib/reporting/service/index.ts @@ -66,6 +66,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. */ @@ -281,106 +287,13 @@ export interface CreateReliableReportingServiceOptionsV1; } -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. */ -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. */ -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. */ -export interface ReliableReportingSubscriptionPolicyV1 { - requestedMetrics: string[]; - requestedDimensions: string[]; - sourceSettings: Omit; - finalityPolicy?: ReportingLedgerConfigurationV1['finalityPolicy']; - canonicalization?: ReportingLedgerConfigurationV1['canonicalization']; -} - /** * Outcome of one maintenance task. A task that resolved but could not process some items is `completed` * with `partialFailure` set; the pass resumes those items next time. @@ -440,17 +353,6 @@ export type ReliableReportingMaintenanceFactoryV1 = ( * * @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 */ -import type { ReportingSubscriptionEngineV1, ReportingSubscriptionFrozenOwedSetV1 } from '../subscriptions/engine'; -import type { PrincipalLifecycleRuntimeSubscriptions, PrincipalStoreScope } from '../../server/principal/types'; -import type { PrincipalLifecycleRuntime } from '../../server/principal/runtime'; -import { reportingConfigurationCapabilitiesV1 } from '../subscriptions/capabilities'; -import { isAdcpVersionAtLeast } from '../../utils/adcp-version-config'; -import type { ReportingDeliveryOfferingWithDimensionsV1 } from '../subscriptions/wire'; -import { - reportingSubscriptionCoverageForReleaseV1, - reportingSubscriptionReleaseFieldsV1, -} from '../../server/reporting-subscription-release'; - export const REPORTING_MANAGED_DELIVERY_COMPOSITION_V1: unique symbol = Symbol.for( '@adcp/sdk/reporting/managed-delivery-composition' ) as never; @@ -1221,36 +1123,6 @@ export function createReliableReportingService( - 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; -} - function createRoutedSource( sources: ReadonlyMap, offerings: ReadonlyMap @@ -2033,33 +1905,5 @@ function deepFreeze(value: T): T { } export { ReportingConfigurationImmutableError, isReportingConfigurationImmutableError } from '../ledger'; -export { - reportingConfigurationCapabilitiesV1, - type ReportingConfigurationCapabilitiesInputV1, - type ReportingConfigurationCapabilitiesV1, -} from '../subscriptions/capabilities'; -export { - createInMemoryReportingSubscriptionEngineStoreV1, - createReportingSubscriptionEngineV1, - 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, - reportingSubscriptionCoverageGrantedV1, -} from '../subscriptions/engine'; -export { - DEFAULT_REPORTING_SUBSCRIPTION_ENGINE_COLLECTION, - createReportingSubscriptionEngineStateStoreV1, - type CreateReportingSubscriptionEngineStateStoreOptionsV1, -} from '../subscriptions/engine-state-store'; 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 index 2dea29659..db38a4dfe 100644 --- a/src/lib/reporting/subscriptions/calendar.ts +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -31,7 +31,11 @@ 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. */ +/** + * 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; @@ -40,9 +44,14 @@ export interface ReportingIsoDurationPartsV1 { seconds: number; } -/** Alignments defined by `core/reporting-schedule.json`. */ +/** + * 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; @@ -57,7 +66,11 @@ export interface ReportingPeriodCalendarSpecV1 { period_anchor?: string; } -/** Ordered, half-open reporting periods derived from one published schedule. */ +/** + * 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; /** @@ -84,7 +97,11 @@ export interface ReportingPeriodCalendarV1 { 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. */ +/** + * 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 TypeError('Invalid reporting period_duration'); const match = DURATION.exec(value); @@ -129,6 +146,8 @@ function formatterFor(timeZone: string): Intl.DateTimeFormat { /** * 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)) { @@ -200,6 +219,8 @@ function parseInstant(value: string, field: string): number { /** * 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); diff --git a/src/lib/reporting/subscriptions/capabilities.ts b/src/lib/reporting/subscriptions/capabilities.ts index 31ff3bd51..16c77f656 100644 --- a/src/lib/reporting/subscriptions/capabilities.ts +++ b/src/lib/reporting/subscriptions/capabilities.ts @@ -9,6 +9,7 @@ */ 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 @@ -30,12 +31,17 @@ export interface ReportingConfigurationCapabilitiesInputV1 { 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. */ +/** + * 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 { diff --git a/src/lib/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index 8dbd361f5..b766a8282 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -51,7 +51,11 @@ 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. */ +/** + * 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. */ @@ -72,7 +76,11 @@ export interface ReportingSubscriptionVersionRecordV1 { destination_method?: ReportingSubscriptionDestinationMethodV1; } -/** Flight values a buy carried before an update replaced them. */ +/** + * 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; @@ -82,7 +90,11 @@ export interface ReportingSubscriptionPriorFlightV1 { completed_at?: string; } -/** A media buy the caller can see through `get_media_buys`. */ +/** + * 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. */ @@ -102,7 +114,11 @@ export interface ReportingSubscriptionBuyV1 { prior_flights?: readonly ReportingSubscriptionPriorFlightV1[]; } -/** One grant of reporting authority over the account to the caller. */ +/** + * 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 @@ -117,6 +133,7 @@ export interface ReportingSubscriptionGrantV1 { 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`. */ @@ -141,6 +158,7 @@ export interface ReportingSubscriptionCoverageAccountV1 { 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. */ @@ -169,7 +187,11 @@ export interface ExpectedSubscriptionPeriodsInputV1 { max_iterations?: number; } -/** One owed period for one (buy, offering) pair. */ +/** + * 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`. */ @@ -183,7 +205,11 @@ export interface ExpectedSubscriptionPeriodV1 { period_end: string; } -/** Owed periods grouped the way the seller's obligations carry them. */ +/** + * 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; @@ -237,6 +263,8 @@ interface AuthorityChain { * * 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'); @@ -343,7 +371,11 @@ function firstOwedBoundary(calendar: ReportingPeriodCalendarV1, handoff: 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`. */ +/** + * 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[] { diff --git a/src/lib/reporting/subscriptions/engine-state-store.ts b/src/lib/reporting/subscriptions/engine-state-store.ts index bb60de400..59129fd6e 100644 --- a/src/lib/reporting/subscriptions/engine-state-store.ts +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -14,10 +14,12 @@ import { InMemoryStateStore, type AdcpStateStore } from '../../server/state-stor 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; @@ -39,7 +41,11 @@ function documentId(scope: Readonly, key: string): string { .digest('hex')}`; } -/** {@link ReportingSubscriptionEngineStoreV1} over any `AdcpStateStore` with `getWithVersion` and `putIfMatch`. */ +/** + * {@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 { diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index fcbc841b7..6ab62ea92 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -68,6 +68,7 @@ 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' @@ -75,7 +76,11 @@ export type ReportingSubscriptionExclusionReasonV1 = | 'excluded_by_buyer' | 'action_required'; -/** One `subscription_coverage[].excluded[]` entry. */ +/** + * 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; reason: ReportingSubscriptionExclusionReasonV1; @@ -85,7 +90,11 @@ export interface ReportingSubscriptionExclusionV1 { overridden_by?: { subscription_id: string } | { delivery_config_id: string }; } -/** One `subscription_coverage[]` entry, typed. */ +/** + * 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; @@ -102,7 +111,11 @@ export interface ReportingSubscriptionCoverageEntryV1 { gaps?: ReportingSubscriptionGapV1[]; } -/** Facts about one account the principal may currently read. */ +/** + * 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; /** @@ -142,6 +155,8 @@ export interface ReportingSubscriptionAccountFactV1 { * `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; @@ -149,7 +164,11 @@ export function reportingSubscriptionCoverageGrantedV1(grant: { 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. */ +/** + * 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'; @@ -170,6 +189,7 @@ export interface ReportingSubscriptionAccountConfigV1 { }; } +/** @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( @@ -255,7 +275,11 @@ interface BuyRevision { previous: ReportingSubscriptionBuyV1; } -/** Durable engine state for one (principal, account). */ +/** + * 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; @@ -263,12 +287,20 @@ export interface ReportingSubscriptionEngineAccountDocumentV1 { lastBuys?: ReportingSubscriptionBuyV1[]; } -/** Durable engine state for one principal: `max_accounts` slots per subscription. */ +/** + * 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. */ +/** + * 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; @@ -277,6 +309,8 @@ export interface ReportingSubscriptionEngineStateV1 { /** * 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'; @@ -296,7 +330,11 @@ export interface ReportingSubscriptionEngineStoreV1 { listPrincipals(tenantId: string): MaybePromise; } -/** Process-local engine store for tests and single-process sellers. */ +/** + * 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, @@ -337,6 +375,7 @@ export function createInMemoryReportingSubscriptionEngineStoreV1(): ReportingSub }; } +/** @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; @@ -365,14 +404,22 @@ export interface CreateReportingSubscriptionEngineOptionsV1 { ) => void | Promise; } -/** A subscription whose readback lists exceed the 1000-entry limit. */ +/** + * 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. */ +/** + * 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[]; } @@ -380,6 +427,7 @@ export interface ReportingSubscriptionReconcileResultV1 { /** 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 }; @@ -457,7 +505,11 @@ export interface ReportingSubscriptionEngineV1 { inspect(principal: Readonly): Promise; } -/** One subscription's frozen owed set on one account. */ +/** + * 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; @@ -521,6 +573,7 @@ function inactiveIntervals(history: StoredReportingSubscriptionHistory): Reporti 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 { diff --git a/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts index 0eaf5022c..1b5ac6de5 100644 --- a/src/lib/reporting/subscriptions/expectations.ts +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -13,6 +13,8 @@ import type { ReportingSubscriptionDestinationMethodV1 } from './selection'; * 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; @@ -64,7 +66,11 @@ export function reportingSubscriptionVersionRecordsV1(input: { }); } -/** Per-offering pins an obligation must match beyond what coverage determines. */ +/** + * 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' @@ -89,6 +95,8 @@ export type SubscriptionExpectationTemplateV1 = Omit< * 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[], diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts index fbb04961c..1ec248b74 100644 --- a/src/lib/reporting/subscriptions/selection.ts +++ b/src/lib/reporting/subscriptions/selection.ts @@ -15,6 +15,8 @@ import type { ReportingDeliveryOfferingWithDimensionsV1 } from './wire'; * `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 & { /** @@ -28,15 +30,22 @@ export type ReportingSubscriptionOfferingV1 = ReportingDeliveryOfferingWithDimen 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. */ +/** + * `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']; @@ -56,14 +65,22 @@ export interface ResolveReportingSubscriptionOfferingsInputV1 { at?: number; } -/** `match` defaults to `exact` when `dimensions` is present and `finest` when absent. */ +/** + * `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. */ +/** + * 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')); } @@ -97,7 +114,11 @@ function canProduceTimezone(offering: ReportingDeliveryOffering, timezone: strin } } -/** Candidate filter from `x-adcp-validation.selection`, before `match` applies. */ +/** + * 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 @@ -134,6 +155,8 @@ export function isReportingSubscriptionCandidateV1( * 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); @@ -175,6 +198,7 @@ export function resolveReportingSubscriptionOfferingsV1(input: ResolveReportingS 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`. */ @@ -185,6 +209,8 @@ export interface ReportingSubscriptionScopeAccountV1 { * 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, diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts index 133ed3b47..8aa4bc861 100644 --- a/src/lib/reporting/subscriptions/wire.ts +++ b/src/lib/reporting/subscriptions/wire.ts @@ -13,7 +13,11 @@ import type { ReportingSubscriptionState, } from '../../types/core.generated'; -/** `core/reporting-delivery-offering.json` with the 3.3 subscription matching fields. */ +/** + * `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 @@ -24,19 +28,31 @@ export type ReportingDeliveryOfferingWithDimensionsV1 = ReportingDeliveryOfferin unmapped_dimensions?: string[]; }; -/** One `inactive_intervals[]` readback entry. */ +/** + * 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`. */ +/** + * `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`. */ +/** + * 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; @@ -46,7 +62,11 @@ export interface ReportingSubscriptionTimezoneCutoverV1 { timezone: string; } -/** One `gaps[]` entry with an optional `offering_id`. */ +/** + * 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; @@ -55,7 +75,11 @@ export interface ReportingSubscriptionGapV1 { reason: 'unauthorized'; } -/** `core/reporting-subscription-coverage.json` with per-offering cutovers and gaps. */ +/** + * `core/reporting-subscription-coverage.json` with per-offering 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' diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 5646d5326..9c0b4f0d4 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -7,11 +7,13 @@ const { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, ReportingConfigurationImmutableError, createReliableReportingService, - createReportingSubscriptionEngineV1, isReportingConfigurationImmutableError, - reportingConfigurationCapabilitiesV1, runReliableReportingServiceConformanceV1, } = require('../../dist/lib/reporting/service/index.js'); +const { + createReportingSubscriptionEngineV1, + reportingConfigurationCapabilitiesV1, +} = require('../../dist/lib/reporting/advanced/index.js'); const { createInlineReportingSourceExecutor, redactedReportingSourceOfferingV1, 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 index eb30f5cde..ce539f182 100644 --- a/test/lib/reporting-subscription-consumer.test.js +++ b/test/lib/reporting-subscription-consumer.test.js @@ -7,14 +7,14 @@ const { InMemoryStateStore } = require('../../dist/lib/server/index.js'); const { createReportingSubscriptionEngineStateStoreV1, createReportingSubscriptionEngineV1, -} = require('../../dist/lib/reporting/service/index.js'); +} = require('../../dist/lib/reporting/advanced/index.js'); +const { createReliableReportingConsumerV1 } = require('../../dist/lib/reporting/consumer/index.js'); const { - createReliableReportingConsumerV1, expectedSubscriptionPeriods, groupExpectedSubscriptionPeriodsV1, reportingSubscriptionVersionRecordsV1, subscriptionExpectedReportingPeriodsV1, -} = require('../../dist/lib/reporting/consumer/index.js'); +} = require('../../dist/lib/reporting/advanced/index.js'); const { syncReportingSubscriptionsV1 } = require('../../dist/lib/index.js'); const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.js'); diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index eeb1bae96..5a442afe6 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -1,8 +1,11 @@ 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 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, @@ -10,7 +13,7 @@ const { groupExpectedSubscriptionPeriodsV1, resolveReportingSubscriptionOfferingsV1, reportingSubscriptionScopeIncludesV1, -} = consumer; +} = advanced; // The JSON table is the cross-SDK parity contract (adcp-client-python mirrors it). const table = require(path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'coverage-cases.json')); @@ -115,10 +118,12 @@ describe('expectedSubscriptionPeriods parity table', () => { } }); - test('ledger and consumer entry points export the same rule', () => { - assert.equal(typeof ledger.expectedSubscriptionPeriods, 'function'); - const input = structuredClone(table.cases[0].input); - assert.deepEqual(ledger.expectedSubscriptionPeriods(input), expectedSubscriptionPeriods(input)); + 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); + } }); }); diff --git a/test/lib/reporting-subscription-delivery.test.js b/test/lib/reporting-subscription-delivery.test.js index 6adafb05e..e953b3195 100644 --- a/test/lib/reporting-subscription-delivery.test.js +++ b/test/lib/reporting-subscription-delivery.test.js @@ -12,10 +12,12 @@ process.env.NODE_ENV = 'test'; const { REPORTING_MANAGED_DELIVERY_COMPOSITION_V1, - createInMemoryReportingSubscriptionEngineStoreV1, createReliableReportingService, - createReportingSubscriptionEngineV1, } = require('../../dist/lib/reporting/service/index.js'); +const { + createInMemoryReportingSubscriptionEngineStoreV1, + createReportingSubscriptionEngineV1, +} = require('../../dist/lib/reporting/advanced/index.js'); const { redactedReportingSourceOfferingV1, redactedReportingSourceRequestV1, diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index 7287b74d7..a833db2fa 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -7,8 +7,8 @@ const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.j const { createInMemoryReportingSubscriptionEngineStoreV1, createReportingSubscriptionEngineV1, -} = require('../../dist/lib/reporting/service/index.js'); -const { expectedSubscriptionPeriods } = require('../../dist/lib/reporting/consumer/index.js'); +} = 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' }; diff --git a/test/lib/reporting-subscription-grants.test.js b/test/lib/reporting-subscription-grants.test.js index 9a89cc727..ead51a294 100644 --- a/test/lib/reporting-subscription-grants.test.js +++ b/test/lib/reporting-subscription-grants.test.js @@ -10,12 +10,12 @@ const { describe, test } = require('node:test'); process.env.NODE_ENV = 'test'; +const { createReliableReportingService } = require('../../dist/lib/reporting/service/index.js'); const { createInMemoryReportingSubscriptionEngineStoreV1, - createReliableReportingService, createReportingSubscriptionEngineV1, reportingSubscriptionCoverageGrantedV1, -} = require('../../dist/lib/reporting/service/index.js'); +} = require('../../dist/lib/reporting/advanced/index.js'); const { createSyncReportingReceiptsHandler, reportingSubscriptionReceiptAdmissibleV1, diff --git a/test/lib/reporting-subscription-postgres.test.js b/test/lib/reporting-subscription-postgres.test.js index 6b04c3584..0fb8c0e64 100644 --- a/test/lib/reporting-subscription-postgres.test.js +++ b/test/lib/reporting-subscription-postgres.test.js @@ -19,14 +19,14 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA let pool; let lib; let server; - let service; + 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'); - service = require('../../dist/lib/reporting/service/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]]); }); @@ -73,12 +73,12 @@ describe('reporting subscriptions on PostgreSQL', { skip: !DATABASE_URL && 'DATA now: () => clock.now, reportingSubscriptions: { listDeliveryConfigIds: () => [] }, }); - const engineStore = service.createReportingSubscriptionEngineStateStoreV1({ + const engineStore = advanced.createReportingSubscriptionEngineStateStoreV1({ store: backing, collection: ENGINE, durability: 'durable', }); - const engine = service.createReportingSubscriptionEngineV1({ + const engine = advanced.createReportingSubscriptionEngineV1({ principal: lifecycle, store: engineStore, offerings: [ diff --git a/test/lib/reporting-subscription-service.test.js b/test/lib/reporting-subscription-service.test.js index e3c358699..98b43b010 100644 --- a/test/lib/reporting-subscription-service.test.js +++ b/test/lib/reporting-subscription-service.test.js @@ -10,11 +10,11 @@ const { describe, test } = require('node:test'); process.env.NODE_ENV = 'test'; +const { createReliableReportingService } = require('../../dist/lib/reporting/service/index.js'); const { createInMemoryReportingSubscriptionEngineStoreV1, - createReliableReportingService, createReportingSubscriptionEngineV1, -} = require('../../dist/lib/reporting/service/index.js'); +} = require('../../dist/lib/reporting/advanced/index.js'); const { redactedReportingSourceOfferingV1, redactedReportingSourceRequestV1, From ed8ed37d378cfb29f6ef2ea936eb944f6890d6b9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 17:49:51 +0000 Subject: [PATCH 35/49] feat(reporting): wind down with windDownGrant instead of revoking Revocation means no access (adcp#8607), so a seller that wants a buyer to finish reconciling keeps a reduced grant. windDownGrant() returns it (get_reporting_status, sync_reporting_receipts and sync_reporting_status, read_only false) and keeps the current grant's authorized_at, because a reduced grant continues the chain. REPORTING_WIND_DOWN_TASKS names the tasks. grant-cases.json adds shared vectors for the coverage grant rule and for grant transitions through the engine, including reduced grants without get_reporting_status, which read as a revocation. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-dx-revocation.md | 8 + docs/guides/REPORTING-LEDGER.md | 23 ++- src/lib/server/decisioning/account.ts | 47 +++++ src/lib/server/decisioning/index.ts | 2 + .../reporting-subscriptions/grant-cases.json | 171 +++++++++++++++++ .../reporting-subscription-wind-down.test.js | 177 ++++++++++++++++++ 6 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 .changeset/reporting-subscription-dx-revocation.md create mode 100644 test/fixtures/reporting-subscriptions/grant-cases.json create mode 100644 test/lib/reporting-subscription-wind-down.test.js 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/docs/guides/REPORTING-LEDGER.md b/docs/guides/REPORTING-LEDGER.md index 5287fe7f4..3ceedddbb 100644 --- a/docs/guides/REPORTING-LEDGER.md +++ b/docs/guides/REPORTING-LEDGER.md @@ -343,11 +343,24 @@ 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. -Access means access: there is no post-revocation window. Once authority for an -account ends, the account is unknown to every task, receipts and statuses -included. 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`. +**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 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/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/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/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), + }); + }); + } +}); From edff7a71ba4ac274d340f88e08320bd92b0a45ec Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 18:06:44 +0000 Subject: [PATCH 36/49] feat(reporting): typed reporting-subscription errors with code, recovery and dispatched Every error the reporting-subscription code raises now carries a stable code and a recovery class from the shared vocabulary, and a local refusal carries dispatched: false (building on #3507). - ReportingSubscriptionError (buyer) with the section 2.12 codes. syncReportingSubscriptionsV1 throws it for the deactivation guard, conflict exhaustion and readback mismatches. - PrincipalLifecycleError becomes an ADCPError with code, recovery and dispatched; applied is renamed appliedResponse and kept as a deprecated alias. - CoverageInputError replaces the coverage function's and the calendar's TypeError and RangeError, with the shared detail codes. - The engine throws ReportingSubscriptionEngineError at run time and PlatformConfigError (now with code, configField and recovery) for configuration mistakes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-dx-errors.md | 10 + docs/guides/PRINCIPAL-LIFECYCLE.md | 27 ++ src/lib/errors/index.ts | 196 +++++++++ src/lib/index.ts | 9 + src/lib/principal/index.ts | 2 + src/lib/principal/lifecycle.ts | 223 +++++++--- src/lib/principal/reporting-subscriptions.ts | 60 ++- src/lib/reporting/advanced/index.ts | 9 + src/lib/reporting/service/index.ts | 31 +- src/lib/reporting/subscriptions/calendar.ts | 97 ++++- .../reporting/subscriptions/capabilities.ts | 18 +- src/lib/reporting/subscriptions/coverage.ts | 125 ++++-- .../subscriptions/engine-state-store.ts | 35 +- src/lib/reporting/subscriptions/engine.ts | 65 ++- .../reporting/subscriptions/expectations.ts | 7 +- src/lib/reporting/subscriptions/selection.ts | 7 +- .../decisioning/runtime/validate-platform.ts | 16 +- .../coverage-cases.json | 27 +- test/lib/reliable-reporting-service.test.js | 11 +- .../reporting-subscription-coverage.test.js | 38 +- .../lib/reporting-subscription-errors.test.js | 381 ++++++++++++++++++ 21 files changed, 1222 insertions(+), 172 deletions(-) create mode 100644 .changeset/reporting-subscription-dx-errors.md create mode 100644 test/lib/reporting-subscription-errors.test.js diff --git a/.changeset/reporting-subscription-dx-errors.md b/.changeset/reporting-subscription-dx-errors.md new file mode 100644 index 000000000..473bace55 --- /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, for a refusal made before sending, `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/docs/guides/PRINCIPAL-LIFECYCLE.md b/docs/guides/PRINCIPAL-LIFECYCLE.md index a6c3a278b..65565aa2d 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` when nothing was sent), `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/src/lib/errors/index.ts b/src/lib/errors/index.ts index 3db385fce..0734e8684 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1272,3 +1272,199 @@ 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'; + +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 { + /** + * Pass `false` only when no bytes of the `sync_principal` write left the process, so the seller + * cannot have acted on it. Omit it otherwise. See {@link isPreDispatchError}. + */ + dispatched?: false; + 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_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` when the SDK refused before sending the `sync_principal` write, so + * nothing changed at the seller ({@link isPreDispatchError}). It is absent when 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 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 (options.dispatched === false) Object.defineProperty(this, 'dispatched', { value: false, enumerable: true }); + if (options.cause !== undefined) Object.defineProperty(this, 'cause', { value: options.cause, configurable: true }); + 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 } : {}), + }; + } +} + +/** + * 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 d70526f58..2764ef57a 100644 --- a/src/lib/principal/index.ts +++ b/src/lib/principal/index.ts @@ -3,6 +3,8 @@ export { PrincipalLifecycleError, PrincipalLifecycleTimeoutError, type PrincipalLifecycleClient, + type PrincipalLifecycleErrorCode, + type PrincipalLifecycleErrorOptions, type PrincipalLifecycleOptions, type PrincipalLifecycleResult, } from './lifecycle'; diff --git a/src/lib/principal/lifecycle.ts b/src/lib/principal/lifecycle.ts index 2261ad64b..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, }); } @@ -320,7 +419,7 @@ export async function guardedSyncPrincipalLifecycle( client: PrincipalLifecycleClient, configuration: PrincipalConfiguration, options: PrincipalLifecycleOptions = {}, - assertReplaceable?: (prior: GetPrincipalResponse['result']) => void + assertReplaceable?: (prior: GetPrincipalResponse['result'], context: { written: boolean }) => void ): Promise { const maxAttempts = options.maxAttempts ?? 3; const setupTimeoutMs = options.setupTimeoutMs ?? 60_000; @@ -343,9 +442,11 @@ export async function guardedSyncPrincipalLifecycle( .filter(destination => destination.active) .map(destination => destination.destination_id) ); - let prior = await readCurrent(client, options); - assertExpectedPrincipalKind(prior, options.expectedPrincipalKind); - assertReplaceable?.(prior); + // 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(); @@ -355,7 +456,9 @@ export async function guardedSyncPrincipalLifecycle( 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 = { @@ -369,40 +472,56 @@ export async function guardedSyncPrincipalLifecycle( : {}), }; 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); - assertReplaceable?.(refreshed); + 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, @@ -415,12 +534,14 @@ export async function guardedSyncPrincipalLifecycle( 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 || @@ -428,17 +549,19 @@ export async function guardedSyncPrincipalLifecycle( 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, }); } @@ -446,7 +569,8 @@ export async function guardedSyncPrincipalLifecycle( } if (readback.kind !== 'current') { throw new PrincipalLifecycleError('Principal configuration disappeared while destination setup was pending.', { - applied, + code: 'PRINCIPAL_SETUP_INTERRUPTED', + appliedResponse: applied, current, }); } @@ -456,7 +580,8 @@ export async function guardedSyncPrincipalLifecycle( 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 index 79f78b4ea..1550aad7b 100644 --- a/src/lib/principal/reporting-subscriptions.ts +++ b/src/lib/principal/reporting-subscriptions.ts @@ -3,6 +3,7 @@ * `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, @@ -28,7 +29,12 @@ export interface SyncReportingSubscriptionsOptionsV1 extends PrincipalLifecycleO } /** - * Replace the caller's complete reporting subscription set. Uses the same guarded + * Replace the caller's complete reporting subscription set. + * + * Throws {@link ReportingSubscriptionError}: `SUBSCRIPTION_WOULD_DEACTIVATE` (with `dispatched: false` + * when nothing was sent), `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 @@ -46,29 +52,47 @@ export async function syncReportingSubscriptionsV1( ): Promise { const { allowDeactivation = false, ...lifecycleOptions } = options; const submittedIds = new Set(subscriptions.map(subscription => subscription.subscription_id)); - const 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 PrincipalLifecycleError( - `The write would deactivate active subscriptions it omits (${omitted.map(id => JSON.stringify(id)).join(', ')}); include them, send active: false, or pass allowDeactivation: true` - ); + let result: PrincipalLifecycleResult; + try { + result = await guardedSyncPrincipalLifecycle( + client, + { reporting_subscriptions: structuredClone([...subscriptions]) } as never, + lifecycleOptions, + (prior, { written }) => { + 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.`, + { wouldDeactivate: omitted, ...(written ? {} : { dispatched: false as const }) } + ); + } } + ); + } 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 PrincipalLifecycleError(message, { applied: result.applied, current: result.current }); + 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}` diff --git a/src/lib/reporting/advanced/index.ts b/src/lib/reporting/advanced/index.ts index 7e2de5300..f1227db5e 100644 --- a/src/lib/reporting/advanced/index.ts +++ b/src/lib/reporting/advanced/index.ts @@ -59,3 +59,12 @@ export { 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/service/index.ts b/src/lib/reporting/service/index.ts index 1abe36e83..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'; @@ -549,26 +550,39 @@ export function createReliableReportingService); if (subscriptionOptions && subscriptionOptions.engine.settings.statusRetentionDays !== options.statusRetentionDays) { - throw new TypeError('The reporting subscription engine must use the statusRetentionDays this service advertises'); + 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'; diff --git a/src/lib/reporting/subscriptions/calendar.ts b/src/lib/reporting/subscriptions/calendar.ts index db38a4dfe..98d10bd5c 100644 --- a/src/lib/reporting/subscriptions/calendar.ts +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -21,6 +21,7 @@ * * @packageDocumentation */ +import { CoverageInputError } from '../../errors'; const DAY_MS = 86_400_000; /** Nominal lengths used only to estimate an ordinal before exact correction. */ @@ -103,9 +104,19 @@ const DURATION = /^P(?=.*[1-9])(?=\d|T)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?=\ * @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 TypeError('Invalid reporting period_duration'); + 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 TypeError(`Invalid reporting period_duration ${JSON.stringify(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), @@ -114,7 +125,11 @@ export function parseReportingPeriodDurationV1(value: string): ReportingIsoDurat seconds: Number(hours ?? 0) * 3_600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0), }; if (!Object.values(parts).every(Number.isSafeInteger)) { - throw new RangeError(`Reporting period_duration ${JSON.stringify(value)} is out of range`); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Reporting period_duration ${JSON.stringify(value)} is out of range`, + 'invalid_period_duration' + ); } return parts; } @@ -151,12 +166,20 @@ function formatterFor(timeZone: string): Intl.DateTimeFormat { */ 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 TypeError(`${field} must be an IANA timezone name`); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${field} must be an IANA timezone name`, + 'invalid_timezone' + ); } try { formatterFor(value); } catch { - throw new TypeError(`${field} ${JSON.stringify(value)} is not a recognized IANA timezone`); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `${field} ${JSON.stringify(value)} is not a recognized IANA timezone`, + 'invalid_timezone' + ); } } @@ -165,7 +188,11 @@ 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 RangeError('Reporting period boundary is outside the supported civil year range'); + 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')); @@ -209,10 +236,11 @@ function parseInstant(value: string, field: string): number { 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 TypeError(`${field} must be an RFC 3339 date-time`); + 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 TypeError(`${field} must be an RFC 3339 date-time`); + if (!Number.isFinite(parsed)) + throw new CoverageInputError('COVERAGE_INPUT_INVALID', `${field} must be an RFC 3339 date-time`, 'invalid_instant'); return parsed; } @@ -229,8 +257,10 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe if (spec.alignment !== 'utc' && hasDate && hasTime) { // adcontextprotocol/adcp#8588: under a local alignment a duration cannot mix date and // time components. - throw new TypeError( - `Reporting period_duration ${JSON.stringify(spec.period_duration)} mixes date and time components under ${spec.alignment} alignment` + 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 !== 'utc' && !hasDate) { @@ -253,7 +283,12 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe break; case 'billing_cycle': { assertReportingIanaTimezoneV1(spec.timezone, 'billing_cycle period_timezone'); - if (spec.period_anchor === undefined) throw new TypeError('billing_cycle alignment requires period_anchor'); + 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); @@ -262,8 +297,10 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe break; } default: - throw new TypeError( - `Unknown reporting period alignment ${JSON.stringify((spec as { alignment?: unknown }).alignment)}` + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Unknown reporting period alignment ${JSON.stringify((spec as { alignment?: unknown }).alignment)}`, + 'invalid_input' ); } const origin = new Date(originWall); @@ -278,7 +315,12 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe const cache = new Map(); function boundary(ordinal: number): number { - if (!Number.isSafeInteger(ordinal)) throw new RangeError('Reporting period ordinal must be a safe integer'); + 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; @@ -298,7 +340,11 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe } const value = instantForWall(wall, timeZone) + ordinal * exactMs; if (!Number.isFinite(value) || value < MIN_INSTANT || value > MAX_INSTANT) { - throw new RangeError('Reporting period boundary is outside the RFC 3339 instant range'); + 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); @@ -308,7 +354,8 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe const originInstant = boundary(0); function ceilOrdinal(instant: number): number { - if (!Number.isFinite(instant)) throw new RangeError('Reporting instant must be finite'); + 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) { @@ -364,8 +411,10 @@ function createSubDailyLocalCalendar(spec: ReportingPeriodCalendarSpecV1, stepMs assertReportingIanaTimezoneV1(spec.timezone, `${spec.alignment} timezone`); } if (!(stepMs > 0) || DAY_MS % stepMs !== 0) { - throw new TypeError( - `Sub-daily period_duration ${JSON.stringify(spec.period_duration)} must divide 24 hours under ${spec.alignment} alignment` + 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; @@ -379,7 +428,11 @@ function createSubDailyLocalCalendar(spec: ReportingPeriodCalendarSpecV1, stepMs 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 RangeError('Reporting period boundary is outside the RFC 3339 instant range'); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'Reporting period boundary is outside the RFC 3339 instant range', + 'invalid_instant' + ); } values.push(value); } @@ -391,7 +444,8 @@ function createSubDailyLocalCalendar(spec: ReportingPeriodCalendarSpecV1, stepMs } const localDay = (instant: number) => Math.floor(wallTime(instant, timeZone) / DAY_MS); function ceil(instant: number): number { - if (!Number.isFinite(instant)) throw new RangeError('Reporting instant must be finite'); + 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; @@ -402,7 +456,8 @@ function createSubDailyLocalCalendar(spec: ReportingPeriodCalendarSpecV1, stepMs return ceil(instant + 1); } function floor(instant: number): number { - if (!Number.isFinite(instant)) throw new RangeError('Reporting instant must be finite'); + 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; diff --git a/src/lib/reporting/subscriptions/capabilities.ts b/src/lib/reporting/subscriptions/capabilities.ts index 16c77f656..5902475c5 100644 --- a/src/lib/reporting/subscriptions/capabilities.ts +++ b/src/lib/reporting/subscriptions/capabilities.ts @@ -7,6 +7,7 @@ * 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 */ @@ -48,13 +49,21 @@ export function reportingConfigurationCapabilitiesV1( const accountConfigurations = input.accountConfigurations ?? true; if (input.subscriptions === undefined) { if (!accountConfigurations) { - throw new TypeError('Reporting registration requires sync_accounts, sync_principal subscriptions, or both'); + 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 TypeError('subscriptions.maxAccounts must be a positive integer'); + throw new PlatformConfigError( + 'subscriptions.maxAccounts must be a positive integer. Set a positive integer or omit it.', + { + configField: 'subscriptions.maxAccounts', + } + ); } if (dimensions !== undefined) { if ( @@ -62,7 +71,10 @@ export function reportingConfigurationCapabilitiesV1( new Set(dimensions).size !== dimensions.length || dimensions.some(value => typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) ) { - throw new TypeError('subscriptions.dimensions must be unique reporting-dimension values'); + throw new PlatformConfigError( + 'subscriptions.dimensions must be unique reporting-dimension values. List each standard dimension once, in snake_case.', + { configField: 'subscriptions.dimensions' } + ); } } return { diff --git a/src/lib/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index b766a8282..ec37519a3 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -28,6 +28,7 @@ * * @packageDocumentation */ +import { CoverageInputError } from '../../errors'; import type { ReportingSubscription } from '../../types/core.generated'; import type { ReportingSubscriptionCoverageWithOfferingsV1, @@ -270,9 +271,9 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn const now = instant(input.now, 'now'); const maxPeriods = input.max_periods ?? DEFAULT_MAX_PERIODS; if (!Number.isSafeInteger(maxPeriods) || maxPeriods < 1) - throw new TypeError('max_periods must be a positive integer'); + 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 TypeError('subscription_id is required'); + 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); @@ -280,7 +281,8 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn const removals = exceptionWindows(input.coverage); const offeringsById = new Map(); for (const offering of input.offerings) { - if (offeringsById.has(offering.offering_id)) throw new TypeError('offering_id values must be unique'); + 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(); @@ -288,12 +290,17 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn const seenBuys = new Set(); const maxIterations = input.max_iterations ?? DEFAULT_MAX_ITERATIONS; if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) { - throw new TypeError('max_iterations must be a positive integer'); + 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 TypeError('media_buy_id values must be unique'); + 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`); @@ -315,7 +322,10 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn let start = Math.max(firstOwedBoundary(calendar, handoff, confirmedAt), calendar.floor(earliestStart)); while (start < regime.until && start < latestStop) { if (++iterations > maxIterations) { - throw new RangeError(`Expected subscription periods exceed the iteration budget (${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); @@ -348,7 +358,10 @@ export function expectedSubscriptionPeriods(input: ExpectedSubscriptionPeriodsIn period_end: new Date(end).toISOString(), }); if (result.length > maxPeriods) { - throw new RangeError(`Expected subscription periods exceed max_periods (${maxPeriods})`); + throw new CoverageInputError( + 'COVERAGE_BUDGET_EXCEEDED', + `Expected subscription periods exceed max_periods (${maxPeriods})` + ); } start = end; } @@ -437,23 +450,40 @@ function normalizeVersions( subscriptionId: string, globalInactive: readonly Window[] ): NormalizedVersion[] { - if (!Array.isArray(records) || records.length === 0) throw new TypeError('versions must list at least one version'); + 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 TypeError('subscription_version must be a positive integer'); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'subscription_version must be a positive integer', + 'invalid_input' + ); } if (record.configuration?.subscription_id !== subscriptionId) { - throw new TypeError(`${label} configuration names a different subscription_id`); + 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 TypeError(`${label} configuration carries a different 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 TypeError(`${label} names a destination; destination_method is required`); + 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); @@ -461,7 +491,11 @@ function normalizeVersions( previous && (record.subscription_version <= previous.record.subscription_version || acceptedAt < previous.acceptedAt) ) { - throw new TypeError('versions must be in ascending subscription_version and accepted_at order'); + 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 @@ -470,7 +504,11 @@ function normalizeVersions( ? 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 TypeError(`${label} superseded_at must equal the next version's 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) { @@ -483,7 +521,11 @@ function normalizeVersions( next === undefined && !globalInactive.some(window => !Number.isFinite(window.until)) ) { - throw new TypeError(`${label} is inactive; deactivated_at or an open inactive_intervals entry is required`); + 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] }); } @@ -492,14 +534,20 @@ function normalizeVersions( /** The readback's `inactive_intervals` as half-open windows. */ function inactiveWindows(intervals: readonly ReportingSubscriptionInactiveIntervalV1[] | undefined): Window[] { - if (intervals !== undefined && !Array.isArray(intervals)) throw new TypeError('inactive_intervals must be an array'); + 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 TypeError('inactive_intervals reactivated_at must be after deactivated_at'); + if (!(until > from)) + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + 'inactive_intervals reactivated_at must be after deactivated_at', + 'invalid_input' + ); return { from, until }; }); } @@ -508,7 +556,8 @@ function authorityChains( grants: readonly ReportingSubscriptionGrantV1[], retentionDays: number | undefined ): AuthorityChain[] { - if (!Array.isArray(grants)) throw new TypeError('account.grants must be an array'); + 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) { @@ -517,9 +566,10 @@ function authorityChains( const unauthorizedAt = grant.unauthorized_at === undefined ? undefined : instant(grant.unauthorized_at, 'grants.unauthorized_at'); if (unauthorizedAt !== undefined && unauthorizedAt < authorizedAt) { - throw new TypeError('A grant cannot end before it began'); + throw new CoverageInputError('COVERAGE_INPUT_INVALID', 'A grant cannot end before it began', 'invalid_input'); } - if (previousEnd === undefined && index > 0) throw new TypeError('Only the last grant may be unrevoked'); + 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) { @@ -529,7 +579,11 @@ function authorityChains( current.interruptions.push({ revokedAt: previousEnd }); delete current.end; } else if (retentionDays === undefined || !Number.isSafeInteger(retentionDays) || retentionDays < 1) { - throw new TypeError('status_retention_days is required to evaluate a re-grant'); + 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; @@ -617,7 +671,11 @@ function timezoneFor(timeline: TimezoneTimeline, offeringId: string, at: number) if (applicable.length === 0) return timeline.current; if (at < applicable[0]!.at) { if (applicable[0]!.previous === undefined) { - throw new TypeError('timezone_cutovers entries require previous_timezone'); + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + 'timezone_cutovers entries require previous_timezone', + 'missing_previous_timezone' + ); } return applicable[0]!.previous; } @@ -821,8 +879,10 @@ function calendarSpec( case 'account_timezone': { const timezone = override ?? accountTimezone; if (timezone === undefined) { - throw new TypeError( - `Offering ${offering.offering_id} is account_timezone aligned; account.timezone is required` + throw new CoverageInputError( + 'COVERAGE_INPUT_INCOMPLETE', + `Offering ${offering.offering_id} is account_timezone aligned; account.timezone is required`, + 'missing_account_timezone' ); } return { ...base, timezone }; @@ -831,8 +891,10 @@ function calendarSpec( // 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 TypeError( - `Offering ${offering.offering_id} resolves its source timezone per account; account.source_timezone is required` + 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 }; @@ -840,7 +902,11 @@ function calendarSpec( case 'billing_cycle': return { ...base, timezone: schedule.period_timezone!, period_anchor: schedule.period_anchor! }; default: - throw new TypeError(`Offering ${offering.offering_id} has an unknown alignment`); + throw new CoverageInputError( + 'COVERAGE_INPUT_INVALID', + `Offering ${offering.offering_id} has an unknown alignment`, + 'invalid_input' + ); } } @@ -873,9 +939,10 @@ function instant(value: unknown, field: string): number { 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 TypeError(`${field} must be an RFC 3339 date-time`); + 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 TypeError(`${field} must be an RFC 3339 date-time`); + 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 index 59129fd6e..355fe9252 100644 --- a/src/lib/reporting/subscriptions/engine-state-store.ts +++ b/src/lib/reporting/subscriptions/engine-state-store.ts @@ -9,6 +9,8 @@ * 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'; @@ -50,10 +52,16 @@ export function createReportingSubscriptionEngineStateStoreV1( options: CreateReportingSubscriptionEngineStateStoreOptionsV1 ): ReportingSubscriptionEngineStoreV1 { if (!options?.store?.getWithVersion || !options.store.putIfMatch) { - throw new TypeError('The reporting subscription engine store requires getWithVersion and 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 TypeError('InMemoryStateStore cannot be declared durable'); + 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; @@ -73,12 +81,17 @@ export function createReportingSubscriptionEngineStateStoreV1( 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 Error('Reporting subscription engine store returned a repeating cursor'); + 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 Error( - `Reporting subscription engine store did not finish within ${MAX_PAGES} pages; refusing a partial list` + 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.` ); } @@ -96,14 +109,20 @@ export function createReportingSubscriptionEngineStateStoreV1( !document.value || typeof document.value !== 'object' ) { - throw new Error('Reporting subscription engine document is malformed or belongs to another principal'); + 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 Error('Reporting subscription engine store returned an invalid revision'); + 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, diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 6ab62ea92..b91a0e120 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -30,6 +30,8 @@ * State is sharded: one document per principal for `max_accounts` slots, and one per * (principal, account), each updated with compare-and-set. */ +import { ReportingSubscriptionEngineError } from '../../errors'; +import { PlatformConfigError } from '../../server/decisioning/runtime/validate-platform'; import type { PendingAction } from '../../types/core.generated'; import type { PrincipalLifecycleRuntimeSubscriptions, @@ -549,7 +551,12 @@ function emptySubscriptionAccount(): SubscriptionAccountState { function parseInstant(value: unknown, field: string): number { const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN; - if (!Number.isFinite(parsed)) throw new TypeError(`${field} must be an RFC 3339 date-time`); + 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; } @@ -578,8 +585,9 @@ export function createReportingSubscriptionEngineV1( options: CreateReportingSubscriptionEngineOptionsV1 ): ReportingSubscriptionEngineV1 { if (!options?.principal?.listReportingSubscriptions) { - throw new TypeError( - 'createReportingSubscriptionEngineV1 requires a principal runtime with reporting subscriptions' + 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 [ @@ -589,25 +597,39 @@ export function createReportingSubscriptionEngineV1( 'isPeriodDelivered', ] as const) { if (typeof options.hooks?.[hook] !== 'function') { - throw new TypeError(`createReportingSubscriptionEngineV1 requires the ${hook} hook`); + throw new PlatformConfigError( + `createReportingSubscriptionEngineV1 requires the ${hook} hook. Supply hooks.${hook}.`, + { configField: `hooks.${hook}` } + ); } } if (!Number.isSafeInteger(options.statusRetentionDays) || options.statusRetentionDays < 1) { - throw new TypeError('statusRetentionDays must be a positive integer'); + 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 TypeError('maxAccounts must be a positive integer'); + 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 TypeError('Production reporting subscription engines require a durable store'); + 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 TypeError('offering_id values must be unique'); + 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, @@ -622,7 +644,10 @@ export function createReportingSubscriptionEngineV1( const result = await store.put(principal, key, value, current?.revision ?? null); if (result.outcome === 'applied') return value; } - throw new Error('Reporting subscription engine state conflicted repeatedly'); + 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( @@ -793,11 +818,17 @@ export function createReportingSubscriptionEngineV1( if (reportingSubscriptionCoverageGrantedV1(account)) accounts.set(account.account_id, account); } if (result.nextCursor === undefined) return accounts; - if (result.nextCursor === cursor) throw new Error('listAuthorizedAccounts returned a repeating cursor'); + 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 Error( - `listAuthorizedAccounts did not finish within ${MAX_AUTHORIZED_ACCOUNT_PAGES} pages; refusing a partial roster` + throw new ReportingSubscriptionEngineError( + 'SUBSCRIPTION_ROSTER_INCOMPLETE', + `listAuthorizedAccounts did not finish within ${MAX_AUTHORIZED_ACCOUNT_PAGES} pages; refusing a partial roster. Return larger pages.` ); } @@ -856,8 +887,9 @@ export function createReportingSubscriptionEngineV1( } else { // The chain continues past a recorded revocation; only the host knows when. if (fact.granted_at === undefined) { - throw new TypeError( - `Account ${JSON.stringify(accountId)} was re-granted inside its authorization chain; the host must report granted_at` + 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`); @@ -1003,8 +1035,9 @@ export function createReportingSubscriptionEngineV1( if (fact?.timezone !== undefined && fact.timezone !== account.timezone) { if (account.timezone !== undefined) { if (fact.timezone_changed_at === undefined) { - throw new TypeError( - `Account ${JSON.stringify(accountId)} changed timezone; the host must report timezone_changed_at` + 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( diff --git a/src/lib/reporting/subscriptions/expectations.ts b/src/lib/reporting/subscriptions/expectations.ts index 1b5ac6de5..eb4a9da21 100644 --- a/src/lib/reporting/subscriptions/expectations.ts +++ b/src/lib/reporting/subscriptions/expectations.ts @@ -2,6 +2,7 @@ * 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'; @@ -47,8 +48,10 @@ export function reportingSubscriptionVersionRecordsV1(input: { return entries.map(entry => { const configuration = input.configurations?.[entry.version] ?? entry.published; if (!configuration) { - throw new TypeError( - `No configuration for ${state.subscription_id} version ${entry.version}: the readback does not carry it, so keep every version you send` + 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]; diff --git a/src/lib/reporting/subscriptions/selection.ts b/src/lib/reporting/subscriptions/selection.ts index 1ec248b74..66d4563df 100644 --- a/src/lib/reporting/subscriptions/selection.ts +++ b/src/lib/reporting/subscriptions/selection.ts @@ -5,6 +5,7 @@ * 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'; @@ -167,9 +168,11 @@ export function resolveReportingSubscriptionOfferingsV1(input: ResolveReportingS 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 TypeError( + 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' + 'a subscription selector cannot be resolved against it', + 'missing_standard_dimensions' ); } return offering.standard_dimensions; diff --git a/src/lib/server/decisioning/runtime/validate-platform.ts b/src/lib/server/decisioning/runtime/validate-platform.ts index 44d4f400e..d8487843c 100644 --- a/src/lib/server/decisioning/runtime/validate-platform.ts +++ b/src/lib/server/decisioning/runtime/validate-platform.ts @@ -94,10 +94,24 @@ const SPECIALISM_REQUIREMENTS: Partial { { configuration_tasks: ['sync_principal'], subscriptions: { supported: true } }, 'configuration_task appears only when configuration_tasks lists sync_accounts' ); - assert.throws(() => reportingConfigurationCapabilitiesV1({ accountConfigurations: false }), TypeError); - assert.throws(() => reportingConfigurationCapabilitiesV1({ subscriptions: { maxAccounts: 0 } }), TypeError); + 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 () => { diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index 5a442afe6..56b31c2bd 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -2,6 +2,7 @@ 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'); @@ -43,7 +44,12 @@ describe('expectedSubscriptionPeriods parity table', () => { if (testCase.expected_error) { assert.throws( () => expectedSubscriptionPeriods(input), - error => error.name === testCase.expected_error + error => + error instanceof CoverageInputError && + error.code === testCase.expected_error.code && + error.detail === testCase.expected_error.detail && + error.recovery === 'correctable' && + !('dispatched' in error) ); return; } @@ -178,20 +184,33 @@ describe('expectedSubscriptionPeriods invariants', () => { test('fails closed above max_periods', () => { const input = structuredClone(baseline); input.max_periods = 2; - assert.throws(() => expectedSubscriptionPeriods(input), RangeError); + 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' }), TypeError); + 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), TypeError); + 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), /destination_method/); + assert.throws(() => expectedSubscriptionPeriods(destination), { + code: 'COVERAGE_INPUT_INCOMPLETE', + detail: 'missing_destination_method', + }); }); }); @@ -343,11 +362,14 @@ describe('createReportingPeriodCalendarV1 (period_generation)', () => { }); test('refuses schedules the protocol does not define', () => { - assert.throws(() => createReportingPeriodCalendarV1({ period_duration: 'P0D', alignment: 'utc' }), TypeError); + 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' }), - TypeError + { code: 'COVERAGE_INPUT_INVALID', detail: 'invalid_timezone' } ); assert.throws( () => @@ -356,7 +378,7 @@ describe('createReportingPeriodCalendarV1 (period_generation)', () => { alignment: 'account_timezone', timezone: 'Mars/Base', }), - TypeError + { code: 'COVERAGE_INPUT_INVALID', detail: 'invalid_timezone' } ); assert.throws( () => createReportingPeriodCalendarV1({ period_duration: 'P1M', alignment: 'billing_cycle', timezone: 'UTC' }), diff --git a/test/lib/reporting-subscription-errors.test.js b/test/lib/reporting-subscription-errors.test.js new file mode 100644 index 000000000..25ee28664 --- /dev/null +++ b/test/lib/reporting-subscription-errors.test.js @@ -0,0 +1,381 @@ +/** + * 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 buyer codes from the shared vocabulary (adcontextprotocol/adcp#8616, +// static/compliance/source/test-vectors/reporting-subscriptions/error-codes.json). +const BUYER_CODES = { + SUBSCRIPTION_WOULD_DEACTIVATE: { recovery: 'correctable', dispatched: 'false' }, + DESTINATION_WOULD_REVOKE: { recovery: 'correctable', dispatched: 'false' }, + SUBSCRIPTION_ID_CLASH: { recovery: 'correctable', dispatched: 'false' }, + SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED: { recovery: 'correctable', dispatched: 'false' }, + SUBSCRIPTION_SELECTOR_UNMATCHED: { recovery: 'correctable', dispatched: 'false' }, + SUBSCRIPTION_CONFLICT_EXHAUSTED: { recovery: 'transient', dispatched: 'absent' }, + SUBSCRIPTION_READBACK_MISMATCH: { recovery: 'terminal', dispatched: 'absent' }, +}; + +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('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.', + expected.dispatched === 'false' ? { dispatched: false } : {} + ); + 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 not marked pre-dispatch', 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('dispatched' in error, false, 'the conflicted write was 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', + }); + }); +}); From c9b3318d8bc2ffdfe42ed71d2858fd49c527b422 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 18:21:35 +0000 Subject: [PATCH 37/49] fix(reporting): align ReportingSubscriptionError with the canonical error vocabulary error-codes.json (adcp#8616, 023bbd26e) fixes dispatched per code: the five refusals the SDK raises before sending always carry dispatched: false, as in the Python SDK. Add the vector context fields deliveryConfigId, accountId and nearest beside wouldDeactivate, wouldRevoke and readback. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-dx-errors.md | 2 +- docs/guides/PRINCIPAL-LIFECYCLE.md | 4 +- src/lib/errors/index.ts | 41 +++++++++++++++---- src/lib/principal/reporting-subscriptions.ts | 8 ++-- .../lib/reporting-subscription-errors.test.js | 10 ++--- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.changeset/reporting-subscription-dx-errors.md b/.changeset/reporting-subscription-dx-errors.md index 473bace55..beb847fd6 100644 --- a/.changeset/reporting-subscription-dx-errors.md +++ b/.changeset/reporting-subscription-dx-errors.md @@ -4,7 +4,7 @@ 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, for a refusal made before sending, `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`. +- `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/docs/guides/PRINCIPAL-LIFECYCLE.md b/docs/guides/PRINCIPAL-LIFECYCLE.md index 65565aa2d..8ba325f8e 100644 --- a/docs/guides/PRINCIPAL-LIFECYCLE.md +++ b/docs/guides/PRINCIPAL-LIFECYCLE.md @@ -94,8 +94,8 @@ 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` when nothing was sent), `SUBSCRIPTION_CONFLICT_EXHAUSTED` +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. diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 0734e8684..ce6f478d4 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1294,6 +1294,15 @@ export type ReportingSubscriptionErrorCode = | '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', @@ -1306,16 +1315,17 @@ const REPORTING_SUBSCRIPTION_RECOVERY: Readonly { + prior => { if (allowDeactivation || prior.kind !== 'current') return; const current = (prior.configuration as { reporting_subscriptions?: ReportingSubscriptionState[] }) .reporting_subscriptions; @@ -69,7 +68,8 @@ export async function syncReportingSubscriptionsV1( 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.`, - { wouldDeactivate: omitted, ...(written ? {} : { dispatched: false as const }) } + // A refused write is never sent. An earlier attempt that met CONFLICT applied nothing. + { wouldDeactivate: omitted } ); } } diff --git a/test/lib/reporting-subscription-errors.test.js b/test/lib/reporting-subscription-errors.test.js index 25ee28664..be8668dcc 100644 --- a/test/lib/reporting-subscription-errors.test.js +++ b/test/lib/reporting-subscription-errors.test.js @@ -81,11 +81,7 @@ function keys() { 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.', - expected.dispatched === 'false' ? { dispatched: false } : {} - ); + 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); @@ -115,7 +111,7 @@ describe('ReportingSubscriptionError', () => { assert.equal(writes, 0); }); - test('an omission found on the re-read after a conflict is not marked pre-dispatch', async () => { + 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(); @@ -149,7 +145,7 @@ describe('ReportingSubscriptionError', () => { error => { assert.equal(error.code, 'SUBSCRIPTION_WOULD_DEACTIVATE'); assert.deepEqual(error.wouldDeactivate, ['concurrent']); - assert.equal('dispatched' in error, false, 'the conflicted write was sent'); + assert.equal(error.dispatched, false, 'the refused write was never sent'); return true; } ); From 3428a008207d21fb058f69f9f4fc990d094dbafa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 18:34:38 +0000 Subject: [PATCH 38/49] test(reporting): run the canonical reporting-subscription coverage vectors Vendor adcp#8616's vectors.json, case.schema.json, error-codes.json and reason-codes.json (023bbd26e) byte for byte under test/fixtures/reporting-subscriptions/canonical with a sha256 pin, a test that fails on drift, and npm run sync:reporting-subscription-vectors to refresh them. The parity test runs all 113 cases strictly and validates each against case.schema.json; the old coverage-cases.json is retired. The vectors expose two coverage bugs, fixed here: - excluded[].offering_id is honoured: such an entry removes only that offering's periods (max-accounts-capacity-per-offering). - A selector period_duration outside the schedule pattern (P1W) throws CoverageInputError COVERAGE_INPUT_INVALID / invalid_period_duration instead of computing an empty owed set. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscription-dx-vectors.md | 8 + .prettierignore | 2 + package.json | 3 +- scripts/check-package-size.mjs | 5 +- .../sync-reporting-subscription-vectors.mjs | 60 + src/lib/reporting/subscriptions/coverage.ts | 11 +- src/lib/reporting/subscriptions/engine.ts | 2 + src/lib/reporting/subscriptions/index.ts | 1 + src/lib/reporting/subscriptions/wire.ts | 16 +- .../canonical/PINNED.json | 13 + .../canonical/case.schema.json | 616 ++ .../canonical/error-codes.json | 181 + .../canonical/reason-codes.json | 255 + .../vectors.json} | 8963 ++++++++++++----- .../reporting-subscription-coverage.test.js | 153 +- .../lib/reporting-subscription-errors.test.js | 38 +- 16 files changed, 7618 insertions(+), 2709 deletions(-) create mode 100644 .changeset/reporting-subscription-dx-vectors.md create mode 100644 scripts/sync-reporting-subscription-vectors.mjs create mode 100644 test/fixtures/reporting-subscriptions/canonical/PINNED.json create mode 100644 test/fixtures/reporting-subscriptions/canonical/case.schema.json create mode 100644 test/fixtures/reporting-subscriptions/canonical/error-codes.json create mode 100644 test/fixtures/reporting-subscriptions/canonical/reason-codes.json rename test/fixtures/reporting-subscriptions/{coverage-cases.json => canonical/vectors.json} (53%) 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/.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/package.json b/package.json index d1f2cfea3..0c730c921 100644 --- a/package.json +++ b/package.json @@ -732,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/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 0ae1896a2..8a731d4e9 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -145,7 +145,10 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 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. -const MAX_PACKED_TARBALL_BYTES = 26_520_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 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/reporting/subscriptions/coverage.ts b/src/lib/reporting/subscriptions/coverage.ts index ec37519a3..9e38b3051 100644 --- a/src/lib/reporting/subscriptions/coverage.ts +++ b/src/lib/reporting/subscriptions/coverage.ts @@ -38,6 +38,7 @@ import type { import { assertReportingIanaTimezoneV1, createReportingPeriodCalendarV1, + parseReportingPeriodDurationV1, type ReportingPeriodCalendarV1, } from './calendar'; import { @@ -464,6 +465,11 @@ function normalizeVersions( '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', @@ -686,7 +692,7 @@ function timezoneFor(timeline: TimezoneTimeline, offeringId: string, at: number) interface ExceptionWindow extends Window { mediaBuyId?: string; - /** Gaps may name one offering; absent applies to every offering. */ + /** Exclusions and gaps may name one offering; absent applies to every offering. */ offeringId?: string; } @@ -700,6 +706,9 @@ function exceptionWindows(coverage: ExpectedSubscriptionPeriodsInputV1['coverage 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 ?? []) { diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index b91a0e120..a75d07abd 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -85,6 +85,8 @@ export type ReportingSubscriptionExclusionReasonV1 = */ 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; diff --git a/src/lib/reporting/subscriptions/index.ts b/src/lib/reporting/subscriptions/index.ts index 6027c59cd..474ae19b7 100644 --- a/src/lib/reporting/subscriptions/index.ts +++ b/src/lib/reporting/subscriptions/index.ts @@ -47,6 +47,7 @@ export type { ReportingConfigurationCapabilitiesInputV1, ReportingConfigurationC export type { ReportingDeliveryOfferingWithDimensionsV1, ReportingSubscriptionCoverageWithOfferingsV1, + ReportingSubscriptionExclusionEntryV1, ReportingSubscriptionGapV1, ReportingSubscriptionInactiveIntervalV1, ReportingSubscriptionStateWithIntervalsV1, diff --git a/src/lib/reporting/subscriptions/wire.ts b/src/lib/reporting/subscriptions/wire.ts index 8aa4bc861..e22365e27 100644 --- a/src/lib/reporting/subscriptions/wire.ts +++ b/src/lib/reporting/subscriptions/wire.ts @@ -76,14 +76,26 @@ export interface ReportingSubscriptionGapV1 { } /** - * `core/reporting-subscription-coverage.json` with per-offering cutovers and gaps. + * 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' + 'timezone_cutovers' | 'gaps' | 'excluded' > & { + excluded: ReportingSubscriptionExclusionEntryV1[]; timezone_cutovers?: ReportingSubscriptionTimezoneCutoverV1[]; gaps?: ReportingSubscriptionGapV1[]; }; 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/coverage-cases.json b/test/fixtures/reporting-subscriptions/canonical/vectors.json similarity index 53% rename from test/fixtures/reporting-subscriptions/coverage-cases.json rename to test/fixtures/reporting-subscriptions/canonical/vectors.json index a6e92ea4a..d71be543a 100644 --- a/test/fixtures/reporting-subscriptions/coverage-cases.json +++ b/test/fixtures/reporting-subscriptions/canonical/vectors.json @@ -1,9 +1,37 @@ { - "$comment": "Cross-SDK parity table for expectedSubscriptionPeriods (AdCP 3.3 media_buy.reporting_subscriptions), conforming to adcontextprotocol/adcp#8585, #8588 (sub-daily local periods) and #8593 (the start bound is the later of accepted_at and authorized_at; a buy is owed every period ending after its confirmed_at). Each case gives expected_obligations (the rows grouped by version, offering and period, with byte-sorted media_buy_ids) and, where the input needs data no AdCP response carries, non_wire_inputs. Each case gives the input and the exact owed periods, or expected_error: the code and detail of the CoverageInputError, from the shared vocabulary in the spec repository's error-codes.json. Compare instants, not strings: the TypeScript SDK emits toISOString() form. Expected rows are hand-derived from the spec rule. subscription_id, account_id and report_definition_id are omitted from expected rows; they are input.subscription_id, input.account.account_id and the offering's report_definition_id. Rows are ordered by period_start, subscription_version, offering_id and media_buy_id (byte order).", + "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": [ @@ -14,40 +42,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -67,11 +75,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -127,8 +131,15 @@ ] }, { + "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": [ @@ -139,40 +150,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -192,11 +183,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -252,8 +239,15 @@ ] }, { + "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": [ @@ -264,40 +258,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -317,11 +291,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -363,8 +333,15 @@ ] }, { + "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": [ @@ -375,40 +352,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -428,11 +385,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -488,8 +441,15 @@ ] }, { + "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": [ @@ -500,40 +460,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ], + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], "canceled_at": "2026-11-05T06:00:00Z" } ], @@ -554,11 +494,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -614,8 +550,15 @@ ] }, { + "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": [ @@ -626,40 +569,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ], + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], "completed_at": "2026-11-04T00:00:00Z" } ], @@ -680,11 +603,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -712,8 +631,16 @@ ] }, { + "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": [ @@ -724,47 +651,21 @@ "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"] - }, + "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" - } - ] - }, + "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" - } - ] + "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": [ @@ -784,11 +685,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -855,12 +752,19 @@ "period_end": "2026-11-08T00:00:00Z", "media_buy_ids": ["mb_1"] } - ], - "non_wire_inputs": ["buys[].prior_flights"] + ] }, { + "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": [ @@ -871,40 +775,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ], + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], "prior_flights": [ { "replaced_at": "2026-11-06T12:00:00Z", @@ -932,11 +816,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1003,12 +883,18 @@ "period_end": "2026-11-08T00:00:00Z", "media_buy_ids": ["mb_1"] } - ], - "non_wire_inputs": ["buys[].prior_flights"] + ] }, { + "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": [ @@ -1019,40 +905,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1072,11 +938,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1118,8 +980,15 @@ ] }, { + "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": [ @@ -1130,40 +999,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1183,11 +1032,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1199,8 +1044,17 @@ "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": [ @@ -1211,40 +1065,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1264,11 +1098,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1310,8 +1140,17 @@ ] }, { + "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": [ @@ -1322,15 +1161,8 @@ "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"] - }, + "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" @@ -1342,41 +1174,21 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1396,11 +1208,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1470,8 +1278,18 @@ ] }, { + "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": [ @@ -1482,15 +1300,8 @@ "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"] - }, + "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" @@ -1502,9 +1313,7 @@ "subscription_id": "analytics-daily", "subscription_version": 2, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -1516,27 +1325,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-07T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -1556,11 +1352,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1581,11 +1373,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1655,8 +1443,16 @@ ] }, { + "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": [ @@ -1667,41 +1463,21 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1721,11 +1497,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -1767,8 +1539,17 @@ ] }, { + "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": [ @@ -1779,40 +1560,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1832,23 +1593,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-06T12:00:00Z"}] }, "expected": [ { @@ -1912,8 +1664,18 @@ ] }, { + "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": [ @@ -1924,15 +1686,8 @@ "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"] - }, + "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" @@ -1944,40 +1699,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -1997,11 +1732,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2043,8 +1774,16 @@ ] }, { + "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": [ @@ -2055,41 +1794,21 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2109,11 +1828,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2125,8 +1840,16 @@ "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": [ @@ -2137,15 +1860,8 @@ "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"] - }, + "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"] } @@ -2154,11 +1870,7 @@ "account": { "account_id": "acct_1", "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } - ], + "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}], "operator": "other-agency.example" }, "buys": [ @@ -2167,12 +1879,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2192,11 +1899,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2208,8 +1911,20 @@ "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": [ @@ -2220,15 +1935,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -2236,12 +1944,7 @@ "account": { "account_id": "acct_1", "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z", - "unauthorized_at": "2026-11-05T06:00:00Z" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}] }, "buys": [ { @@ -2249,12 +1952,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2274,11 +1972,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2320,8 +2014,20 @@ ] }, { + "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": [ @@ -2332,15 +2038,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -2348,15 +2047,7 @@ "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" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] }, "buys": [ { @@ -2364,12 +2055,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2389,11 +2075,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2450,8 +2132,20 @@ ] }, { + "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": [ @@ -2462,15 +2156,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -2478,15 +2165,7 @@ "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" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] }, "buys": [ { @@ -2494,12 +2173,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2519,11 +2193,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2609,8 +2279,20 @@ ] }, { + "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": [ @@ -2621,15 +2303,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -2637,15 +2312,7 @@ "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" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] }, "buys": [ { @@ -2653,12 +2320,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2678,11 +2340,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2740,8 +2398,20 @@ ] }, { + "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": [ @@ -2752,40 +2422,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2805,26 +2455,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] - } + "coverage": {"gaps": [{"from": "2026-11-04T00:00:00Z", "until": "2026-11-05T00:00:00Z", "reason": "unauthorized"}]} }, "expected": [ { @@ -2860,8 +2498,17 @@ ] }, { + "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": [ @@ -2872,52 +2519,27 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -2937,11 +2559,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -2950,27 +2568,10 @@ "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" - } + {"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"} ] } }, @@ -3008,8 +2609,18 @@ ] }, { + "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": [ @@ -3020,40 +2631,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -3073,25 +2664,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] - } + "coverage": {"excluded": [{"reason": "capacity", "from": "2026-11-04T00:00:00Z"}]} }, "expected": [ { @@ -3113,8 +2693,18 @@ ] }, { + "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": [ @@ -3125,40 +2715,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -3178,11 +2748,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -3195,9 +2761,7 @@ "reason": "overridden", "from": "2026-11-04T00:00:00Z", "until": "2026-11-05T00:00:00Z", - "overridden_by": { - "delivery_config_id": "acme-analytics" - } + "overridden_by": {"delivery_config_id": "acme-analytics"} } ] } @@ -3236,8 +2800,18 @@ ] }, { + "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": [ @@ -3248,28 +2822,13 @@ "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"] - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", @@ -3279,12 +2838,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -3306,11 +2860,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -3331,11 +2881,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -3356,11 +2902,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -3381,11 +2923,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -3442,8 +2980,17 @@ ] }, { + "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": [ @@ -3454,40 +3001,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"]}] } ], "offerings": [ @@ -3507,11 +3034,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -3532,11 +3055,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -3557,11 +3076,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -3582,11 +3097,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -3599,8 +3110,18 @@ "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": [ @@ -3611,28 +3132,13 @@ "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"] - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", @@ -3642,12 +3148,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -3669,11 +3170,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -3694,11 +3191,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -3719,11 +3212,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -3744,11 +3233,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -3805,8 +3290,18 @@ ] }, { + "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": [ @@ -3817,9 +3312,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -3831,15 +3324,7 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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", @@ -3849,12 +3334,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -3876,11 +3356,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -3901,11 +3377,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -3926,11 +3398,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -3951,11 +3419,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -4012,8 +3476,18 @@ ] }, { + "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": [ @@ -4024,27 +3498,13 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, - "selector": { - "feed_purpose": "analytics", - "period_duration": "P1D", - "required_finality": "official" - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", @@ -4054,12 +3514,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -4081,11 +3536,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4106,11 +3557,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -4131,11 +3578,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -4156,11 +3599,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -4217,8 +3656,17 @@ ] }, { + "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": [ @@ -4229,9 +3677,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -4243,15 +3689,7 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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", @@ -4261,12 +3699,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -4288,11 +3721,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4313,11 +3742,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -4338,11 +3763,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -4363,11 +3784,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -4550,8 +3967,16 @@ ] }, { + "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": [ @@ -4562,28 +3987,13 @@ "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"] - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", @@ -4591,14 +4001,8 @@ "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": [] - } + {"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}, + {"package_id": "pkg_2", "reporting_delivery_offering_ids": []} ] } ], @@ -4619,11 +4023,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4644,11 +4044,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -4669,11 +4065,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -4694,11 +4086,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -4755,8 +4143,17 @@ ] }, { + "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": [ @@ -4767,40 +4164,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg", "off_c_pkg", "off_d_pkg"]}] } ], "offerings": [ @@ -4820,11 +4197,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4845,11 +4218,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4870,11 +4239,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT1H", - "alignment": "utc", - "delivery_sla": "PT1H" - }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4895,11 +4260,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -4955,8 +4316,19 @@ ] }, { + "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": [ @@ -4967,9 +4339,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -4981,27 +4351,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-07T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}] } ], "offerings": [ @@ -5021,11 +4378,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5046,11 +4399,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"], @@ -5118,12 +4467,19 @@ "period_end": "2026-11-07T00:00:00Z", "media_buy_ids": ["mb_1"] } - ], - "non_wire_inputs": ["offerings[].available_from/withdrawn_at"] + ] }, { + "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": [ @@ -5134,40 +4490,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -5187,11 +4523,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5261,8 +4593,16 @@ ] }, { + "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": [ @@ -5273,40 +4613,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -5326,11 +4646,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5386,8 +4702,18 @@ ] }, { + "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": [ @@ -5398,40 +4724,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -5451,11 +4757,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5463,13 +4765,7 @@ ], "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" - } - ] + "timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles", "previous_timezone": "America/New_York"}] } }, "expected": [ @@ -5548,8 +4844,17 @@ ] }, { + "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": [ @@ -5560,9 +4865,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -5575,27 +4878,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-05T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -5615,11 +4905,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5675,8 +4961,16 @@ ] }, { + "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": [ @@ -5687,40 +4981,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h_pkg_hourly"]}] } ], "offerings": [ @@ -5740,11 +5014,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT1H", - "alignment": "utc", - "delivery_sla": "PT1H" - }, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -5814,59 +5084,43 @@ ] }, { + "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" - }, + "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"] - }, + "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" - } + "destination": {"destination_id": "billing-archive"} } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m_billing_monthly"]}] } ], "offerings": [ @@ -5884,7 +5138,12 @@ "schema_dialect": "https://json-schema.org/draft/2020-12/schema", "schema_ref_policy": "local_fragment_only", "grain": "daily", - "primary_keys": ["account_id", "package_id"] + "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", @@ -5902,7 +5161,8 @@ "transport": "s3", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } } ], @@ -5956,8 +5216,15 @@ ] }, { + "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": [ @@ -5968,40 +5235,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -6021,11 +5268,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6081,59 +5324,45 @@ ] }, { + "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" - }, + "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"] - }, + "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" - } + "destination": {"destination_id": "billing-archive"} } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m_billing_monthly"]}] } ], "offerings": [ @@ -6151,7 +5380,12 @@ "schema_dialect": "https://json-schema.org/draft/2020-12/schema", "schema_ref_policy": "local_fragment_only", "grain": "daily", - "primary_keys": ["account_id", "package_id"] + "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", @@ -6169,7 +5403,8 @@ "transport": "s3", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } } ], @@ -6223,8 +5458,19 @@ ] }, { + "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": [ @@ -6235,48 +5481,22 @@ "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"] - }, + "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": {"destination_id": "archive", "path_template": "a/{account_id}"} }, - "destination_method": { - "pattern": "file_transfer", - "transport": "gcs" - } + "destination_method": {"pattern": "file_transfer", "transport": "gcs"} } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg", "off_s3_pkg", "off_t_gcs_pkg"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_s3_pkg", "off_t_gcs_pkg"]}] } ], "offerings": [ @@ -6296,11 +5516,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6321,11 +5537,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -6334,7 +5546,8 @@ "transport": "s3", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } }, { @@ -6353,11 +5566,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -6366,7 +5575,8 @@ "transport": "gcs", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } } ], @@ -6420,8 +5630,15 @@ ] }, { + "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": [ @@ -6432,52 +5649,27 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -6497,11 +5689,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6571,8 +5759,19 @@ ] }, { + "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": [ @@ -6583,9 +5782,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -6597,27 +5794,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/Los_Angeles", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz", "off_f_pkg_acct_tz"]}] } ], "offerings": [ @@ -6637,11 +5821,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6662,11 +5842,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6774,8 +5950,19 @@ ] }, { + "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": [ @@ -6786,9 +5973,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -6800,27 +5985,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative"]}] } ], "offerings": [ @@ -6840,11 +6012,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -6865,27 +6033,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] - } + "coverage": {"gaps": [{"offering_id": "off_a_pkg", "from": "2026-11-04T00:00:00Z", "until": "2026-11-05T00:00:00Z", "reason": "unauthorized"}]} }, "expected": [ { @@ -6963,8 +6118,18 @@ ] }, { + "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": [ @@ -6975,28 +6140,13 @@ "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" - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "America/New_York", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", @@ -7006,12 +6156,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -7033,11 +6178,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7058,11 +6199,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -7083,11 +6220,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -7108,11 +6241,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -7169,8 +6298,19 @@ ] }, { + "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": [ @@ -7181,9 +6321,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7198,11 +6336,7 @@ "account": { "account_id": "acct_1", "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } - ], + "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}], "source_timezone": "America/New_York" }, "buys": [ @@ -7211,12 +6345,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_resolved"]}] } ], "offerings": [ @@ -7236,12 +6365,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "source_timezone", - "delivery_sla": "PT6H", - "period_timezone_policy": "account_resolved" - }, + "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"] @@ -7253,8 +6377,19 @@ "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": [ @@ -7265,9 +6400,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7279,27 +6412,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-05T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_s_fixed"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_fixed"]}] } ], "offerings": [ @@ -7381,8 +6501,19 @@ ] }, { + "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": [ @@ -7393,9 +6524,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7407,27 +6536,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -7447,11 +6563,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7463,8 +6575,15 @@ "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": [ @@ -7475,40 +6594,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ], + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}], "canceled_at": "2026-11-05T02:00:00Z" } ], @@ -7529,11 +6628,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7545,8 +6640,15 @@ "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": [ @@ -7557,40 +6659,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -7610,11 +6692,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7626,8 +6704,18 @@ "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": [ @@ -7639,9 +6727,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7660,9 +6746,7 @@ "subscription_id": "analytics-daily", "subscription_version": 2, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7681,9 +6765,7 @@ "subscription_id": "analytics-daily", "subscription_version": 3, "active": true, - "scope": { - "account_ids": ["acct_1"] - }, + "scope": {"account_ids": ["acct_1"]}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7697,27 +6779,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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_e_pkg_acct_tz"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -7737,11 +6806,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7839,8 +6904,21 @@ ] }, { + "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": [ @@ -7851,9 +6929,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7872,9 +6948,7 @@ "subscription_id": "analytics-daily", "subscription_version": 2, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -7887,27 +6961,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/Los_Angeles", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -7927,11 +6988,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -7939,13 +6996,7 @@ ], "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" - } - ] + "timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles", "previous_timezone": "America/New_York"}] } }, "expected": [ @@ -8038,59 +7089,43 @@ ] }, { + "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" - }, + "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"] - }, + "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" - } + "destination": {"destination_id": "billing-archive"} } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2025-12-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_m31"]}] } ], "offerings": [ @@ -8108,7 +7143,12 @@ "schema_dialect": "https://json-schema.org/draft/2020-12/schema", "schema_ref_policy": "local_fragment_only", "grain": "daily", - "primary_keys": ["account_id", "package_id"] + "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", @@ -8126,7 +7166,8 @@ "transport": "s3", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } } ], @@ -8180,59 +7221,43 @@ ] }, { + "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" - }, + "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"] - }, + "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" - } + "destination": {"destination_id": "billing-archive"} } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2025-11-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_y29"]}] } ], "offerings": [ @@ -8250,7 +7275,12 @@ "schema_dialect": "https://json-schema.org/draft/2020-12/schema", "schema_ref_policy": "local_fragment_only", "grain": "daily", - "primary_keys": ["account_id", "package_id"] + "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", @@ -8268,7 +7298,8 @@ "transport": "s3", "orchestration": "producer_managed", "destination_modes": ["existing"], - "formats": ["jsonl"] + "provider": {"domain": "seller.example"}, + "format": "jsonl" } } ], @@ -8308,8 +7339,16 @@ ] }, { + "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": [ @@ -8320,9 +7359,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -8334,27 +7371,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/Santiago", - "grants": [ - { - "authorized_at": "2026-02-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"]}] } ], "offerings": [ @@ -8374,11 +7398,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -8462,8 +7482,18 @@ ] }, { + "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": [ @@ -8474,9 +7504,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -8488,27 +7516,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/Santiago", - "grants": [ - { - "authorized_at": "2026-02-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_s_pkg_acct_tz"]}] } ], "offerings": [ @@ -8528,11 +7543,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -8588,8 +7599,16 @@ ] }, { + "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": [ @@ -8600,9 +7619,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -8614,27 +7631,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "Pacific/Apia", - "grants": [ - { - "authorized_at": "2011-11-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -8654,11 +7658,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -8728,8 +7728,18 @@ ] }, { + "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": [ @@ -8740,9 +7750,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "PT24H", @@ -8754,27 +7762,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-02-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_h24_acct_tz"]}] } ], "offerings": [ @@ -8794,11 +7789,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT24H", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "PT24H", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -8854,8 +7845,16 @@ ] }, { + "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": [ @@ -8866,40 +7865,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_u24"]}] } ], "offerings": [ @@ -8919,11 +7898,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT24H", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "PT24H", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -8979,8 +7954,18 @@ ] }, { + "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": [ @@ -8991,9 +7976,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "pacing", "period_duration": "PT1H", @@ -9005,27 +7988,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "Asia/Singapore", - "grants": [ - { - "authorized_at": "2026-10-01T00:00:00Z" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] } ], "offerings": [ @@ -9045,11 +8015,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT1H", - "alignment": "account_timezone", - "delivery_sla": "PT1H" - }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -9105,8 +8071,16 @@ ] }, { + "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": [ @@ -9117,9 +8091,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "pacing", "period_duration": "PT1H", @@ -9131,27 +8103,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_p_hourly_local"]}] } ], "offerings": [ @@ -9171,11 +8130,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "PT1H", - "alignment": "account_timezone", - "delivery_sla": "PT1H" - }, + "schedule": {"period_duration": "PT1H", "alignment": "account_timezone", "delivery_sla": "PT1H"}, "supported_finality": ["snapshot"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -9539,8 +8494,20 @@ ] }, { + "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": [ @@ -9551,15 +8518,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -9568,17 +8528,9 @@ "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" - } + {"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": [ @@ -9587,12 +8539,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -9612,11 +8559,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -9673,8 +8616,20 @@ ] }, { + "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": [ @@ -9685,15 +8640,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -9702,17 +8650,9 @@ "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" - } + {"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": [ @@ -9721,12 +8661,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -9746,11 +8681,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -9864,8 +8795,20 @@ ] }, { + "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": [ @@ -9876,15 +8819,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -9892,15 +8828,7 @@ "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" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-04T06:00:00Z"}, {"authorized_at": "2026-10-01T00:00:00Z"}] }, "buys": [ { @@ -9908,12 +8836,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -9933,11 +8856,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -9945,15 +8864,7 @@ ], "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" - } - ] - } + "coverage": {"gaps": [{"from": "2026-11-04T00:00:00Z", "until": "2026-11-06T00:00:00Z", "reason": "unauthorized"}]} }, "expected": [ { @@ -10003,8 +8914,20 @@ ] }, { + "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": [ @@ -10015,15 +8938,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -10032,14 +8948,8 @@ "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" - } + {"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": [ @@ -10048,12 +8958,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -10073,11 +8978,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -10134,8 +9035,18 @@ ] }, { + "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": [ @@ -10146,15 +9057,8 @@ "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"] - }, + "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" @@ -10166,9 +9070,7 @@ "subscription_id": "analytics-daily", "subscription_version": 2, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -10180,27 +9082,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-07T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg", "off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -10220,11 +9109,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -10245,11 +9130,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -10347,8 +9228,19 @@ ] }, { + "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": [ @@ -10359,15 +9251,8 @@ "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"] - }, + "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" @@ -10379,41 +9264,21 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -10433,11 +9298,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -10507,8 +9368,19 @@ ] }, { + "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": [ @@ -10519,40 +9391,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -10572,23 +9424,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-06T00:00:00Z"}] }, "expected": [ { @@ -10652,8 +9495,18 @@ ] }, { + "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": [ @@ -10664,15 +9517,8 @@ "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"] - }, + "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" @@ -10684,41 +9530,21 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -10738,23 +9564,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] + "inactive_intervals": [{"deactivated_at": "2026-11-04T12:00:00Z", "reactivated_at": "2026-11-06T12:00:00Z"}] }, "expected": [ { @@ -10818,8 +9635,17 @@ ] }, { + "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": [ @@ -10830,40 +9656,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -10883,26 +9689,14 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } - ] - } + "coverage": {"excluded": [{"reason": "capacity", "from": "2026-11-04T12:00:00Z", "until": "2026-11-06T12:00:00Z"}]} }, "expected": [ { @@ -10952,8 +9746,18 @@ ] }, { + "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": [ @@ -10964,9 +9768,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -10978,27 +9780,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["a", "B"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["a", "B"]}] } ], "offerings": [ @@ -11018,11 +9807,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -11043,11 +9828,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -11103,8 +9884,18 @@ ] }, { + "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": [ @@ -11115,9 +9906,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -11128,27 +9917,14 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_q_pkg_placement", "off_a_pkg"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_q_pkg_placement", "off_a_pkg"]}] } ], "offerings": [ @@ -11168,11 +9944,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "placement"] @@ -11193,11 +9965,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -11253,8 +10021,18 @@ ] }, { + "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": [ @@ -11265,9 +10043,7 @@ "subscription_id": "analytics-daily", "subscription_version": 1, "active": true, - "scope": { - "all_authorized_accounts": true - }, + "scope": {"all_authorized_accounts": true}, "selector": { "feed_purpose": "analytics", "period_duration": "P1D", @@ -11279,15 +10055,7 @@ } } ], - "account": { - "account_id": "acct_1", - "timezone": "America/New_York", - "grants": [ - { - "authorized_at": "2026-10-01T00: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", @@ -11297,12 +10065,7 @@ "packages": [ { "package_id": "pkg_1", - "reporting_delivery_offering_ids": [ - "off_a_pkg", - "off_b_pkg_creative", - "off_c_pkg_country", - "off_d_pkg_device" - ] + "reporting_delivery_offering_ids": ["off_a_pkg", "off_b_pkg_creative", "off_c_pkg_country", "off_d_pkg_device"] } ] } @@ -11324,11 +10087,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -11349,11 +10108,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "creative"] @@ -11374,11 +10129,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package", "country"] @@ -11399,11 +10150,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "utc", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"], @@ -11416,8 +10163,17 @@ "expected_obligations": [] }, { + "id": "numeric-offset-timezone-is-refused", "name": "numeric offset timezone is refused", - "rule": "iana_timezone: a numeric offset defines no DST rules.", + "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": [ @@ -11428,48 +10184,28 @@ "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"] - }, + "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" - } - ] - }, + "account": {"account_id": "acct_1", "timezone": "+05:00", "grants": [{"authorized_at": "2026-10-01T00:00:00Z"}]}, "buys": [ { "media_buy_id": "mb_1", "confirmed_at": "2026-11-02T15:00:00Z", "start_time": "2026-11-03T00:00:00Z", "end_time": "2026-11-06T00:00:00Z", - "packages": [ - { - "package_id": "pkg_1", - "reporting_delivery_offering_ids": ["off_a_pkg"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ { - "offering_id": "off_a_pkg", + "offering_id": "off_e_pkg_acct_tz", "feed_purpose": "analytics", - "report_definition_id": "rd_off_a_pkg", - "report_definition_uri": "https://seller.example/report-definitions/off_a_pkg.json", + "report_definition_id": "rd_off_e_pkg_acct_tz", + "report_definition_uri": "https://seller.example/report-definitions/off_e_pkg_acct_tz.json", "report_definition_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "reporting_profile": { "id": "adcp.delivery.v1", @@ -11481,11 +10217,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -11493,14 +10225,18 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected_error": { - "code": "COVERAGE_INPUT_INVALID", - "detail": "invalid_timezone" - } + "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": [ @@ -11511,39 +10247,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -11563,11 +10280,7 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "schedule": {"period_duration": "P1D", "alignment": "account_timezone", "delivery_sla": "PT6H"}, "supported_finality": ["official"], "reconciliation_mode": "delivery_only", "standard_dimensions": ["package"] @@ -11575,14 +10288,20 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected_error": { - "code": "COVERAGE_INPUT_INCOMPLETE", - "detail": "missing_account_timezone" - } + "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": [ @@ -11593,40 +10312,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_e_pkg_acct_tz"]}] } ], "offerings": [ @@ -11646,34 +10345,30 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "account_timezone", - "delivery_sla": "PT6H" - }, + "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" - } - ] - } + "coverage": {"timezone_cutovers": [{"effective_at": "2026-11-05T05:00:00Z", "timezone": "America/Los_Angeles"}]} }, - "expected_error": { - "code": "COVERAGE_INPUT_INCOMPLETE", - "detail": "missing_previous_timezone" - } + "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": [ @@ -11684,40 +10379,20 @@ "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"] - }, + "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" - } - ] - }, + "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -11737,25 +10412,29 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" - }, + "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" - } + "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": [ @@ -11766,15 +10445,8 @@ "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"] - }, + "scope": {"all_authorized_accounts": true}, + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official", "dimensions": ["package"]}, "reconciliation_mode": "delivery_only" } } @@ -11782,15 +10454,7 @@ "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" - } - ] + "grants": [{"authorized_at": "2026-10-01T00:00:00Z", "unauthorized_at": "2026-11-05T06:00:00Z"}, {"authorized_at": "2026-11-06T18:00:00Z"}] }, "buys": [ { @@ -11798,12 +10462,7 @@ "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"] - } - ] + "packages": [{"package_id": "pkg_1", "reporting_delivery_offering_ids": ["off_a_pkg"]}] } ], "offerings": [ @@ -11823,11 +10482,5024 @@ "grain": "daily", "primary_keys": ["account_id", "package_id"] }, - "schedule": { - "period_duration": "P1D", - "alignment": "utc", - "delivery_sla": "PT6H" + "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"] @@ -11835,10 +15507,7 @@ ], "now": "2026-12-01T00:00:00Z" }, - "expected_error": { - "code": "COVERAGE_INPUT_INCOMPLETE", - "detail": "missing_status_retention_days" - } + "expected_error": {"code": "COVERAGE_INPUT_INCOMPLETE", "detail": "missing_source_timezone", "wire_valid": true} } ] } diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index 56b31c2bd..bdfafd789 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -16,8 +16,21 @@ const { reportingSubscriptionScopeIncludesV1, } = advanced; -// The JSON table is the cross-SDK parity contract (adcp-client-python mirrors it). -const table = require(path.join(__dirname, '..', 'fixtures', 'reporting-subscriptions', 'coverage-cases.json')); +// 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(); @@ -32,57 +45,105 @@ function comparable(rows) { })); } -describe('expectedSubscriptionPeriods parity table', () => { - test('the table is non-trivial and every case is uniquely named', () => { - assert.ok(table.cases.length >= 75); +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.name, () => { - 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), - })) - ); + 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', () => { // The subscription schema is unchanged by adcontextprotocol/adcp#8585 apart from // descriptions, so the cached 3.3.0-beta.1 copy validates the table's configurations. @@ -107,7 +168,7 @@ describe('expectedSubscriptionPeriods parity table', () => { const validate = ajv.getSchema( 'https://adcontextprotocol.org/schemas/3.3.0-beta.1/core/reporting-subscription.json' ); - for (const testCase of table.cases) { + 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)}`); } diff --git a/test/lib/reporting-subscription-errors.test.js b/test/lib/reporting-subscription-errors.test.js index be8668dcc..ba19e6d9e 100644 --- a/test/lib/reporting-subscription-errors.test.js +++ b/test/lib/reporting-subscription-errors.test.js @@ -31,17 +31,13 @@ const { createTestPrincipalLifecycle } = require('../helpers/principal-runtime.j const SCOPE = { tenantId: 't', principalId: 'p' }; const CTX = { callerMutationScope: { tenant_id: 't', principal_id: 'p', principal_kind: 'buyer_agent' } }; -// The buyer codes from the shared vocabulary (adcontextprotocol/adcp#8616, -// static/compliance/source/test-vectors/reporting-subscriptions/error-codes.json). -const BUYER_CODES = { - SUBSCRIPTION_WOULD_DEACTIVATE: { recovery: 'correctable', dispatched: 'false' }, - DESTINATION_WOULD_REVOKE: { recovery: 'correctable', dispatched: 'false' }, - SUBSCRIPTION_ID_CLASH: { recovery: 'correctable', dispatched: 'false' }, - SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED: { recovery: 'correctable', dispatched: 'false' }, - SUBSCRIPTION_SELECTOR_UNMATCHED: { recovery: 'correctable', dispatched: 'false' }, - SUBSCRIPTION_CONFLICT_EXHAUSTED: { recovery: 'transient', dispatched: 'absent' }, - SUBSCRIPTION_READBACK_MISMATCH: { recovery: 'terminal', dispatched: 'absent' }, -}; +// 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 { @@ -78,6 +74,26 @@ function keys() { 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)) { From 88977b29d71ddbcaf3ef126e496ae7258e10d995 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 18:53:25 +0000 Subject: [PATCH 39/49] chore(reporting): raise the packed-byte budget for the reporting-subscription DX slices CI merges with main, where the packed tarball measured 26,525,628 bytes against the 26,520,000 budget. Allow about 35 KB of compressor variance. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 0ae1896a2..8a731d4e9 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -145,7 +145,10 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 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. -const MAX_PACKED_TARBALL_BYTES = 26_520_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 From 68e15635a6ffafc2357c49c1f52b3d654b3bbba8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:04:54 +0000 Subject: [PATCH 40/49] test(reporting): validate the parity table against the pinned AdCP bundle Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-coverage.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index eeb1bae96..e98245843 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -75,12 +75,12 @@ describe('expectedSubscriptionPeriods parity table', () => { } test('every subscription in the table is schema-valid', () => { - // The subscription schema is unchanged by adcontextprotocol/adcp#8585 apart from - // descriptions, so the cached 3.3.0-beta.1 copy validates the table's configurations. + // Validate against the bundle the SDK pins (ADCP_VERSION). const Ajv = require('ajv'); const addFormats = require('ajv-formats'); const fs = require('node:fs'); - const root = path.join(__dirname, '..', '..', 'schemas', 'cache', '3.3.0-beta.1'); + 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']) { @@ -96,7 +96,7 @@ describe('expectedSubscriptionPeriods parity table', () => { } } const validate = ajv.getSchema( - 'https://adcontextprotocol.org/schemas/3.3.0-beta.1/core/reporting-subscription.json' + `https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json` ); for (const testCase of table.cases) { for (const version of testCase.input.versions) { From be83582138cfae75853a2b7cd3f52a422663dc2f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:22:57 +0000 Subject: [PATCH 41/49] test(reporting): serve the pinned AdCP release (3.3.0-beta.2) in subscription tests Main adopted 3.3.0-beta.2, which no longer ships the beta.1 bundle and defines the fields settled after beta.1 (standard_dimensions, inactive_intervals, prior_versions[].configuration, previous_timezone, per-offering entries), so the release gate now emits them to beta.2 callers. inactive in coverage stays gated until beta.3. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../principal-reporting-subscriptions.test.js | 39 +++++++++++++------ test/lib/reliable-reporting-service.test.js | 8 +--- .../lib/reporting-subscription-engine.test.js | 17 ++++---- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index 69a08992d..903187d9f 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -15,16 +15,19 @@ const { 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: '3.3.0-beta.1', + servedAdcpVersion: PINNED, }; const CTX_UNPINNED = { callerMutationScope: CTX.callerMutationScope }; function assertSchemaValid(toolName, response) { - const outcome = validateResponse(toolName, response, '3.3.0-beta.1'); + const outcome = validateResponse(toolName, response, PINNED); assert.equal(outcome.valid, true, JSON.stringify(outcome.issues)); } @@ -182,9 +185,11 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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, [ - { subscription_version: 1, accepted_at: '2026-11-01T12:00:00.000Z', superseded_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()] }); @@ -217,14 +222,18 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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) only to callers served - // a release whose bundle defines it (3.3.0-beta.1's closed state schema does not), and never - // to a response without a served release; the internal state carries it. + // 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.equal(on.result.configuration.reporting_subscriptions[0].inactive_intervals, undefined); + 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); @@ -609,7 +618,7 @@ describe('sync_principal reporting_subscriptions (option on)', () => { }, }; // reporting-dimension.json is extensible: receivers MUST accept unknown values. - const outcome = validateRequest('sync_principal', request, '3.3.0-beta.1'); + 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)); @@ -625,9 +634,15 @@ describe('sync_principal reporting_subscriptions (option on)', () => { 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 3.3.0-beta.1, whose prior_versions items are closed, the field is omitted. + // 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, undefined); + 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); }); diff --git a/test/lib/reliable-reporting-service.test.js b/test/lib/reliable-reporting-service.test.js index 01b06057c..547289c42 100644 --- a/test/lib/reliable-reporting-service.test.js +++ b/test/lib/reliable-reporting-service.test.js @@ -694,12 +694,8 @@ describe('ReliableReportingService', () => { 'notification_configs', 'reporting_subscriptions', ]); - // standard_dimensions is defined after 3.3.0-beta.1, whose offering schema is closed. - const { offerings: advertised, ...rest } = structuredClone(service.capabilities); - assert.deepEqual(result.structuredContent.media_buy.reporting_delivery, { - ...rest, - offerings: advertised.map(({ standard_dimensions: _dimensions, ...offering }) => offering), - }); + // 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, { diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index 7287b74d7..a45a3bcf9 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -1,4 +1,7 @@ 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'; @@ -159,10 +162,10 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { } buyer.grants.set(account.account_id, grants); } - // Served at 3.3.0-beta.1 the readback omits fields that release does not define and is - // schema-valid; read without a served release, it carries them all. - const served = await lifecycle.protocol.getPrincipal({}, { ...CTX, servedAdcpVersion: '3.3.0-beta.1' }); - const outcome = validateResponse('get_principal', served, '3.3.0-beta.1'); + // 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) { @@ -181,7 +184,7 @@ function world({ maxAccounts, backfillOnRegrant = false } = {}) { } /** What the buyer expects, from its observations only. */ async function buyerExpected(accountId, now, subscriptionId = 'analytics-daily') { - // The complete readback (served at 3.3.0-beta.1 the wire omits inactive_intervals). + // 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 => ({ @@ -503,7 +506,7 @@ describe('reporting subscription engine conformance (buyer periods equal seller const outcome = validateResponse( 'get_reporting_status', { status: 'completed', view: 'summary', subscription_coverage: coverage }, - '3.3.0-beta.1' + PINNED ); assert.equal( outcome.issues?.some(issue => /subscription_coverage/.test(issue.pointer ?? issue.path ?? '')) ?? false, @@ -557,7 +560,7 @@ describe('reporting subscription engine conformance (buyer periods equal seller } = require('../../dist/lib/server/reporting-subscription-release.js'); const beta1 = reportingSubscriptionCoverageForReleaseV1( entry, - reportingSubscriptionReleaseFieldsV1('3.3.0-beta.1') + 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); From e56c2a86c193d122447a1579a8fd49cc6390c94b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:25:08 +0000 Subject: [PATCH 42/49] chore(reporting): packed-size budget for the buyer runtime after main's beta.2 adoption Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index eb21bf927..86387e012 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -141,7 +141,9 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 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. -const MAX_PACKED_TARBALL_BYTES = 26_465_000; +// 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. +const MAX_PACKED_TARBALL_BYTES = 26_510_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 From fb2b27924ce0fa3842a1eb86bba9aa380806447d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:29:20 +0000 Subject: [PATCH 43/49] test(reporting): the option-off golden transcript serves main's 3.3.0-beta.2 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reporting-subscriptions/ledger-option-off-golden.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json index 269205dc4..f0867e9b6 100644 --- a/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json +++ b/test/fixtures/reporting-subscriptions/ledger-option-off-golden.json @@ -465,7 +465,7 @@ ], "summary": { "status": "completed", - "adcp_version": "3.3-beta.1", + "adcp_version": "3.3-beta.2", "adcp_major_version": 3, "view": "summary", "ledger_snapshot_id": "snapshot-0", @@ -518,7 +518,7 @@ }, "periods": { "status": "completed", - "adcp_version": "3.3-beta.1", + "adcp_version": "3.3-beta.2", "adcp_major_version": 3, "view": "periods", "ledger_snapshot_id": "snapshot-1", @@ -941,7 +941,7 @@ } }, "consumerStatus": { - "adcp_version": "3.3-beta.1", + "adcp_version": "3.3-beta.2", "adcp_major_version": 3, "status": "completed", "results": [ From 91712f9d1779fe76ffff3b477a8f1935a0c42797 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:49:23 +0000 Subject: [PATCH 44/49] style(reporting): format the coverage parity test Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-coverage.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index d2cd24c9e..eedc4aed9 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -98,9 +98,7 @@ describe('expectedSubscriptionPeriods parity table', () => { ajv.addSchema(schema); } } - const validate = ajv.getSchema( - `https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json` - ); + const validate = ajv.getSchema(`https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json`); for (const testCase of table.cases) { for (const version of testCase.input.versions) { assert.equal(validate(version.configuration), true, `${testCase.name}: ${JSON.stringify(validate.errors)}`); From 4fbfdac2878145969456e4c6427b47aa08d5c455 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 19:50:43 +0000 Subject: [PATCH 45/49] style(reporting): format the parity-table schema check Co-Authored-By: Claude Opus 5.5 (1M context) --- test/lib/reporting-subscription-coverage.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index e98245843..4f0697c01 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -95,9 +95,7 @@ describe('expectedSubscriptionPeriods parity table', () => { ajv.addSchema(schema); } } - const validate = ajv.getSchema( - `https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json` - ); + const validate = ajv.getSchema(`https://adcontextprotocol.org/schemas/${pinned}/core/reporting-subscription.json`); for (const testCase of table.cases) { for (const version of testCase.input.versions) { assert.equal(validate(version.configuration), true, `${testCase.name}: ${JSON.stringify(validate.errors)}`); From f4b3020c86f1be8cd07a5ad479632da541591fb2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 20:18:00 +0000 Subject: [PATCH 46/49] chore(reporting): raise the packed-byte budget at the bottom of the DX stack With main's beta.2 adoption the DX slices measure up to 26,526,144 packed bytes. Set the 26,560,000 cap here so every slice in the stack passes. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-package-size.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 19f55fcec..0a02b0964 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -152,7 +152,10 @@ import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifac // 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_520_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 From 3f5bf10bf648f5727d7776d5a0098cb4f0a84a86 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 22:24:10 +0000 Subject: [PATCH 47/49] fix(reporting): refuse a sub-daily billing_cycle calendar A sub-daily billing_cycle was routed to the local sub-daily calendar, which aligns to local midnight and ignores period_anchor, so every boundary was off by the anchor's time of day. adcontextprotocol/adcp#8622 makes a sub-daily billing_cycle invalid; refuse it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/reporting/subscriptions/calendar.ts | 8 ++++++++ test/lib/reporting-subscription-coverage.test.js | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/lib/reporting/subscriptions/calendar.ts b/src/lib/reporting/subscriptions/calendar.ts index 2dea29659..de0e8e4f0 100644 --- a/src/lib/reporting/subscriptions/calendar.ts +++ b/src/lib/reporting/subscriptions/calendar.ts @@ -212,6 +212,14 @@ export function createReportingPeriodCalendarV1(spec: ReportingPeriodCalendarSpe `Reporting period_duration ${JSON.stringify(spec.period_duration)} mixes date and time components under ${spec.alignment} alignment` ); } + 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 TypeError( + `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.` + ); + } if (spec.alignment !== 'utc' && !hasDate) { return createSubDailyLocalCalendar(spec, duration.seconds * 1_000); } diff --git a/test/lib/reporting-subscription-coverage.test.js b/test/lib/reporting-subscription-coverage.test.js index 4f0697c01..a6129dcdc 100644 --- a/test/lib/reporting-subscription-coverage.test.js +++ b/test/lib/reporting-subscription-coverage.test.js @@ -355,6 +355,17 @@ describe('createReportingPeriodCalendarV1 (period_generation)', () => { () => 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', + }), + /billing_cycle alignment does not allow/ + ); }); }); From 64770b7522f26bd394c4ba93e90d398707dd7ccf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 22:28:58 +0000 Subject: [PATCH 48/49] fix(reporting): only an explicit false clears a pending replace_account_configs A later write that merely omitted replace_account_configs cleared both the applied marker and a committed-but-unconfirmed replacement, so a failed host hook was silently abandoned. An omitted flag now leaves both untouched; an explicit false clears them, so a later true applies again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../principal/reporting-subscriptions.ts | 6 ++-- .../principal-reporting-subscriptions.test.js | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/lib/server/principal/reporting-subscriptions.ts b/src/lib/server/principal/reporting-subscriptions.ts index 1a467be7b..afdcbd46d 100644 --- a/src/lib/server/principal/reporting-subscriptions.ts +++ b/src/lib/server/principal/reporting-subscriptions.ts @@ -354,8 +354,10 @@ export function applyReportingSubscriptions( feedPurpose: subscription.selector.feed_purpose, at: now, }; - } else if (subscription.replace_account_configs !== true) { - // A flag turned off and on again applies again. + } 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; } diff --git a/test/lib/principal-reporting-subscriptions.test.js b/test/lib/principal-reporting-subscriptions.test.js index 903187d9f..badd221b5 100644 --- a/test/lib/principal-reporting-subscriptions.test.js +++ b/test/lib/principal-reporting-subscriptions.test.js @@ -486,10 +486,41 @@ describe('sync_principal reporting_subscriptions (option on)', () => { } } assert.equal(replacements.length, 1, 'resends, dry runs, other subscriptions and an active toggle reuse version 1'); - // The spec's other trigger: the flag changing to true (an omitted flag is its default, false). + // 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, 2, 'a flag change to true applies again'); + 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 () => { From a12175f6ad8f04537c31d50ffa976970ff1abb19 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 22:28:59 +0000 Subject: [PATCH 49/49] fix(reporting): refresh a changed pending action and report the latest owed period end - An open action_required exception now takes the host's current pending_action (url, message, reason) when it changes, keeps its from, and notifies principal.changed. It used to keep the first action forever. - accounts_unauthorized last_owed_period_end takes the latest frozen period_end. Frozen periods are not stored in end order, so the last entry could under-state the window. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/reporting/subscriptions/engine.ts | 25 ++++++++- .../lib/reporting-subscription-engine.test.js | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/lib/reporting/subscriptions/engine.ts b/src/lib/reporting/subscriptions/engine.ts index 78a0f5b02..416e33ba0 100644 --- a/src/lib/reporting/subscriptions/engine.ts +++ b/src/lib/reporting/subscriptions/engine.ts @@ -30,6 +30,7 @@ * 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 type { PendingAction } from '../../types/core.generated'; import type { PrincipalLifecycleRuntimeSubscriptions, @@ -418,6 +419,16 @@ export interface ReportingSubscriptionEngineV1 { inspect(principal: Readonly): Promise; } +/** + * 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(); } @@ -651,7 +662,17 @@ export function createReportingSubscriptionEngineV1( changed = true; } for (const item of desired) { - if (open.some(entry => entry.key === item.key)) continue; + 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; @@ -1360,7 +1381,7 @@ export function createReportingSubscriptionEngineV1( ...(calendar ? { last_owed_period_end: iso(calendar.floor(unauthorizedAt)) } : withheld.length - ? { last_owed_period_end: withheld.at(-1)!.period_end } + ? { last_owed_period_end: latestPeriodEnd(withheld) } : {}), withheld_period_count: count, }); diff --git a/test/lib/reporting-subscription-engine.test.js b/test/lib/reporting-subscription-engine.test.js index a45a3bcf9..194df7d1e 100644 --- a/test/lib/reporting-subscription-engine.test.js +++ b/test/lib/reporting-subscription-engine.test.js @@ -874,6 +874,61 @@ describe('reporting subscription engine conformance (buyer periods equal seller 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', {