Repository navigation
feat(reporting): add buyer reconcile and explain on client.reporting - #1768
Conversation
client.reporting.reconcile reports every owed period of every covered account with a status, the spec reason code, the explanation sentence and the responsible party. Everything is read from the wire on each run: the roster is list_accounts intersected with the subscription scopes plus their accounts_unauthorized entries, grant history is authorized_at, regranted_at and unauthorized_at, and buys, offerings, coverage and obligations come from get_media_buys, capabilities and a complete get_reporting_status walk. The owed set is the shared owed rule; lines are delivered, pending, late, missing_obligation or withheld, and not_owed with its reason on request. grace defaults to one hour. definitive is true only when every read completed, and issues name what to retry. An account whose authority was lost reads from the last facts cached for it, with its periods withheld. client.reporting.explain returns the buyer CoverageTrace, identical to the seller trace for the same facts. CoverageExplainer prepares one coverage input for many classifications. ReportingSubscriptionTracker becomes an internal detail. CLI: python -m adcp reporting reconcile and explain with --agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ile-explain # Conflicts: # src/adcp/reporting/subscriptions/explain.py
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive buyer reporting reconcile/explain surface, no blocking findings.
Checked:
- Public-surface additions (
client.reporting.reconcile()/explain(), newreconcile.py,CoverageExplainerrefactor) ship under a correctfeat(reporting):prefix — no semver break, no removed/renamed exports,ReportingSubscriptionTrackerstays importable. - Explain refactor is behavior-preserving: keyword-only params and defaults unchanged,
sort_tracesuses.get()defensively with no shape change. - Partial-failure model verified (broad excepts → ReconcileIssue,
definitive=False, pagination bounded at_MAX_PAGES=1000); owed periods always carry ≥1 media_buy_id somedia_buy_ids[0]is safe. - No forward-compat/discriminated-union, import-layering, generated-code, or credential-in-metadata issues. CI gates intact; tests green per PR (eight scenarios incl. buyer/seller digest parity).
Medium findings
- src/adcp/reporting/subscriptions/reconcile.py:454 — get_principal failure crashes reconcile/explain instead of becoming an issue (breaks the otherwise-uniform issue-based error model; worth a follow-up).
One medium finding, no critical/high, no escalation trigger, no team gate, gated_paths false, high_risk false. Falls through to approve (rows 1–8 do not fire; a single medium is below the row-8 threshold of three).
Medium findings
- src/adcp/reporting/subscriptions/reconcile.py:454 — get_principal failure crashes reconcile/explain instead of becoming an issue
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive, experimental AdCP 3.3 buyer reconcile/explain surface on client.reporting, correctly signaled as feat: (minor). No blocking findings.
Checked:
- Semver signal correct: additive public surface, no removed/renamed exports, no required→optional flips, no response-model shape breaks → non-breaking
feat:is appropriate. - reconcile.py (899 new): owed→line mapping, grace/finality classification, grant reconstruction, and obligation key (delivery_config_id==subscription_id, confirmed by delivered test) all verified;
_MAX_PAGESbounds every pager; failures 2-6 degrade correctly. - explain.py:
CoverageExplainerextraction is a faithful refactor;elsewhereloop rename andsort_traces.get()guards preserve prior semantics; thin**inputswrapper keeps keyword-only public signature backward compatible. - No V1/generated-code/credential/import-layering/CI-gate issues; no public export removed.
- Tests cover delivered, pending/missing+grace, not_owed, since/unknown-account, partial roster, withheld, buyer==seller digest parity, and CLI.
Medium findings (non-blocking):
- src/adcp/reporting/subscriptions/reconcile.py:453 —
get_principalfailure raises instead of emitting the documented non-definitive report (single wire read with no try/except).
One medium finding, no escalation trigger (high_risk false, gated_paths false, no team gate). Falls through to row 9 → approve. Worth a follow-up to wrap the read_principal call so a failed get_principal degrades to a non-definitive report like failures 2-6 do.
Medium findings
- src/adcp/reporting/subscriptions/reconcile.py:453 — get_principal failure raises instead of a non-definitive report
reconcile now turns a failed get_principal into a SUBSCRIPTION_PRINCIPAL_UNAVAILABLE issue with an empty, non-definitive report, like every other read. explain, which has no issue channel, still raises. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — subsequent review, prior blocking concern resolved, no current findings.
This push responds to the prior run's single medium finding (reconcile.py:453 — get_principal failure raising instead of producing a non-definitive report). The reviewer confirms the fix: load() now takes strict (default False); reconcile degrades a get_principal failure to a SUBSCRIPTION_PRINCIPAL_UNAVAILABLE issue with definitive=False and empty periods, while explain passes strict=True so the read failure raises (correct, since explain has no issue channel). A test covers both paths and ReportingSubscriptionError is exported.
Decision-table walk:
- Row 1 (critical/high): no such findings.
- Row 2 (gated paths): gated_paths=false; also review_decision=APPROVED. N/A.
- Row 3/5 (high-risk deleted/modified): high_risk=false.
- Row 4 (medium data-loss/schema/infra): no findings.
- 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 in this run.
- Row 9: approve.
No author-team gate, no gated-path gate, and the prior medium is resolved with test coverage. Clean fix.
… withheld A period that ended before the caller lost authority, with no re-grant since, is withheld, never missing or late, whether or not it was due at the loss. 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.
Checked: this is a focused reporting-subscription reconcile fix. explain.py classifies an owed period ending at or before an open revocation as owed:withheld (L982); reconcile.py mirrors that trace reason into status=withheld (L1624); _offering_facts suppresses the non-wire account-resolved source_timezone from the facts/digest (L479-484). The classify arm is bounded by the upstream authorization step so it fires only for periods under the currently-revoked chain, and reconcile's withheld branch sits after the delivered check, drawing trace_reason from the same explainer. A new test covers the added branch.
Gate posture: high_risk=false, gated_paths=false, review_decision=APPROVED, no no-auto-approve team match. No public-surface/semver, forward-compat union, import-layering, generated-code, credential, or CI-gate concerns implicated. No findings at any tier → decision table falls through rows 1–8 to row 9 (approve).
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — buyer reconcile/explain re-review; one non-blocking medium finding.
No no-auto-approve team match, gated_paths false, review_decision APPROVED, high_risk false. This is a subsequent review following a prior clean approve (delta is a merge of main plus the buyer reconcile/explain feature).
Medium findings
- src/adcp/reporting/subscriptions/explain.py:1336 — flight_candidates lacks the per-(s,e) zone dedup select() has; under multi-zone offerings the include_not_owed audit path can double-count periods across zones.
Only one medium finding, no critical/high. The reviewer verified the load-bearing paths are safe: the _line finality gate fail-closes (unknown finality → official / -1, delivered requires revision_id, withheld checked first); every wire-read failure degrades to a ReconcileIssue with definitive=False and paging bounded by _MAX_PAGES; CoverageExplainer extraction preserves prior behavior and buyer/seller inputs_digest parity (test-covered). Public additions are additive — no semver break.
Decision table: rows 1–8 do not fire (no critical/high; gated_paths satisfied; no deleted/modified high-risk; medium category is not data-loss/schema/infra; prior decision was approve not escalate; no team gate; fewer than three mediums). Falls through to row 9 → approve. The single zone-dedup medium is worth addressing in a follow-up but does not block.
Medium findings
- src/adcp/reporting/subscriptions/explain.py:1336 — flight_candidates lacks the per-(s,e) zone dedup select() has
flight_candidates enumerated a buy's flight once per zone the offering's periods can take, so include_not_owed could list one period twice and inflate summary.not_owed. Each period is now listed once, as select does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Clean review with no Critical/High/Medium findings. This is a subsequent push following a prior approve; the single prior Medium (explain.py:1336 — flight_candidates lacked per-(s,e) zone dedup) has been resolved: seen is now per-offering and keyed on (s,e), mirroring select(). The delta correctly defers zone and standard_dimensions validation to read-time (coverage.py _offering_zone, resolution.py dimensions_published/_is_candidate), with full validation coverage of every zone _timezone_at can return and the refusal raised only after all candidate match checks pass — consistent with the "refuse only when read" thesis, backed by parity tests.
No author team gate (no no-auto-approve match). gated_paths is false and review_decision is APPROVED. high_risk is false. No rows 1–8 fire, so the outcome falls through to row 9: approve.
Slice P10 of the reporting-subscriptions DX plan (
.context/dx-reporting-subscriptions.md§2.8, §6.2). Stacked on #1766 (P11,CoverageTrace).client.reporting.reconcile()Everything is read from the wire on each run. There is no static account list and no grant history to record.
list_accounts∩ each subscription's scope (includingoperator_domains), plus itsaccounts_unauthorized[]get_principal(prior_versions[].configuration,inactive_intervals)authorization.authorized_at+regranted_at, plusaccounts_unauthorized[].unauthorized_at. The loss before a re-grant is the lastunauthorized_atseen, else the start of the seller'sgaps[]entry, which removes the same periods.get_media_buys, each package'sreporting_delivery_offering_idssnapshot (without one, every advertised offering, the same fallback the SDK seller applies)backfill_on_regrant,status_retention_daysget_reporting_statussummary (excluded,gaps,timezone_cutovers)get_reporting_statusperiods walk (load_reporting_ledgerandevaluate_reporting_ledger)Each owed period that has ended becomes a
PeriodLinewith wire-style keys:status:delivered,pending,late,missing_obligation,withheld, ornot_owed(only withinclude_not_owed);reason: the spec reason code, from the trace;responsible_party:seller,buyer(a billing receipt owed) ornone;message: the trace's sentence;expected_at,reporting_obligation_id,reporting_revision_id,finalityandreceipt.Other behaviour:
gracedefaults to one hour, not zero.definitiveis true only when the roster, every account's walk and every owed set completed. Otherwiseissuesname what to retry, and a partial roster is never used.withheld. The client keeps a small per-account cache (BuyerReportingState) for this.client.reporting.explain()client.reporting.explain(account_id=..., period=..., subscription_id=None, media_buy_id=None, offering_id=None)returns the sameCoverageTracethe seller'sexplain_coveragebuilds, withside="buyer". Against the managed seller, buyer and seller traces of a period have equalinputs_digestanddiff_coverage_tracesfinds no difference.Also in this PR
CoverageExplainerprepares one coverage input and classifies many periods;explain_subscription_coveragenow uses it.ReportingSubscriptionTrackerbecomes an internal detail. Its docstring points toreconcileandexplain, and it stays importable.python -m adcp reporting reconcile --agent <alias> [--since] [--include-not-owed] [--json]python -m adcp reporting explain --agent <alias> --account … --period …Tests
tests/test_reporting_subscription_reconcile.pyruns against the SDK managed seller installed in process. The buyer reaches it only through wire tasks (tests/reporting_inprocess.py).PeriodLineshape.pendingwithin grace, thenmissing_obligationowned by the seller.grace=0is honoured.include_not_owedlists the pre-confirmation period asbefore_confirmation.sinceand explicit accounts narrow the report; an unknown account is an issue.list_accountsmakes the report not definitive.withheld(owed:withheld).reconcile --jsonandexplain --agent.The explain, facade, consumer and public-API suites are green. ruff and mypy are clean.
Parity notes for the TypeScript SDK (T11)
PeriodLinekeys and the status and responsible-party vocabularies are as in the plan.ReconcileReporthasas_of,definitive,summary,periods,issuesandaccounts.🤖 Generated with Claude Code