Skip to content

feat(reporting): add the buyer receipts policy to reconcile - #1773

Merged
bokelley merged 11 commits into
nextfrom
claude/issue-1695-p14-receipts
Nov 1, 2026
Merged

bokelley merged 11 commits into
nextfrom
claude/issue-1695-p14-receipts

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Slice P14 of the reporting-subscriptions DX plan (.context/dx-reporting-subscriptions.md §2.10, §6.2). Stacked on #1772 (P13), then #1768 (P10) and #1766 (P11).

API

async def commit(revision: VerifiedRevision):
    job = await warehouse.load(revision.resource)
    return CommitResult(commit_ref=job.id)        # or "defer", or Reject(["ROW_COUNT_MISMATCH"])

report = await client.reporting.reconcile(
    receipts=ReceiptPolicy(mode="verified", commit=commit, resource_reader=reader))
report.receipts.submitted; report.receipts.deferred; report.receipts.rejected

Rules

Off by default. Nothing is receipted on read.

  • Billing (consumer_receipt) periods show receipt: {"status": "owed_by_buyer"} with responsible_party: "buyer"; the logic is in P10.
  • The client logs a warning once.
  • report.receipts is None.

mode="verified" needs commit and exactly one of resource_reader (the built-in ManifestReportingInspector) or inspect. Each account's ledger walk feeds it. For each consumer_receipt obligation whose current revision is materialized and not yet receipted:

  1. The SDK verifies the materialization itself, giving the observed row count, control totals and digest.
  2. If it does not match its revision, the receipt is rejected with the SDK's codes (ROW_COUNT_MISMATCH, CANONICAL_DIGEST_MISMATCH, …) and commit is not called.
  3. Otherwise commit(VerifiedRevision) decides:
    • CommitResult(ref) gives an accepted receipt with consumer_commit_ref;
    • "defer" submits nothing;
    • Reject(codes) gives a rejected receipt with those codes.
  4. Receipts go out in one sync_reporting_receipts per account, and the ledger is walked again so the lines reflect them.

Adjustments are offered to adjustments(adjustment) and deferred. Billing never auto-accepts. Accepting needs the raw adjustment evidence, so it stays on the manual path.

client.reporting.submit_receipts(account_id, receipts=..., adjustment_receipts=...) is the typed manual wrapper over sync_reporting_receipts. It returns item results and raises ReportingSubscriptionError when the task fails.

Tests

tests/test_reporting_subscription_receipts.py runs over the billing ledger fixture from test_reporting_reconciliation.py:

  • Policy validation.
  • A commit ref gives an accepted receipt carrying it. Once receipted, the next run submits nothing.
  • defer submits nothing; Reject sends your codes.
  • An SDK-found row-count mismatch is rejected without calling commit.
  • off submits nothing.
  • submit_receipts records a receipt and refuses an empty call.

The events and reconcile suites are green. ruff and mypy are clean. The buyer guide's Receipts for billing feeds section and the README are updated.

🤖 Generated with Claude Code

reconcile takes receipts=ReceiptPolicy. Off is the default, and nothing
is receipted on read; billing periods then show receipt status
owed_by_buyer with the buyer responsible, and the client warns once. In
verified mode the SDK verifies each unreceipted consumer_receipt
materialization itself, through the built-in manifest inspector or an
inspect adapter. A mismatch is receipted rejected with the SDK codes without
calling commit; otherwise the commit hook returns CommitResult for an
accepted receipt carrying its consumer_commit_ref, defer, or Reject with
codes. Adjustments are offered to an adjustments hook and deferred.
submit_receipts is the typed manual path over sync_reporting_receipts.

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 buyer receipts policy on reconcile, correctly signalled and tested.

No blocking (critical/high) findings. gated_paths is false, high_risk is false, no author-team gate, and no prior decision. Two Medium findings exist, which is below the row-8 threshold of three, so the PR falls through to row 9 (approve).

Medium findings (non-blocking):

  • src/adcp/reporting/subscriptions/receipts.py:176 — Built-in inspector raises on mismatch instead of producing documented rejected receipt; non-matching materialization fails the whole account walk rather than becoming a rejected receipt, and the behavior is untested (tests use a custom inspect).
  • src/adcp/reporting/subscriptions/receipts.py:164 — Rejected receipts never dedup; a persistently-rejected period re-writes a fresh rejected receipt to the seller's billing ledger on every reconcile.

These are worth a follow-up look (especially the billing-ledger re-write), but neither is critical/high and failures fail-closed to non-definitive. Semver signal checked (feat, additive exports + ReconcileReport.receipts default None), call-site updates and write-failure handling (RECEIPT_WRITE_FAILED raised then degraded to an issue) verified.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:176 — Built-in inspector raises on mismatch instead of producing documented rejected receipt
  • src/adcp/reporting/subscriptions/receipts.py:164 — Rejected receipts never dedup; reconcile re-submits them unbounded each run

@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 delta, prior blocking issues addressed, no new findings.

The reviewer surfaced no findings in this run. The delta correctly carries forward previous subscriptions/unauthorized (losses) on a partial events poll and routes a failed principal read to a reconcile issue (SUBSCRIPTION_PRINCIPAL_UNAVAILABLE) while still raising for explain via the new strict flag. The merge is properly gated behind not report.definitive (events.py:515), so genuine removals on a healthy poll still fire and current-wins merge preserves real state changes. Both get_principal and list_accounts failure paths are tested.

The two prior Medium findings lived in receipts.py and are absent from this delta. No no-auto-approve team match, gated_paths is false, review_decision is APPROVED, high_risk is false. No decision-table trigger fires (rows 1–8 all clear) → row 9 approve.

bokelley and others added 2 commits November 1, 2026 18:11
…r failures

The receipts policy now skips a materialization already receipted, rejected
included, and gives each receipt a stable id per obligation, revision and
materialization, so a refusal is never re-sent on every run. A revision the
verifier cannot verify, including a mismatch the built-in inspector refuses,
is listed in report.receipts.failed with its code and receipted nothing,
instead of failing the account walk.

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 delta pass, no blocking or medium findings.

Checked:

  • events.py:546 — doorbell moved to clear-before-poll (not after ack), correctly closing the lost-wakeup window; a ring landing during poll/yield/ack now survives to the next iteration. Guarded by the new test_a_ring_during_a_cycle_is_not_swallowed.
  • receipts.py — stable _receipt_id per (obligation, revision, materialization) with id-only dedup (L363-371) makes accepted/rejected receipts terminal and idempotent; dedup matches on the three ids regardless of status or prior uuid-format id, so it is backward-compatible.
  • receipts.py:384-394 — inspect try/except is fail-open-per-revision but visible via report.failed + a warning, and does not swallow CancelledError.
  • await inspect(context) still has no explicit timeout, but that line predates this delta and the try/except does not worsen it — noted as non-blocking follow-up.

No public-surface/semver, forward-compat union, import-layering, generated-code, credential, or CI-gate concerns in the diff. Only Low-tier note: the L363-371 dedup re-implements the existing _receipt_targets helper.

Gates: high_risk false, gated_paths false (review_decision APPROVED anyway), no no-auto-approve team match. Prior decision was approve. No critical/high/medium findings → decision table falls through to row 9.

@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 receipts policy for reconcile, net improvement over prior clean pass.

No critical/high findings. One medium finding, no escalation triggers (high_risk false, gated_paths false with review_decision APPROVED, no team gate), so rows 1–8 do not fire → row 9 approve.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:215 — Built-in ManifestReportingInspector path records a verbose str(enum) code in report.receipts.failed/log instead of the bare value, breaking the documented (reporting_obligation_id, code) contract; the test only exercises the raw-string path so it misses it. Fix is _enum(error.code).

Checked and clean: timeouts (inspector readers carry timeout_seconds, seller write reuses adopter client), idempotent stable receipt ids, re-receipt guards (accepted short-circuit + ids-only rejected guard), mode-off warn-once. The 2→3-tuple _obligations change is private with its single caller updated — no public-surface/semver impact. Non-blocking follow-up: on a sync_reporting_receipts write failure the report is marked non-definitive but submitted/rejected lists are already populated with the un-persisted batch; a caller trusting those lists without checking definitive on a billing path could be misled — worth a look but does not meet the medium bar here as a single surfaced concern.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:215 — Built-in inspector failure records verbose enum repr in report.receipts.failed

@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 — delta since prior approve resolves the one outstanding medium finding with no new issues.

What I checked:

  • receipts.py:216 — the prior Medium finding (verbose enum repr in report.receipts.failed) is resolved: the inspector error code now resolves via _enum(raw) returning .value, and test_reporting_subscription_receipts.py asserts the clean "MANIFEST_DIGEST_MISMATCH".
  • events.py:551-555 — new ReportingEventConflictError guard re-polls on an ack race; optimistic-concurrency loads the advanced snapshot so the next ack succeeds (no busy-loop), and stop is honored at the loop top.
  • No wire-shape, public-surface, or semver concerns in the diff; both fixes are tested.

Decision table: no critical/high/medium findings remain. gated_paths is false (and review_decision is APPROVED anyway). No high-risk flag, no team gate. Rows 1–8 do not fire → row 9 approve. Prior blocking issue addressed; this is a net improvement.

@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 subsequent review, prior medium resolved.

This is a follow-up push on a previously-approved PR. The delta is three small, clean changes:

  • receipts.py:388 now uses _enum(raw) (returns str(getattr(value,"value",value))), resolving the prior medium finding (verbose enum repr in report.receipts.failed). The enum's value (e.g. MANIFEST_DIGEST_MISMATCH) is now recorded; plain strings fall through unchanged.
  • events.py:550-555 catches ReportingEventConflictError and continues, keeping the events() generator alive after another worker acks first; competing-consumer semantics mean no loss/duplicate on re-poll (recovery="transient" confirms intent).
  • Both new branches have tests.

Gate checks:

  • No critical/high/medium findings (row 1, 4, 5, 6, 8 — no match).
  • gated_paths: false (row 2 n/a); review_decision: APPROVED.
  • high_risk: false, no deletions (rows 3, 5 n/a).
  • No no-auto-approve team match (row 7 n/a).
  • Falls through to row 9 → approve.

The prior medium was addressed and no new findings surfaced.

@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 receipts policy for reporting subscriptions.

Checked:

  • Verified-mode correctness: gates on commit + exactly one of resource_reader/inspect; SDK-found mismatches reject without calling commit.
  • Idempotency: stable per-(obligation,revision,materialization) receipt ids make once-per-materialization skip and off-mode warn-once hold; tests cover each.
  • Largest-file rule on receipts.py (+293): the once-per-materialization guard (L360-371) correctly subsumes _receipt_matches to also skip prior rejections.
  • Operational readiness: failed receipt write raises RECEIPT_WRITE_FAILED surfaced as a reconcile issue (definitive=False); verifier exceptions isolated per-revision into report.receipts.failed.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:234 — commit hook is at-least-once on receipt-write failure; the at-least-once guarantee on this billing/durable path is undocumented.

Decision: no critical/high findings; exactly one medium with no escalation-triggering category (not data-loss/schema/infra); high_risk false; gated_paths false and review_decision APPROVED; no no-auto-approve team. Falls through to row 9. Prior decision was approve. Net: single documentation-gap medium, approvable.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:234 — commit hook is at-least-once on receipt-write failure; not documented

@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 public surface: adds the buyer receipts policy (ReceiptPolicy/CommitResult/Reject/VerifiedRevision, reconcile(receipts=...), submit_receipts). Correct feat shape, forward-compat and import layering untouched. Verified receipt-id stability (stable per obligation/revision/materialization so resends dedup by id), fail-closed walked/mode gate, per-revision inspect isolation, and the SDK-vs-adapter mismatch asymmetry matching docs.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:234 — commit hook unwrapped: a raise mid-batch discards the account's already-built receipts and re-commits unreceipted obligations on retry (double-load risk on a billing path); no test covers it.

Decision rationale: no critical/high findings (row 1 N/A). gated_paths false; high_risk false; only a single medium finding with no escalation-triggering category (data-loss/schema/infra N/A — this is a billing-path warn/unwrap concern but does not fall into rows 4/5). No no-auto-approve team match. Fewer than three medium findings. Falls through to row 9 → approve. Prior decision was also approve; this is a net-neutral/improvement path. The single medium on the commit-wrap/retry double-load is worth a follow-up but does not block per the decision table.

Medium findings

  • src/adcp/reporting/subscriptions/receipts.py:234 — commit hook unwrapped: a raise discards the account's whole receipt batch and re-commits on retry

bokelley and others added 2 commits November 1, 2026 19:09
…st-once contract

A commit hook that raises no longer discards the receipts already built for
the account's other revisions: it is listed in report.receipts.failed as
COMMIT_FAILED and retried on the next reconcile. The docstring and buyer
guide state that commit runs at least once per revision and must be
idempotent on revision.resource.

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 — no blocking or medium findings in this run.

This is a subsequent review. The prior run's single medium finding (receipts.py:234 — unwrapped commit hook discarding the whole account receipt batch on a raise) has been resolved: commit now runs inside a try/except (receipts.py:410-423), so a failure drops only its own obligation into report.failed as COMMIT_FAILED and the rest of the account's batch still commits. Failure is surfaced via structured report.failed and retried with documented idempotency — correct shape, not warn-only-hidden. The events.py generation counter behaves correctly (first joins stay at generation 1; rejoins increment for a fresh account.covered id), confirmed by test. No new Critical/High/Medium findings.

Gate check: gated_paths is false; review_decision is APPROVED; no no-auto-approve team match; high_risk is false. No escalation triggers (no deletions, no schema/data-loss/infra medium). Decision table rows 1–8 do not fire → row 9 approve.

bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

TS parity note from adcp-client#3582 (T15, the TS port of this slice):

  • Same receipt ids. TS uses reporting-receipt: + sha256(obligation \x1f revision \x1f materialization)[:40], as _receipt_id here does.
  • One difference to consider: when adjustments are offered. apply_receipt_policy reaches the adjustments loop only after the "already receipted" continue. An adjustment corrects an official revision after the buyer receipted it, so in practice the hook would never see one. TS offers (and defers) the pending adjustments of each consumer_receipt obligation's current revision before that check, so it sees them whether or not the revision still needs a receipt. An adjustment counts as pending when it has no adjustment_receipts entry. A test covers an adjustment on an already-receipted revision.

🤖 Generated with Claude Code

Base automatically changed from claude/issue-1695-p13-events to next November 1, 2026 22:14
@bokelley
bokelley merged commit 9072f35 into next Nov 1, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-p14-receipts branch November 1, 2026 22:14

bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Another parity note, from review of adcp-client#3582. TS now reports a rejected billing revision differently.

The policy treats any receipt, rejected included, as final for that materialization and never resends it. The period line, though, kept saying receipt: owed_by_buyer with responsible_party: buyer until an accepted receipt existed, so a rejection nagged the buyer forever.

TS now reads:

  • owed_by_buyer (buyer) until the buyer states anything;
  • nothing once accepted;
  • rejected_by_buyer (seller: correct and re-deliver) once the buyer rejected the current revision.

In Python, receipt_owed comes from MISSING_MATCHING_CONSUMER_RECEIPT, which I believe only an accepted receipt clears, so the same thing probably happens there. If you adopt it, rejected_by_buyer is the TS spelling.

TS also bounds the policy's sync_reporting_receipts write with the reconcile read timeout.

🤖 Generated with Claude Code

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