Repository navigation
feat(reporting): explain coverage with a shared CoverageTrace - #1766
Conversation
Add explain_subscription_coverage, which classifies periods of one subscription on one account against the facts owed_subscription_periods reads. The verdict is the owed rule's own, and the reason is the first rule, in the spec's fixed order, that rejects the period, with the facts it read. Reason codes, verdicts and rules come from the spec's reason-codes.json in the packaged compliance tree, and explanations render from one template per reason, so buyer and seller print identical text for identical facts. The CoverageTrace JSON carries trace_version, side, subject, verdict, reason, explanation, steps and an inputs_digest over wire-visible facts local to the period. diff_coverage_traces names the first differing rule and whether the two sides read the same inputs. A seller-only block carries buy visibility and the obligation id, and for_buyer strips it. Seller surface: ReliableReportingService.explain_coverage and ReportingSubscriptionEngine.explain_coverage, including buy_not_visible for buys the caller cannot see. CLI: python -m adcp reporting explain, against the seller service named by --app, and python -m adcp reporting diff. The coverage input normalization moves into a shared context, so the owed rule and explain read one preparation. Every canonical vector is checked: each period near each buy is explained, and the verdict equals the vector's expected owed rows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive feature with one non-blocking medium.
This PR adds a new explain_subscription_coverage/CoverageTrace surface, a seller explain_coverage helper, and a python -m adcp reporting CLI. The reviewer confirms the change is cleanly additive: new exports only, owed_subscription_periods's public signature is unchanged, and the _coverage_context extraction is behavior-preserving (the unchanged owed-loop body references only locals still bound). feat: is the correct semver signal. No credentials-in-metadata, generated-code, import-layering, or forward-compat concerns, and AGENTS.md's handler table tracks AdCP task handlers (not these library functions), so no handler entry is owed.
Medium findings
- src/adcp/reporting/subscriptions/explain.py:348 — inputs_digest omits
now, so a period_not_ended-vs-owed divergence across a clock boundary yields equal-digest/different-verdict that diff_coverage_traces may mislabel as an SDK logic bug on the reconciliation path.
Decision: no critical/high findings; gated_paths false; high_risk false; no author-team gate; only one medium finding. Falls through to row 9 → approve. Worth a follow-up on the digest to include the clock-boundary input, but not blocking.
Medium findings
- src/adcp/reporting/subscriptions/explain.py:348 — inputs_digest omits now, so period_not_ended vs owed yields equal-digest/different-verdict mislabeled as a logic bug
The producer row-upload test stamps its worker lease with the settling harness clock, fixed at 2026-11-01T02:10Z, while the row store checks the lease against the database's clock_timestamp. Once the wall clock passed that instant plus the lease, every run failed with STATE_UNAVAILABLE: no live worker lease covers this row upload, on next and on every pull request. The test now runs its turn at the wall clock, so the lease lies in the real future. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit ae15bab)
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
This is a subsequent push following a prior approve. The delta since the prior run is a single test-only fix in tests/conformance/reporting/test_reporting_row_storage_object_pg.py: it sets clock[0] = datetime.now(timezone.utc) before run_worker() so the injected lease stamp lands in the real future, since the PG row upload validates the worker lease against the database's clock_timestamp() (real wall time). The fix is correct, minimal, and confined to the test harness.
No new findings were surfaced in this run. The prior Medium in explain.py:348 (inputs_digest omits now) is outside this delta and non-blocking.
Decision-table walk: no critical/high findings (row 1 n/a); gated_paths is false and review_decision is APPROVED (row 2 n/a); not high-risk, no deletions (rows 3,5 n/a); no medium data-loss/schema/infra findings (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 in this run (row 8 n/a). Falls through to row 9 → approve.
A period the owed rule owes is period_not_ended before its end and owed after it, so two traces taken on either side of the end carried equal inputs_digest but different verdicts, and diff_coverage_traces blamed an SDK logic bug for clock skew. The digest now includes whether the period has ended; now itself still is not part of it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass, no blocking or medium findings.
This delta adds period_ended (candidate.end <= now) to the coverage-trace inputs digest, as the exact logical complement of the existing end > now predicate that drives period_not_ended (same normalized now, same boundary). The effect is that two traces taken across the period-end boundary now differ in their digest inputs rather than colliding with divergent verdicts — closing a false "logic bug in one SDK" signal in diff_coverage_traces. Change is accompanied by docstring/doc updates and a test.
Checked against repo gates: no public-surface semver break, no hand-edits to generated code or types/domains/**, no discriminated-union fallback changes, no import-layering violations, no credentials in ctx_metadata, and CI gates remain armed. No minor-release wire-translation pathway is implicated — this is an internal digest-input change. The prior run on head 90c16ec also approved with no findings; current run surfaces none either.
Decision table: no critical/high/medium findings (row 1, 4-8 n/a), gated_paths false and review_decision APPROVED (row 2 n/a), high_risk false (rows 3/5 n/a), prior decision was approve (row 6 n/a), no team gate (row 7 n/a). Falls through to row 9 — approve.
…hheld reason-codes.json defines owed:withheld as an owed period that ended at or before the latest unauthorized_at with no re-grant since. explain used it only for periods not yet due at the loss, and called the rest owed. It now follows the definition, which the TypeScript explainer also applies. The trace digest's offering facts carry only the zone the offering publishes, so an account-resolved source zone no longer makes the two SDKs' digests differ. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — one medium finding, no blocking issues, no escalation triggers.
This is a subsequent review (prior decision: approve). The delta is two logic refinements to the new explain.py:
- The
owed:withheldchange (explain.py:980) correctly prioritizes current loss of authority over backfill when a period ended at/before the latestunauthorized_atwith no re-grant, and ships with a dedicated test. Sound. - The
_offering_factsperiod_timezonenulling for non-fixedsource_timezone(explain.py:480) is correct digest-hygiene, flagged Medium only because it ships without a regression test (silent reintroduction of buyer/seller digest divergence if removed).
Medium findings
- src/adcp/reporting/subscriptions/explain.py:480 —
source_timezoneperiod_timezone nulling has no regression test
Decision-table walk: no critical/high findings (row 1 n/a). gated_paths=false (row 2 n/a). high_risk=false, no deletions (rows 3,5 n/a). The single medium finding is a missing-test gap, not 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). Only 1 medium finding (row 8 requires ≥3; n/a). Falls through to row 9 → approve.
Medium findings
- src/adcp/reporting/subscriptions/explain.py:480 — source_timezone period_timezone nulling has no regression test
…e digest Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — prior Medium resolved, no new findings.
This subsequent-review delta is a single test addition (tests/test_reporting_subscription_explain.py) that closes the prior Medium finding from the previous run: source_timezone/period_timezone nulling now has regression coverage. The new test exercises both branches of _offering_facts (explain.py:1324-1329) — account_resolved/source_timezone nulls period_timezone and two differing resolved zones produce identical facts, while policy=fixed retains the value.
Current run surfaces no findings. The prior [medium] explain.py:480 finding is addressed by this PR. No blocking issues, no escalation triggers, no author team gate, gated_paths false, review_decision APPROVED. Net improvement over the prior pass.
Decision table: rows 1–8 do not fire (zero findings) → row 9 approve.
Differential testing in adcp#8679 found three coverage input errors raised for values no schedule reads. The account timezone and each timezone cutover are now validated only where an account_timezone calendar is built, and previous_timezone is required only for a cutover that applies to such an offering. An offering without standard_dimensions constructs, and is refused with missing_standard_dimensions only when it is a candidate for the selector, as error-codes.json states. Each minimized repro from adcp-client-python#1769 is a test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass with no blocking or medium findings.
This is a subsequent review (prior decision: approve, clean). The final commit fix(reporting): refuse coverage input only where a schedule reads it makes two validations lazy to match error-codes.json:
coverage.pyadds_offering_zone(~L875) that validates exactly the zone a calendar reads (account zone iff no cutover applies, each applicable cutover otherwise), symmetric with_timezone_at; the early-return unvalidatedaccount_timezoneis never consumed.resolution.pyrecordsdimensions_published=Falseinstead of raising, and_is_candidate(L480-488) defersmissing_standard_dimensionsto candidate-time, with refusal still surfacing at the single caller.
Tests cover all three paths (issue 1769, adcp#8679). No credential/semver/forward-compat/import-layering/generated-code concerns triggered.
Decision table: no critical/high (row 1 n/a), gated_paths false + review_decision APPROVED (row 2 n/a), high_risk false (rows 3/5 n/a), no medium findings (rows 4/8 n/a), no prior escalate (row 6 n/a), no team gate (row 7 n/a) → row 9 approve.
Slice P11 of the reporting-subscriptions DX plan (
.context/dx-reporting-subscriptions.md§2.5, §6.2). P10 (buyerreconcileandexplain) builds on this PR and will be stacked on it.What it adds
explain_subscription_coverage(...)inadcp.reporting.subscriptions.explain. It takes the input ofowed_subscription_periods, plusperiod, and optionallymedia_buy_idandoffering_id. It returns oneCoverageTraceper (buy, offering, period) selected.owedexactly whenowed_subscription_periodsowes it. The classifier computes the owed set withinclude_unresolved=Trueand walks the rules against it.scope…timezone), that rejects the period. The trace includes the facts that rule read.reason-codes.jsonin the packaged compliance tree (adcp/_compliance/<ADCP_VERSION>/test-vectors/reporting-subscriptions/), viacoverage_reasons(). A test checks thatCOVERAGE_REASON_CODESmatches the vendored spec file.period: aYYYY-MM-DDdate read in each offering's zone, an instant (the period containing it), or{start, end}, which must be a period of the schedule.CoverageTraceJSON:trace_version,side,subject,verdict,reason,explanation,steps[](rule,result,facts),inputs_digestandseller_only.explanationcomes fromEXPLAIN_TEMPLATES, one sentence per reason. The spec has noexplain-templates.jsonyet (plan X2), so the SDK ships the table, ready to move into the spec as is.inputs_digesthashes wire-visible facts local to the period:authorized_at,regranted_at,unauthorized_at);get_media_buysreturns it;excluded[]entries covering the period start;Both sides can build this from what they read.
nowis excluded.diff_coverage_traces(a, b)names the first differing rule. It also says whether the two sides read the same inputs (equal digests mean a logic bug) or different state.seller_onlyholdsbuy_visibleand, when the period is owed,reporting_obligation_id.trace.for_buyer()strips it.Seller surface
ReliableReportingService.explain_coverage(principal, account_id, *, period, subscription_id=None, media_buy_id=None, offering_id=None, invisible_buys=()).ReportingSubscriptionEngine.explain_coverage(...)reads the same recordsowed_periodsreads, and feeds in thegaps[]the engine serves, so seller step facts match the buyer's.invisible_buysproduces the seller-onlybuy_not_visible.python -m adcp reporting explain --app module:service --principal … --account … --period … [--json]python -m adcp reporting diff buyer.json seller.jsonRefactor. The input normalization in
owed_subscription_periodsmoves into_coverage_context, so the owed rule and explain share one preparation. Behaviour is unchanged: the 120 parity vectors and the TS-parity suite pass.Docs. Guide 3, Coverage, explained, now has:
The seller guide's Troubleshooting with explain is filled in. Anchors are unchanged.
Tests
tests/test_reporting_subscription_explain.py:confirmationwith different digests.buy_not_visible, range validation, and the CLIexplainanddiffare covered.Also green: the engine, managed, service, beta3-engine, public-API, import-layering, import-memory and CLI suites. ruff and mypy are clean.
Parity notes for the TypeScript SDK (T12)
Public names:
explain_subscription_coverage/explainSubscriptionCoverage,CoverageTrace,CoverageTraceStep,diff_coverage_traces,coverage_reasons,EXPLAIN_TEMPLATES, andreporting.explain_coverage/explainCoverage.traces[0].inputs_digestissha256:overcanonical_json_utf8_v1of the facts document above.🤖 Generated with Claude Code