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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/accounts-ensure-and-change.md
Original file line number Diff line number Diff line change
@@ -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`.
60 changes: 60 additions & 0 deletions docs/reporting-subscriptions-buyer.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions docs/reporting-subscriptions-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions docs/reporting-subscriptions-errors.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/reporting-subscriptions-migration.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading