Repository navigation
feat(reporting): conform subscription coverage to the AdCP 3.3.0-beta.3 rules - #1755
Conversation
There was a problem hiding this comment.
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.
_authorizedrewrite (coverage.py L768-791) provably equivalent to prior floor/ceil logic;_billing_stubprepend (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.
d53111b to
d5f724b
Compare
There was a problem hiding this comment.
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_atexport,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=)andholds_grantadded compatibly;require_subscription_grantfalls back to ACCOUNT_NOT_FOUND via getattr. - Largest-file review (coverage.py, ~250 net lines): The
_authorizedrefactor 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 bystart=Ewhich never collides with grid boundaries — no double-owe on the contiguous seam. - Termination:
_sticky_resolutionloop terminates (resolved_at strictly increases, bounded byat). - 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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
….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>
c8714cd to
5925a3a
Compare
| # 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( |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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
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 ()): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_authorizednow 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_packagewhile-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
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/vendorsstatic/compliance/source/test-vectors/reporting-subscriptions/atd5f8de383df13525e11721ee3d493ee76352210f. That is the signed 3.3.0-beta.3 source commit, the same asschemas/releases/3.3.0-beta.3.json. The vendored files are byte-identical to specmain, pinned by SHA-256.2026.6(IANA 2026f), as the vectors require.available_from/withdrawn_at) andflight_historynow validate against the 3.3.0-beta.3 schemas. The onewire_valid: falsecase must fail validation.SubscriptionBuy.from_media_buy. No buyer-retained input is left (non_wire_inputsis gone).The shared coverage rule (buyer and seller run the same function)
PT24HequalsP1D.billing_cycleCOVERAGE_INPUT_INVALID/invalid_period_duration.cancellation_effective_atwhen present, elsecancellation.canceled_at.SubscriptionBuyFlight.from_flight_history. The flight in force at a period's end is read fromflight_history[].accepted_atandconfirmed_at, among the offerings available then. Only a withdrawal of a resolved offering re-resolves it.a-newly-available-offering-never-re-resolvesreplaces the oldresolution-change-is-a-cutover, and one Python test that asserted the old rule is updated.reactivation-after-a-schedule-change-never-owes-twice).[E, first incoming boundary)as one period.OwedSubscriptionPeriod.stubflags 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
_stubsuffix on its persistedperiod_key, so every ledger store, including PostgreSQL, keeps it without a migration.ReportingObligationRecord.stubreads it back, and thecore/reporting-obligation.jsonprojection servesstub: true. A test plans a real cutover into the in-memory ledger and checks the record and the wire.SubscriptionBuy.from_media_buy: the per-packagereporting_delivery_offering_idssnapshot,flight_historyandcancellation_effective_at.buy_offeringsstill overrides. Offerings carryavailable_from/withdrawn_at.account_idinprimary_keys(#8619). An offering delivered to destinations (one withmethod) whosereporting_profile.primary_keysomitsaccount_idlogs areporting.subscription.offering_primary_keyswarning 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 byaccount_idyet. 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_accountsserves the latest re-grant within the current chain from the grant journal, computed in the same journal write asauthorized_at(the one-load test still holds).authorization_regranted_at()is exported next toauthorization_chain_start().get_reporting_status) refuses a task it does not list withSCOPE_INSUFFICIENT. An account without a covering grant still readsACCOUNT_NOT_FOUND. One existing test is updated, and a new one covers a grant withoutsync_reporting_receipts/sync_reporting_status.match: exactwithdimensions.Parity with the TypeScript SDK (adcp-client #3559 / #3560)
resolve_reporting_subscription_package(TSresolveReportingSubscriptionPackageV1): the sticky per-package resolution, shared by the buyer's and the seller's coverage.assert_reporting_task_granted(TSassertReportingTaskGrantedV1): refuses a task a resolved account's grant leaves out withSCOPE_INSUFFICIENT.adcp.reporting.advanced. The Python names keep the stack's convention of noV1suffix.regranted_atis a host input.AuthorizedAccount.regranted_atandGrantObservation.regranted_atcarry the wire value, and the journal dates a re-grant from it when it falls between the loss and the observation.authorized_atstays the chain start.grant-cases.jsonis TS's copy byte for byte: the step's engine-onlygranted_atis now the wireregranted_at.prior_flightsis nowflight_history, andgranted_atis nowregranted_at.Docs
docs/reporting-subscriptions-coverage.mdcovers the delivery stop,regranted_at, billing stubs, sticky resolution, reactivation and the day-start rule.docs/reporting-subscriptions-seller.mdcovers the beta.3 buy inputs and the offering requirements.docs/reliable-reporting-service.mdcoversSCOPE_INSUFFICIENT.Local verification
ruff check src/andmypy src/adcp/are clean.tests/test_reporting_*.py, principal and public-API test passes: 1,902 passed, 52 skipped.🤖 Generated with Claude Code