From e74becd7716d5b31224899c774754fbe268451f1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 20:15:08 +0000 Subject: [PATCH] docs(reporting): guides, README section and agent notes for reporting subscriptions Docs slice P9 of the reporting-subscriptions DX plan: - README: a "Reporting subscriptions (AdCP 3.3, experimental)" section with the seller block, the buyer calls, storage, grants and links to the guides. - Guide skeletons with the plan's titles and anchors, so cross-links with the TypeScript guides line up: - serving reporting subscriptions (seller); - subscribing to reports (buyer); - coverage, explained; - accounts: ensure and change; - migrating from account-level reporting_delivery_configs; - testing with the reporting test kit; - error reference. Sections whose features arrive in later betas say so. - ADCPClient.sync_principal's docstring starts: "Replaces each submitted section. To add a reporting subscription, use client.reporting.subscribe." - llms.txt lists the buyer calls, the seller block and the anti-patterns in do and don't form. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 33 ++++++++ docs/accounts-ensure-and-change.md | 35 +++++++++ docs/reporting-subscriptions-buyer.md | 60 +++++++++++++++ docs/reporting-subscriptions-coverage.md | 58 ++++++++++++++ docs/reporting-subscriptions-errors.md | 23 ++++++ docs/reporting-subscriptions-migration.md | 13 ++++ docs/reporting-subscriptions-seller.md | 92 +++++++++++++++++++++++ docs/reporting-subscriptions-testing.md | 13 ++++ llms.txt | 20 +++++ src/adcp/client.py | 4 +- 10 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 docs/accounts-ensure-and-change.md create mode 100644 docs/reporting-subscriptions-buyer.md create mode 100644 docs/reporting-subscriptions-coverage.md create mode 100644 docs/reporting-subscriptions-errors.md create mode 100644 docs/reporting-subscriptions-migration.md create mode 100644 docs/reporting-subscriptions-seller.md create mode 100644 docs/reporting-subscriptions-testing.md diff --git a/README.md b/README.md index 608c98cb9..e15a48e57 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This README serves both sides of an AdCP integration. Jump to what you're doing: - [Simple vs. Standard API](#simple-vs-standard-api) - [Available Test Helpers](#available-test-helpers) - [Quick Start: Distributed Operations](#quick-start-distributed-operations) +- [Reporting subscriptions (AdCP 3.3, experimental)](#reporting-subscriptions-adcp-33-experimental) - [AdCP version support](#adcp-version-support) - [Documentation](#documentation) - [Features](#features) @@ -342,6 +343,37 @@ async with ADCPMultiAgentClient( # Connections automatically cleaned up here ``` +## Reporting subscriptions (AdCP 3.3, experimental) + +A buyer registers reporting once on its principal, and the subscription follows its grants across every account. **Sellers** add one block to the Reliable Reporting service they already run: + +```python +from adcp.reporting import ReliableReportingService, SubscriptionOptions +from adcp.storage.postgres import postgres_storage + +reporting = ReliableReportingService( + storage=postgres_storage(dsn=os.environ["DATABASE_URL"]), # memory_storage() in development + account_context=resolve_account_context, + subscriptions=SubscriptionOptions(max_accounts=500), +) +reporting.sources.register("gam", gam_source, capability_offerings=[daily_offering]) +platform = reporting.install(MySeller()) # serves get_principal/sync_principal, reconciles on writes +``` + +**Buyers** use patch-shaped writes that never deactivate by omission: + +```python +await client.reporting.subscribe(analytics_daily, register_destination=archive) +await client.reporting.unsubscribe("pacing-hourly") +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. + +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) + ## AdCP version support SDK 8 is built against **AdCP 3.2.1**, the AdCP 3.2 general-availability @@ -385,6 +417,7 @@ the SDK's supported versions in the error details. - **[Production seller path](docs/production-seller.md)** - Choose the server abstraction and wire durable multi-tenant tasks and webhook delivery - **[Validation contract](docs/validation-contract.md)** - Canonical wire validation versus structural Pydantic models - **[Account lifecycle](docs/account-lifecycle.md)** - Provisioning registry, account policy, seller resolution, and shared public feed overlays +- **[Serving reporting subscriptions](docs/reporting-subscriptions-seller.md)** and **[subscribing to reports](docs/reporting-subscriptions-buyer.md)** - AdCP 3.3 principal-level reporting subscriptions (experimental) - **[Reporting source adapters](docs/reporting-source-adapters.md)** - Per-metric availability evidence, coverage, and control totals for inline delivery fetches - **[Migrating from SDK 6 to 7](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes - **[Migrating from SDK 7 to 8](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v7_to_v8.md)** - Secure webhook defaults and telemetry changes diff --git a/docs/accounts-ensure-and-change.md b/docs/accounts-ensure-and-change.md new file mode 100644 index 000000000..580466434 --- /dev/null +++ b/docs/accounts-ensure-and-change.md @@ -0,0 +1,35 @@ +# Accounts: ensure and change + +## Outcomes + +`await client.accounts.ensure(key, ...)` returns an `AccountOutcome`. Pending, ambiguous and rejected are values, not errors: + +| Outcome | Meaning | `next_step(outcome)` | +|---|---|---| +| `AccountReady(account_id, status, via)` | use `account_id` | `none` | +| `AccountPending(action, request_id)` | waiting on the seller, or on a buyer-side human | `wait_for_seller` or `send_human_to_url` | +| `AccountAmbiguous(candidates, request_id)` | several of your accounts match; `await outcome.choose(account_id)` | `pick_candidate` | +| `AccountRejected(errors)` | declined, or a buyer action expired | `contact_seller` | + +`ensure` calls `request_account` when the seller advertises `account.requests`, and `sync_accounts` otherwise. It is idempotent: a repeat call never opens a duplicate request. Only a failure to process the call raises `AccountRequestError`. See [Account lifecycle](account-lifecycle.md) for storage and policies. + +## Pending actions for humans and agents + +`PendingAction(waiting_on, kind, message, url, expires_at)`: +- `url` is for a person to open. It is not a bearer link; never fetch it. +- `message` is untrusted. Render it with `format_pending_action(action)`. +- Agents switch on `next_step(outcome)`, never on `kind`, which is extensible. + +Call `await client.accounts.refresh()` from a cron job or an `account_request.changed` webhook to learn what moved. `await client.accounts.pending()` lists what is still waiting. + +## Separate accounts + +`client.accounts.change(...)` with `allow_separate_account`, and `client.accounts.use(...)`, arrive in a later beta. A seller opening a separate account never re-points the registry on its own. + +## Money changes and revision + +A change to `billing`, `billing_entity` or `payment_terms` needs the explicit account `revision` it was approved against. Send it with `request_account` until `change()` lands. + +## Cold start + +`list_accounts` with `include_requests` reads back your open requests and their pending actions. `refresh()` uses `account_request_ids`. diff --git a/docs/reporting-subscriptions-buyer.md b/docs/reporting-subscriptions-buyer.md new file mode 100644 index 000000000..fa0ee3e72 --- /dev/null +++ b/docs/reporting-subscriptions-buyer.md @@ -0,0 +1,60 @@ +# Subscribing to reports (buyer) + +`client.reporting` registers AdCP 3.3 reporting subscriptions (experimental) safely. Every write is patch-shaped over the replace-shaped wire: other subscriptions are kept byte for byte, versions are chosen for you, and nothing is deactivated by omission. + +## Subscribe + +```python +result = await client.reporting.subscribe( + { + "subscription_id": "analytics-daily", + "scope": {"all_authorized_accounts": True}, + "operator_domains": ["wppmedia.fr"], + "selector": {"feed_purpose": "analytics", "period_duration": "P1D", + "required_finality": "official", "dimensions": ["package"]}, + "reconciliation_mode": "delivery_only", + "destination": {"destination_id": "reporting-archive", + "path_template": "analytics/{account_id}/{period_start}"}, + }, + register_destination=archive_destination, +) +result.action, result.subscription_version +``` + +- **Versioning.** The version is bumped only when the selector, scope, destination, `reconciliation_mode`, `exclude_account_ids` or `operator_domains` change. +- **Pre-dispatch checks.** These fail with `dispatched=False`: + - the scope must be explicit; + - the destination must exist; + - a multi-account path needs `{account_id}`; + - the id must not clash with a `delivery_config_id`; + - the operator scope must be safe; + - the selector must match an offering. +- **Conflicts.** A conflicting write is re-read and retried. + +## Unsubscribe + +`await client.reporting.unsubscribe("analytics-daily")` is the only way to deactivate. Subscribing again reactivates the subscription at the same version. + +## Why replace needs a flag + +`replace_subscriptions(list, deactivate_omitted=True)` is the expert complete-set write. Sending `[]` needs `deactivate_all=True`. A raw `client.sync_principal(...)` carrying `reporting_subscriptions` or `reporting_destinations` is checked before dispatch. It is refused with `SUBSCRIPTION_WOULD_DEACTIVATE` or `DESTINATION_WOULD_REVOKE` unless you pass `allow_deactivation=True` or `allow_revocation=True`. + +## Operator domains for agencies + +An `all_authorized_accounts` subscription follows every grant, including accounts another operator grants later. When the principal spans operators, `subscribe` refuses an all-accounts scope without `operator_domains` (`SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED`). Pass `acknowledge_cross_operator=True` to proceed anyway. + +## Destinations + +`register_destination=` upserts the named destination in the same write. Omitting a destination from a raw `sync_principal` revokes it for every account. + +## Reconcile + +`client.reporting.reconcile()` and `explain()` arrive in a later beta. Until then, `expected_subscription_periods(...)` and `ReportingSubscriptionTracker` compute owed periods from your own records (see [Coverage, explained](reporting-subscriptions-coverage.md#the-owed-rules)). + +## Events + +`client.reporting.poll()`, `events()` and `handle_webhook()` arrive in a later beta. Until then, pass each `principal.changed` payload to `tracker.handle_notification(...)`. + +## Receipts for billing feeds + +The receipts policy arrives in a later beta. Submit receipts with `sync_reporting_receipts` after your warehouse commits the data. diff --git a/docs/reporting-subscriptions-coverage.md b/docs/reporting-subscriptions-coverage.md new file mode 100644 index 000000000..7980023d0 --- /dev/null +++ b/docs/reporting-subscriptions-coverage.md @@ -0,0 +1,58 @@ +# Coverage, explained + +Both sides compute which periods a subscription owes with one function, `expected_subscription_periods`, over the same facts. Never compute periods yourself. + +## The owed rules + +A period of a resolved offering is owed for an account when all of these hold: +- the account is in the subscription's scope; +- the caller held a grant listing `get_reporting_status` for it; +- the period starts at or after the version's coverage start; +- the subscription was active; +- a confirmed buy's flight overlaps the period; +- no exception or gap removes it; +- the period has ended. + +The rules are applied in a fixed order: `scope`, `authorization`, `version`, `activity`, `offering`, `flight`, `confirmation`, `exception`, `gap`, `cutover`, `timezone`. + +## Reason codes + +The frozen vocabulary (`COVERAGE_REASON_CODES`, from the spec's `reason-codes.json`): + +| Reason | Rule | +|---|---| +| `not_in_scope`, `excluded:excluded_by_buyer`, `buy_not_visible` (seller only) | scope | +| `not_authorized` | authorization | +| `before_coverage_start` | version | +| `subscription_inactive` | activity | +| `excluded:offering_unsupported` | offering | +| `outside_flight`, `after_stop` | flight | +| `before_confirmation` | confirmation | +| `excluded:capacity`, `excluded:overridden`, `excluded:action_required` | exception | +| `gap:unauthorized` | gap | +| `cutover_stretch`, `owed_under_other_version` | cutover | +| `period_not_ended`, `owed`, `owed:regrant_backfill`, `owed:withheld` | outcome | + +## Worked traces + +The `CoverageTrace` format and its template sentences arrive with `explain` in a later beta. The cases below are among the spec's canonical vectors, which this SDK runs in `tests/test_reporting_subscription_parity.py`. + +### Confirmation day + +A period that ends at or before the buy's `confirmed_at` is not owed (`before_confirmation`). The period in which the buy is confirmed ends after it, so it is owed in full. + +### Cancellation mid-period + +A buy stops at the earliest of `end_time`, `canceled_at` and `completed_at`. A period that starts at or after the stop is not owed (`after_stop`). The period containing the stop is owed in full. + +### Re-grant gap + +A re-grant inside `status_retention_days` continues the chain, so `authorized_at` is kept. Unless the seller advertises `backfill_on_regrant`, the periods from the last boundary at or before `unauthorized_at` to the first boundary at or after the re-grant are a gap (`gap:unauthorized`). + +### Timezone cutover + +A timezone change takes effect at its `effective_at`, and each zone owes only its own span. A previous-zone period starting at or after `effective_at`, or a new-zone period starting before it, is not owed (`cutover_stretch`). + +### DST + +`P1D` periods follow the local calendar, so DST days are 23 or 25 hours long. `PT24H` periods are always 24 hours. diff --git a/docs/reporting-subscriptions-errors.md b/docs/reporting-subscriptions-errors.md new file mode 100644 index 000000000..8bd7716ed --- /dev/null +++ b/docs/reporting-subscriptions-errors.md @@ -0,0 +1,23 @@ +# Error reference + +Every SDK error carries `code` and `recovery` (`transient`, `correctable` or `terminal`). Its message ends with the fix. + +**`dispatched`.** `dispatched=False` means no bytes of the task left the process, so the operation certainly had no effect on the seller. Check it with `is_pre_dispatch_error(error)`. The property is absent (`None`) when the call may have reached the seller, and on local computations and configuration checks. + +| Code | Class | `dispatched` | `recovery` | Fix | +|---|---|---|---|---| +| `SUBSCRIPTION_WOULD_DEACTIVATE` | `ReportingSubscriptionError` | `False` | correctable | use `unsubscribe()`, or pass `allow_deactivation=True` | +| `DESTINATION_WOULD_REVOKE` | `ReportingSubscriptionError` | `False` | correctable | pass `allow_revocation=True` | +| `SUBSCRIPTION_ID_CLASH` | `ReportingSubscriptionError` | `False` | correctable | rename; the message names the clashing `delivery_config_id` | +| `SUBSCRIPTION_OPERATOR_SCOPE_REQUIRED` | `ReportingSubscriptionError` | `False` | correctable | add `operator_domains`, or pass `acknowledge_cross_operator=True` | +| `SUBSCRIPTION_SELECTOR_UNMATCHED` | `ReportingSubscriptionError` | `False` | correctable | adjust the selector; the message names the nearest offerings | +| `SUBSCRIPTION_CONFLICT_EXHAUSTED` | `ReportingSubscriptionError` | absent | transient | retry later | +| `SUBSCRIPTION_READBACK_MISMATCH` | `ReportingSubscriptionError` | absent | terminal | contact the seller; `readback` is attached | +| `COVERAGE_INPUT_INCOMPLETE`, `COVERAGE_INPUT_INVALID` | `CoverageInputError` | n/a | correctable | supply the named input | +| `COVERAGE_BUDGET_EXCEEDED` | `CoverageInputError` | n/a | correctable | narrow the input | +| `REPORTING_STORAGE_NOT_DURABLE` | `ReliableReportingConfigurationError` | n/a | correctable | `storage=postgres_storage(...)`, or `ADCP_ENV=development` | +| `REPORTING_SCHEMA_BEHIND` | `ReliableReportingConfigurationError` | n/a | correctable | run `python -m adcp migrate` | +| `SUBSCRIPTIONS_REQUIRE_STANDARD_DIMENSIONS` | `ReliableReportingConfigurationError` | n/a | correctable | publish `standard_dimensions` on the named offering | +| `SUBSCRIPTION_ROSTER_UNAVAILABLE` | `ReliableReportingConfigurationError` | n/a | correctable | serve `list_accounts`, or supply `SubscriptionOptions.accounts` | + +`AccountRequestError` is raised only when `client.accounts.ensure` could not be processed; it carries the wire codes. Account outcomes are values. diff --git a/docs/reporting-subscriptions-migration.md b/docs/reporting-subscriptions-migration.md new file mode 100644 index 000000000..b4cacf5a1 --- /dev/null +++ b/docs/reporting-subscriptions-migration.md @@ -0,0 +1,13 @@ +# Migrating from account-level `reporting_delivery_configs` + +## Precedence + +A caller's own active `sync_accounts` `reporting_delivery_configs` entry for a `feed_purpose` takes precedence over its subscriptions on that account. The subscription records an `overridden` exception, and the two never owe the same period. On the managed path, supply `SubscriptionOptions.account_delivery_configs` so the engine sees those configurations. + +## Overrides + +Give each configuration its `active_from`, so the exception opens at that instant. A `subscription_id` never equals a `delivery_config_id` the caller uses: they share one namespace. `reporting.principals.reporting_subscription_ids(...)` lets `sync_accounts` refuse a clash. + +## `replace_account_configs` + +A subscription with `replace_account_configs: true` ends the caller's own account-level configurations with the same `feed_purpose` on in-scope accounts where it resolves an offering. It applies once per `subscription_version`, or when the flag changes to true. Supply `SubscriptionOptions.end_account_configs(principal, account_id, ids, *, at)` together with `account_delivery_configs`; without them the flag is refused with `UNSUPPORTED_FEATURE`. diff --git a/docs/reporting-subscriptions-seller.md b/docs/reporting-subscriptions-seller.md new file mode 100644 index 000000000..fecdad747 --- /dev/null +++ b/docs/reporting-subscriptions-seller.md @@ -0,0 +1,92 @@ +# Serving reporting subscriptions (seller) + +AdCP 3.3 reporting subscriptions (`media_buy.reporting_subscriptions`, experimental) let a buyer register reporting once on its principal through `sync_principal`, instead of once per account through `sync_accounts`. A subscription follows the buyer's grants: when you grant an account, it is covered; when you revoke it, coverage stops. + +This guide covers the managed path, where the SDK derives everything from surfaces you already serve. The [reliable reporting service guide](reliable-reporting-service.md#reporting-subscriptions-adcp-33-experimental) documents the building blocks underneath. + +```python +from adcp.reporting import ReliableReportingService, SubscriptionOptions +from adcp.storage.postgres import postgres_storage + +reporting = ReliableReportingService( + storage=postgres_storage(dsn=os.environ["DATABASE_URL"]), + account_context=resolve_account_context, + subscriptions=SubscriptionOptions(max_accounts=500), +) +reporting.sources.register("gam", gam_source, capability_offerings=[daily_offering]) +platform = reporting.install(MySeller()) +``` + +## Prerequisites + +The SDK reads these surfaces under each principal's identity. A principal sees exactly what it could see through `get_media_buys`. + +| Input | Read from | Must be present | Override | +|---|---|---|---| +| Authorized accounts | `list_accounts` (a decisioning platform's `accounts.list`) | `account_id`, `timezone` (IANA), `operator`, `authorization.allowed_tasks` | `SubscriptionOptions.accounts` | +| Visible buys | `get_media_buys` | `confirmed_at`, `start_time`, `end_time`, `cancellation.canceled_at`, `completed_at`, `packages[].package_id` | `SubscriptionOptions.buys` | +| Package offerings | every registered offering | | `SubscriptionOptions.buy_offerings` | +| Offerings | `capability_offerings` on each registered source | `standard_dimensions` on every offering | none | +| Report content | `account_context`, called with `configuration.subscription` set | | `SubscriptionOptions.configure` | + +A decisioning `Account` carries `timezone=` and `operator=`. An account without a timezone is refused rather than read as UTC. + +## What the SDK derives + +- **The principal runtime.** `install()` serves `get_principal` and `sync_principal` and advertises the principal section. The service's `PrincipalService` is `reporting.principals`. +- **Capabilities.** `configuration_tasks`, the `subscriptions` block, `standard_dimensions` on every offering, and the feature id, at the release each caller is served. +- **Scheduling.** + - `sync_principal`, `sync_accounts`, `create_media_buy` and `update_media_buy` queue a reconcile that runs after the response. + - Each worker turn reconciles every principal a period boundary has passed for, then plans obligations. + - Call `await reporting.touch(account_id=...)` after a change the SDK did not serve. +- **Grants.** The grant journal (see [Grants and wind-down](#grants-and-wind-down)). + +## Overrides + +Each `SubscriptionOptions` field replaces one derivation: +- `accounts(principal, *, cursor)` and `buys(principal, account_id)` replace the roster and the buys; +- `buy_offerings(media_buy)` and `configure(generation)` replace package offerings and report content; +- `account_delivery_configs(principal, account_id)` and `end_account_configs(...)` enable precedence and `replace_account_configs`; +- `is_period_delivered(principal, account_id, period, *, by)` gives exact withheld counts; +- `resolve_principal(request, context)` replaces principal resolution. + +If your account access depends on a live upstream token, supply `accounts`. A listing that fails under the service context raises `SUBSCRIPTION_ROSTER_UNAVAILABLE`, and is never read as an empty roster. + +## Bring your own producer and ledger + +Not yet on the managed path in Python. Use the advanced composition: build `ReportingSubscriptionEngine` yourself and pass `reporting_subscriptions=ReportingSubscriptionServiceOptions(...)`. The bring-your-own-producer option (`producer`) arrives in a later beta. + +## Grants and wind-down + +The SDK is the only source of `authorization.authorized_at`. +- **Chain rule.** A re-grant before `unauthorized_at + status_retention_days` continues the chain, so `authorized_at` does not move. A later re-grant starts a new chain. +- **Projection.** Served `list_accounts` and `sync_accounts` carry the journal's value, which is the value coverage uses. A host value that moves backwards is rejected (`authorized_at_regressed`). +- **Push.** `await reporting.grants.changed(principal, account_id, at=..., authorized=..., allowed_tasks=...)` records a change the moment your entitlement service knows it. + +`authorized_at` is when the *current authorization chain* began, not when access was last granted. Never set it to "now" on a re-grant, and never move it earlier. + +**Wind down, don't revoke.** Revocation means no access at all, receipts and statuses included. To end a relationship while the buyer finishes reconciling: + +```python +from adcp.server import wind_down_grant + +account.authorization = wind_down_grant(account.authorization) +``` + +## Storage and migrations + +Pass one storage object: `memory_storage()` in development, `postgres_storage(dsn=... | pool=...)` in production. Process-local storage is refused outside development (`REPORTING_STORAGE_NOT_DURABLE`). + +Apply schema changes with `python -m adcp migrate [--status | --dry-run]` before starting a release. Startup refuses a schema that is behind (`REPORTING_SCHEMA_BEHIND`). `0002 reporting_subscription_generations` is a stopped-worker upgrade: stop every reporting worker first. + +## Workers and scaling + +Run `reporting.run_worker()` on a schedule, or `start()` with `worker_interval`. Workers share the stores; engine state is written by compare-and-swap, so several workers agree. Each platform read is bounded by `SubscriptionOptions.read_timeout`. + +## Telemetry + +Today: `planning_error_handler`, `turn.subscription_errors`, the engine's `evaluation_errors` and `authorized_at_regressions`, and structured log events. OpenTelemetry metrics and `reporting.health()` arrive in a later beta. + +## Troubleshooting with explain + +`reporting.explain_coverage(...)` and the shared `CoverageTrace` arrive in a later beta. Until then, `engine.owed_periods(principal, subscription_id, account_id)` lists the owed periods. The coverage reason codes are in [Coverage, explained](reporting-subscriptions-coverage.md#reason-codes). diff --git a/docs/reporting-subscriptions-testing.md b/docs/reporting-subscriptions-testing.md new file mode 100644 index 000000000..4a3d87a13 --- /dev/null +++ b/docs/reporting-subscriptions-testing.md @@ -0,0 +1,13 @@ +# Testing with the reporting test kit + +## Testing today + +`ReliableReportingService(storage=memory_storage(), subscriptions=...)` runs in process under pytest. Drive it with a `DeterministicReportingClock` and `run_worker()`, and call `await reporting.drain_subscription_triggers()` before asserting on triggered reconciles. `tests/test_reporting_subscription_managed.py` is a worked example. + +## The test kit + +`reporting_test_kit(...)` and the `adcp_reporting_kit` pytest fixture arrive in a later beta. They add a virtual clock, grants, accounts, buys, and `assert_agreement`. Python sellers run the TypeScript storyboard CLI against their URL: + +``` +npx adcp storyboard run https://seller.example/mcp --storyboards reporting_subscriptions --auth "$TOKEN" +``` diff --git a/llms.txt b/llms.txt index 30b973c3a..885f98f25 100644 --- a/llms.txt +++ b/llms.txt @@ -72,6 +72,26 @@ surface cannot bind. NEVER import `adcp.types._generated`, and never pin a numbered class name (`Assets162`) — those renumber on every schema regen. `import adcp` is lightweight; the pydantic type graph builds on first type use. +## Reporting subscriptions (AdCP 3.3, experimental) +Buyer calls: `client.reporting.subscribe(sub, register_destination=...)`, +`client.reporting.unsubscribe(id)`, `client.reporting.list_subscriptions()`, +`client.reporting.preview(sub)`, `client.accounts.ensure(key, ...)` (returns +`AccountReady | AccountPending | AccountAmbiguous | AccountRejected`). +Seller block: `ReliableReportingService(storage=..., subscriptions=SubscriptionOptions(...))` +then `reporting.install(platform)`. Guides: docs/reporting-subscriptions-seller.md, +docs/reporting-subscriptions-buyer.md. + +- DO add or change one subscription with `client.reporting.subscribe`. DON'T send + raw `sync_principal` with one subscription: each section replaces the whole set. +- DON'T pick `subscription_version` yourself: `subscribe` chooses it. +- DO send an explicit scope: `{"all_authorized_accounts": True}` or `{"account_ids": [...]}`. +- DON'T compute reporting periods with local time; use + `expected_subscription_periods` (and, later, `client.reporting.reconcile`). +- DO switch on `next_step(outcome)` for accounts. DON'T fetch + `pending_action.url`: show it to a person. +- Sellers: DON'T call `engine.reconcile` from request handlers; the service + schedules it. DO end relationships with `wind_down_grant()`, not revocation. + ## Validation ```bash pytest tests/ -v # Tests diff --git a/src/adcp/client.py b/src/adcp/client.py index 0eb77261a..43ad87394 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -4453,8 +4453,10 @@ async def sync_principal( allow_revocation: bool = False, options: TaskOptions | None = None, ) -> TaskResult[SyncPrincipalResponse]: - """Declaratively replace selected authenticated-principal configuration. + """Replaces each submitted section. To add a reporting subscription, use + ``client.reporting.subscribe``. + Declaratively replaces selected authenticated-principal configuration. Each section is complete desired state: a subscription missing from ``reporting_subscriptions`` is deactivated, and a destination missing from ``reporting_destinations`` is revoked for every account. To add