From 07a828b7f5c8fd04fd2aaac596a48eb187727e74 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 13:42:47 +0000 Subject: [PATCH] feat(reporting): settle reporting-subscription ambiguities found by the SDK implementations Offerings publish standard_dimensions / unmapped_dimensions so match is resolvable from the wire; exact without dimensions, period_timezone matching, civil vs elapsed period arithmetic, per-offering cutover E, authorization chains for authorized_at, reactivation as a fresh start with inactive_intervals readback, scope_resolved_at as period end, and per-offering gaps / timezone cutovers with previous_timezone. Repins the worst-case capabilities size (91,804,421 bytes). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/reporting-subscriptions-clarify.md | 15 ++++++++ docs/building/by-layer/L1/security.mdx | 2 +- .../task-reference/get_reporting_status.mdx | 27 +++++++++++---- docs/protocol/sync_principal.mdx | 12 ++++--- .../source/core/account-authorization.json | 2 +- .../core/reporting-delivery-offering.json | 18 ++++++++++ .../source/core/reporting-schedule.json | 2 +- .../core/reporting-subscription-coverage.json | 7 ++-- .../core/reporting-subscription-state.json | 14 ++++++++ .../source/core/reporting-subscription.json | 10 +++--- tests/capabilities-structural-bounds.test.cjs | 2 +- tests/reporting-subscriptions.test.cjs | 34 +++++++++++++++++++ tests/x-added-in-3-3-sweep.test.cjs | 2 ++ 13 files changed, 125 insertions(+), 22 deletions(-) create mode 100644 .changeset/reporting-subscriptions-clarify.md diff --git a/.changeset/reporting-subscriptions-clarify.md b/.changeset/reporting-subscriptions-clarify.md new file mode 100644 index 00000000000..a3f39706464 --- /dev/null +++ b/.changeset/reporting-subscriptions-clarify.md @@ -0,0 +1,15 @@ +--- +"adcontextprotocol": minor +--- + +Settle the reporting-subscription ambiguities the TypeScript and Python SDK implementations hit. Each rule below is what both SDKs now compute. + +- **Mapped dimensions on the wire.** Offerings gain experimental `standard_dimensions` (the breakdown in the standard vocabulary) and `unmapped_dimensions` (native dimensions with no mapping), so a buyer resolves `match` from the wire exactly as the seller does. Sellers advertising `media_buy.reporting_subscriptions` MUST publish `standard_dimensions` on every offering. The documented worst-case capabilities size moves to 91,804,421 bytes. +- **`match: exact` without `dimensions`.** Every candidate qualifies and the lowest `offering_id` wins. +- **`period_timezone`.** An `account_timezone` offering uses it in place of the account's zone. A `utc` offering matches only a UTC alias. A `source_timezone` or fixed `billing_cycle` offering matches only its own published zone. A buyer-configured `billing_cycle` offering never matches a subscription. +- **Period arithmetic.** Years, months, and days are civil time. Hours, minutes, and seconds are exact elapsed time, so `PT24H` differs from `P1D` across DST. Zero-length periods are never owed. +- **Cutovers across different offerings.** E is computed per incoming offering: a continuing offering hands off at its own boundary, and a new one starts at the latest outgoing end. +- **Authorization chains.** `authorized_at` is the start of the current authorization chain. A re-grant within `status_retention_days` continues the chain, so withheld periods stay owed; a later re-grant starts a new one. +- **Reactivation.** Turning an inactive subscription back on is a fresh start. The readback lists `inactive_intervals` so a buyer can recompute after losing local state. +- **`scope_resolved_at`.** For subscription obligations it is the period end. +- **Per-offering exceptions.** `gaps[]` and `timezone_cutovers[]` entries can carry `offering_id` when offerings' boundaries differ, and timezone cutovers carry `previous_timezone`. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 90f10ebf117..48f1e81180a 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -2557,7 +2557,7 @@ schema-valid. The annotations are registered as a 4.0 tightening in the [4.0 compatibility checks](/docs/reference/migration/v4-compatibility#capabilities-stay-within-their-structural-bounds). **Worst case.** With the 4.0 bounds applied, the largest schema-valid -capabilities document is 91,262,597 bytes (about 87 MiB), counting only the +capabilities document is 91,804,421 bytes (about 88 MiB), counting only the members the schema declares. `scripts/capabilities-structural-bounds.cjs` computes it and `tests/capabilities-structural-bounds.test.cjs` pins it, so the figure moves only when a limit moves. The calculation assumes every declared member is diff --git a/docs/media-buy/task-reference/get_reporting_status.mdx b/docs/media-buy/task-reference/get_reporting_status.mdx index 2209cb5dffb..0309e004aa8 100644 --- a/docs/media-buy/task-reference/get_reporting_status.mdx +++ b/docs/media-buy/task-reference/get_reporting_status.mdx @@ -677,7 +677,7 @@ periods that: - the subscription version's `accepted_at`, in the [`get_principal`](/docs/protocol/get_principal) readback; - the buy's `confirmed_at`, when the seller committed to it; and - - the account's `authorization.authorized_at`, when the current grant began; and + - the account's `authorization.authorized_at`, when the current authorization chain began. A re-grant within `status_retention_days` of losing authority continues the chain and leaves it unchanged; and 2. overlap the buy's flight, from its `start_time` to its **stop time**. The stop time is the earliest of `end_time`, `cancellation.canceled_at`, and `completed_at`, which sellers advertising `media_buy.reporting_subscriptions` populate for every @@ -689,8 +689,8 @@ A buy canceled mid-period owes that period and none after it. A buy confirmed on November 1 with a December 1 start owes nothing before December 1. Every buy has an `end_time`, so there is no open-ended case. -Flight values are taken as of each period's `scope_resolved_at`, matching how each -obligation freezes its `media_buy_ids`. An +Flight values are taken as of each period's `scope_resolved_at`, which for a subscription +obligation is the period end, matching how each obligation freezes its `media_buy_ids`. An [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) extension adds only periods not yet resolved. Reinstating or extending a buy after a period has elapsed never makes that period owed retroactively. @@ -732,9 +732,20 @@ owed. - **Resolution change.** If the seller adds an offering that changes the resolution, the old offering is outgoing and the new one incoming, so the change never touches a period already started. +- **Different offerings on each side.** With `match: all`, or a resolution change, the + outgoing and incoming sides can resolve different offerings. E is then computed per + incoming offering: an offering present on both sides hands off at the end of its own + last period that started before T; a newly resolved offering starts at the latest + such end across the outgoing offerings. +- **Reactivation.** Turning an inactive subscription back on is a fresh start at the + reactivation instant, under the ordinary start rule. Nothing that starts while the + subscription was inactive is owed. The readback lists each interval in + `inactive_intervals`, so a buyer can recompute after losing local state. - **Account timezone change.** For account-timezone periods, the old timezone's schedule is outgoing. The E instant is listed as `effective_at` in - `timezone_cutovers[]`, and `list_accounts` shows the new `timezone` and `revision`. + `timezone_cutovers[]`, with the old zone as `previous_timezone`, and `list_accounts` + shows the new `timezone` and `revision`. When the account's offerings have different + boundaries, the seller lists one entry per `offering_id`. Each obligation names the `offering_id` and `report_definition_id` it used, and that offering is authoritative for the obligation's timing: its alignment, anchor, and @@ -826,13 +837,17 @@ data. statuses for revisions the caller read before `unauthorized_at`. Every other reference to the account is unavailable. - **Re-grant.** The account leaves `accounts_unauthorized[]` and is readable here - again. Withheld periods are owed again, due at the re-grant time plus the offering's + again. The re-grant continues the earlier authorization chain, so + `authorization.authorized_at` keeps its original value. Withheld periods are owed again, due at the re-grant time plus the offering's `delivery_sla` rather than their original `expected_at`. With `subscriptions.backfill_on_regrant: true`, no gap is recorded and every period in the revoked interval is owed, on the same re-grant deadline. Otherwise the interval is recorded in `gaps[]` with reason `unauthorized`, `from` the last boundary at or before `unauthorized_at` `until` the first boundary at or after the re-grant, and its - periods are not owed. + periods are not owed. Boundaries differ between offerings, so when the account's + offerings have different boundaries the seller records one gap per `offering_id`, + each with that offering's boundaries; a gap without `offering_id` applies to every + offering. - **Expired entry.** After `status_retention_days` the `accounts_unauthorized[]` entry drops. A later re-grant is then a fresh coverage start from the new `authorized_at`, with no gap claimed. diff --git a/docs/protocol/sync_principal.mdx b/docs/protocol/sync_principal.mdx index 7639f3ce521..0a93335b9fa 100644 --- a/docs/protocol/sync_principal.mdx +++ b/docs/protocol/sync_principal.mdx @@ -323,14 +323,16 @@ An account is covered while the scope selects it and the principal is authorized | `required_finality` | `snapshot` or `official`, which the offering must list in `supported_finality`. | | `alignment` | Optional. Only offerings whose schedule uses this alignment (`utc`, `account_timezone`, `source_timezone`, `billing_cycle`) are candidates. | | `dimensions` | Optional breakdown in the standard vocabulary: `package`, `creative`, `country`, `region`, `metro`. The vocabulary is extensible: an unknown value matches no offering. | -| `match` | `exact` picks the offering whose dimension set equals `dimensions`. Native dimensions that don't map to the vocabulary count as distinct, so `package × device` does not equal `package`. `finest` picks the offering with the most mapped dimensions among those that include `dimensions`. Ties under `exact` and `finest` go to the lowest `offering_id`. `all` picks every offering that includes `dimensions`, each with its own obligations. Defaults to `exact` when `dimensions` is present and `finest` when it is absent. Billing subscriptions SHOULD use `exact`. | -| `period_timezone` | Optional IANA timezone override, only when `account_ids` names exactly one account. | +| `match` | `exact` picks the offering whose dimension set equals `dimensions`. Native dimensions that don't map to the vocabulary count as distinct, so `package × device` does not equal `package`. `finest` picks the offering with the most mapped dimensions among those that include `dimensions`. Ties under `exact` and `finest` go to the lowest `offering_id`. `all` picks every offering that includes `dimensions`, each with its own obligations. Defaults to `exact` when `dimensions` is present and `finest` when it is absent. An explicit `exact` with no `dimensions` lets every candidate qualify, and the lowest `offering_id` wins. Billing subscriptions SHOULD use `exact`. | +| `period_timezone` | Optional IANA timezone override, only when `account_ids` names exactly one account. An `account_timezone` offering uses it in place of the account's zone. A `utc` offering matches only a UTC alias (`UTC`, `Etc/UTC`). A `source_timezone` or fixed `billing_cycle` offering matches only when its published `period_timezone` is the same zone. | + +Each offering publishes its breakdown in the standard vocabulary as `standard_dimensions`, and any native dimensions it can't map as `unmapped_dimensions`, so a buyer resolves `match` from the wire exactly as the seller does. Sellers advertising subscriptions MUST publish `standard_dimensions` on every offering, empty for totals-only offerings. A `billing_cycle` offering whose anchor the buyer configures never matches a subscription, because a subscription can't supply the anchor. For example, if a seller offers `package`, `package × creative`, and `package × country` breakdowns, `dimensions: ["package"]` resolves to the `package` offering under the default `exact`; `finest` would pick one of the two finer offerings by `offering_id`, and `all` would pick all three. `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. 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. 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. @@ -341,7 +343,7 @@ For example, if a seller offers `package`, `package × creative`, and `package A buy confirmed on November 1 with a December 1 start owes nothing before December 1. Flight values are taken as of each period's `scope_resolved_at`. So an [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) extension adds only periods not yet resolved, and reinstating or extending a buy never makes an elapsed period owed after the fact. The period containing the stop time is owed in full. Timing (`expected_at`) comes from each obligation's offering. The seller reports only exceptions, and each removes periods; none adds one. See [`get_reporting_status`](/docs/media-buy/task-reference/get_reporting_status#which-periods-are-owed). -**Versions and cutovers.** A new `subscription_version` is required whenever delivery semantics or scope change: the selector, `reconciliation_mode`, destination, `scope`, `exclude_account_ids`, or `operator_domains`. Each version has its own `accepted_at`. `active` and `replace_account_configs` change in place. Changes are **cutovers** at the instant they take effect, T. The outgoing schedule owes its periods that start before T, in full, ending at E. The incoming schedule owes its periods that start at or after E. No period is owed twice. When the two schedules' boundaries don't align, the stretch from E to the incoming schedule's next boundary is owed under neither; it is shorter than one incoming period. Version changes (for accounts in both versions, T is the new `accepted_at`), deactivation, overrides, `replace_account_configs`, offering resolution changes, and account timezone changes all use this rule. See [cutovers](/docs/media-buy/task-reference/get_reporting_status#which-periods-are-owed). +**Versions and cutovers.** A new `subscription_version` is required whenever delivery semantics or scope change: the selector, `reconciliation_mode`, destination, `scope`, `exclude_account_ids`, or `operator_domains`. Each version has its own `accepted_at`. `active` and `replace_account_configs` change in place. Changes are **cutovers** at the instant they take effect, T. The outgoing schedule owes its periods that start before T, in full, ending at E. The incoming schedule owes its periods that start at or after E. No period is owed twice. When the two schedules' boundaries don't align, the stretch from E to the incoming schedule's next boundary is owed under neither; it is shorter than one incoming period. Version changes (for accounts in both versions, T is the new `accepted_at`), deactivation, overrides, `replace_account_configs`, offering resolution changes, and account timezone changes all use this rule. When the two sides resolve different offerings, E is computed per incoming offering: an offering that continues across T hands off at the end of its own last period started before T; a new offering starts at the latest such end across the outgoing offerings. Reactivating an inactive subscription is a fresh start at the reactivation instant, and nothing starting while it was inactive is owed; the readback lists each interval in `inactive_intervals`. See [cutovers](/docs/media-buy/task-reference/get_reporting_status#which-periods-are-owed). Obligations from a subscription carry `delivery_config_id` equal to `subscription_id` and `delivery_config_version` equal to `subscription_version`. Subscription IDs and the principal's `sync_accounts` `delivery_config_id` values therefore share one namespace, and the seller rejects a collision. A `subscription_id` is never `.` or `..`. @@ -355,7 +357,7 @@ To end its own account-level overrides without calling `sync_accounts`, a princi **Exceptions without polling.** [`get_principal`](/docs/protocol/get_principal) lists, per subscription, `accounts_with_exceptions[]`: each account with an open exception and its reasons. An agency doesn't have to poll `get_reporting_status` account by account. `principal.changed` with reason `reporting_subscription_changed` fires whenever an account's exception opens or closes. -**Lost authority.** When the principal loses authority for an account the subscription covered, `get_principal` lists it under that subscription's `accounts_unauthorized[]` with `unauthorized_at`, `last_owed_period_end`, and the count of withheld periods. The entry stays until re-grant or for `status_retention_days`. After that it drops, and a later re-grant is a fresh coverage start from the new `authorized_at`, with no gap claimed. Each addition or removal fires `principal.changed` with reason `reporting_subscription_changed`, as does a subscription moving to `action_required`. [`get_reporting_status`](/docs/media-buy/task-reference/get_reporting_status) treats the account as unknown, as for any unauthorized caller. +**Lost authority.** When the principal loses authority for an account the subscription covered, `get_principal` lists it under that subscription's `accounts_unauthorized[]` with `unauthorized_at`, `last_owed_period_end`, and the count of withheld periods. The entry stays until re-grant or for `status_retention_days`. A re-grant before that continues the earlier grant: `authorized_at` stays at the first grant and withheld periods are owed again. After that the entry drops, and a later re-grant is a fresh coverage start from the new `authorized_at`, with no gap claimed. Each addition or removal fires `principal.changed` with reason `reporting_subscription_changed`, as does a subscription moving to `action_required`. [`get_reporting_status`](/docs/media-buy/task-reference/get_reporting_status) treats the account as unknown, as for any unauthorized caller. How coverage starts and ends, and what happens on revocation and re-grant, is in [`get_reporting_status`](/docs/media-buy/task-reference/get_reporting_status#subscription-coverage). diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index af392c42669..a0848dd3ae6 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -47,7 +47,7 @@ "format": "date-time", "x-status": "experimental", "x-added-in": "3.3.0", - "description": "When the current grant to this caller began. Reporting subscriptions cover the account from the first period boundary at or after it. Sellers advertising media_buy.reporting_subscriptions MUST populate it." + "description": "When the caller's current authorization chain for this account began. A re-grant before the previous unauthorized_at plus status_retention_days continues the chain and leaves authorized_at unchanged; a later re-grant starts a new chain and sets it to the re-grant time. Reporting subscriptions cover the account from the first period boundary at or after it. Sellers advertising media_buy.reporting_subscriptions MUST populate it." }, "read_only": { "type": "boolean", diff --git a/static/schemas/source/core/reporting-delivery-offering.json b/static/schemas/source/core/reporting-delivery-offering.json index 2c5d7db0698..8c7bb10c1dc 100644 --- a/static/schemas/source/core/reporting-delivery-offering.json +++ b/static/schemas/source/core/reporting-delivery-offering.json @@ -23,6 +23,24 @@ "offering_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.:-]{1,128}$", "x-entity": "reporting_offering" }, "feed_purpose": { "$ref": "#/definitions/ReportingFeedPurpose" }, "report_definition_id": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{1,255}$", "x-entity": "reporting_definition", "description": "Immutable semantic definition for metric, grain, attribution, action-report-time, timezone/calendar, source/API mapping, and restatement/finality policy. Configurations and revisions MUST echo this exact value." }, + "standard_dimensions": { + "type": "array", + "items": { "$ref": "/schemas/enums/reporting-dimension.json" }, + "maxItems": 16, + "uniqueItems": true, + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Experimental (media_buy.reporting_subscriptions). The offering's breakdown mapped onto the standard reporting dimension vocabulary, so a buyer can resolve a subscription selector's match from the wire. Empty for an offering that reports totals only. Sellers advertising media_buy.reporting_subscriptions MUST include it on every offering." + }, + "unmapped_dimensions": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 128 }, + "maxItems": 16, + "uniqueItems": true, + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Experimental (media_buy.reporting_subscriptions). Native report-definition dimensions with no standard mapping. Present when any exist. They count as distinct under match exact and never count toward finest." + }, "report_definition_uri": { "type": "string", "format": "uri", "pattern": "^https://(?![^/]*@)(?!localhost(?:[:/]|$))(?!\\[)(?!\\d+(?:\\.\\d+){3}(?::|/|$))(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(?::\\d+)?(?:/|$)", "description": "Retrievable immutable reporting-report-definition.json document on the authenticated seller/provider or AdCP-registry origin." }, "report_definition_sha256": { "type": "string", "pattern": "^[A-Fa-f0-9]{64}$", "description": "Digest of the exact report-definition bytes. SDKs verify this before parsing and cache by digest." }, "reporting_profile": { diff --git a/static/schemas/source/core/reporting-schedule.json b/static/schemas/source/core/reporting-schedule.json index 96a4e9881b4..df543038efd 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. Calendar durations use local civil-time arithmetic in the selected IANA timezone, including DST transitions; they are not converted to fixed seconds. 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.", + "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.", "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-coverage.json b/static/schemas/source/core/reporting-subscription-coverage.json index ffdee49d9ef..8532acf8d4d 100644 --- a/static/schemas/source/core/reporting-subscription-coverage.json +++ b/static/schemas/source/core/reporting-subscription-coverage.json @@ -83,10 +83,12 @@ }, "timezone_cutovers": { "type": "array", - "description": "Account timezone changes affecting account_timezone-aligned periods, retained for status_retention_days. A cutover: periods under the old timezone are owed when they start before the change, so they end by effective_at; periods under the new timezone are owed when they start at or after effective_at. Any stretch from effective_at to the first new-timezone boundary is owed under neither.", + "description": "Account timezone changes affecting account_timezone-aligned periods, retained for status_retention_days. A cutover: periods under the old timezone are owed when they start before the change, so they end by effective_at; periods under the new timezone are owed when they start at or after effective_at. Any stretch from effective_at to the first new-timezone boundary is owed under neither. Period boundaries differ between offerings, so when the account's covered offerings have different boundaries the seller lists one entry per offering_id; an entry without offering_id applies to every offering on the account.", "items": { "type": "object", "properties": { + "offering_id": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The offering whose account_timezone periods this cutover applies to, when covered offerings have different boundaries. Absent: every account_timezone offering on the account." }, + "previous_timezone": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The IANA timezone in effect before the change. Sellers MUST include it: the account's current timezone already shows the new zone, so a buyer cannot otherwise recover the old schedule." }, "effective_at": { "type": "string", "format": "date-time", "description": "End of the last old-timezone period that started before the change." }, "timezone": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The account's new IANA timezone, as list_accounts shows it." } }, @@ -96,10 +98,11 @@ }, "gaps": { "type": "array", - "description": "Intervals between a loss of authority and re-grant that the seller did not backfill (backfill_on_regrant false or absent). Periods starting in [from, until) are not owed. With backfill_on_regrant true no gap is recorded. A re-grant after the accounts_unauthorized entry expired is a fresh coverage start from authorized_at, with no gap. Retained for status_retention_days after until.", + "description": "Intervals between a loss of authority and re-grant that the seller did not backfill (backfill_on_regrant false or absent). Periods starting in [from, until) are not owed. With backfill_on_regrant true no gap is recorded. A re-grant after the accounts_unauthorized entry expired is a fresh coverage start from authorized_at, with no gap. Retained for status_retention_days after until. Period boundaries differ between offerings, so when the account's covered offerings have different boundaries the seller lists one entry per offering_id, each with that offering's boundaries; an entry without offering_id applies to every offering on the account.", "items": { "type": "object", "properties": { + "offering_id": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The offering whose boundaries from and until use, when covered offerings have different boundaries. Absent: the gap applies to every offering on the account." }, "from": { "type": "string", "format": "date-time", "description": "Last period boundary at or before unauthorized_at." }, "until": { "type": "string", "format": "date-time", "description": "First period boundary at or after the re-grant." }, "reason": { "type": "string", "enum": ["unauthorized"] } diff --git a/static/schemas/source/core/reporting-subscription-state.json b/static/schemas/source/core/reporting-subscription-state.json index 6a92e161727..c6eb440213f 100644 --- a/static/schemas/source/core/reporting-subscription-state.json +++ b/static/schemas/source/core/reporting-subscription-state.json @@ -44,6 +44,20 @@ "format": "date-time", "description": "When active false was applied. Present when and only when state is inactive." }, + "inactive_intervals": { + "type": "array", + "maxItems": 64, + "description": "Intervals during which the subscription was inactive, oldest first, retained while any obligation they affect is retained, so a buyer can recompute expected periods after local-state loss. Reactivation is a fresh start: nothing that starts during an interval is owed, and coverage resumes at the first boundary at or after reactivated_at. The current interval, while the subscription is inactive, has no reactivated_at.", + "items": { + "type": "object", + "properties": { + "deactivated_at": { "type": "string", "format": "date-time", "description": "When active false was applied." }, + "reactivated_at": { "type": "string", "format": "date-time", "description": "When active true was applied again. Absent for the current interval while the subscription is inactive." } + }, + "required": ["deactivated_at"], + "additionalProperties": false + } + }, "destination_ref": { "type": "string", "minLength": 1, diff --git a/static/schemas/source/core/reporting-subscription.json b/static/schemas/source/core/reporting-subscription.json index 15a0ba4e8d1..a1d9f584b25 100644 --- a/static/schemas/source/core/reporting-subscription.json +++ b/static/schemas/source/core/reporting-subscription.json @@ -86,13 +86,13 @@ "match": { "type": "string", "enum": ["exact", "finest", "all"], - "description": "exact selects the offering whose dimension set equals dimensions; native dimensions that do not map to the vocabulary count as distinct, so package by device does not equal package. finest selects the single offering with the most mapped dimensions among those including every selector dimension. Ties under exact and finest go to the lowest offering_id in byte order. all selects every offering including every selector dimension, each with its own obligations. Defaults to exact when dimensions is present and finest when it is absent. Billing subscriptions SHOULD use exact." + "description": "exact selects the offering whose dimension set equals dimensions; native dimensions that do not map to the vocabulary count as distinct, so package by device does not equal package. finest selects the single offering with the most mapped dimensions among those including every selector dimension. Ties under exact and finest go to the lowest offering_id in byte order. all selects every offering including every selector dimension, each with its own obligations. Defaults to exact when dimensions is present and finest when it is absent. With match exact and no dimensions, every candidate qualifies and the lowest offering_id wins. Matching reads each offering's standard_dimensions and unmapped_dimensions. Billing subscriptions SHOULD use exact." }, "period_timezone": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "IANA timezone override for period boundaries. Only valid when scope.account_ids names exactly one account. Only offerings that can produce periods in that zone match." + "description": "IANA timezone override for period boundaries. Only valid when scope.account_ids names exactly one account. An account_timezone offering matches and uses this zone in place of the account's. A utc offering matches only when this is a UTC alias (UTC or Etc/UTC). A source_timezone or fixed billing_cycle offering matches only when its published period_timezone equals this one. A billing_cycle offering whose anchor the buyer configures never matches a subscription, which has no way to supply the anchor." } }, "required": ["feed_purpose", "period_duration", "required_finality"], @@ -169,10 +169,10 @@ } ], "x-adcp-validation": { - "coverage": "Coverage is computable by the buyer without asking. An account is in coverage while scope (with exclude_account_ids and operator_domains) selects it and the caller is authorized for it: its authorization object, when advertised, lists get_reporting_status in allowed_tasks. Covered buys are the confirmed media buys in that account the caller can see through get_media_buys. For each covered buy and resolved offering, the owed periods are the complete periods of that offering's published schedule (period_timezone overriding its timezone) that start on or after the first boundary at or after the latest of this version's accepted_at, the buy's confirmed_at, and the account's authorization.authorized_at, and that overlap the buy's flight from start_time to its stop time: the earliest of end_time, cancellation.canceled_at, and completed_at. Flight values are evaluated at each period's scope_resolved_at, so an extension adds only unresolved periods and an elapsed period never becomes owed retroactively. The period containing the stop time is owed in full; periods are never clipped. The seller reports only exceptions, each removing periods; none adds one.", - "cutover": "Every change effective at instant T that hands periods from an outgoing schedule to an incoming one uses one rule: the outgoing schedule owes its periods that start before T, in full; let E be the end of the last of them (E is T when T is an outgoing boundary); the incoming schedule owes its periods that start at or after E. Nothing is owed twice. When the incoming schedule has no boundary at E, the stretch from E to its first boundary after E is owed under neither; it is shorter than one incoming period and is empty when both schedules share boundaries (same alignment and timezone, nested durations). This applies to version changes (for accounts in both versions; T is the new accepted_at), deactivation (no incoming schedule), overrides and replace_account_configs (the caller's configuration and the subscription, either direction), resolution changes, and account timezone changes. Exception from and until values are such E instants.", + "coverage": "Coverage is computable by the buyer without asking. An account is in coverage while scope (with exclude_account_ids and operator_domains) selects it and the caller is authorized for it: its authorization object, when advertised, lists get_reporting_status in allowed_tasks. Covered buys are the confirmed media buys in that account the caller can see through get_media_buys. For each covered buy and resolved offering, the owed periods are the complete periods of that offering's published schedule (period_timezone overriding its timezone) that start on or after the first boundary at or after the latest of this version's accepted_at, the buy's confirmed_at, and the account's authorization.authorized_at, and that overlap the buy's flight from start_time to its stop time: the earliest of end_time, cancellation.canceled_at, and completed_at. Flight values are evaluated at each period's scope_resolved_at, so an extension adds only unresolved periods and an elapsed period never becomes owed retroactively. The period containing the stop time is owed in full; periods are never clipped. The seller reports only exceptions, each removing periods; none adds one. For subscription obligations, scope_resolved_at is the period end. Authorization is evaluated as a chain: a re-grant before unauthorized_at plus status_retention_days continues the earlier grant, so authorized_at stays at the chain's first grant and withheld periods stay owed; a later re-grant starts a new chain at the re-grant. Reactivating an inactive subscription is a fresh start: the deactivation is a cutover with no incoming schedule, nothing that starts during the inactive interval is owed, and coverage resumes at the first boundary at or after the reactivation instant listed in inactive_intervals.", + "cutover": "Every change effective at instant T that hands periods from an outgoing schedule to an incoming one uses one rule: the outgoing schedule owes its periods that start before T, in full; let E be the end of the last of them (E is T when T is an outgoing boundary); the incoming schedule owes its periods that start at or after E. Nothing is owed twice. When the incoming schedule has no boundary at E, the stretch from E to its first boundary after E is owed under neither; it is shorter than one incoming period and is empty when both schedules share boundaries (same alignment and timezone, nested durations). This applies to version changes (for accounts in both versions; T is the new accepted_at), deactivation (no incoming schedule), overrides and replace_account_configs (the caller's configuration and the subscription, either direction), resolution changes, and account timezone changes. When the outgoing and incoming sides resolve different offerings (match all, or a resolution change), E is computed per incoming offering: when the offering continues across T, E is the end of its own last outgoing period that starts before T; otherwise E is the latest such end across the outgoing offerings. An incoming side with no outgoing predecessor, or whose predecessor resolved nothing, starts at T under the ordinary start rule. Exception from and until values are such E instants.", "precedence": "Per account and feed_purpose, precedence is caller-scoped. During 3.x the caller's own active sync_accounts reporting_delivery_configs entry for the account replaces every subscription of that caller; otherwise the caller's active account_ids subscription naming the account replaces its all_authorized_accounts subscription. Another principal's configurations and subscriptions never affect this one, and overridden_by never names them. A caller never receives two feeds for the same account and feed_purpose.", - "selection": "The seller maps each offering's report definition dimensions onto the standard vocabulary. An offering is a candidate when feed_purpose, period_duration, reconciliation_mode, required_finality (in supported_finality), and a present alignment match, a present destination matches its method, and the product lists it in reporting_delivery_offering_ids. match then applies. Each (buy, resolved offering) pair is owed its own periods. When some of a buy's packages resolve and others do not, the buy is covered for the resolved ones and carries an offering_unsupported exception naming the uncovered package_ids. The resolved offering_id and report_definition_id appear on each obligation and govern its timing. A resolution change is a cutover (see cutover).", + "selection": "The seller maps each offering's report definition dimensions onto the standard vocabulary and publishes the result on the offering as standard_dimensions, with any native dimensions it cannot map as unmapped_dimensions, so the buyer resolves match from the wire. An offering is a candidate when feed_purpose, period_duration, reconciliation_mode, required_finality (in supported_finality), and a present alignment match, a present destination matches its method, and the product lists it in reporting_delivery_offering_ids. match then applies. Each (buy, resolved offering) pair is owed its own periods. When some of a buy's packages resolve and others do not, the buy is covered for the resolved ones and carries an offering_unsupported exception naming the uncovered package_ids. The resolved offering_id and report_definition_id appear on each obligation and govern its timing. A resolution change is a cutover (see cutover).", "replace_account_configs": "On the request that first applies a subscription_version with replace_account_configs true, or that changes the flag to true, the seller sets each matching configuration of the same caller to active false, cut over at that request (the configuration outgoing, the subscription incoming); sync_accounts and list_accounts readback show it, and the subscription's overridden exception ends at the cutover. A later sync_accounts entry takes precedence again. Subscription writes, including these deactivations, are caller configuration, not account changes: they never advance the account's revision or conflict with an in-review request_account change. Not applied on dry_run.", "destination": "destination_id MUST name a destination present in the caller's reporting_destinations after the same request applies; otherwise the whole request fails. When more than one active subscription of the caller shares a destination, every path_template MUST contain {account_id} and the templates MUST differ. Destination proof establishes where data may go, never which account data the caller may receive: every delivery re-checks account authority. Every delivered row carries account_id." }, diff --git a/tests/capabilities-structural-bounds.test.cjs b/tests/capabilities-structural-bounds.test.cjs index 6d335c53a2b..b71dfc88037 100644 --- a/tests/capabilities-structural-bounds.test.cjs +++ b/tests/capabilities-structural-bounds.test.cjs @@ -26,7 +26,7 @@ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8'); const security = read('docs/building/by-layer/L1/security.mdx'); // The documented worst case. Update security.mdx with this number. -const DOCUMENTED_WORST_CASE_BYTES = 91_262_597; +const DOCUMENTED_WORST_CASE_BYTES = 91_804_421; const DOCUMENTED_OPEN_POSITIONS = 105; const rows = bounds.coverage(); diff --git a/tests/reporting-subscriptions.test.cjs b/tests/reporting-subscriptions.test.cjs index 16500aabc50..66bf305b098 100644 --- a/tests/reporting-subscriptions.test.cjs +++ b/tests/reporting-subscriptions.test.cjs @@ -584,3 +584,37 @@ test('readback exception reasons stay in sync with the coverage enum', () => { const coverage = read('core/reporting-subscription-coverage.json').definitions.ReportingSubscriptionExclusionReason.enum; assert.deepEqual(state, coverage); }); + +test('offerings publish mapped dimensions so a buyer resolves match from the wire', () => { + const offering = read('core/reporting-delivery-offering.json'); + assert.equal(offering.properties.standard_dimensions.items.$ref, '/schemas/enums/reporting-dimension.json'); + assert.match(offering.properties.standard_dimensions.description, /MUST include it on every offering/); + assert.match(offering.properties.unmapped_dimensions.description, /count as distinct under match exact/); + const subscription = read('core/reporting-subscription.json'); + assert.match(subscription['x-adcp-validation'].selection, /publishes the result on the offering as standard_dimensions/); + assert.match(subscription.properties.selector.properties.match.description, /With match exact and no dimensions, every candidate qualifies and the lowest offering_id wins/); + assert.match(subscription.properties.selector.properties.period_timezone.description, /utc offering matches only when this is a UTC alias/); +}); + +test('cutovers, authorization chains, reactivation, and period arithmetic are pinned', () => { + const v = read('core/reporting-subscription.json')['x-adcp-validation']; + assert.match(v.cutover, /E is computed per incoming offering/); + assert.match(v.coverage, /scope_resolved_at is the period end/); + assert.match(v.coverage, /re-grant before unauthorized_at plus status_retention_days continues the earlier grant/); + assert.match(v.coverage, /Reactivating an inactive subscription is a fresh start/); + assert.match(read('core/account-authorization.json').properties.authorized_at.description, /continues the chain and leaves authorized_at unchanged/); + assert.match(read('core/reporting-schedule.json')['x-adcp-validation'].period_generation, /Hours, minutes, and seconds are exact elapsed time/); + assert.match(read('core/reporting-schedule.json')['x-adcp-validation'].period_generation, /zero length and is never owed/); +}); + +test('readback lists inactive intervals; coverage entries can be per offering', () => { + const vState = validate('/schemas/core/reporting-subscription-state.json'); + const state = read('core/reporting-subscription-state.json'); + assert.ok(state.properties.inactive_intervals, 'inactive_intervals readback'); + assert.deepEqual(state.properties.inactive_intervals.items.required, ['deactivated_at']); + const coverage = read('core/reporting-subscription-coverage.json'); + assert.ok(coverage.properties.gaps.items.properties.offering_id); + assert.ok(coverage.properties.timezone_cutovers.items.properties.offering_id); + assert.match(coverage.properties.timezone_cutovers.items.properties.previous_timezone.description, /Sellers MUST include it/); + assert.ok(vState, 'state schema compiles'); +}); diff --git a/tests/x-added-in-3-3-sweep.test.cjs b/tests/x-added-in-3-3-sweep.test.cjs index ab6f027d54e..c4ae067da96 100644 --- a/tests/x-added-in-3-3-sweep.test.cjs +++ b/tests/x-added-in-3-3-sweep.test.cjs @@ -132,6 +132,8 @@ const PROPERTIES_3_3 = [ ['core/reporting-obligation.json', ['properties', 'offering_id']], ['core/media-buy.json', ['properties', 'completed_at']], ['core/account-authorization.json', ['properties', 'authorized_at']], + ['core/reporting-delivery-offering.json', ['properties', 'standard_dimensions']], + ['core/reporting-delivery-offering.json', ['properties', 'unmapped_dimensions']], ['media-buy/get-media-buys-response.json', ['properties', 'media_buys', 'items', 'properties', 'completed_at']], ];