Skip to content

feat(reporting): add buyer reconcile and explain on client.reporting - #1768

Merged
bokelley merged 9 commits into
nextfrom
claude/issue-1695-p10-reconcile-explain
Nov 1, 2026
Merged

bokelley merged 9 commits into
nextfrom
claude/issue-1695-p10-reconcile-explain

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

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()

report = await client.reporting.reconcile(since="2026-11-01", grace=timedelta(hours=1))
for line in report.periods:
    if line.responsible_party == "seller":
        print(line.account_id, line.period["start"], line.status, line.reason, line.message)

Everything is read from the wire on each run. There is no static account list and no grant history to record.

Input Source
Roster list_accounts ∩ each subscription's scope (including operator_domains), plus its accounts_unauthorized[]
Versions get_principal (prior_versions[].configuration, inactive_intervals)
Grant history authorization.authorized_at + regranted_at, plus accounts_unauthorized[].unauthorized_at. The loss before a re-grant is the last unauthorized_at seen, else the start of the seller's gaps[] entry, which removes the same periods.
Buys get_media_buys, each package's reporting_delivery_offering_ids snapshot (without one, every advertised offering, the same fallback the SDK seller applies)
Offerings, backfill_on_regrant, status_retention_days capabilities
Coverage get_reporting_status summary (excluded, gaps, timezone_cutovers)
Obligations a complete get_reporting_status periods walk (load_reporting_ledger and evaluate_reporting_ledger)

Each owed period that has ended becomes a PeriodLine with wire-style keys:

  • status: delivered, pending, late, missing_obligation, withheld, or not_owed (only with include_not_owed);
  • reason: the spec reason code, from the trace;
  • responsible_party: seller, buyer (a billing receipt owed) or none;
  • message: the trace's sentence;
  • expected_at, reporting_obligation_id, reporting_revision_id, finality and receipt.

Other behaviour:

  • grace defaults to one hour, not zero.
  • definitive is true only when the roster, every account's walk and every owed set completed. Otherwise issues name what to retry, and a partial roster is never used.
  • An account whose authority was lost is reported from the last facts the client read for it, with its unpaid periods 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 same CoverageTrace the seller's explain_coverage builds, with side="buyer". Against the managed seller, buyer and seller traces of a period have equal inputs_digest and diff_coverage_traces finds no difference.

Also in this PR

  • CoverageExplainer prepares one coverage input and classifies many periods; explain_subscription_coverage now uses it.
  • ReportingSubscriptionTracker becomes an internal detail. Its docstring points to reconcile and explain, and it stays importable.
  • CLI:
    • python -m adcp reporting reconcile --agent <alias> [--since] [--include-not-owed] [--json]
    • python -m adcp reporting explain --agent <alias> --account … --period …
  • Docs: the buyer guide's Reconcile section is written, with a new Explain section; the README buyer snippet is updated.

Tests

tests/test_reporting_subscription_reconcile.py runs against the SDK managed seller installed in process. The buyer reaches it only through wire tasks (tests/reporting_inprocess.py).

  • The derived roster and delivered lines, with the full PeriodLine shape.
  • No worker turn: pending within grace, then missing_obligation owned by the seller. grace=0 is honoured.
  • include_not_owed lists the pre-confirmation period as before_confirmation.
  • since and explicit accounts narrow the report; an unknown account is an issue.
  • A failed list_accounts makes the report not definitive.
  • A grant lost before a period was due: the account is still reported, with the period withheld (owed:withheld).
  • Buyer and seller explain the same period identically (equal digests, empty diff).
  • CLI reconcile --json and explain --agent.

The explain, facade, consumer and public-API suites are green. ruff and mypy are clean.

Parity notes for the TypeScript SDK (T11)

  • PeriodLine keys and the status and responsible-party vocabularies are as in the plan.
  • ReconcileReport has as_of, definitive, summary, periods, issues and accounts.
  • The fleet fan-out on the multi-agent client is not in this PR.

🤖 Generated with Claude Code

bokelley and others added 2 commits November 1, 2026 17:19
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

@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 buyer reporting reconcile/explain surface, no blocking findings.

Checked:

  • Public-surface additions (client.reporting.reconcile()/explain(), new reconcile.py, CoverageExplainer refactor) ship under a correct feat(reporting): prefix — no semver break, no removed/renamed exports, ReportingSubscriptionTracker stays importable.
  • Explain refactor is behavior-preserving: keyword-only params and defaults unchanged, sort_traces uses .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 so media_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

@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, 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_PAGES bounds every pager; failures 2-6 degrade correctly.
  • explain.py: CoverageExplainer extraction is a faithful refactor; elsewhere loop rename and sort_traces .get() guards preserve prior semantics; thin **inputs wrapper 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_principal failure 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>

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

bokelley and others added 2 commits November 1, 2026 18:26
… 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>

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

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

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

bokelley and others added 2 commits November 1, 2026 18:51
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>

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

Base automatically changed from claude/issue-1695-p11-coverage-trace to next November 1, 2026 22:13
@bokelley
bokelley merged commit c830344 into next Nov 1, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-p10-reconcile-explain branch November 1, 2026 22:14
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