Skip to content

feat(reporting): explain coverage with a shared CoverageTrace - #1766

Merged
bokelley merged 6 commits into
nextfrom
claude/issue-1695-p11-coverage-trace
Nov 1, 2026
Merged

bokelley merged 6 commits into
nextfrom
claude/issue-1695-p11-coverage-trace

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Slice P11 of the reporting-subscriptions DX plan (.context/dx-reporting-subscriptions.md §2.5, §6.2). P10 (buyer reconcile and explain) builds on this PR and will be stacked on it.

What it adds

explain_subscription_coverage(...) in adcp.reporting.subscriptions.explain. It takes the input of owed_subscription_periods, plus period, and optionally media_buy_id and offering_id. It returns one CoverageTrace per (buy, offering, period) selected.

  • Verdict: the owed rule's own. A period is owed exactly when owed_subscription_periods owes it. The classifier computes the owed set with include_unresolved=True and walks the rules against it.
  • Reason: the first rule, in the spec's fixed order (scope … timezone), that rejects the period. The trace includes the facts that rule read.
  • Reason codes: read at runtime from the spec's reason-codes.json in the packaged compliance tree (adcp/_compliance/<ADCP_VERSION>/test-vectors/reporting-subscriptions/), via coverage_reasons(). A test checks that COVERAGE_REASON_CODES matches the vendored spec file.
  • period: a YYYY-MM-DD date read in each offering's zone, an instant (the period containing it), or {start, end}, which must be a period of the schedule.

CoverageTrace JSON: trace_version, side, subject, verdict, reason, explanation, steps[] (rule, result, facts), inputs_digest and seller_only.

  • explanation comes from EXPLAIN_TEMPLATES, one sentence per reason. The spec has no explain-templates.json yet (plan X2), so the SDK ships the table, ready to move into the spec as is.

  • inputs_digest hashes wire-visible facts local to the period:

    • the version in effect;
    • the authorization projection (authorized_at, regranted_at, unauthorized_at);
    • the zone in effect;
    • the buy as get_media_buys returns it;
    • the offering;
    • the excluded[] entries covering the period start;
    • whether an authorization gap covers the period.

    Both sides can build this from what they read. now is 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_only holds buy_visible and, 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 records owed_periods reads, and feeds in the gaps[] the engine serves, so seller step facts match the buyer's. invisible_buys produces the seller-only buy_not_visible.
  • CLI:
    • python -m adcp reporting explain --app module:service --principal … --account … --period … [--json]
    • python -m adcp reporting diff buyer.json seller.json

Refactor. The input normalization in owed_subscription_periods moves 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:

  • Explaining a period, with the trace format and the diff;
  • the reason-code table with each sentence;
  • worked traces (confirmation day, cancellation mid-period, re-grant gap, timezone cutover, DST) taken from real output.

The seller guide's Troubleshooting with explain is filled in. Anchors are unchanged.

Tests

tests/test_reporting_subscription_explain.py:

  • Every canonical vector. Each period of each listed offering near each buy is explained (billing stubs included). The verdict must equal the vector's expected owed rows. The reason must be a spec code with a matching verdict, assigned by the last step, which is the only failing one, with steps in rule order. 111 cases, about 2,700 periods, covering 17 of the 20 reason codes.
  • The plan's confirmation-day example renders word for word.
  • A buyer trace and a seller trace agree. A stale buyer read differs at confirmation with different digests.
  • JSON round-trips.
  • buy_not_visible, range validation, and the CLI explain and diff are covered.
  • The managed seller's owed traces carry exactly the obligation ids in its ledger.

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, and reporting.explain_coverage / explainCoverage.

  • The facade returns a list of traces, because omitted filters select several. The plan's single-trace example is traces[0].
  • inputs_digest is sha256: over canonical_json_utf8_v1 of the facts document above.

🤖 Generated with Claude Code

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>

@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 — 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)

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

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>

@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 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>

@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 — 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:withheld change (explain.py:980) correctly prioritizes current loss of authority over backfill when a period ended at/before the latest unauthorized_at with no re-grant, and ships with a dedicated test. Sound.
  • The _offering_facts period_timezone nulling for non-fixed source_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_timezone period_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>

@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 — 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>

@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 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.py adds _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 unvalidated account_timezone is never consumed.
  • resolution.py records dimensions_published=False instead of raising, and _is_candidate (L480-488) defers missing_standard_dimensions to 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.

@bokelley
bokelley merged commit a025dbd into next Nov 1, 2026
41 of 42 checks passed
@bokelley
bokelley deleted the claude/issue-1695-p11-coverage-trace branch November 1, 2026 22:13
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