Repository navigation
Conversation
…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>
There was a problem hiding this comment.
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.pyreach-through, nodomains/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_grantand deterministicmax_accountsordering correct,owed_periodsconformance asserted period-by-period. - high_risk flag is true only because
src/adcp/server/__init__.pyandprincipal.pyare 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 noprincipal.changedevent. 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
… 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>
0aa205b to
053e100
Compare
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 nofeat!:/BREAKING CHANGE:required. - The temporary
_endedcarrier ispop'd unconditionally before thedelivered is Noneshort-circuit (engine.py:1920), so it never leaks into serialized status; the defaultwithheld_period_countfrom_accounts_unauthorizedis 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>
6562333 to
9e1e2f0
Compare
There was a problem hiding this comment.
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
_endedinternal 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>
9e1e2f0 to
db034db
Compare
There was a problem hiding this comment.
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.pydiscriminated-union fallback arms or the asset vocabulary beyond what the reviewer confirmed;_forward_compat.pyis in high-risk globs but the modification preserves its contract. - Logic spot-checks: registry
_next_stateversion monotonicity andinactive_intervalsreactivation match readback tests; engine_apply_grantre-grant/loss clamping andmax_accountsserved-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.Lockthen 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>
db034db to
b1f81be
Compare
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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_setand options areNone, fail withUNSUPPORTED_FEATURE. Do the same for any non-empty section the seller does not list insupported_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_changedwithout a timezone, thenreconcilereports 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_timezoneoffering for an account with no zone is an error or anaction_requiredexception. - Never record a change away from an unknown zone.
- Build the wire entry from the change's own
previous_timezone.
- Make the timezone required, with no default. An
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_regranton: the account is overridden by the caller's ownacct-pacingconfig 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_periodsthen raises "version 1 names destination 'A'; pass its pattern and transport", because_owedpasses only current destinations (engine.py:472-476,coverage.py:860-866).reconcileraises too, once any covered account is unauthorized, since_accounts_unauthorizedcalls_owed. - The same happens for an offering whose
delivery_slahas months (#1696 finding 5), and for anaccount_resolvedsource_timezoneoffering without a resolved zone (resolution.py:256-262). Both reachitem.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 passSubscriptionVersionWindow(destination=...); the field already exists. - Validate offerings at engine construction.
- Isolate per-subscription failures in
_evaluate, surfacing them as an issue oraction_required.
- Snapshot each version's destination pattern and transport into the version record at accept time (
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: falseat T2. The readback hasaccepted_at = T2anddeactivated_at = T1, andsubscription_version_windowsraises "a version cannot end before it was accepted" for both the buyer andowed_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_windowstoleratedeactivated_at < accepted_atby clamping.
- For a version first accepted inactive, set its window end to
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-utcplus account-timezonepkg): the account config takes over at 10-05T12Z, givingfrom = 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 beTitself, or the minimum E, with no period owed twice and no period lost. The maximum E over-owes. _schedulesalso resolves over the union of all buys' products as one product, rather than per package as_regimesdoes. For closing at:441, it usesbuys=(), which means all seller offerings.- Fix: use the minimum E (or T) for
fromanduntil, resolve per package, and ask the spec to say which E an exception withoutoffering_iduses.
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, andactive_at()inside it becomesTruefor 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_fromtoAccountDeliveryConfig(and accept change instants from the host), and use it as T. At minimum, document thatreconcilemust run synchronously within thesync_accountsandrequest_accountwrites.
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_buysis called for unauthorized accounts (engine.py:399-400). Should the host allow that? Andsubscription_coveragegates on the engine's last-reconciled authority (:746), not live host authority. Should the host re-check authority before serving it? - Unimplemented outcomes.
action_requiredexceptions andpending_actionare never produced. - ID collisions. The
delivery_config_idshook is optional, so collision checks can be silently off. The reverse check is missing too:sync_accountsshould reject adelivery_config_idequal to asubscription_id, including retained inactive ones.
Release gating
The gate is correct and fails safe on absent or unparseable versions:
3.3-beta.1and no context strip the fields;3.3-beta.2,rcand3.3serve them.- Gaps merged to account-wide only over-remove, so they fail safe.
_gate_subscription_fieldsre-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: allwith mixed boundaries under override;authorization_changedwithout 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>
b1f81be to
d29719c
Compare
d29719c to
d749e03
Compare
There was a problem hiding this comment.
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 afeat!:/BREAKING CHANGEfooter. - Forward-compat:
_forward_compat.pychange adds extensible dimensions viaReportingDimensionToken; 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_SUFFIXESintact. - Wire-change pathway (DR-0032/0033):
_gate_subscription_fieldsfail-closes, dropping adcp#8585/#8588 fields below 3.3.0-beta.2;replace_account_configs is Truerefused 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.
There was a problem hiding this comment.
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
lowfinding (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: falseandreview_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).
_cutovermin(ceil) logic and version-regression guard both verified safe and test-pinned. - Prior decision was
approvewith 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.
5857c8d to
bad220c
Compare
|
Thanks. Here is each finding against the current head ( Fixed
Deferred (reason)
Questions
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_configswithUNSUPPORTED_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)=Falsestrips adcp#8585 fields rather than leaking to pre-beta.2 callers. - Path safety:
render_path_templateis 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>
bad220c to
1a08b49
Compare
There was a problem hiding this comment.
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.pychange 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 underself._store.lock; per-subscription evaluation isolated via try/except at engine.py:724. The unboundedwhile Truepagination in_list_accountsis 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_templatesegment-escaping is/-only; a buyer-chosensubscription_idwith 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
1a08b49 to
58aa12e
Compare
…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>
58aa12e to
277db7b
Compare
There was a problem hiding this comment.
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_accountspreserves 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.pybreaks. - High-risk flag is driven by
(modified)server/types files, but the reviewer found no medium-or-higher concern on them;_forward_compat.pychange 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>
There was a problem hiding this comment.
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.
Second slice of #1695. It conforms to adcontextprotocol/adcp#8585, which settled the spec ambiguities. It targets
nextso 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(...)), orset_reporting_subscriptions(), accepts thesync_principalreporting_subscriptionssection. Without the option, the section is rejected withUNSUPPORTED_FEATURE, as the spec requires for an unsupported section, and every other section behaves exactly as before.test_without_the_option_the_section_is_rejectedpins both.replace_account_configs: trueis rejected the same way until it is implemented.With the option on:
exclude_account_idsonly with all-accounts scope;period_timezoneonly for one named account, and only a real IANA zone; billing needsofficial+consumer_receipt, andconsumer_receiptneeds a destination; the destination must exist after the same request; only the four placeholders,{account_id}for multi-account scope,path_templaterequired for multi-accountfile_transfer, and distinct templates on a shared destination; thedelivery_config_idnamespace, 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.activeandreplace_account_configschange in place. A new version records its ownaccepted_atand pushes the old one ontoprior_versionswithsuperseded_at. An omitted subscription becomesinactivewithdeactivated_atand is retained.dry_runandexpected_configuration_versionbehave as for the other sections.core/reporting-subscription-state.json. A destination inaction_required,inactiveorrejectedmoves the subscription toaction_requiredwith an issue.update_subscription_status()recordsaccounts_with_exceptionsandaccounts_unauthorizedwithout changingconfiguration_version. Both changes emitprincipal.changedwith reasonreporting_subscription_changed;PrincipalChangegainssubscription_id.ReportingSubscriptionEngineauthorized_atstays at the chain start across a re-grant within retention. When the host reports anauthorized_atat or before the loss,reconcile()stamps the re-grant at the instant it observes authority again.list_authorized_accounts(principal, cursor=)(paged) andaccount_buys(principal, account_id)(buy facts, package offering ids, earlier flights) are required.account_delivery_configsis optional and supplies precedence.authorization_changed()andreconcile()stamp grants and losses; the SDK never infers authority.fromanduntilare the E instants):overridden: the caller's ownsync_accountsconfig, or itsaccount_idssubscription over an all-accounts one;capacity: undermax_accounts, served accounts keep their slots and the remaining slots go byaccount_idbyte order, as in TS #3508;excluded_by_buyer;offering_unsupported: per buy, or withpackage_idsper package.sync_principalgains an optionalcontext, likeget_principal. Both dropinactive_intervalsfor 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.subscription_coverage(principal, account_id)returns entries withstatus,excluded,gapsandtimezone_cutovers. Timezone cutovers carryprevious_timezone. Gaps and cutovers carry one entry peroffering_idwhen the covered offerings' boundaries differ, and a single entry otherwise (adcp#8585). It returns nothing once authority is lost.accounts_with_exceptionsandaccounts_unauthorized(withlast_owed_period_endandwithheld_period_count) are kept current inget_principal, and each change firesprincipal.changed.owed_periods()runs the sameexpected_subscription_periodsthe buyer runs.receipt_roster()derives receipt duty from activeconsumer_receiptsubscriptions.Conformance
tests/test_reporting_subscription_engine.pyruns a buyer and a seller side by side. The buyer computes only from what it can see: theget_principalreadback,subscription_coverage[], its own buys and its grant history. The tests assertbuyer == sellerperiod by period, includingexpected_at, for these #3504 cases:confirmed_at, cancellation and early completion;max_accounts, including an account freed by a revocation and sticky slots on re-grant;account_idssubscription over all-accounts;exact,finestandallagainst 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_atare 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; overrideactive_from;inactive_intervalsnever dropped; canonical version comparison; andrender_path_template().Deviations and ambiguities
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_countfalls 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 optionalis_period_delivered(principal, account_id, period, by=)gets the exact count: owed periods that ended byunauthorized_atand were not delivered by then.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_accountsconflicts. Two such subscriptions with the same purpose conflict unless theiroperator_domainsare disjoint, becauseexclude_account_idscan't make them disjoint for accounts granted later.sync_principal.mdx,UNSUPPORTED_FEATURE), so after review it is rejected.Not in this PR (remaining)
ReportingObligationRecords. The producer closes and acquires periods by walkingcommitted_periodsfrom a configuration's activation, so subscription obligations need a period gate inReportingProducer._close_elapsed_periods. Two more problems stand in the way:(subscription_id, subscription_version)for the same period, whichmatch: alland per-product resolution both require. The generation key needsoffering_id, in both stores.ReliableReportingService, so nothing advertisessync_principalyet.🤖 Generated with Claude Code