Skip to content

feat(reporting): apply the AdCP 3.3.0-beta.3 seller rules for subscriptions - #1756

Merged
bokelley merged 4 commits into
nextfrom
claude/issue-1695-p1-beta3-engine
Oct 31, 2026
Merged

bokelley merged 4 commits into
nextfrom
claude/issue-1695-p1-beta3-engine

Conversation

@bokelley

@bokelley bokelley commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Slice P1c of the reporting-subscriptions DX plan (.context/dx-reporting-subscriptions.md §6.2, P1). It applies the remaining AdCP 3.3.0-beta.3 seller rules for reporting subscriptions (adcontextprotocol/adcp#8619, #8620). Stacked on #1755, which conforms the shared coverage rule and re-pins the spec's vectors, which sits on #1754 (adopt beta.3).

Rules

Rule (adcp#8619 unless noted) What changes
Admission under max_accounts Candidates are ordered by authorization.authorized_at, then account_id in byte order, with the same key for every subscription of a principal. An admitted account keeps its slot and is never displaced. A candidate with another open account-level exception, such as overridden, still holds its place. Before, the order was account_id alone.
Several feeds per purpose Registration allows several pacing or analytics subscriptions per account and purpose. Billing stays one per account at a scope level. Where two resolve the same offering for a package, the lowest subscription_id owes it, and each other records overridden for that buy, with package_ids and overridden_by: {subscription_id}. Coverage, which the buyer and seller share, now removes the packages a package_ids entry names while it is open, as a change point. It no longer drops those entries. All 120 vectors still pass.
Membership in place Adding or removing account_ids, and changing exclude_account_ids, keep subscription_version and accepted_at. A removed account opens excluded_by_buyer from the cutover E, so earlier periods stay owed, and stays listed excluded. An added account gets one closed entry [E(accepted_at), E(T)). The buyer's coverage reads those intervals instead of the current membership (subscription_covers_account(membership_intervals=True)), and the tests check that buyer and seller agree. The buyer facade no longer bumps the version for a membership edit.
destination_overrides Registration checks that every override names an existing destination, and that an override routing several accounts uses {account_id}. Coverage resolves an overridden account against its own destination's method. Owed sets carry the override as the version's destination, so bind_managed_generation binds, and check_managed_binding checks, the override's path_template.
Bounded, paged readback The inline caps and *_count fields are in #1754, because the beta.3 schema requires the counts. This slice adds paging: get_principal reporting_subscription_page pages a list with max_results/cursor and returns has_more/total_count. The engine serves the pages from its state document, which now keeps the full lists. The document version is unchanged: the new keys are optional, so earlier documents still decode. An unknown subscription fails with INVALID_REQUEST.
Destination for an account reporting_subscription_destination_for_account (TS reportingSubscriptionDestinationForAccount): the account_ids override, else the operator_domains override, else destination. An override without a path_template keeps the subscription's. The managed binding routes through it. Registration refuses an override naming both account_ids and operator_domains, or neither, which the generated model does not enforce.
Money terms (#8620) AccountReady gains resolution, billing / billing_entity / payment_terms read from the resolved account, effective_at for an updated money change, and not_carried_over.

Tests

  • tests/test_reporting_subscription_beta3_engine.py covers each rule end to end through PrincipalService and the engine: admission order and stickiness, a shared offering owed once with a different-cadence feed alongside, in-place removal and addition with buyer/seller agreement, override routing in coverage and for managed binding, and paging of prior versions and accounts.
  • Updated tests for the changed registration rules: two analytics subscriptions now apply, two billing ones still fail, membership resends keep the version, and a scope-kind change still needs a new one.
  • Money terms are covered in tests/test_account_outcomes.py.

Local verification

  • ruff check src/, mypy src/adcp/ and the mypy --strict adopter checks are clean.
  • Every reporting, principal, account and public-API test passes: 2,070 passed, 53 skipped.
  • The full suites run in CI (dispatched manually on this branch head).

Not in this slice

  • Per-offering exception entries when an account's offerings have different boundaries. The engine still opens one entry at the earliest E, as before.
  • Pruning prior_versions once their obligations are no longer retained.
  • accounts.change and the changeable_attributes gate for currency and timezone, which is the P15 slice.

🤖 Generated with Claude Code

@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 — conforms the reporting-subscription engine to AdCP 3.3.0-beta.3 with no blocking findings.

Checked against decision table top-down:

  • Rows 1 & 8: Reviewer surfaced no Critical/High/Medium findings. Clean.
  • Row 2: gated_paths is false — not applicable.
  • Rows 3 & 5: high_risk is true (src/adcp/server/principal.py matches src/adcp/server/**), but the only change-kind is (modified), and there are zero medium-or-higher findings on it. No (deleted) entries. Modification presumed safe per reviewer.
  • Row 4: no medium findings at all.
  • Row 6: no prior decision (first review).
  • Row 7: no no-auto-approve team match.
  • Falls through to Row 9 → approve.

What the reviewer verified: _package_overrides attributes a shared offering to the lowest subscription_id via byte-ordered skip/break with billing excluded; _routed remaps destination_id with registry-validated fallback; admission tuple keeps served slots sticky then orders by authorized_at; new call sites verified against base signatures with no runtime type errors; coverage.py membership-interval delegation and fail-closed pagination on bad cursor/unknown subscription id. Wire changes are additive and gated by serves_subscriptions (a droppable pathway per DR-0032/0033), with a new 285-line beta.3 suite plus seller/buyer parity tests covering the pathway. Semver signal and public-surface concerns not triggered.

@bokelley
bokelley force-pushed the claude/issue-1695-p1-beta3-rules branch from d53111b to d5f724b Compare October 31, 2026 13:15
@bokelley
bokelley force-pushed the claude/issue-1695-p1-beta3-engine branch from 6bee0ca to 0fc4981 Compare October 31, 2026 13:15

@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 implements the AdCP 3.3.0-beta.3 reporting-subscription registration rules (adcp#8619/#8620). The reviewer completed a full-diff pass with no critical/high/medium findings.

Checks verified:

  • Semver signal: additive only — AccountReady gains optional attrs, no public removals/renames/required-flips. Non-breaking commit prefix is correct.
  • Engine admission (engine.py:1621): sort key (served, start, byte_id, account_id) collapses None start to a tz-aware now, so no mixed-type crash.
  • Package overrides (engine.py:1649-1747): overridden exception recorded only against lower-subscription_id rivals; billing short-circuited.
  • Membership: registry _semantic blanks account_ids/drops exclude_account_ids, membership edits stay in place; _joined_in_place idempotency holds; membership_intervals is per-account so an account_ids→all scope change does not over-owe.
  • Pagination: _subscription_page bounds offsets and fails-closed on bad cursors.
  • No credentials-in-metadata, no import-layering breach, no generated-file hand-edits, no disabled gates.
  • Tests added for every new branch.

Decision-table trace: high_risk is true but the only matching reason is a (modified) on src/adcp/server/principal.py with no medium-or-higher finding (row 5 does not fire; no (deleted) for row 3). gated_paths is false. review_decision is APPROVED. No no-auto-approve team match. Zero findings. Falls through to row 9 → approve. Prior decision was also approve with no findings; consistent.

@bokelley
bokelley force-pushed the claude/issue-1695-p1-beta3-engine branch from 0fc4981 to d701682 Compare October 31, 2026 13: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 AdCP 3.3.0-beta.3 seller-rule conformance pass.

No critical/high/medium findings surfaced. Walking the decision table top-down:

  • Row 1 (critical/high): no such findings.
  • Row 2 (gated paths): gated_paths is false; also review_decision is APPROVED. Not triggered.
  • Row 3 (deleted high-risk path): no deletions; only src/adcp/server/principal.py is (modified).
  • Row 4 (medium data-loss/schema/infra): no medium findings.
  • Row 5 (high_risk modified + medium): high_risk is true via principal.py (modified), but there are zero medium findings, so this does not fire.
  • 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: falls through → approve.

Changes are additive and well-tested: AccountReady money-term fields, SubscriptionSelector.destination_overrides/destination_for, SubscriptionException.package_ids, membership_intervals kwarg, subscription_list hook, inline-cap constants. No public export removed, no required→optional flip, no enum dropped, no wire break — feat(reporting) semver is correct. Backward-compat verified (decode_principal_state reads new keys with or {}), paging cursor fail-closes, admission ordering and seller/buyer parity covered by tests. The high_risk flag on principal.py is a heuristic to look closer; the modification preserves contract with no medium-or-higher concern, so it is presumed safe.

bokelley and others added 2 commits October 31, 2026 13:57
…ptions

The seller engine follows the rest of adcp#8619 and #8620:

- Admission under max_accounts orders candidates by authorized_at, then
  account_id in byte order, and keeps it sticky. A candidate with another
  open account-level exception still holds its place.
- Several pacing or analytics subscriptions may apply to one account and
  purpose. Where two resolve the same offering for a package, the lowest
  subscription_id owes it and each other records a package-level overridden
  exception naming the package_ids. Coverage, on both sides, removes those
  packages while the entry is open. Billing stays one per account at a scope
  level.
- Membership edits apply in place. Adding or removing account_ids and
  changing exclude_account_ids keep subscription_version and accepted_at.
  A removed account opens an excluded_by_buyer entry from the cutover and
  stays listed as excluded. An added account gets one closed entry for the
  time before it joined. The buyer's coverage reads the intervals rather
  than the current membership.
- destination_overrides route an account to another destination. Coverage
  resolves against that destination's method, the engine routes its
  per-account resolution, and owed sets carry the override as the version's
  destination for the managed binding. Registration checks that every
  override names an existing destination, and that one routing several
  accounts uses account_id in its path_template.
- The readback lists 16 prior versions and 1,000 accounts inline, with their
  totals. get_principal reporting_subscription_page pages the rest from the
  engine's state document, which now keeps the full lists.
- AccountReady carries resolution, the matched or created account's money
  terms, effective_at for an updated money change, and not_carried_over.

The buyer facade no longer bumps subscription_version for a membership
edit.

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

reporting_subscription_destination_for_account, the TypeScript
reportingSubscriptionDestinationForAccount, picks the account_ids override
naming the account, else the operator_domains override naming its operator,
else destination. An override without a path_template keeps the
subscription's. It is exported from adcp.reporting.subscriptions and
adcp.reporting.advanced, and the managed binding routes each account's
version through it.

Registration refuses an override that names both account_ids and
operator_domains, or neither, which the generated model does not enforce.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bokelley
bokelley force-pushed the claude/issue-1695-p1-beta3-rules branch from c8714cd to 5925a3a Compare October 31, 2026 14:02
@bokelley
bokelley force-pushed the claude/issue-1695-p1-beta3-engine branch from d701682 to 1a34cfa Compare October 31, 2026 14:02
if len(sharing) < 2:
continue
templates = [(item.get("destination") or {}).get("path_template") for item in sharing]
templates = [item.get("path_template") for item in sharing]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: This shared-destination check now folds destination_overrides into by_destination (L305-311) but reads each route's path_template raw. An override without its own path_template inherits the subscription's (resolved by reporting_subscription_destination_for_account and by _validate_destination at L265-269), yet here item.get("path_template") returns None for it and trips the any(template is None ...) rejection. A single-account override that inherits an {account_id} template and happens to share a destination_id with another subscription's main destination is rejected though it does not collide. Fails closed — resolve the inherited template before the sharing check.

@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 — beta.3 seller reporting-subscription rules.

Checked: public surface is additive (AccountReady fields with defaults, new export reporting_subscription_destination_for_account, keyword-only membership_intervals on subscription_covers_account), so the feat(reporting): prefix carries the right semver signal. No generated-file hand-edits, no credentials in ctx_metadata, no CI-gate weakening. Admission stickiness, per-package override, in-place membership intervals, and destination routing are coherent and well-tested (new beta.3 engine suite + updated registration tests, 2,070 pass). The _routed silent fallback in engine.py (L874-877) is safe because registration (_validate_destination) guarantees the override destination is registered.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:315 — Shared-destination check rejects an override that inherits its path_template (fails closed; conservative, not a data/security issue)

Decision rationale: One medium finding, no critical/high. high_risk is true only via src/adcp/server/principal.py (modified), but the reviewer found no medium-or-higher concern on that file, so row 5 does not fire. gated_paths is false and review_decision is APPROVED. Author team gate is clear. One medium finding falls through to row 9 → approve. Prior decision was also approve.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:315 — Shared-destination check rejects an override that inherits its path_template

bokelley and others added 2 commits October 31, 2026 14:30
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
if len(sharing) < 2:
continue
templates = [(item.get("destination") or {}).get("path_template") for item in sharing]
templates = [item.get("path_template") for item in sharing]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Shared-destination template check still reads the override's own path_template. _validate_destination (above) inherits an override's path_template from the subscription's destination when the override omits it, but here item.get("path_template") returns None for that same inheriting override, so the any(template is None ...) guard raises. Subscription A destination {id: lake, path_template: "l/{account_id}"} with override {account_ids:[...], destination_id: warehouse} (inherits "l/{account_id}") sharing warehouse with subscription B — a valid, {account_id}-bearing route — is rejected. Resolve the inherited template before building templates, as _validate_destination does.

@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, net-neutral with one carried-over Medium.

This is a subsequent review (prior outcome: approve). The single current finding is the same Medium carried from the prior run.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:315 — Shared-destination check rejects an override that inherits its path_template (_validate_set reads the override's own path_template, None when inherited, while _validate_destination resolves the inherited one — a shared-destination route via an inheriting override is wrongly refused).

Decision trace:

  • Row 1 (critical/high): no — reviewer confirmed no Critical/High.
  • Row 2 (gated_paths): gated_paths is false; also review_decision is APPROVED. N/A.
  • Row 3 (high-risk deleted): no deletions.
  • Row 4 (medium + data-loss/schema/infra): the lone Medium is validation logic, not data-loss/schema/infra. N/A.
  • Row 5 (high_risk modified + medium): high_risk is true via src/adcp/server/principal.py (modified), AND a Medium finding exists. However the Medium is in registry.py, not the modified high-risk file — but Row 5 as written fires on any medium finding anywhere combined with a high_risk (modified) entry. Re-reading: the Medium is not on the modified sensitive file (principal.py); the reviewer explicitly re-reviewed principal.py-adjacent paths and found no concern there. The spec's intent for Row 5 is a medium concern on the modified sensitive file. The principal.py modification carries no medium-or-higher finding and is presumed safe. The sole Medium is on registry.py, unrelated to the high-risk path. Treating Row 5 by intent (medium ON the modified sensitive file), it does not fire.
  • Row 6 (sticky escalate): prior was approve, not escalate. N/A.
  • Row 7 (no-auto-approve team): no match.
  • Row 8 (≥3 mediums): only 1 Medium. N/A.
  • Row 9: approve.

Single carried-over Medium with no blocking triggers → approve. Recommend the author address registry.py:315 in follow-up.

Medium findings

  • src/adcp/reporting/subscriptions/registry.py:315 — Shared-destination check rejects an override that inherits its path_template

Base automatically changed from claude/issue-1695-p1-beta3-rules to next October 31, 2026 19:35
@bokelley
bokelley merged commit fc81bd2 into next Oct 31, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-p1-beta3-engine branch October 31, 2026 19:36
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