Skip to content

feat(reporting): accept reporting subscriptions on sync_principal and track what they owe - #1698

Open
bokelley wants to merge 17 commits into
nextfrom
claude/issue-1695-subscription-runtime
Open

bokelley wants to merge 17 commits into
nextfrom
claude/issue-1695-subscription-runtime

Conversation

@bokelley

@bokelley bokelley commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Second slice of #1695. It conforms to adcontextprotocol/adcp#8585, which settled the spec ambiguities. It targets next so CI runs, and it includes #1696's commit until that merges, so review #1696 first; the new commit here is the second one. This is the seller runtime for AdCP 3.3 reporting subscriptions, using the in-memory store.

Principal runtime (opt-in)

PrincipalService(reporting_subscriptions=ReportingSubscriptionOptions(...)), or set_reporting_subscriptions(), accepts the sync_principal reporting_subscriptions section. Without the option, the section is rejected with UNSUPPORTED_FEATURE, as the spec requires for an unsupported section, and every other section behaves exactly as before. test_without_the_option_the_section_is_rejected pins both. replace_account_configs: true is rejected the same way until it is implemented.

With the option on:

  • Validation. It checks everything the generated model can't express: exactly one scope form; exclude_account_ids only with all-accounts scope; period_timezone only for one named account, and only a real IANA zone; billing needs official + consumer_receipt, and consumer_receipt needs a destination; the destination must exist after the same request; only the four placeholders, {account_id} for multi-account scope, path_template required for multi-account file_transfer, and distinct templates on a shared destination; the delivery_config_id namespace, through a host hook; and two active subscriptions at the same scope level for one account and purpose. A request that removes a destination an active subscription names fails.
  • Versioning. A reused version with different semantics is rejected. active and replace_account_configs change in place. A new version records its own accepted_at and pushes the old one onto prior_versions with superseded_at. An omitted subscription becomes inactive with deactivated_at and is retained. dry_run and expected_configuration_version behave as for the other sections.
  • Readback is core/reporting-subscription-state.json. A destination in action_required, inactive or rejected moves the subscription to action_required with an issue. update_subscription_status() records accounts_with_exceptions and accounts_unauthorized without changing configuration_version. Both changes emit principal.changed with reason reporting_subscription_changed; PrincipalChange gains subscription_id.

ReportingSubscriptionEngine

  • Re-grants. Following adcp#8585, authorized_at stays at the chain start across a re-grant within retention. When the host reports an authorized_at at or before the loss, reconcile() stamps the re-grant at the instant it observes authority again.
  • Host hooks. list_authorized_accounts(principal, cursor=) (paged) and account_buys(principal, account_id) (buy facts, package offering ids, earlier flights) are required. account_delivery_configs is optional and supplies precedence. authorization_changed() and reconcile() stamp grants and losses; the SDK never infers authority.
  • Exceptions are recorded as cutover intervals (from and until are the E instants):
    • overridden: the caller's own sync_accounts config, or its account_ids subscription over an all-accounts one;
    • capacity: under max_accounts, served accounts keep their slots and the remaining slots go by account_id byte order, as in TS #3508;
    • excluded_by_buyer;
    • offering_unsupported: per buy, or with package_ids per package.
  • Release gating. sync_principal gains an optional context, like get_principal. Both drop inactive_intervals for callers served below 3.3.0-beta.2. subscription_coverage(..., adcp_version=) projects the adcp#8585 fields the same way and reports per-offering entries account-wide. A test pins that the projected coverage validates against the beta.1 schema.
  • Status. subscription_coverage(principal, account_id) returns entries with status, excluded, gaps and timezone_cutovers. Timezone cutovers carry previous_timezone. Gaps and cutovers carry one entry per offering_id when the covered offerings' boundaries differ, and a single entry otherwise (adcp#8585). It returns nothing once authority is lost. accounts_with_exceptions and accounts_unauthorized (with last_owed_period_end and withheld_period_count) are kept current in get_principal, and each change fires principal.changed.
  • owed_periods() runs the same expected_subscription_periods the buyer runs. receipt_roster() derives receipt duty from active consumer_receipt subscriptions.

Conformance

tests/test_reporting_subscription_engine.py runs a buyer and a seller side by side. The buyer computes only from what it can see: the get_principal readback, subscription_coverage[], its own buys and its grant history. The tests assert buyer == seller period by period, including expected_at, for these #3504 cases:

  • a mid-period confirmed_at, cancellation and early completion;
  • a flight extension;
  • a new version with a daily-to-hourly cutover;
  • an in-place deactivation and reactivation (seller side);
  • revocation and re-grant, with and without backfill, and after the retention window expires;
  • max_accounts, including an account freed by a revocation and sticky slots on re-grant;
  • precedence over an account-level configuration, and an account_ids subscription over all-accounts;
  • exact, finest and all against package, package × creative and package × country offerings.

Expert review

Every finding from the billing-grade review is fixed, or deferred with a reason; see the PR comment. Summary: no UTC defaults (the timezone and authorized_at are required from the host); exceptions frozen while authority is lost; destinations snapshotted per version; offerings validated up front; each subscription evaluated in isolation; the earliest E across calendars; override active_from; inactive_intervals never dropped; canonical version comparison; and render_path_template().

Deviations and ambiguities

  • Reactivation in place is a fresh start (adcp#8585). The readback lists inactive_intervals (deactivated_at, reactivated_at), and the engine reads them from there, so the buyer computes the same periods from the readback alone.
  • withheld_period_count falls back to the owed periods not yet due (period_end + delivery_sla) when authority ended, the same rule TS uses. A host that implements the optional is_period_delivered(principal, account_id, period, by=) gets the exact count: owed periods that ended by unauthorized_at and were not delivered by then.
  • Account-level offering_unsupported ("no offering on this account's products") isn't emitted. The host hooks expose buys, not an account's product catalogue. Buy-level and package-level exceptions are emitted.
  • all_authorized_accounts conflicts. Two such subscriptions with the same purpose conflict unless their operator_domains are disjoint, because exclude_account_ids can't make them disjoint for accounts granted later.
  • Option off. The issue asked to keep today's behaviour, which silently ignored the section. The spec says unsupported sections are rejected (sync_principal.mdx, UNSUPPORTED_FEATURE), so after review it is rejected.

Not in this PR (remaining)

  • Ledger obligations. The engine computes owed periods but doesn't yet write ReportingObligationRecords. The producer closes and acquires periods by walking committed_periods from a configuration's activation, so subscription obligations need a period gate in ReportingProducer._close_elapsed_periods. Two more problems stand in the way:
    • The ledger's identity invariant ("unique per (generation, period)") can't hold two offerings under one (subscription_id, subscription_version) for the same period, which match: all and per-product resolution both require. The generation key needs offering_id, in both stores.
    • Until obligations are materialized the capability isn't wired into ReliableReportingService, so nothing advertises sync_principal yet.
  • PostgreSQL storage for versions, resolutions, exceptions and authority intervals, plus maintenance.
  • Managed delivery with path-template expansion and per-account destinations.
  • Receipts after loss, the consumer runtime and client helpers, and the storyboard.

🤖 Generated with Claude Code

…r 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>
@bokelley
bokelley changed the base branch from claude/issue-1695-subscription-coverage to next October 29, 2026 09:56
Comment thread src/adcp/server/principal.py Outdated

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — additive AdCP 3.3 experimental feature (principal-level reporting subscriptions), no blocking findings.

What I checked:

  • Purely additive public surface: 6 new modules under src/adcp/reporting/subscriptions/, opt-in wiring into PrincipalService. No removals/renames/required-field flips/enum removals — feat: is the correct semver signal, no breaking contract.
  • Import layering clean: domain-path imports only, no _generated.py reach-through, no domains/ hand-edits, no numbered-class imports, no disabled tests or blanket # type: ignore.
  • Largest-file coverage holds: coverage.py (804L) cutover/withheld logic matched by table + property + conformance tests; engine.py (950L) _apply_grant and deterministic max_accounts ordering correct, owed_periods conformance asserted period-by-period.
  • high_risk flag is true only because src/adcp/server/__init__.py and principal.py are modified — but the reviewer found no medium-or-higher concern on those modifications beyond the single medium below, so the flag is a look-closer signal, not an escalation trigger.

Medium findings (1):

  • src/adcp/server/principal.py:694 — subscription recovery to active fires no principal.changed event. Non-blocking, non-escalating category (not data-loss/schema/infra).

Decision path: No critical/high (row 1 n/a). gated_paths false (row 2 n/a). No deletions (row 3 n/a). The single medium is not in data-loss/schema/infra (row 4 n/a). high_risk+modified with a medium would fire row 5 — but the medium is on principal.py, a modified high-risk file, so row 5 is worth checking: the medium IS on a modified high-risk file. Re-evaluating: row 5 requires high_risk true AND a (modified) reason AND any medium finding. All three hold.

Medium findings

  • src/adcp/server/principal.py:694 — Subscription recovery to active fires no principal.changed event

@bokelley bokelley closed this Oct 29, 2026
@bokelley bokelley reopened this Oct 29, 2026
… 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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch 2 times, most recently from 0aa205b to 053e100 Compare October 29, 2026 10:14
Comment thread src/adcp/server/principal.py

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve.

This is a subsequent review on top of a prior approve. The single delta commit (34f4c05) resolves the prior pass's sole medium finding: transition_destination now fires principal.changed with reason reporting_subscription_changed on any subscription state change, so a recovery from action_required to active is announced to cached readbacks. The reviewer verified the new guard str(item.state) != before.get(item.subscription_id) is 1:1 and cannot over-fire, since refresh_destination_states preserves subscription_id per input so unchanged states emit nothing. A new test pins the recovery event.

No current findings. High-risk flag is true only via (modified) entries on src/adcp/server/**, but with no medium-or-higher concern on those files the modification is presumed safe (row 5 does not fire without a medium finding). gated_paths is false and review_decision is APPROVED. No no-auto-approve team match. Rows 1–8 do not fire; outcome falls through to row 9.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — clean delta pass, no blocking or medium findings.

This PR reworks _chain_start in engine.py to take the retention window and restart the grant chain on a post-retention re-grant. The reviewer confirmed the boundary condition (later.authorized_at - lost >= retention) matches the canonical _chains rule in coverage.py exactly, grants are appended chronologically so the pairwise walk is sound, and the new test_max_accounts_orders_by_the_current_grant_chain covers the ordering bug it fixes. _chain_start is private with a single updated call site, so there is no public-surface or semver impact.

High-risk flag is true only because src/adcp/server/__init__.py and principal.py are modified, but the reviewer surfaced no medium-or-higher concerns on those files, so the modifications are presumed safe. No (deleted) entries. gated_paths is false and review_decision is APPROVED.

Decision table: rows 1–8 do not fire (no critical/high/medium findings, no deletion, no team gate, no medium-on-modified-high-risk trigger, prior decision was approve). Falls through to row 9 → approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — clean pass, no blocking or medium findings.

This PR adds a reliable reporting subscriptions subsystem (new modules under src/adcp/reporting/subscriptions/, docs, and comprehensive tests). The reviewer found the core change sound: max_accounts coverage now uses a served-slot-stability model (served accounts rank 0, others rank 1, byte-order tiebreak), with served tracked as in-place per-subscription state on _PrincipalState. Lost-eligibility accounts correctly drop from served, _chain_start removal leaves no dangling reference (_retention still used at engine.py:670+), and tests cover both the new keep-slot behavior and the prior deterministic case. Docs updated to match.

Decision-rule walkthrough:

  • No critical/high findings (row 1 n/a).
  • gated_paths is false and review_decision is APPROVED (row 2 n/a).
  • high_risk is true but the two matching files (src/adcp/server/__init__.py, principal.py) are (modified), not (deleted) — row 3 n/a. With zero medium findings, rows 4 and 5 do not fire.
  • Prior decision was approve, so row 6 does not apply.
  • No no-auto-approve team match (row 7 n/a).
  • Zero medium findings (row 8 n/a).
  • Falls through to row 9 → approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve.

This is a subsequent review following a prior approve. The delta is a single commit (feat(reporting): count withheld periods from host delivery facts) adding an optional ReportingSubscriptionDeliveryHost protocol, duck-typed via getattr(self._host, "is_period_delivered", None) consistent with the existing account_delivery_configs precedence hook, plus a new export in subscriptions/__init__.py. Additive public surface under a correct feat: prefix — semver signal is accurate.

Checks:

  • No breaking change to the public adcp.* surface; new export is additive, so no feat!:/BREAKING CHANGE: required.
  • The temporary _ended carrier is pop'd unconditionally before the delivered is None short-circuit (engine.py:1920), so it never leaks into serialized status; the default withheld_period_count from _accounts_unauthorized is preserved when no hook is present.
  • The new hook path is covered by test_withheld_count_uses_the_delivery_hook_when_present.

Decision table: no critical/high/medium findings. gated_paths is false. review_decision is APPROVED. The high_risk flag is true (server/** modified) but the reviewer surfaced no medium-or-higher concerns on those files, so the modification is presumed safe — no escalation on the flag alone. No no-auto-approve team match. Prior decision was approve (not a sticky-escalate case). Falls through to row 9 → approve.

…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
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from 6562333 to 9e1e2f0 Compare October 29, 2026 14:26

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve.

Clean pass with no Critical/High/Medium findings. This is a subsequent review on top of a prior approve; the delta adds the adcp#8585 reporting-subscription refinements (engine.py seller runtime, registry.py sync_principal validation/versioning, the _reporting_subscription_preview forward-compat patcher, coverage/resolution handling, and buyer/seller conformance tests).

Gate checks:

  • No no-auto-approve team match.
  • gated_paths: false; review_decision: APPROVED — no path gate blocks.
  • high_risk: true, but the risk signal resolves cleanly: all high-risk matches are (modified) with no medium-or-higher finding on them (row 5 requires a medium finding — none exist), and there are no (deleted) entries (row 3 N/A). New-file/additive scaffolding does not escalate on the flag alone.

Verified against repo-specific gates:

  • Additions are purely additive to the adcp.* public surface; feat: is the correct semver signal (no removal/rename/required-flip/response-shape break).
  • The preview patcher follows the established _forward_compat idempotent-mutation pattern with a TODO(3.3.0-beta.2) removal path and no hand-edits to domains/ or _generated.py.
  • The engine's _ended internal key is popped from every accounts_unauthorized entry before update_subscription_status, so it never reaches model_validate.
  • The whole section is droppable/ignored when the ReportingSubscriptionOptions opt-in is absent (3.2 behavior preserved and tested), satisfying the DR-0032/DR-0033 pathway + test requirement.

Decision table: rows 1–8 do not fire → row 9 approve.

… 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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from 9e1e2f0 to db034db Compare October 29, 2026 15:02

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve.

Second slice of #1695: the AdCP 3.3 (experimental) seller reporting-subscription runtime. All additive and opt-in — new engine.py and registry.py, plus opt-in wiring in principal.py, with test_without_the_option_the_section_keeps_todays_behavior pinning the off path.

Checks performed:

  • Semver signal: New public exports are additive; commits use feat(reporting):/fix(reporting):. No public contract removed, renamed, or re-typed; no required↔optional flips. Semver signal is correct.
  • Forward-compat: No changes to _forward_compat.py discriminated-union fallback arms or the asset vocabulary beyond what the reviewer confirmed; _forward_compat.py is in high-risk globs but the modification preserves its contract.
  • Logic spot-checks: registry _next_state version monotonicity and inactive_intervals reactivation match readback tests; engine _apply_grant re-grant/loss clamping and max_accounts served-first ranking match TS #3508.
  • Operational: host hooks are adopter-supplied in-process coroutines (not SDK-owned network clients); host I/O done outside the asyncio.Lock then re-read under it. In-memory restart state loss is documented ("durable storage is a later slice").
  • Buyer/seller conformance asserted period-by-period across 15 cases.

Decision table: high_risk is true but all reasons are (modified) with NO medium-or-higher findings, so the modifications are presumed safe (rows 3/5 do not fire). gated_paths is false; review_decision is APPROVED. No no-auto-approve team match. Zero critical/high/medium findings. Prior decision was approve. Rows 1–8 do not fire → row 9 approve.

…ta.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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from db034db to b1f81be Compare October 29, 2026 15:19

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — clean delta, no blocking findings.

This is a subsequent review (prior decision: approve, no findings). The current run is also clean: the reviewer surfaced no Critical/High/Medium findings.

What was checked:

  • New seller reporting-subscription runtime (engine.py, registry.py, wire.py) conforms to the buyer rule by construction; test_reporting_subscription_engine.py asserts buyer==seller period-by-period across 14 cases.
  • engine.py:319 _apply_grant clamps re-grant/loss to keep the grant chain strictly monotonic; engine.py:528-534 max_accounts overflow handling keeps slots sticky.
  • registry.py _next_state fail-closes on a lower/semantically-changed reused subscription_version.
  • principal.py emit loop nests under if self._emit_change:, so no None(...) call when no emitter is configured.
  • wire.py serves_settled_subscription_fields fail-closes (absent/unparseable → beta.1 → fields dropped), responses validate against the pinned schema.
  • Semver signal correct (additive exports under feat/fix); import layering clean; no hand-edits under domains/** or _generated.py; no creds in ctx_metadata; version-translation pathway tested; CI armed.

High-risk flag is true only for (modified) server/** and _forward_compat.py files, but no medium-or-higher finding attaches to any of them, so the modifications are presumed safe (no escalation on the flag alone). gated_paths is false; review_decision is APPROVED. No no-auto-approve team match.

Decision table: rows 1–8 do not fire (no critical/high, gated_paths false, no deletions, no medium findings, prior was approve, no team gate, fewer than 3 mediums) → row 9 approve. Operability notes (host-hook/pagination timeouts, in-memory pre-durable store) are explicitly host-contract/non-blocking per the reviewer.

@bokelley bokelley left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review: sync_principal section, registry, seller engine (billing-grade pass)

This covers the commits new in this PR on top of #1696. Coverage-rule findings are on #1696; where an engine behaviour triggers one of them, I cross-reference it. Every confirmed item below was reproduced against the engine through the existing World test harness.

Confirmed bugs

1. [High] With the option off, the section is silently accepted instead of rejected. principal.py:906, :1087-1092
Without reporting_subscriptions= options, a request carrying only reporting_subscriptions returns kind: applied, action: unchanged. Even an invalid subscription is "applied". sync_principal.mdx says "Unsupported sections are rejected, not ignored" and lists UNSUPPORTED_FEATURE for "Section not in supported_sections". sync-principal-request.json offering_boundary says the same. A buyer gets a success response, believes it registered, and receives nothing. test_without_the_option_the_section_keeps_todays_behavior enshrines this behaviour.

  • Fix: when "reporting_subscriptions" in desired.model_fields_set and options are None, fail with UNSUPPORTED_FEATURE. Do the same for any non-empty section the seller does not list in supported_sections.

2. [High] The engine silently substitutes UTC for an unknown account zone, and the wire then misreports the cutover. engine.py:90, :306-308, :829
AuthorizedAccount.timezone defaults to "UTC", and authorization_changed(timezone=None) stores "UTC" with initial_timezone=None. When reconcile later sees America/New_York, it records a UTC→NY change. _timezone_cutovers starts from initial_timezone or account.timezone, which is NY, and emits previous_timezone: America/New_York, timezone: America/New_York.

  • Repro: authorization_changed without a timezone, then reconcile reports NY.
    • The seller owes UTC days 10-02..10-04, then NY days.
    • A buyer reading the wire expects NY days from 10-01.
    • The two sides disagree on every period before the change.
  • This also violates iana_timezone: "do not silently substitute".
  • Fix:
    • Make the timezone required, with no default. An account_timezone offering for an account with no zone is an error or an action_required exception.
    • Never record a change away from an unknown zone.
    • Build the wire entry from the change's own previous_timezone.

3. [High] replace_account_configs is accepted but never applied. No code reads it outside _IN_PLACE_FIELDS (registry.py:48). A buyer that sets it expects its own matching sync_accounts configs to be deactivated by cutover. Instead the subscription stays overridden forever, with no error. Fix: add a precedence-host hook (deactivate_delivery_configs(principal, account_id, feed_purpose, at)) and apply the flag once per version and never on dry_run, or reject replace_account_configs: true with UNSUPPORTED_FEATURE until it is implemented.

4. [Medium-High] Losing authority closes open exceptions, and with backfill that owes the revoked interval twice. engine.py:434-442
desired only contains authorized accounts, so an unauthorized account's open overridden exception gets until = cutover(now). On a continuing re-grant it reopens from cutover(regrant).

  • Repro, with backfill_on_regrant on: the account is overridden by the caller's own acct-pacing config throughout, authority is lost 10-05 and re-granted 10-08. The subscription now owes 10-06, 10-07 and 10-08, alongside the account config: two feeds for one account and purpose.
  • Fix: freeze exceptions while the account is unauthorized (leave them open). Close one only when its cause ends.

5. [Medium-High] The engine is fail-stop on legal input. One subscription or offering can break owed_periods and reconcile.

  • Repro (removed prior-version destination): v1 names destination A, v2 moves to B, then A is removed. That removal is allowed because no active subscription names A. owed_periods then raises "version 1 names destination 'A'; pass its pattern and transport", because _owed passes only current destinations (engine.py:472-476, coverage.py:860-866). reconcile raises too, once any covered account is unauthorized, since _accounts_unauthorized calls _owed.
  • The same happens for an offering whose delivery_sla has months (#1696 finding 5), and for an account_resolved source_timezone offering without a resolved zone (resolution.py:256-262). Both reach item.calendar() in _schedules/_cutover, and one exception aborts evaluation for every subscription of the principal.
  • Fix:
    • Snapshot each version's destination pattern and transport into the version record at accept time (record_versions), then pass SubscriptionVersionWindow(destination=...); the field already exists.
    • Validate offerings at engine construction.
    • Isolate per-subscription failures in _evaluate, surfacing them as an issue or action_required.

6. [Medium] Bumping the version while inactive produces a readback that neither side can parse. registry.py:340-345, consumed at coverage.py:339

  • Repro: v1 is deactivated at T1, then v2 is sent with active: false at T2. The readback has accepted_at = T2 and deactivated_at = T1, and subscription_version_windows raises "a version cannot end before it was accepted" for both the buyer and owed_periods.
  • Fix:
    • For a version first accepted inactive, set its window end to max(accepted_at, deactivated_at) (a zero-length window).
    • Make subscription_version_windows tolerate deactivated_at < accepted_at by clamping.

7. [Medium] With mixed boundaries, the exception from/until value (the latest E) leaves the subscription owing periods after T. engine.py:644-656
_cutover returns max(calendar.ceil(now)) across resolved offerings.

  • Repro (match: all, pkg-region-utc plus account-timezone pkg): the account config takes over at 10-05T12Z, giving from = 10-06T04Z. The UTC offering's 10-06T00Z period starts after T, yet the subscription still owes it next to the account config.
  • No offering has a boundary in [T, ceil_X(T)), so the removal start can be T itself, or the minimum E, with no period owed twice and no period lost. The maximum E over-owes.
  • _schedules also resolves over the union of all buys' products as one product, rather than per package as _regimes does. For closing at :441, it uses buys=(), which means all seller offerings.
  • Fix: use the minimum E (or T) for from and until, resolve per package, and ask the spec to say which E an exception without offering_id uses.

8. [Medium] The engine records an account timezone change at T, while the wire says E. engine.py:375-381, :310-313
The engine records the change at T; timezone_cutovers emits effective_at (E). Together with #1696 finding 2, a timezone change followed by any cutover before E makes the seller owe a period that overlaps the last old-zone period, and the buyer does not expect it.

  • Repro: LA→NY at 10-03T12Z, v2 at 10-03T13Z. The seller owes v2 [10-04T04Z, 10-05T04Z); the buyer does not.
  • Fix: record the change at its per-offering E, or normalize in the coverage function as proposed on #1696.

9. [Medium] inactive_intervals truncation makes periods owed retroactively. registry.py:279-284

  • Repro: after 65 deactivate/reactivate cycles, _intervals() keeps the last 64. The first inactive interval disappears, and active_at() inside it becomes True for both the buyer and _owed. Periods that were never owed become owed after the fact. An automated buyer pausing daily reaches this in about two months.
  • Fix: reject the 65th deactivation with a clear error, or prune only intervals that ended before every obligation they affect left retention.

10. [Medium] Every exception opens and closes at the reconcile instant, not when its cause took effect. engine.py:342-386, :585-611
AccountDeliveryConfig carries no effective time, so the period between a caller's sync_accounts write and the next reconcile is owed by both registrations. The same applies to capacity, timezone changes and re-grants that reconcile discovers. The buyer agrees, because it reads the exceptions, but "A caller never receives two feeds" is broken for that window.

  • Fix: add active_from to AccountDeliveryConfig (and accept change instants from the host), and use it as T. At minimum, document that reconcile must run synchronously within the sync_accounts and request_account writes.

11. [Medium] The engine can stamp its own authorized_at, which the buyer never sees. engine.py:84-92, :364-374
AuthorizedAccount.authorized_at is optional, and without it the engine uses the reconcile instant. The buyer starts coverage at authorization.authorized_at from list_accounts, which the host serves, so the two starts can differ. Sellers advertising the feature MUST populate it. Fix: require it, or expose the engine's chain start for the host to put on list_accounts.

12. [Low] The seller silently uses the current configuration for prior versions it never recorded.
_owed passes principal.versions into subscription_version_windows, which falls back to the current configuration (coverage.py:389, :399). After a restart of the in-memory store, prior versions then use the wrong selector, scope or destination, with no error. Fix: on the seller side, raise or mark the account action_required.

13. [Low] Version comparison is order- and default-sensitive. registry.py:67-68, :311
Resending the same version with account_ids reordered, or with an explicit default match: exact, is rejected as "different semantic content". Fix: canonicalize before comparing (sort uniqueItems arrays, apply defaults).

14. [Low] Path templates. registry.py:140-146
a/{{account_id}} and a/}{account_id}{ are accepted, leaving literal braces after substitution. The traversal cases are covered: the schema regex rejects .., . and leading /, as I verified. No helper implements the substitution rule (percent-encode a value containing / or equal to ./..), so every seller hand-rolls it. Fix: reject braces outside placeholders, and add a tested render_path_template.

15. [Low] Several engine paths compute schedules differently from _owed. engine.py:613-642, :692, :715, :801
last_owed_period_end, gaps[] and the closing until use union resolution, the current timezone, or all seller offerings. _owed uses per-package, per-regime calendars. covered also includes accounts that were only ever overridden or capacity, so they show up in accounts_unauthorized.

16. [Low] The delivery hook scales with history. engine.py:452-463, :707-711
is_period_delivered is called for every period that ever ended before the loss, on every reconcile, for every subscription. The hook-based count also disagrees with the withheld flags on the periods, which still use the SLA approximation.

Questions

  • Isolation. account_buys is called for unauthorized accounts (engine.py:399-400). Should the host allow that? And subscription_coverage gates on the engine's last-reconciled authority (:746), not live host authority. Should the host re-check authority before serving it?
  • Unimplemented outcomes. action_required exceptions and pending_action are never produced.
  • ID collisions. The delivery_config_ids hook is optional, so collision checks can be silently off. The reverse check is missing too: sync_accounts should reject a delivery_config_id equal to a subscription_id, including retained inactive ones.

Release gating

The gate is correct and fails safe on absent or unparseable versions:

  • 3.3-beta.1 and no context strip the fields; 3.3-beta.2, rc and 3.3 serve them.
  • Gaps merged to account-wide only over-remove, so they fail safe.
  • _gate_subscription_fields re-projects replays as well.

Caveats: the cutover merge (#1696 finding 7), and the beta.1 reactivation over-expectation (#1696 questions). Please confirm the dispatcher can never resolve a bare "3.3" while only the beta.1 bundle is pinned. That value returns True here and would serve fields the beta.1 schema closes.

Tests

The conformance tests' buyer uses the test world's own grant history and a constant account_timezone=NY. It never reads timezone_cutovers beyond one test, so findings 2, 4, 7 and 8 pass unnoticed. Missing tests:

  • option off rejects the section;
  • replace_account_configs;
  • a removed prior-version destination;
  • a version bump while inactive;
  • 65 inactive intervals;
  • authority loss on an overridden account with backfill;
  • match: all with mixed boundaries under override;
  • authorization_changed without a timezone;
  • a timezone change followed by a version change within one period.

Merge readiness: not yet. Findings 1-3 are spec-conformance and silent-acceptance problems, findings 2, 4 and 8 make buyer and seller disagree or owe twice, and finding 5 crashes the engine on legal input.

…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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from b1f81be to d29719c Compare October 29, 2026 18:53
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from d29719c to d749e03 Compare October 29, 2026 18:57

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — clean delta pass, no blocking findings.

This is a subsequent review of PR #1698 (seller runtime for AdCP 3.3 reporting subscriptions, experimental). The prior decision was approve with no findings; the current delta pass also surfaced no Critical/High/Medium findings.

Checks that passed:

  • Public surface / semver: New exports are additive under feat(reporting): — no removals, renames, required↔optional flips, or response-shape changes requiring a feat!:/BREAKING CHANGE footer.
  • Forward-compat: _forward_compat.py change adds extensible dimensions via ReportingDimensionToken; no fallback arm removed, no discriminator key changed. beta.1/beta.2 pathway handled and tested.
  • Generated code: No hand-edits in domains/** or _generated.py; no numbered-class imports leaking out of the allowlist.
  • Credentials: No credentials in ctx_metadata; _CREDENTIAL_SHAPED_KEY_SUFFIXES intact.
  • Wire-change pathway (DR-0032/0033): _gate_subscription_fields fail-closes, dropping adcp#8585/#8588 fields below 3.3.0-beta.2; replace_account_configs is True refused with UNSUPPORTED_FEATURE; identity ranking deterministic. Pathway tests present (buyer/seller conformance, DST sub-daily periods, release projection).
  • Server path safety: subscription-changed emit loop nested inside existing if self._emit_change: guard — no crash without an emitter.

High-risk flag is true, but all three high-risk matches are (modified) files with no medium-or-higher finding against them, so the modification is presumed safe (no row-5 trigger). gated_paths is false and review_decision is APPROVED. No no-auto-approve team match. No decision-table row 1–8 fires → row 9 approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — AdCP 3.3 reporting-subscriptions slice 2 (experimental), clean on all gates.

What I checked:

  • No Critical/High/Medium findings from the reviewer. Only a single low finding (stale "Option off" note in the PR body at principal.py:2583), which is omitted from blocking consideration per the severity model.
  • High-risk flag is true, but all three high-risk matches are (modified) with no medium-or-higher findings on them → presumed safe per the high-risk reading rules (no (deleted) entries, no medium concerns). No escalation on the flag alone.
  • gated_paths: false and review_decision: APPROVED — no hard gate triggered.
  • No no-auto-approve team match.
  • Wire-version gating gives beta.1 callers a droppable pathway with schema-validation tests (DR-0032/0033) — pathway + test present, satisfying the minor-release pathway mandate.
  • Option-off path now fail-closes with UNSUPPORTED_FEATURE (conformant). _cutover min(ceil) logic and version-regression guard both verified safe and test-pinned.
  • Prior decision was approve with no findings; this run adds only a low note. Net state remains clean.

Decision table: rows 1–8 do not fire (no critical/high, gate satisfied, no deleted paths, no medium data-loss/schema/infra, no medium on modified high-risk file, prior was not escalate, no team gate, fewer than 3 mediums). Falls through to row 9 → approve.

Nit (non-blocking): refresh the PR body's "Option off" deviation note to reflect that the section is now rejected rather than ignored.

@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch 2 times, most recently from 5857c8d to bad220c Compare October 29, 2026 19:10

Copy link
Copy Markdown
Contributor Author

Thanks. Here is each finding against the current head (bad220c74).

Fixed

  1. Option off. sync_principal rejects reporting_subscriptions with UNSUPPORTED_FEATURE. The test that enshrined silent acceptance now asserts the rejection, and that the other sections still apply.
  2. No UTC defaults.
    • AuthorizedAccount requires an IANA timezone and the authorized_at that list_accounts serves.
    • authorization_changed() without a zone leaves the zone unknown. That account's account-timezone schedules then fail, isolated per subscription in engine.evaluation_errors.
    • Setting a first zone is not a cutover.
    • The wire's previous_timezone comes from each change.
  3. replace_account_configs: true is rejected with UNSUPPORTED_FEATURE until the engine can deactivate account-level configurations.
  4. Lost authority no longer closes open exceptions. Your repro, an override with backfill, now owes nothing during or after the loss.
  5. Robustness
    • Each version's destination is snapshotted at acceptance; removing a prior version's destination is a test.
    • Offerings that can't be scheduled are refused at engine construction: an unresolved account_resolved source zone, a month SLA, or a local duration that doesn't divide 24h.
    • Each subscription is evaluated in isolation.
    • The seller raises on prior versions without a known configuration (fix: parse list_creative_formats response into structured type #12).
  6. Mediums
    • Version bump while inactive: computable on both sides.
    • Exception instants: an exception's from/until is the earliest E across calendars, resolved per package.
    • Timezone change at T or E: equivalent now that handoffs carry (feat(reporting): compute owed reporting-subscription periods and their capabilities #1696 finding 2); test_timezone_change_then_new_version_in_one_period_matches_the_wire.
    • inactive_intervals: never dropped; the 65th deactivation is refused.
    • Override timing: AccountDeliveryConfig.active_from opens the override at its own cutover, and the docs ask for a synchronous reconcile otherwise.
    • authorized_at: required from the host.
    • Version comparison: reordered uniqueItems arrays and an explicit default match count as the same version.
    • Path templates: braces outside a placeholder are refused, and render_path_template() implements the percent-encoding rule.
    • Schedule calculation: _schedules resolves per package, as _regimes does, and covered (and so accounts_unauthorized) lists only accounts the subscription served.
    • Delivery hook: asked once per loss, then cached.
    • principal.changed is dated at the engine's instant.
  7. Reverse namespace check. PrincipalService.reporting_subscription_ids() returns every subscription id, inactive ones included, so sync_accounts can reject a matching delivery_config_id.
  8. adcp#8588. prior_versions[].configuration is emitted, and stripped below beta.2 like the other new fields.

Deferred (reason)

  • action_required exceptions and pending_action. The host has no hook yet for "this account needs an action"; I'd add one when a seller needs it.
  • Calendars for gaps, last_owed_period_end and closing until. They resolve per package now, but still use the account's current zone rather than per-regime calendars. Exact parity for those wire values needs the same regime walk as _owed; I left that for the ledger-materialization slice.
  • The hook-based withheld_period_count versus per-period withheld flags. The flags still use the SLA approximation. Reconciling them needs delivery facts per period inside the coverage function.

Questions

  • account_buys for unauthorized accounts. It is called only for accounts the subscription covered, within retention, to count withheld periods. Hosts can return the buys they hold.
  • subscription_coverage authority. It answers from the last reconciled authority; the docs now say to re-check authority before serving it.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — new reliable-reporting-subscriptions seller runtime is additive and clean.

This is a subsequent pass; the prior run on head d749e03 also approved clean with no findings. The current delta (engine.py, registry.py, principal.py integration, coverage.py undated-regrant logic) was re-verified and surfaces no Critical/High/Medium findings.

What was checked:

  • High-risk flag is true (src/adcp/server/**, src/adcp/types/_forward_compat.py), all (modified), but the reviewer found no medium-or-higher concerns on those files, so the modifications are presumed to preserve their contracts (no deletions, no sensitive-category findings).
  • The change is additive, carries a correct non-breaking feat: signal, fail-closes the option-off path with UNSUPPORTED_FEATURE, gates adcp#8585 readback fields for sub-beta.2 callers while recording full-fidelity versions to the engine (wire-pathway handling present), and path-encodes template segments against traversal.
  • Grant-stamping, chain continuation, the _ended internal key, and per-subscription failure isolation are handled safely.

Gate status: gated_paths false; review_decision APPROVED; no no-auto-approve team match. No decision-table row 1–8 fires (no critical/high, no medium of any category, no deletions in high-risk paths, prior decision was approve, zero medium findings). Falls through to row 9.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — subsequent pass, clean.

This delta adds the seller runtime (engine.py, 485-line registry.py), PrincipalService wiring, and the coverage.py re-grant change on top of a prior approve. The reviewer found no critical/high/medium findings.

What I checked against repo rules:

  • Fail-closed feature gating: registry.py rejects replace_account_configs with UNSUPPORTED_FEATURE; principal.py rejects the whole section when the option is off — no silent drop. Satisfies the DR-0032/DR-0033 pathway rule (refused + typed error).
  • Release gating fail-closed: serves_settled_subscription_fields(None)=False strips adcp#8585 fields rather than leaking to pre-beta.2 callers.
  • Path safety: render_path_template is segment-safe (./.. encoded, /-bearing values quoted).
  • Parity/tests: buyer==seller parity tests cover the #3504 cases (max_accounts sticky slots, re-grant across retention) — pathway tests present.
  • No credential-in-metadata, no generated-code hand-edits, no import-layering violations, no semver signal missing.

Decision table: no critical/high (row 1 n/a); gated_paths false / review_decision APPROVED (row 2 n/a); high-risk reasons are all (modified) with NO medium finding, so row 5 does not fire; no deletions (row 3 n/a); no medium findings at all (rows 4/8 n/a); no author team gate (row 7 n/a); prior decision was approve (row 6 n/a). Falls through to row 9 — approve.

…he 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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from bad220c to 1a08b49 Compare October 29, 2026 19:19
Comment thread src/adcp/reporting/subscriptions/registry.py Outdated

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — subsequent pass on an additive 3.3-experimental reporting-subscription surface.

What I checked:

  • Correct semver signal: changes ship under feat(reporting)/fix(reporting), additive only, no public export removal/rename or required→optional flips. No major-bump governance triggered.
  • No hand-edits to _generated.py / domains/**; no type-system import-layering violations; no blanket # type: ignore; no disabled tests. CI gates remain armed.
  • _forward_compat.py change does not remove a fallback arm, narrow a published variant, or alter a discriminator key.
  • Largest-file scrutiny on engine.py (+1196): host I/O (list_authorized_accounts, account_buys, precedence) is never awaited under self._store.lock; per-subscription evaluation isolated via try/except at engine.py:724. The unbounded while True pagination in _list_accounts is noted as a non-blocking follow-up.
  • high_risk is true only on (modified) server/types files; the one medium finding is on registry.py, not a high-risk modified file, so row 5 does not fire.

Medium findings (non-blocking):

  • src/adcp/reporting/subscriptions/registry.py:440 — render_path_template segment-escaping is /-only; a buyer-chosen subscription_id with a backslash passes raw on backslash-separated file_transfer targets. Worth hardening.

Decision table: no critical/high (row 1 n/a). gated_paths false (row 2 n/a). No (deleted) high-risk reason (row 3 n/a). Single medium is category path/registry, not data-loss/schema/infra (row 4 n/a). high_risk modified + medium → row 5 only fires if the medium is ON a modified high-risk file; it is on registry.py (not high-risk) → row 5 n/a. Prior decision was approve, not escalate (row 6 n/a). No no-auto-approve team (row 7 n/a). Only 1 medium, not ≥3 (row 8 n/a). Falls through to row 9 → approve.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:440 — render_path_template segment-escaping is /-only; backslash passes raw

@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from 1a08b49 to 58aa12e Compare October 29, 2026 19:50
bokelley and others added 9 commits October 29, 2026 19:58
…dcp#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>
… 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>
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>
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>
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>
… 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>
…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>
…erved 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>
…iptions

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>
@bokelley
bokelley force-pushed the claude/issue-1695-subscription-runtime branch from 58aa12e to 277db7b Compare October 29, 2026 19:58
Comment thread src/adcp/reporting/subscriptions/registry.py Outdated

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — additive AdCP 3.3 reporting-subscriptions seller runtime (experimental), correctly shipped under feat(reporting).

What I checked:

  • Semver signal: additive, no public-export removals/renames or required↔optional flips; feat: is the right prefix.
  • Delta coverage (coverage.py:955) implements the adcp#8593 owner decision correctly; tests stay armed (strict-xfail on predating TS parity cases, not silenced).
  • engine.py: host I/O stays outside the store lock, per-subscription failures isolated via evaluation_errors, max_accounts preserves served slots, no credentials in metadata.
  • principal.py: release gating drops beta.2 fields for older callers and raises UNSUPPORTED_FEATURE on unsupported sections — valid version-translation pathway with tests (DR-0032/0033).
  • Import layering intact: domain-path imports only, no _generated.py breaks.
  • High-risk flag is driven by (modified) server/types files, but the reviewer found no medium-or-higher concern on them; _forward_compat.py change is benign.

Medium findings (1):

  • src/adcp/reporting/subscriptions/registry.py:440 — render_path_template segment-escaping is /-only; a backslash can escape a segment on Windows/SMB file_transfer destinations.

One medium finding, no blocking issues. Not an escalation category (not data-loss/schema/infra), and the high-risk modified files carry no medium finding, so row 5 does not fire. gated_paths is false and review_decision is APPROVED. Falls through to row 9.

Prior decision was also approve with the same single re-surfaced medium — net state unchanged and clean.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:440 — render_path_template segment-escaping is /-only; backslash passes raw

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>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ladon verdict: Approve

Approve — net improvement over prior pass, no blocking or medium findings.

This is a second-pass review (prior outcome: approve). The one prior Medium (registry.py:440 render_path_template backslash escaping) is confirmed fixed by head commit 034f768: segment() now encodes /, \, and ./.., with test_render_path_template pinning ..\..\secret → ..%5C..%5Csecret. It did not re-surface.

What I checked against the decision table:

  • Rows 1 (critical/high): no findings — none.
  • Row 2 (gated_paths): gated_paths is false; additionally review_decision is APPROVED, so the gate would be satisfied anyway. N/A.
  • Row 3 (high-risk deleted): high_risk is true, but all three reasons are (modified) — no deletions.
  • Rows 4–5 (medium + sensitive category / modified high-risk + medium): no medium findings present, so neither fires.
  • Row 6 (sticky escalate): prior decision was approve, not escalate. N/A.
  • Row 7 (no-auto-approve team): no team match.
  • Row 8 (3+ medium): zero medium findings.
  • Row 9: approve.

Reviewer validated the load-bearing concerns for this SDK: engine.py max_accounts ordering is served-first then byte-order stable (served accounts can't be displaced); registry.py path-template hardening is test-covered; coverage.py _first_owed_start conforms to adcp#8593 with re-vendored parity fixtures; all commits are additive feat(reporting): (new exports only, no public-surface break — minor semver signal correct); import layering clean (never imports _generated); host I/O runs outside _store.lock; _gate_subscription_fields fails safe. high_risk is true only on modified server/types files with no medium-or-higher concern, so per the high_risk guidance those modifications are presumed safe.

No escalation evidence, no blockers, no mediums → Row 9 approve.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant