diff --git a/.changeset/prior-versions-configuration.md b/.changeset/prior-versions-configuration.md new file mode 100644 index 00000000000..cdb8941a2d4 --- /dev/null +++ b/.changeset/prior-versions-configuration.md @@ -0,0 +1,7 @@ +--- +"adcontextprotocol": minor +--- + +Reporting subscriptions (experimental): each `prior_versions` entry on the subscription readback now carries the `configuration` that version had (scope, selector, destination, `reconciliation_mode`), and sellers MUST include it. A buyer that lost local state can then recompute a prior version's owed periods; before, the readback said when a version was accepted and superseded but not what it covered. + +Reporting schedules (experimental): fix sub-daily periods under a local alignment (`account_timezone`, `source_timezone`, `billing_cycle`). They were anchored to local midnight on 1970-01-01 plus elapsed hours, so hourly periods in Asia/Singapore ran 07:30–08:30 local (its 1970 offset was +07:30), Asia/Kathmandu hours started at :15, and America/New_York PT6H boundaries drifted an hour with DST. A sub-daily duration under a local alignment now MUST divide 24 hours, and its boundaries are local wall-clock multiples counted afresh from each local midnight. A 23-hour DST day has 23 hourly periods and a 25-hour day 25; a nonexistent wall time yields no boundary, and a repeated one yields one per offset. Durations under a local alignment cannot mix date and time components. `utc` alignment is unchanged. diff --git a/docs/media-buy/task-reference/get_reporting_status.mdx b/docs/media-buy/task-reference/get_reporting_status.mdx index 0309e004aa8..8c1dd8ed56c 100644 --- a/docs/media-buy/task-reference/get_reporting_status.mdx +++ b/docs/media-buy/task-reference/get_reporting_status.mdx @@ -719,9 +719,10 @@ owed. - **New version.** A new `subscription_version` gets its own `accepted_at`, which is T. Scope changes count, so adding an account starts a new version. For an account in both versions the old version is outgoing and the new one incoming. An account that - is new in the new version follows the ordinary start rule. Earlier versions' - `accepted_at` values stay in the readback as `prior_versions` while their - obligations are retained. + is new in the new version follows the ordinary start rule. Earlier versions stay + in the readback as `prior_versions`, each with its `accepted_at`, `superseded_at`, + and `configuration`, while their obligations are retained, so a buyer can + recompute after losing local state. - **Deactivation** at `deactivated_at` has no incoming schedule: periods that started before it stay owed, and no later ones are created. - **Overrides and `replace_account_configs`.** When the principal's own account-level diff --git a/docs/protocol/sync_principal.mdx b/docs/protocol/sync_principal.mdx index 0a93335b9fa..7ed476c7bc0 100644 --- a/docs/protocol/sync_principal.mdx +++ b/docs/protocol/sync_principal.mdx @@ -332,7 +332,7 @@ For example, if a seller offers `package`, `package × creative`, and `package `reconciliation_mode` must also match the offering. Without a `destination` the subscription selects only API-delivered (Core) offerings; with one, only offerings whose `method` matches that destination's pattern and transport. Billing subscriptions require `official` finality and `consumer_receipt`, and `consumer_receipt` requires a destination. The seller resolves the selector separately for each account and each buy's products. Each (buy, resolved offering) pair is owed its own periods, and each obligation names its `offering_id` and `report_definition_id`. An account or buy with no matching offering gets an `offering_unsupported` exception. When only some of a buy's packages resolve, the buy is covered for those, and the exception names the uncovered `package_ids`. Neither case fails the request or the subscription. -**Period boundaries.** The resolved offering's published schedule governs period boundaries: its alignment (account timezone, UTC, an upstream source timezone, or a billing cycle), anchor, and timezone, which the buyer reads from the offering. Use the selector's `alignment` to require one. `period_timezone` overrides the timezone for a subscription scoped to exactly one account. Years, months, and days are civil time in that timezone; hours, minutes, and seconds are exact elapsed time, so `PT24H` and `P1D` differ across a DST change. When an account's timezone changes, for example through a [`request_account`](/docs/accounts/tasks/request_account) change, account-timezone periods switch at the first old-timezone boundary at or after the change. The switch is recorded in `get_reporting_status` as a `timezone_cutovers` entry, and [`list_accounts`](/docs/accounts/tasks/list_accounts) shows the new `timezone` and `revision`. +**Period boundaries.** The resolved offering's published schedule governs period boundaries: its alignment (account timezone, UTC, an upstream source timezone, or a billing cycle), anchor, and timezone, which the buyer reads from the offering. Use the selector's `alignment` to require one. `period_timezone` overrides the timezone for a subscription scoped to exactly one account. Years, months, and days are civil time in that timezone; hours, minutes, and seconds are exact elapsed time, so `PT24H` and `P1D` differ across a DST change. Sub-daily periods under a local alignment restart at each local midnight instead: hourly periods start on the local hour in every zone, a 23-hour DST day has 23 of them and a 25-hour day 25, and the duration must divide 24 hours. When an account's timezone changes, for example through a [`request_account`](/docs/accounts/tasks/request_account) change, account-timezone periods switch at the first old-timezone boundary at or after the change. The switch is recorded in `get_reporting_status` as a `timezone_cutovers` entry, and [`list_accounts`](/docs/accounts/tasks/list_accounts) shows the new `timezone` and `revision`. **Destinations.** `destination.destination_id` names an entry in this principal's `reporting_destinations`, which must still exist after the same request applies. Every delivered row carries `account_id`. A `path_template` is a relative path with no `.` or `..` segments; allowed placeholders are `{account_id}`, `{subscription_id}`, `{offering_id}`, and `{period_start}`. Each substituted value fills exactly one path segment: the seller percent-encodes (RFC 3986) any value that contains `/` or equals `.` or `..`. It must contain `{account_id}` when the scope can cover more than one account, and is then required for `file_transfer` destinations. When more than one active subscription of the principal shares a destination, every template must contain `{account_id}` and the templates must differ. When an agency must keep advertisers apart, an account-scoped subscription points that advertiser's feed at its own destination; sellers that support subscriptions MUST support that. diff --git a/docs/reference/whats-new-in-3-3.mdx b/docs/reference/whats-new-in-3-3.mdx index c0b1a4ea1cb..f756195a6d4 100644 --- a/docs/reference/whats-new-in-3-3.mdx +++ b/docs/reference/whats-new-in-3-3.mdx @@ -56,7 +56,7 @@ A buyer can keep an opportunity open after the first response and let sellers se A buyer agent can now make itself known to a seller it has never spoken to. The new Web Bot Auth signing profile identifies the signer by its origin and verifies against that origin's key directory, with no capabilities call and no seller-issued credential. The operator's trust.json lists its agents; brands publish grants in theirs, and sellers publish their onboarding posture in `account.agent_onboarding`. `get_principal` reports relationship status and expiry. The RFC 9421 profile is deprecated in 3.3 and works through 3.x. See [A buyer agent onboards itself](/docs/accounts/buyer-agent-self-onboarding) and the [agent identity migration guide](/docs/reference/migration/agent-identity-3-3). -**Account requests (experimental, beta.1).** Where a seller supports `account.requests`, `request_account` is the one account write. Send `brand` and `operator` to open an account, or an `account_id` with the attributes to change (the brand's `countries`, `operator_unit`, `billing`, `billing_entity`, `payment_terms`). Each write is a request that either resolves in the same response or goes `in_review` with one pending-action shape saying whose turn it is. A `revision` guards the change, and it is required when the change touches money. `list_accounts` reads open requests with `include_requests` and pages them separately from accounts through `requests_pagination`. The unreleased `get_account_requests` task is gone. `sync_accounts` stays stable everywhere: where `account.requests` is supported, buyers SHOULD use `request_account`, `list_accounts`, and `sync_principal` instead, and an entry carrying only subscription fields never provisions or reviews an account. See [`request_account`](/docs/accounts/tasks/request_account). +**Account requests (experimental, beta.1).** Where a seller supports `account.requests`, `request_account` is the one account write. Send `brand` and `operator` to open an account, or an `account_id` with the attributes to change (the brand's `countries`, `operator_unit`, `billing`, `billing_entity`, `payment_terms`). Each write is a request that either resolves in the same response or goes `in_review` with one pending-action shape saying whose turn it is. A `revision` guards the change, and it is required when the change touches money. `list_accounts` reads open requests with `include_requests` and pages them separately from accounts through `requests_pagination`. The unreleased `get_account_requests` task is gone. `sync_accounts` stays stable everywhere: where `account.requests` is supported, buyers SHOULD use `request_account`, `list_accounts`, and `sync_principal` instead, and an entry carrying only subscription fields never provisions or reviews an account. In beta.2, a change request can also resolve to an account the caller already holds (`matched`) or, when the buyer sets `allow_separate_account`, to a new account carrying the change (`created`), leaving the named account untouched. A seller that would only accept the change on a new account otherwise refuses with `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`. A `sync_accounts` settings update under review carries its `request_id`, so a refusal is visible. Two accounts that end up alike after a change are not a conflict, and `countries` decide products, prices, terms and reviews, never targeting; narrowing them never changes an accepted buy. See [`request_account`](/docs/accounts/tasks/request_account). ## Linear TV edges @@ -158,7 +158,7 @@ The experimental account change feed (`account.change_feed`) gains two changes a ## Reporting subscriptions on the principal (experimental, beta.1) -Reliable Reporting was configured one account at a time, so a new account reported nothing until someone configured it. `sync_principal` now takes `reporting_subscriptions`: one registration per feed purpose, scoped to `all_authorized_accounts: true` or to named `account_ids`, with a selector that works across sellers (`feed_purpose`, `period_duration`, `required_finality`, optional `dimensions`, and `match`). The buyer can work out coverage without asking: a confirmed buy is owed every complete period that starts on or after the later of the subscription version's `accepted_at`, the buy's `confirmed_at`, and the account's `authorization.authorized_at`, through the buy's final period. `get_reporting_status` reports only exceptions under `subscription_coverage`. Exceptions remove periods from what is owed and never add any, so a missing report still means an owed period with nothing delivered. Revoked accounts appear in the subscription readback on `get_principal`, not as silent gaps. A caller's own `sync_accounts` delivery config still wins for that account and feed purpose. Sellers list accepted registration tasks in `reporting_delivery.configuration_tasks[]`. `configuration_task` stays `"sync_accounts"` for older clients and is deprecated. Feature id `media_buy.reporting_subscriptions`. See [Reporting subscriptions](/docs/protocol/sync_principal#reporting-subscriptions) and [Subscription coverage](/docs/media-buy/task-reference/get_reporting_status#subscription-coverage). +Reliable Reporting was configured one account at a time, so a new account reported nothing until someone configured it. `sync_principal` now takes `reporting_subscriptions`: one registration per feed purpose, scoped to `all_authorized_accounts: true` or to named `account_ids`, with a selector that works across sellers (`feed_purpose`, `period_duration`, `required_finality`, optional `dimensions`, and `match`). The buyer can work out coverage without asking: a confirmed buy is owed every complete period that starts on or after the later of the subscription version's `accepted_at`, the buy's `confirmed_at`, and the account's `authorization.authorized_at`, through the buy's final period. `get_reporting_status` reports only exceptions under `subscription_coverage`. Exceptions remove periods from what is owed and never add any, so a missing report still means an owed period with nothing delivered. Revoked accounts appear in the subscription readback on `get_principal`, not as silent gaps. A caller's own `sync_accounts` delivery config still wins for that account and feed purpose. Sellers list accepted registration tasks in `reporting_delivery.configuration_tasks[]`. `configuration_task` stays `"sync_accounts"` for older clients and is deprecated. In beta.2, offerings publish `standard_dimensions` so a buyer resolves the selector from the wire, and the readback carries `inactive_intervals`, per-version configuration in `prior_versions`, and per-offering gaps and timezone cutovers. Period arithmetic (civil days, elapsed hours), cutovers across different offerings, and authorization chains on re-grant are now pinned. Feature id `media_buy.reporting_subscriptions`. See [Reporting subscriptions](/docs/protocol/sync_principal#reporting-subscriptions) and [Subscription coverage](/docs/media-buy/task-reference/get_reporting_status#subscription-coverage). ## Experimental in 3.3 diff --git a/static/schemas/source/core/reporting-schedule.json b/static/schemas/source/core/reporting-schedule.json index df543038efd..3a1aebfdfbe 100644 --- a/static/schemas/source/core/reporting-schedule.json +++ b/static/schemas/source/core/reporting-schedule.json @@ -34,7 +34,7 @@ } ], "x-adcp-validation": { - "period_generation": "Producer and consumer MUST derive the same ordered half-open intervals from period_duration, alignment, period_anchor, and period_timezone when applicable. utc alignment uses 1970-01-01T00:00:00Z as interval zero. account_timezone uses local midnight on that date in the account's resolved IANA timezone. source_timezone uses local midnight on that date in the explicit period_timezone. billing_cycle uses its explicit period_anchor expressed in period_timezone. Every boundary is calculated directly from that origin and the interval ordinal by multiplying each ISO 8601 duration component by the ordinal and applying years, months, days, hours, minutes, then seconds. Years, months, and days use local civil-time arithmetic in the selected IANA timezone, including DST transitions; they are not converted to fixed seconds. Hours, minutes, and seconds are exact elapsed time added after the civil date components, so PT24H differs from P1D across a DST transition. Month/year addition preserves the origin's local day and time, clamping to the target month's final valid day when necessary. A nonexistent local boundary advances by the timezone gap; an ambiguous local boundary uses the earlier offset. Thus a clamped February boundary does not shift a March 31 anchor. A period whose start and end coincide (for example, a skipped civil date) has zero length and is never owed.", + "period_generation": "Producer and consumer MUST derive the same ordered half-open intervals from period_duration, alignment, period_anchor, and period_timezone when applicable. utc alignment uses 1970-01-01T00:00:00Z as interval zero. account_timezone uses local midnight on that date in the account's resolved IANA timezone. source_timezone uses local midnight on that date in the explicit period_timezone. billing_cycle uses its explicit period_anchor expressed in period_timezone. Every boundary is calculated directly from that origin and the interval ordinal by multiplying each ISO 8601 duration component by the ordinal and applying years, months, days, hours, minutes, then seconds. Years, months, and days use local civil-time arithmetic in the selected IANA timezone, including DST transitions; they are not converted to fixed seconds. Hours, minutes, and seconds are exact elapsed time added after the civil date components, so PT24H differs from P1D across a DST transition. Sub-daily local periods are the exception: with account_timezone, source_timezone, or billing_cycle alignment, a period_duration made only of hours, minutes, and seconds MUST divide 24 hours, and its boundaries are the instants whose local wall-clock time is a whole multiple of the duration after local midnight, counted afresh each local day. So hourly periods start on the local hour in every zone, whatever its 1970 offset, a 23-hour DST day has 23 hourly periods and a 25-hour day 25, a wall time that does not exist yields no boundary, and a repeated wall time yields one boundary per offset. With those alignments a period_duration cannot mix date and time components. Month/year addition preserves the origin's local day and time, clamping to the target month's final valid day when necessary. A nonexistent local boundary advances by the timezone gap; an ambiguous local boundary uses the earlier offset. Thus a clamped February boundary does not shift a March 31 anchor. A period whose start and end coincide (for example, a skipped civil date) has zero length and is never owed.", "iana_timezone": "Every period_timezone and echoed period.source_timezone value MUST be a recognized IANA Time Zone Database zone name or link, including UTC. Reject unknown identifiers before accepting a configuration; do not silently substitute the host timezone or a numeric offset.", "source_compatibility": "Before accepting a configuration, the seller MUST prove that its upstream source can produce every configured half-open period exactly, either natively or by lossless re-bucketing from finer-grained source data. source_timezone MUST equal the upstream reporting calendar used by the pinned report definition. A structural incompatibility MUST fail configuration validation and MUST NOT be represented as indefinitely waiting reporting. If incompatibility is discovered after acceptance, the configuration becomes action_required with the seller responsible." }, diff --git a/static/schemas/source/core/reporting-subscription-state.json b/static/schemas/source/core/reporting-subscription-state.json index c6eb440213f..900d3646ab7 100644 --- a/static/schemas/source/core/reporting-subscription-state.json +++ b/static/schemas/source/core/reporting-subscription-state.json @@ -27,13 +27,17 @@ }, "prior_versions": { "type": "array", - "description": "Earlier versions, newest first, retained while any obligation of that version is retained, so a buyer can recompute their expected periods after local-state loss.", + "description": "Earlier versions, newest first, retained while any obligation of that version is retained, so a buyer can recompute their expected periods after local-state loss. Each entry carries the configuration that version had.", "items": { "type": "object", "properties": { "subscription_version": { "type": "integer", "minimum": 1 }, "accepted_at": { "type": "string", "format": "date-time" }, - "superseded_at": { "type": "string", "format": "date-time", "description": "The next version's accepted_at, the cutover instant: this version owes its periods that start before it, in full." } + "superseded_at": { "type": "string", "format": "date-time", "description": "The next version's accepted_at, the cutover instant: this version owes its periods that start before it, in full." }, + "configuration": { + "$ref": "/schemas/core/reporting-subscription.json", + "description": "The configuration this version had: its scope, selector, destination, and reconciliation_mode. Sellers MUST include it, so a buyer that lost local state can recompute this version's owed periods." + } }, "required": ["subscription_version", "accepted_at", "superseded_at"], "additionalProperties": false diff --git a/tests/reporting-subscriptions.test.cjs b/tests/reporting-subscriptions.test.cjs index 66bf305b098..40e7a499071 100644 --- a/tests/reporting-subscriptions.test.cjs +++ b/tests/reporting-subscriptions.test.cjs @@ -618,3 +618,16 @@ test('readback lists inactive intervals; coverage entries can be per offering', assert.match(coverage.properties.timezone_cutovers.items.properties.previous_timezone.description, /Sellers MUST include it/); assert.ok(vState, 'state schema compiles'); }); + +test('prior_versions carry the configuration each version had', () => { + const item = read('core/reporting-subscription-state.json').properties.prior_versions.items; + assert.equal(item.properties.configuration.$ref, '/schemas/core/reporting-subscription.json'); + assert.match(item.properties.configuration.description, /Sellers MUST include it/); +}); + +test('sub-daily local periods restart at each local midnight, not at 1970 offsets', () => { + const rule = read('core/reporting-schedule.json')['x-adcp-validation'].period_generation; + assert.match(rule, /MUST divide 24 hours/); + assert.match(rule, /counted afresh each local day/); + assert.match(rule, /a 23-hour DST day has 23 hourly periods and a 25-hour day 25/); +});