Skip to content

feat(reporting): conform subscription coverage to the AdCP 3.3.0-beta.3 rules - #1755

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

bokelley merged 5 commits into
nextfrom
claude/issue-1695-p1-beta3-rules

Conversation

@bokelley

@bokelley bokelley commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Slice P1b of the reporting-subscriptions DX plan (.context/dx-reporting-subscriptions.md §6.2, P1). The subscription engine now follows the AdCP 3.3.0-beta.3 rules that the spec's coverage vectors pin. Stacked on #1754. The remaining seller-engine rules (admission order, several feeds per purpose, in-place membership, destination_overrides, the paged readback, account-request money terms) follow in the next PR on top of this one.

Parity: the spec's vectors, re-pinned

  • tests/fixtures/reporting_subscriptions/spec/ vendors static/compliance/source/test-vectors/reporting-subscriptions/ at d5f8de383df13525e11721ee3d493ee76352210f. That is the signed 3.3.0-beta.3 source commit, the same as schemas/releases/3.3.0-beta.3.json. The vendored files are byte-identical to spec main, pinned by SHA-256.
  • The vectors changed in adcp#8617, #8618, #8619 and #8622. All 120 cases pass, with tzdata still pinned to 2026.6 (IANA 2026f), as the vectors require.
  • As in the spec's own vector test, every case's subscriptions, offerings (with available_from / withdrawn_at) and flight_history now validate against the 3.3.0-beta.3 schemas. The one wire_valid: false case must fail validation.
  • The test builds every buy through the new wire projection, SubscriptionBuy.from_media_buy. No buyer-retained input is left (non_wire_inputs is gone).

The shared coverage rule (buyer and seller run the same function)

Rule Spec Change
Day start #8622 Under a local alignment each local date's first instant is a boundary: local midnight, or the first instant after a DST gap at midnight. Days nest in sub-daily schedules, and PT24H equals P1D.
Sub-daily billing_cycle #8622 Refused as COVERAGE_INPUT_INVALID / invalid_period_duration.
Delivery stop #8617 cancellation_effective_at when present, else cancellation.canceled_at.
Flight history #8618 SubscriptionBuyFlight.from_flight_history. The flight in force at a period's end is read from flight_history[].
Sticky per-package resolution #8619 A package resolves at the later of accepted_at and confirmed_at, among the offerings available then. Only a withdrawal of a resolved offering re-resolves it. a-newly-available-offering-never-re-resolves replaces the old resolution-change-is-a-cutover, and one Python test that asserted the old rule is updated.
Reactivation #8622 Resumes at the first boundary at or after the later of the reactivation and the end of the last period owed before the deactivation (reactivation-after-a-schedule-change-never-owes-twice).
Billing stubs #8617 A cutover onto a billing feed owes [E, first incoming boundary) as one period. OwedSubscriptionPeriod.stub flags it. Pacing and analytics leave the stretch unowed.

The authority checks are now stated on the period (start >= authorized_at, end <= unauthorized_at, no overlap with an interruption) rather than on a calendar. They are equivalent for grid periods, and also correct for a stub.

Seller side

  • Stub obligations. The planner marks a stub with a _stub suffix on its persisted period_key, so every ledger store, including PostgreSQL, keeps it without a migration. ReportingObligationRecord.stub reads it back, and the core/reporting-obligation.json projection serves stub: true. A test plans a real cutover into the in-memory ledger and checks the record and the wire.
  • Wire inputs on the managed path. Buys are read with SubscriptionBuy.from_media_buy: the per-package reporting_delivery_offering_ids snapshot, flight_history and cancellation_effective_at. buy_offerings still overrides. Offerings carry available_from / withdrawn_at.
  • account_id in primary_keys (#8619). An offering delivered to destinations (one with method) whose reporting_profile.primary_keys omits account_id logs a reporting.subscription.offering_primary_keys warning naming the offering. It is warned rather than refused because the production conformance seller's reference writer and digest-pinned row contract don't key rows by account_id yet. Refusing would break existing managed sellers on upgrade. Enforcing it is a follow-up once the reference row contract migrates.
  • authorization.regranted_at (#8618). list_accounts serves the latest re-grant within the current chain from the grant journal, computed in the same journal write as authorized_at (the one-load test still holds). authorization_regranted_at() is exported next to authorization_chain_start().
  • Partial grants (#8619). A grant that keeps the account covered (it lists get_reporting_status) refuses a task it does not list with SCOPE_INSUFFICIENT. An account without a covering grant still reads ACCOUNT_NOT_FOUND. One existing test is updated, and a new one covers a grant without sync_reporting_receipts / sync_reporting_status.
  • Billing selectors (#8619). Billing subscriptions must use match: exact with dimensions.

Parity with the TypeScript SDK (adcp-client #3559 / #3560)

  • resolve_reporting_subscription_package (TS resolveReportingSubscriptionPackageV1): the sticky per-package resolution, shared by the buyer's and the seller's coverage.
  • assert_reporting_task_granted (TS assertReportingTaskGrantedV1): refuses a task a resolved account's grant leaves out with SCOPE_INSUFFICIENT.
  • Both are exported from adcp.reporting.advanced. The Python names keep the stack's convention of no V1 suffix.
  • regranted_at is a host input. AuthorizedAccount.regranted_at and GrantObservation.regranted_at carry the wire value, and the journal dates a re-grant from it when it falls between the loss and the observation. authorized_at stays the chain start.
  • grant-cases.json is TS's copy byte for byte: the step's engine-only granted_at is now the wire regranted_at.
  • No non-wire inputs are left: prior_flights is now flight_history, and granted_at is now regranted_at.
  • An absent served version is the pinned release in both SDKs.

Docs

  • docs/reporting-subscriptions-coverage.md covers the delivery stop, regranted_at, billing stubs, sticky resolution, reactivation and the day-start rule.
  • docs/reporting-subscriptions-seller.md covers the beta.3 buy inputs and the offering requirements.
  • docs/reliable-reporting-service.md covers SCOPE_INSUFFICIENT.

Local verification

  • ruff check src/ and mypy src/adcp/ are clean.
  • Parity: 125 passed (120 vectors, plus the pin, schema and vocabulary checks).
  • Every tests/test_reporting_*.py, principal and public-API test passes: 1,902 passed, 52 skipped.
  • The full suites run in CI (dispatched manually on this branch head).

🤖 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.

Clean review of beta.3 subscription-coverage conformance (24 files, +2084/-351; coverage.py the only >200-net source file). No Critical/High/Medium findings.

Checks confirmed:

  • Semver signal correct: public additions (authorization_regranted_at, OwedSubscriptionPeriod.stub, SubscriptionBuy.cancellation_effective_at, observe_grants(regrants=)) are additive with defaults → feat:/minor is appropriate.
  • No hand-edits to generated types (domains/**, _generated.py).
  • Error details stay in the frozen vocabulary (fail-closed constructor assert); SCOPE_INSUFFICIENT vs ACCOUNT_NOT_FOUND leaks no account existence.
  • _authorized rewrite (coverage.py L768-791) provably equivalent to prior floor/ceil logic; _billing_stub prepend (L1027-1035) order-safe.
  • Parity vectors re-pinned by SHA-256 to the signed beta.3 source commit; ruff/mypy/parity clean; CI dispatched.

No team gate, no gated paths, high_risk false, first review. Falls through the decision table 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 pass, no blocking findings.

This PR adopts AdCP 3.3.0-beta.3 reporting-subscription rules (billing stubs, sticky resolution, flight_history/cancellation_effective_at on the wire, SCOPE_INSUFFICIENT, day-start boundaries) with matching spec vectors. The reviewer confirmed:

  • Semver signal: All public changes are additive (authorization_regranted_at export, OwedSubscriptionPeriod.stub, SubscriptionBuy.cancellation_effective_at, new classmethods). No removals or required-flips, so no major-bump requirement is triggered.
  • Backward-compat: observe_grants(regrants=) and holds_grant added compatibly; require_subscription_grant falls back to ACCOUNT_NOT_FOUND via getattr.
  • Largest-file review (coverage.py, ~250 net lines): The _authorized refactor from calendar-based to period-based bounds is behavior-preserving for boundary-aligned periods, and the non-boundary stub [E, first) is prepended once per cutover billing regime, keyed by start=E which never collides with grid boundaries — no double-owe on the contiguous seam.
  • Termination: _sticky_resolution loop terminates (resolved_at strictly increases, bounded by at).
  • No worker/queue/HTTP surface touched; no generated-code hand-edits; no import-layering violations.

Decision table: no critical/high/medium findings (row 1 n/a). gated_paths is false (row 2 n/a). high_risk is false (rows 3/5 n/a). No medium data-loss/schema/infra (row 4 n/a). Prior decision was approve, not escalate (row 6 n/a). No no-auto-approve team match (row 7 n/a). Zero medium findings (row 8 n/a). Falls through to row 9 → approve. review_decision is APPROVED.

# adcp#8619: consolidated destinations validate and never
# collide only when every delivered row carries its account.
# Warned, not refused, while existing row contracts migrate.
logger.warning(

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 downgrades the account_id guard from refuse to warn on a durable reporting path. Per the comment at L624-625, a consolidated destination "never collide only when every delivered row carries its account" — so a managed-delivery offering (method set) whose reporting_profile.primary_keys omit account_id now proceeds to from_offering and gets delivered, and rows from different accounts can collide (one account's reporting row overwrites another's / cross-account mixing), with only a reporting.subscription.offering_primary_keys warn-log as signal. Fail-closed beats fail-open here. The structured-log observability is the right shape, but warn-only leaves no exit: nothing flips this back to a refusal once row contracts have migrated. Worth a tracked follow-up (issue/milestone) tying the re-arm to a release, or the collision guard stays disarmed permanently.

@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 review, net-clean delta.

This is a three-file-focused delta that softens the newly-added beta3 account_id-in-primary_keys enforcement in managed.py from a hard refusal to a warn-only structured log, with matching docs and test updates.

Medium findings (non-blocking):

  • src/adcp/reporting/subscriptions/managed.py:627 — account_id row-collision guard downgraded from refuse to warn-only on the durable delivery path. The warn-only path lets a managed-delivery offering missing account_id proceed to delivery (consolidated-destination rows can collide, nothing re-arms the refusal post-migration). Tests correctly cover both the new warn behavior and the API-delivered no-warn case.

Decision path: No critical/high findings (row 1 no). gated_paths is false and review_decision is APPROVED anyway (row 2 no). high_risk is false (rows 3/5 no). The single medium finding's category is not data-loss/schema/infra per the reviewer's classification, so row 4 does not fire. Prior decision was approve, not escalate (row 6 no). No no-auto-approve team match (row 7 no). Only 1 medium finding — fewer than 3 (row 8 no). Falls through to row 9 → approve.

The prior run on head d5f724b was also a clean approve; this run surfaces one new medium on newly-added code, which per the subsequent-review rules is normal and does not warrant downgrading. No spec-drift, layering, or wire-shape regressions in the delta.

Medium findings

  • src/adcp/reporting/subscriptions/managed.py:627 — account_id row-collision guard downgraded from refuse to warn-only on durable delivery path

bokelley and others added 3 commits October 31, 2026 13:57
….3 rules

Re-pin the cross-SDK parity test to the spec's coverage vectors at the signed
3.3.0-beta.3 source commit d5f8de383df1, which adcp#8617, #8618, #8619 and
#8622 changed. All 120 cases pass with tzdata still pinned to 2026f, and each
case's subscriptions, offerings and flight_history now validate against the
3.3.0-beta.3 schemas, as the spec's own vector test checks.

The shared coverage rule, which the buyer and the seller engine both run:

- Day start: under a local alignment each local date's first instant is a
  boundary, so a nonexistent midnight still starts the day, days nest in
  every sub-daily schedule, and PT24H equals P1D.
- A sub-daily billing_cycle is refused as invalid_period_duration.
- The cancellation's delivery stop is cancellation_effective_at when present.
- Flights come from the buy's flight_history.
- Resolution is per package and sticky: a package resolves at the later of
  the version's accepted_at and the buy's confirmed_at, and only a withdrawal
  of a resolved offering re-resolves it.
- Reactivation resumes after the end of the last period owed before the
  deactivation, so a change made while inactive never owes twice.
- A cutover onto a billing feed owes the stretch from E to the first incoming
  boundary as one stub period, flagged stub.

Read the beta.3 wire: SubscriptionBuy.from_media_buy takes a get_media_buys
buy with its per-package offering snapshot, flight_history and
cancellation_effective_at, and SubscriptionOffering.from_offering reads
available_from and withdrawn_at. The managed path uses both, and refuses an
offering delivered to destinations whose primary_keys omit account_id.

On the seller side, stub obligations keep the flag through every ledger store
and serve stub true on core/reporting-obligation.json. list_accounts serves
authorization.regranted_at from the grant journal, computed in the same write
as authorized_at. A partial grant that keeps the account covered refuses a
task it does not list with SCOPE_INSUFFICIENT instead of ACCOUNT_NOT_FOUND.
Billing subscriptions must use match exact with dimensions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
adcp#8619 requires every managed-delivery offering's row schema to declare
account_id and list it in primary_keys. Refusing the configuration broke the
production conformance seller, whose reference writer and digest-pinned row
contract do not key rows by account_id yet. Log a
reporting.subscription.offering_primary_keys warning naming the offering
instead, while row contracts migrate.

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

Align with adcontextprotocol/adcp-client#3559 and #3560:

- resolve_reporting_subscription_package, the TypeScript
  resolveReportingSubscriptionPackageV1, is the sticky per-package
  resolution the buyer and the seller share, exported from
  adcp.reporting.advanced.
- assert_reporting_task_granted, the TypeScript assertReportingTaskGrantedV1,
  refuses a task a resolved account's grant leaves out with
  SCOPE_INSUFFICIENT.
- A host reports a re-grant inside the chain as authorization.regranted_at,
  as AuthorizedAccount.regranted_at or GrantObservation.regranted_at. The
  grant journal dates the re-grant from it when it falls between the loss
  and the observation. authorized_at keeps the chain start.
- grant-cases.json is the TypeScript copy: the re-grant step names
  regranted_at, not the engine-only granted_at.

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
# adcp#8619: consolidated destinations validate and never
# collide only when every delivered row carries its account.
# Warned, not refused, while existing row contracts migrate.
logger.warning(

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: A managed-delivery offering (method set) whose reporting_profile.primary_keys omit account_id is warned, not refused. On a consolidated destination its rows cannot carry their account, so rows collide across accounts on a durable billing/delivery path — and the only signal is a warn log, not an alerting path. 3.3.0-beta.3 requires account_id in primary_keys for every delivered offering. Warn-during-migration is a deliberate, documented choice, so this is non-blocking, but it stays a live row-collision risk until the refuse is turned on. (Re-surfaced from the prior pass.)

@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 the 3.3.0-beta.3 reporting-subscription work; net improvement, no blocking findings.

This is a follow-up to a prior approve. The developer continued the beta.3 reporting-subscription work (billing-stub coverage, sticky per-package resolution, reactivation handoff, DST day-start boundary, regranted_at journal threading, and the SCOPE_INSUFFICIENT vs ACCOUNT_NOT_FOUND split). The reviewer confirms all changes are additive to the public surface under an already-signaled feat! beta bump, no gate was weakened (schema-parity test broadened, not disabled), and spec vectors plus new unit tests cover every new branch.

Medium findings

  • src/adcp/reporting/subscriptions/managed.py:627 — account_id primary_keys guard is warn-only on durable delivery path (carried over from prior run; unchanged)

Decision trace: no critical/high findings (row 1 N/A). gated_paths is false (row 2 N/A). high_risk is false (rows 3, 5 N/A). The single medium finding is not in data-loss/schema/infra category (row 4 N/A). Prior decision was approve, not escalate (row 6 N/A). No no-auto-approve team match (row 7 N/A). Only 1 medium finding — fewer than three (row 8 N/A). Falls through to row 9 → approve. review_decision is APPROVED. One pre-existing medium below the escalation/comment thresholds falls through to approve exactly as the decision rules specify.

Medium findings

  • src/adcp/reporting/subscriptions/managed.py:627 — account_id primary_keys guard is warn-only on durable delivery path

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>
)
profile = offering.get("reporting_profile")
keys = profile.get("primary_keys") if isinstance(profile, Mapping) else None
if offering.get("method") is not None and "account_id" not in (keys or ()):

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: A consolidated-destination offering (method set) whose reporting_profile.primary_keys omit account_id is warn-only, not refused. On a billing/delivery path this is the collision case the comment itself names: rows from different accounts delivered to the same destination share a key space, so a missing account_id key risks cross-account row overwrite/dedup. Warn-during-migration is the documented beta.3 choice (reporting.subscription.offering_primary_keys), but the failure stays only in a logger.warning — a durable delivery-correctness gap that never reaches an alerting path. Still applies from the prior pass; non-blocking.

@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.0-beta.3 reporting-subscription adoption, subsequent pass.

The sole prior blocking concern was never critical/high; the one finding carries forward as a medium and does not trigger escalation.

What was checked:

  • Largest changed source file coverage.py (>200 net lines): reworked _authorized now judges authority on period [start,end) rather than calendar-ceil/floor, letting the off-grid billing stub be judged like any other period — verified safe. resolve_reporting_subscription_package while-loop terminates (resolved_at strictly increases, bounded by instant).
  • Public surface is additive only (assert_reporting_task_granted, authorization_regranted_at, resolve_reporting_subscription_package, SubscriptionBuy.from_media_buy, OwedSubscriptionPeriod.stub, new dataclass fields with defaults) — no public break, semver signal OK. New exports are reporting helpers, not ADCPHandler methods, so no AGENTS.md handler-table gap.
  • SCOPE_INSUFFICIENT path (holds_grant) backward-compatible via getattr.

Medium findings (non-blocking):

  • src/adcp/reporting/subscriptions/managed.py:623 — account_id primary_keys guard remains warn-only on the consolidated billing delivery path; a deliberate migration choice but a durable-correctness gap that never reaches alerting.

Decision: 1 medium finding, no critical/high, gated_paths false, review_decision APPROVED, no team gate, high_risk false. Rows 1–8 do not fire → row 9 approve. A single medium on a subsequent pass is not a reason to downgrade from the prior approve.

Medium findings

  • src/adcp/reporting/subscriptions/managed.py:623 — account_id primary_keys guard is warn-only on consolidated billing delivery path

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