Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,15 @@ platform = reporting.install(MySeller()) # serves get_principal/sync_principal,
```python
await client.reporting.subscribe(analytics_daily, register_destination=archive)
await client.reporting.unsubscribe("pacing-hourly")
report = await client.reporting.reconcile() # every owed period: delivered, late, missing, withheld
traces = await client.reporting.explain(account_id="acc_a", period="2026-11-03") # why, rule by rule
outcome = await client.accounts.ensure(key, billing="operator") # AccountReady | AccountPending | ...
```

- **Storage.** One object; process-local storage is refused in production. Apply schema changes with `python -m adcp migrate`.
- **Grants.** The SDK is the only source of `authorized_at`. End a relationship with `wind_down_grant()` instead of revoking.
- **Coming in later betas:** buyer `reconcile`, `explain` and events, the test kit, and explain traces.
- **Explain.** Seller `reporting.explain_coverage(...)` and buyer `client.reporting.explain(...)` return the same `CoverageTrace`; `python -m adcp reporting diff` compares them.
- **Coming in later betas:** buyer events and the test kit.

Guides: [seller](docs/reporting-subscriptions-seller.md) · [buyer](docs/reporting-subscriptions-buyer.md) · [coverage](docs/reporting-subscriptions-coverage.md) · [accounts](docs/accounts-ensure-and-change.md) · [migrating from account-level configs](docs/reporting-subscriptions-migration.md) · [testing](docs/reporting-subscriptions-testing.md) · [errors](docs/reporting-subscriptions-errors.md)

Expand Down
42 changes: 41 additions & 1 deletion docs/reporting-subscriptions-buyer.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,47 @@ An `all_authorized_accounts` subscription follows every grant, including account

## Reconcile

`client.reporting.reconcile()` and `explain()` arrive in a later beta. Until then, `owed_subscription_periods(...)` and `ReportingSubscriptionTracker` compute owed periods from your own records (see [Coverage, explained](reporting-subscriptions-coverage.md#the-owed-rules)).
```python
report = await client.reporting.reconcile(since="2026-11-01")
print(report.definitive, report.summary)
for line in report.periods:
if line.responsible_party == "seller":
print(line.account_id, line.period["start"], line.status, line.reason, line.message)
```

`reconcile()` reads everything from the wire on each run. Never compute periods yourself.
- **Roster:** `list_accounts` ∩ your subscriptions' scopes, plus each subscription's `accounts_unauthorized[]`. There is no account list to keep.
- **Grant history:** `authorization.authorized_at` and `regranted_at` from `list_accounts`, and `unauthorized_at` from `accounts_unauthorized[]`. You never record grants.
- **Facts:** buys from `get_media_buys`, offerings from capabilities, and `subscription_coverage[]` and the obligations from a complete `get_reporting_status` periods walk.

Each owed period that has ended becomes a `PeriodLine`, with wire-style keys and `to_dict()`:

| `status` | Meaning | `responsible_party` |
|---|---|---|
| `delivered` | a current revision meets the selector's `required_finality` | `none`, or `buyer` when a billing receipt is owed (`receipt.status: owed_by_buyer`) |
| `pending` | not yet due: within `grace` of the period end, or of `expected_at` | `none` |
| `late` | an obligation exists, but its revision is more than `grace` past `expected_at` | `seller` |
| `missing_obligation` | no obligation `grace` after the period ended | `seller` |
| `withheld` | owed, and your authority is currently lost | `none` |
| `not_owed` | only with `include_not_owed=True`: every other period of each buy's flight, with its reason | `none` |

- `reason` is the spec reason code and `message` its sentence, the same as `explain`.
- `grace` defaults to one hour, never zero, so a fresh period is not flagged as missing.
- `report.definitive` is true only when the roster, every account's obligations walk and every owed set completed. Otherwise `report.issues` say what to retry; a partial roster is never used.
- An account whose authority you lost is reported from the last facts read for it, with its unpaid periods `withheld`.

From a shell: `python -m adcp reporting reconcile --agent <alias> [--since 2026-11-01] [--json]`.

## Explain

```python
traces = await client.reporting.explain(account_id="acc_a", subscription_id="analytics-daily",
media_buy_id="mb_1", period="2026-11-03")
```

`explain()` returns the same `CoverageTrace` the seller's `explain_coverage` builds, with `side="buyer"`. Compare the two with `diff_coverage_traces` or `python -m adcp reporting diff buyer.json seller.json`. See [Coverage, explained](reporting-subscriptions-coverage.md#explaining-a-period). The CLI form is `python -m adcp reporting explain --agent <alias> --account <id> --period <date>`.

`ReportingSubscriptionTracker` is now an internal detail of these two calls. It stays importable for code that already uses it.

## Events

Expand Down
73 changes: 70 additions & 3 deletions src/adcp/reporting/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
a coroutine function), the way ASGI servers name an app. ``--tenant`` sets the
principal's tenant.

Buyer side, against a saved agent alias, a URL or a JSON agent config::

python -m adcp reporting explain --agent my-seller --account acc_a --period 2026-11-03
python -m adcp reporting reconcile --agent my-seller [--since 2026-11-01] [--json]

Comparing a buyer's trace with a seller's::

python -m adcp reporting diff buyer.json seller.json
Expand All @@ -29,7 +34,7 @@
from collections.abc import Sequence
from typing import Any

USAGE = "python -m adcp reporting {explain,diff} ..."
USAGE = "python -m adcp reporting {explain,reconcile,diff} ..."


def _load_app(target: str) -> Any:
Expand Down Expand Up @@ -73,9 +78,34 @@ def _print_traces(traces: Sequence[Any], *, as_json: bool) -> None:
print(f" {trace.explanation}")


def _agent_client(identifier: str) -> Any:
from adcp.__main__ import resolve_agent_config
from adcp.client import ADCPClient
from adcp.types.core import AgentConfig, Protocol

config = dict(resolve_agent_config(identifier))
config.setdefault("id", config.get("agent_uri", "unknown"))
protocol = config.get("protocol", "mcp")
config["protocol"] = Protocol(protocol.lower()) if isinstance(protocol, str) else protocol
return ADCPClient(AgentConfig(**config))


async def _explain(args: argparse.Namespace) -> int:
from adcp._principal_ref import PrincipalRef

if args.agent:
async with _agent_client(args.agent) as client:
traces = await client.reporting.explain(
account_id=args.account,
period=args.period,
subscription_id=args.subscription,
media_buy_id=args.media_buy,
offering_id=args.offering,
)
_print_traces(traces, as_json=args.json)
return 0
if not args.principal:
raise SystemExit("--app needs --principal")
service = await _resolve_app(args.app)
traces = await service.explain_coverage(
PrincipalRef(args.tenant, args.principal),
Expand All @@ -89,6 +119,32 @@ async def _explain(args: argparse.Namespace) -> int:
return 0


async def _reconcile(args: argparse.Namespace) -> int:
async with _agent_client(args.agent) as client:
report = await client.reporting.reconcile(
since=args.since, include_not_owed=args.include_not_owed
)
if args.json:
print(json.dumps(report.to_dict(), indent=2))
return 0
summary = report.summary
print(
f"{'definitive' if report.definitive else 'NOT definitive'} as of {report.as_of}: "
f"{summary.owed} owed, {summary.delivered} delivered, {summary.pending} pending, "
f"{summary.late} late, {summary.missing_obligation} missing, "
f"{summary.withheld} withheld"
)
for issue in report.issues:
print(f" ! {issue.code}: {issue.message}")
for line in report.periods:
print(
f"{line.account_id} {line.subscription_id} v{line.subscription_version} "
f"{line.period['start']}..{line.period['end']} {line.status} [{line.reason}] "
f"{line.responsible_party}"
)
return 0


def _read_traces(path: str) -> list[Any]:
from adcp.reporting.subscriptions.explain import CoverageTrace

Expand Down Expand Up @@ -140,8 +196,10 @@ def main(argv: Sequence[str]) -> int:
explain = commands.add_parser(
"explain", help="Explain why periods are or are not owed (seller, from your service)"
)
explain.add_argument("--app", required=True, help="module:attribute of your service")
explain.add_argument("--principal", required=True, help="principal id")
source = explain.add_mutually_exclusive_group(required=True)
source.add_argument("--app", help="seller: module:attribute of your service")
source.add_argument("--agent", help="buyer: saved alias, URL or JSON agent config")
explain.add_argument("--principal", default=None, help="seller: principal id")
explain.add_argument("--tenant", default=None, help="tenant id, when multi-tenant")
explain.add_argument("--account", required=True, help="account_id")
explain.add_argument(
Expand All @@ -153,12 +211,21 @@ def main(argv: Sequence[str]) -> int:
explain.add_argument("--media-buy", default=None, help="one media_buy_id")
explain.add_argument("--offering", default=None, help="one offering_id")
explain.add_argument("--json", action="store_true", help="print the CoverageTrace JSON")
reconcile = commands.add_parser(
"reconcile", help="List what a seller owes your subscriptions, and each status (buyer)"
)
reconcile.add_argument("--agent", required=True, help="saved alias, URL or JSON config")
reconcile.add_argument("--since", default=None, help="skip periods starting earlier")
reconcile.add_argument("--include-not-owed", action="store_true")
reconcile.add_argument("--json", action="store_true")
diff = commands.add_parser("diff", help="Compare two CoverageTrace JSON files")
diff.add_argument("left")
diff.add_argument("right")
args = parser.parse_args(list(argv))
if args.command == "diff":
return _diff(args)
if args.command == "reconcile":
return asyncio.run(_reconcile(args))
return asyncio.run(_explain(args))


Expand Down
14 changes: 14 additions & 0 deletions src/adcp/reporting/subscriptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
from adcp.reporting.subscriptions.explain import (
EXPLAIN_TEMPLATES,
TRACE_VERSION,
CoverageExplainer,
CoverageReason,
CoverageTrace,
CoverageTraceDifference,
Expand All @@ -104,6 +105,13 @@
SubscriptionPlanResult,
subscription_obligation_id,
)
from adcp.reporting.subscriptions.reconcile import (
BuyerReportingState,
PeriodLine,
ReconcileIssue,
ReconcileReport,
ReconcileSummary,
)
from adcp.reporting.subscriptions.registry import (
ReportingSubscriptionValidationError,
apply_reporting_subscriptions,
Expand Down Expand Up @@ -132,6 +140,12 @@
from adcp.server.reporting_grants import REPORTING_WIND_DOWN_TASKS, wind_down_grant

__all__ = [
"BuyerReportingState",
"CoverageExplainer",
"PeriodLine",
"ReconcileIssue",
"ReconcileReport",
"ReconcileSummary",
"EXPLAIN_TEMPLATES",
"TRACE_VERSION",
"CoverageReason",
Expand Down
7 changes: 6 additions & 1 deletion src/adcp/reporting/subscriptions/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,12 @@ class _AccountRecord:


class ReportingSubscriptionTracker:
"""A buyer's own records for its subscriptions with one seller.
"""A buyer's own records for its subscriptions with one seller (advanced).

Most buyers call ``client.reporting.reconcile()`` and ``explain()``
instead: they derive the roster and grant history from the wire on each
run, so there is nothing to record. This tracker stays for code that
keeps its own records.

Register through :meth:`sync` so every version's configuration is kept.
Record what :func:`list_accounts` shows with :meth:`observe_account`.
Expand Down
Loading
Loading