Skip to content

feat(reporting): settle reporting-subscription ambiguities found by the SDK implementations - #8585

Merged
bokelley merged 1 commit into
mainfrom
bokelley/reporting-subscriptions-clarify
Oct 29, 2026
Merged

bokelley merged 1 commit into
mainfrom
bokelley/reporting-subscriptions-clarify

Conversation

@bokelley

@bokelley bokelley commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The TypeScript (adcontextprotocol/adcp-client#3505, #3508, #3509) and Python (adcontextprotocol/adcp-client-python#1696, #1698, #1699) implementations of principal-level reporting subscriptions each hit the same spec gaps independently. Both SDKs converged on one reading, checked by a shared 46-case parity table. This PR makes that reading normative before beta.2.

Gap Rule
A buyer can't resolve match from the wire Offerings gain experimental standard_dimensions (standard vocabulary) and unmapped_dimensions; sellers advertising subscriptions MUST publish standard_dimensions on every offering
exact without dimensions Every candidate qualifies; the lowest offering_id wins
"offerings that can produce periods in that zone" (period_timezone) account_timezone uses the override; utc matches only a UTC alias; source_timezone and fixed billing_cycle match only their own published zone; a buyer-configured billing_cycle never matches a subscription
Hours, minutes and seconds: civil or elapsed? Years, months and days are civil; hours, minutes and seconds are exact elapsed time (PT24H ≠ P1D across DST); zero-length periods are never owed
E when the two sides resolve different offerings Per incoming offering: a continuing offering hands off at its own boundary; a new one starts at the latest outgoing end
authorized_at "current grant" versus withheld periods staying owed authorized_at starts the current authorization chain; a re-grant within status_retention_days continues it; a later one starts fresh
Reactivation undefined, and the readback loses its instant A fresh start at reactivation; the readback lists inactive_intervals
scope_resolved_at for subscription obligations The period end
gaps[] and timezone_cutovers[] are per account, but boundaries are per offering Optional offering_id per entry; timezone cutovers carry previous_timezone, because the account already shows the new zone

The worst-case capabilities size moves from 91,262,597 to 91,804,421 bytes, because of the two new 16-entry arrays on every offering. tests/capabilities-structural-bounds.test.cjs and security.mdx are repinned.

SDK follow-ups once this merges:

  • Publish and read standard_dimensions / unmapped_dimensions instead of host-supplied dimensions.
  • Emit inactive_intervals, previous_timezone, and per-offering gap and cutover entries.
  • Fix one TS parity-table case whose scope has both all_authorized_accounts and account_ids, which the schema forbids.

Tests: tests/reporting-subscriptions.test.cjs gains three tests. test:schemas, test:json-schema, test:compliance-snippets, the MCP projection and capabilities bounds tests, the x-added-in sweep, test:docs-nav, and test:owned-links pass locally.

🤖 Generated with Claude Code


Open workspace in Conductor

…he 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) <noreply@anthropic.com>
@aao-secretariat aao-secretariat Bot added the ladon/needs-human-review Ladon has escalated this PR for human review. label Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Escalate to human review

Escalate — gated schema paths require human/CODEOWNERS approval.

This PR modifies published protocol source under static/schemas/source/** (6 files), which is a deterministic hard approval gate (gated_paths: true), and review_decision is REVIEW_REQUIRED (not APPROVED). Per decision rule row 2, this must escalate until a real human/CODEOWNERS approval lands.

The reviewer found no Critical/High/Medium findings: schema↔docs coherence holds across sync_principal.mdx and get_reporting_status.mdx, the changeset is present and correctly scoped, the x-added-in sweep and worst-case-bytes tests are updated/pinned, no new undiscriminated oneOf, and no dist/ artifact edits. These are beta.2 clarifications/additions to the experimental media_buy.reporting_subscriptions surface. The diff is mechanically clean — but the gate on published schema source is non-overridable without an APPROVED review.

Escalation reasons

  • Modifies gated schema source files (static/schemas/source/core/reporting-subscription.json and 5 sibling files) — review_decision is REVIEW_REQUIRED; human/CODEOWNERS approval required before merge.

Why human review

  • Modifies gated schema source under static/schemas/source/core/ (reporting-subscription.json, reporting-subscription-state.json, reporting-subscription-coverage.json, reporting-schedule.json, reporting-delivery-offering.json, account-authorization.json); gated_paths is true and review_decision is REVIEW_REQUIRED (not APPROVED) — human/CODEOWNERS approval required.
  • This PR touches a path under a hard, non-overridable approval gate (static/schemas/source/core/account-authorization.json (modified) matches static/schemas/source/**; static/schemas/source/core/reporting-delivery-offering.json (modified) matches static/schemas/source/**; static/schemas/source/core/reporting-schedule.json (modified) matches static/schemas/source/**; static/schemas/source/core/reporting-subscription-coverage.json (modified) matches static/schemas/source/**; static/schemas/source/core/reporting-subscription-state.json (modified) matches static/schemas/source/**; static/schemas/source/core/reporting-subscription.json (modified) matches static/schemas/source/**) and the current GitHub review decision is 'REVIEW_REQUIRED', not APPROVED. This is a hard gate enforced in code — Ladon cannot auto-approve until a human/CODEOWNERS approval is recorded, regardless of how clean the diff is.

@bokelley
bokelley merged commit 6deac55 into main Oct 29, 2026
66 checks passed
@bokelley
bokelley deleted the bokelley/reporting-subscriptions-clarify branch October 29, 2026 14:11
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

adcontextprotocol/adcp#8585 settles the ambiguities both SDKs hit.

- Offerings publish standard_dimensions and unmapped_dimensions.
  SubscriptionOffering.from_offering reads them from the wire, and sellers use
  with_subscription_dimensions() to publish them.
  subscription_capabilities() refuses an offering without them.
- Coverage consumes the readback's inactive_intervals.
- timezone_cutovers[] carry previous_timezone and an optional offering_id, and
  gaps[] carry an optional offering_id. An entry with an offering_id applies to
  that offering only, and previous_timezone supplies the zone before the first
  cutover.
- period_timezone follows the normative text: a utc offering matches only UTC
  or Etc/UTC, a source_timezone offering only its published zone (never an
  account-resolved one), and an account_timezone offering takes the override.

The beta.1 models close these objects, so _reporting_subscription_preview adds
the fields to the generated classes until a 3.3.0-beta.2 bundle carries them.
The vendored TypeScript parity table keeps a scope valid under maxProperties 1
in three cases, matching the fix pending upstream.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client that referenced this pull request Oct 29, 2026
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) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…r capabilities (#1696)

* feat(reporting): compute owed reporting-subscription periods and their capabilities

Add adcp.reporting.subscriptions, the shared half of AdCP 3.3 principal-level
reporting subscriptions (media_buy.reporting_subscriptions):

- expected_subscription_periods(): the coverage rule from
  core/reporting-subscription.json as a pure function over plain data. It
  owes each confirmed buy and resolved offering the complete periods that
  start at or after the latest of the version's accepted_at, the buy's
  confirmed_at and the account's authorized_at, and that overlap the flight
  as of each period's scope_resolved_at. It applies cutovers for versions,
  deactivation, resolution changes and account timezone changes, removes
  exceptions and gaps over [from, until), and handles revocation and
  re-grant with and without backfill_on_regrant. Results are
  ExpectedReportingPeriod values, so they feed evaluate_reporting_ledger and
  the consumer-status planner unchanged.
- resolve_subscription_offerings(): exact / finest / all selection with ties
  to the lowest offering_id in byte order.
- subscription_capabilities() and advertise_reporting_subscriptions():
  configuration_tasks, the deprecated configuration_task only when
  sync_accounts is listed, the subscriptions block, the feature id and the
  principal section.

Civil-time schedules carry the normative local-midnight origin explicitly, so
they do not depend on the ledger's legacy anchor default.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): derive subscription periods exactly as the TypeScript SDK does

Align expected_subscription_periods with adcontextprotocol/adcp-client#3505 so
that both SDKs owe identical periods. The spec leaves each of these choices
open:

- A dedicated period calendar. Years, months and days are local civil
  arithmetic with the day clamped from the origin. Hours, minutes and seconds
  are exact elapsed time. Calendar months (P1M billing cycles) are supported,
  and numeric UTC offsets are refused.
- Cutovers are paired per buy over the union of its packages. A continuing
  offering hands off at E of its own outgoing calendar. A newly resolved one
  starts at the latest E of everything the previous regime resolved.
- A period_timezone override moves account_timezone and account-resolved
  schedules into its zone, while utc and fixed-zone schedules match only
  their own zone.
- Versions may carry in-place inactive intervals and their destination
  method. The coverage entry's timezone_cutovers are applied.
- A re-grant continues authority only before unauthorized_at plus
  status_retention_days, and a re-grant without retention is an error.

The TypeScript SDK's 46-case table is vendored and run as a parity test.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(reporting): adopt the reporting-subscription rules settled in adcp#8585

adcontextprotocol/adcp#8585 settles the ambiguities both SDKs hit.

- Offerings publish standard_dimensions and unmapped_dimensions.
  SubscriptionOffering.from_offering reads them from the wire, and sellers use
  with_subscription_dimensions() to publish them.
  subscription_capabilities() refuses an offering without them.
- Coverage consumes the readback's inactive_intervals.
- timezone_cutovers[] carry previous_timezone and an optional offering_id, and
  gaps[] carry an optional offering_id. An entry with an offering_id applies to
  that offering only, and previous_timezone supplies the zone before the first
  cutover.
- period_timezone follows the normative text: a utc offering matches only UTC
  or Etc/UTC, a source_timezone offering only its published zone (never an
  account-resolved one), and an account_timezone offering takes the override.

The beta.1 models close these objects, so _reporting_subscription_preview adds
the fields to the generated classes until a 3.3.0-beta.2 bundle carries them.
The vendored TypeScript parity table keeps a scope valid under maxProperties 1
in three cases, matching the fix pending upstream.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): take each offering's prior zone from its own timezone cutover

Follow adcp#8585: the account's timezone is its current zone, as list_accounts
shows it. Before a timezone change, an offering's account-timezone periods use
that change's previous_timezone, considering only the changes that apply to
the offering. An offering with no applicable change uses the current zone
throughout. A timezone change without previous_timezone is now refused, and so
is a wire offering without standard_dimensions.

Previously the first cutover's previous_timezone became the base zone for
every offering, so a cutover scoped to one offering could shift another
offering's boundaries.

Re-vendor the TypeScript parity table at f8b2fddd (54 cases, with the scope
fix now upstream).

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(reporting): serve adcp#8585 subscription fields only to 3.3.0-beta.2 callers

The server validates responses against the release it serves, and the pinned
3.3.0-beta.1 schemas close the objects adcp#8585 extends. The new wire module
gates the new fields on 3.3.0-beta.2 or later, as the TypeScript SDK does:

- standard_dimensions and unmapped_dimensions on offerings;
- inactive_intervals on the readback;
- previous_timezone and offering_id on timezone_cutovers[] and gaps[].

For older callers, per-offering gaps collapse into one account-wide gap
spanning them, and per-offering cutovers into one entry at the latest
effective_at.

The parity test now also validates every subscription in the TypeScript table
against core/reporting-subscription.json.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): address the coverage-rule review and adopt adcp#8588 periods

Fixes from the expert review of the coverage rule:

- An empty flight (stopped at or before it started) overlaps no period, so a
  buy canceled before its flight owes nothing.
- Each regime carries its per-offering handoff. E for a continuing or new
  offering is the latest of the outgoing calendar's boundary and the outgoing
  regime's own handoff, so two cutovers inside one period never owe a period
  twice. A timezone change passed at T or at its E now gives the same result.
- The extensible reporting-dimension vocabulary parses on selectors, offerings
  and the capability block; an unknown selector value matches nothing.
- Zone names must be IANA zones (posixrules and right/... are refused), and
  offerings with a year or month delivery_sla are refused at construction.
- The beta.1 projection keeps distinct timezone changes distinct and drops
  prior_versions[].configuration. The feature id is refused without the
  principal section. Stale docstrings are corrected.

adcp#8588 (pending for 3.3.0-beta.2):

- Sub-daily periods under a local alignment restart at each local midnight on
  wall-clock multiples, so a DST day has 23 or 25 hourly periods. The duration
  must divide 24 hours and cannot mix date and time components.
- prior_versions[].configuration is consumed, and
  require_prior_configurations makes a missing one an error. A version
  accepted while inactive has an empty window.

The parity runner now checks row order, expected_obligations and the error
class.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): accept undated continuation re-grants and re-vendor the parity table

Re-vendor the TypeScript coverage table at a99597a4: 79 cases, adding
- empty flights;
- two cutovers inside one period;
- sub-daily local periods;
- DST gaps and folds at midnight;
- a skipped civil date;
- PT24H versus P1D;
- month-end clamps;
- three-grant chains;
- simultaneous changes;
- unknown dimensions.

All pass under the strict runner.

Match TS on one rule the table pins: a grant whose authorized_at is at or
before the previous loss continues the chain at an unknown instant. This
happens because adcp#8585 keeps authorized_at at the chain start after a
re-grant inside retention. The buyer removes no gap of its own; the seller's
gaps[] decide. Grants are read in input order.

The schema check accepts dimension values outside the beta.1 enum, because
the vocabulary is x-extensible.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): owe the period containing confirmed_at in full, per adcp#8593

Owner decision for 3.3.0-beta.2:

- 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 then owed every period that ends after its confirmed_at, so the
  confirmation period is owed in full.
- An empty flight still owes nothing.

The rule lives in the one function that holds it, _first_owed_start.

Re-vendor the TypeScript parity table at f8b48c0c. It has 81 cases,
conformed to the same decision, and adds an ASAP buy confirmed mid-day and a
monthly billing buy confirmed mid-month. All pass under the strict runner.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
…cp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Oct 29, 2026
… track what they owe (#1698)

* feat(reporting): accept reporting subscriptions on sync_principal and track what they owe

Seller runtime for AdCP 3.3 principal-level reporting subscriptions, on top of
the shared coverage rule.

Principal runtime (opt-in): PrincipalService(reporting_subscriptions=...) or
set_reporting_subscriptions() accepts the sync_principal
reporting_subscriptions section. Without the option the section is ignored as
before. With it, the service:
- validates what the generated model cannot: exactly one scope form,
  exclude_account_ids only with all-accounts scope, period_timezone only for
  one named account, billing and consumer_receipt rules, destinations that
  exist after the same request, path_template placeholders and
  {account_id}, distinct templates on a shared destination, the
  delivery_config_id namespace, and same-level conflicts;
- versions subscriptions: reused versions must keep their semantics; active
  and replace_account_configs change in place; new versions record
  accepted_at and prior_versions; omitted subscriptions are deactivated and
  retained;
- reads back core/reporting-subscription-state.json. A destination needing
  action moves the subscription to action_required, and
  update_subscription_status() records seller-derived status without
  changing configuration_version. Both emit principal.changed with reason
  reporting_subscription_changed.

ReportingSubscriptionEngine: host hooks list_authorized_accounts and
account_buys, plus optional account_delivery_configs for precedence;
authorization_changed() and reconcile() track grants and losses. It records
these exceptions as cutover intervals: overridden (the caller's own
sync_accounts config, or its account_ids subscription), capacity under a
deterministic max_accounts order, excluded_by_buyer, and per-buy and
per-package offering_unsupported. It also maintains accounts_with_exceptions
and accounts_unauthorized (with withheld counts) and serves
subscription_coverage with gaps and timezone_cutovers. owed_periods() runs
the same expected_subscription_periods a buyer runs, and receipt_roster()
derives receipt duty from active consumer_receipt subscriptions.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): announce a subscription's recovery from action_required

transition_destination fired principal.changed with reason
reporting_subscription_changed only when a subscription moved to
action_required. Fire it on any subscription state change, so a caller that
caches the readback learns when the destination is ready again.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): order max_accounts by the current grant chain

The capacity order used an account's first grant ever. A re-grant at or after
unauthorized_at + status_retention_days is a fresh coverage start, so it now
starts a new chain for ordering too, matching the coverage rule. Also pin that
destination transitions work when no principal.changed emitter is wired.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): keep max_accounts slots with the accounts already served

Align the capacity order with the TypeScript SDK (adcp-client#3508). Accounts
already served keep their slots, and the remaining slots go by account_id in
byte order. A newly granted account never displaces one being served, which
also settles the re-grant ordering case without depending on grant history.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(reporting): count withheld periods from host delivery facts when available

A host may implement is_period_delivered(principal, account_id, period, by=).
When it does, withheld_period_count counts the owed periods that ended at or
before unauthorized_at and were not delivered by then. Without the hook, the
engine still counts the periods that were not yet due when authority ended,
as the TypeScript SDK does.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(reporting): emit the reporting-subscription fields settled in adcp#8585

Seller side of adcontextprotocol/adcp#8585.

- The readback lists inactive_intervals. Deactivating, including by omission,
  opens an interval, and turning the subscription back on records
  reactivated_at. The engine reads reactivations from the readback instead of
  tracking them itself, so a buyer recomputes the same periods after
  local-state loss.
- timezone_cutovers[] carry previous_timezone. Both timezone_cutovers[] and
  gaps[] carry one entry per offering_id when the covered offerings'
  boundaries differ, and a single entry for every offering when they agree.
  Coverage now resolves against the account's buys rather than every
  offering.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(reporting): gate the adcp#8585 readback and coverage fields by served release

sync_principal gains an optional context, like get_principal. Both drop
inactive_intervals from the subscription readback for callers served below
3.3.0-beta.2, or with no known release, because the server validates
responses against the pinned 3.3.0-beta.1 schema.
ReportingSubscriptionEngine.subscription_coverage takes adcp_version. For
older callers it drops previous_timezone and offering_id, and reports
per-offering gaps and cutovers account-wide. Tests pin that the projected
coverage validates against the beta.1 schema.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): address the seller-runtime review of reporting subscriptions

Fixes from the expert review of the principal runtime and engine:

- With the option off, sync_principal rejects reporting_subscriptions with
  UNSUPPORTED_FEATURE instead of silently applying nothing.
  replace_account_configs: true is rejected the same way until it is
  implemented.
- No UTC defaults. AuthorizedAccount requires an IANA timezone and the
  authorized_at that list_accounts serves. An account with no known zone
  fails its account-timezone schedules, isolated per subscription in
  evaluation_errors. Setting a first zone is not a cutover, and the wire's
  previous_timezone comes from each change.
- Losing authority no longer closes open exceptions, so a backfilled re-grant
  never owes a second feed next to an override.
- Each version's destination is snapshotted at acceptance, offerings that
  cannot be scheduled are refused at construction, prior versions without a
  known configuration are an error, and one failing subscription never stops
  the others.
- Account-level exceptions use the earliest E across calendars, resolved per
  package. AccountDeliveryConfig.active_from opens an override at its own
  cutover. accounts_unauthorized lists only accounts the subscription
  served. Delivery facts are asked once per loss.
- Readback: prior_versions carry their configuration (adcp#8588, beta.2+
  only). A version bump while inactive stays computable. inactive_intervals
  are never dropped: the 65th deactivation is refused.
- Validation: resends that only reorder uniqueItems arrays or spell out the
  default match are the same version, and braces outside placeholders are
  refused. render_path_template() and
  PrincipalService.reporting_subscription_ids() are added.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(reporting): keep backslashes from escaping a path-template segment

render_path_template percent-encoded only values containing "/" or equal to
"." or "..". account_id has no character-set constraint, so a value such as
"..\..\x" could gain or escape a segment on a backslash-separated
file_transfer target (SMB, UNC). Values containing a backslash are now encoded
too. Other values are left as the spec writes them, so paths stay identical
across SDKs.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ladon/needs-human-review Ladon has escalated this PR for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant