Skip to content
29 changes: 29 additions & 0 deletions docs/reporting-subscriptions-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,32 @@ Every SDK error carries `code` and `recovery` (`transient`, `correctable` or `te
| `ACCOUNT_REQUESTS_UNSUPPORTED` | the seller does not advertise `account.requests` | change the account through `sync_accounts` |

A seller rejection is a value: `AccountRejected`. Its `requires_new_account` is `True` for `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`; resend with `allow_separate_account=True`. A `CONFLICT` on a money change is raised as `AccountRequestError`, because the human must approve the change against the new revision.

## Reconcile issues

`client.reporting.reconcile()` and `poll()` never return a smaller success. When a read fails, `report.definitive` is `False` and `report.issues` carries one `ReconcileIssue(code, message, account_id, subscription_id)` per problem. The message ends with what to retry. These are values, not exceptions.

| Code | Cause | Fix |
|---|---|---|
| `SUBSCRIPTION_PRINCIPAL_UNAVAILABLE` | `get_principal` failed, so nothing could be computed | retry once the seller answers (`explain()` raises `ReportingSubscriptionError` instead) |
| `SUBSCRIPTION_OFFERINGS_UNAVAILABLE` | capabilities could not be read | retry; no offering can resolve until it succeeds |
| `SUBSCRIPTION_ROSTER_UNAVAILABLE` | `list_accounts` failed or did not finish paging, or `accounts_unauthorized` lists more than its inline page | retry; a partial roster is never used |
| `SUBSCRIPTION_ACCOUNT_UNREADABLE` | `get_media_buys` or `get_reporting_status` failed for one account | retry; the account is reported from the last facts read |
| `SUBSCRIPTION_LEDGER_UNAVAILABLE` | the account's obligations walk did not complete | retry; the periods are listed without delivery status |
| `SUBSCRIPTION_ACCOUNT_NOT_COVERED` | an explicit `accounts=` id is not readable, or no subscription covers it | check `list_accounts` and the subscription scopes |
| `COVERAGE_INPUT_INCOMPLETE`, `COVERAGE_INPUT_INVALID` | an owed set could not be computed, for example a missing account zone | the `detail` names the input |

## Events and receipts

| Code | Class | `dispatched` | `recovery` | Fix |
|---|---|---|---|---|
| `REPORTING_EVENTS_CONFLICT` | `ReportingEventConflictError` | absent | transient | another worker acknowledged this snapshot first; poll again |

When the seller does not record submitted receipts, `reconcile` raises `ReportingReconciliationError` with `code` `RECEIPT_WRITE_FAILED` (it carries no `recovery`); reconcile again.

A revision the receipts policy could not verify is listed in `report.receipts.failed` as `(reporting_obligation_id, code)`. The code is the inspector's, for example `MANIFEST_DIGEST_MISMATCH`, or `INSPECTION_FAILED`. Nothing is receipted for it.

## Coverage traces

`explain()` and `explain_coverage()` raise `CoverageInputError` (`COVERAGE_INPUT_INVALID`, detail `invalid_input`) when `period` is not a date, an instant or `{start, end}`, or when `{start, end}` is not a period of the offering's schedule. They raise `KeyError` for an unknown `subscription_id`. A trace's `reason` is always a code from the spec's `reason-codes.json`; see [Coverage, explained](reporting-subscriptions-coverage.md#reason-codes).

26 changes: 23 additions & 3 deletions docs/reporting-subscriptions-migration.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
# Migrating from account-level `reporting_delivery_configs`

A subscription registers reporting once on the principal, so it follows the caller's grants across every account. Account-level `reporting_delivery_configs`, written through `sync_accounts`, register reporting once per account. The two coexist: during a migration, both can be live on the same account.

```python
# 1. Subscribe, scoped to the accounts already served at account level.
subscription = {
"subscription_id": "analytics-daily", # never equal to a delivery_config_id
"scope": {"account_ids": ["acc_a", "acc_b"]},
"selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official"},
"reconciliation_mode": "delivery_only",
}
await client.reporting.subscribe(subscription)
# 2. Check what the subscription would own: your account-level configs still take precedence.
report = await client.reporting.reconcile(include_not_owed=True) # overridden periods read excluded:overridden
# 3. Hand over: end the account-level configs where the subscription resolves an offering.
Comment thread
bokelley marked this conversation as resolved.
Resolved
await client.reporting.subscribe({**subscription, "replace_account_configs": True})
# 4. Widen the scope once every account is handed over.
await client.reporting.subscribe({**subscription, "scope": {"all_authorized_accounts": True},
"operator_domains": ["agency.example"]})
```

## 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.
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: `reconcile()` and `explain()` read those periods as `excluded:overridden`. 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.
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. `client.reporting.subscribe` refuses a clash before dispatch (`SUBSCRIPTION_ID_CLASH`). On the seller side, `reporting.principals.reporting_subscription_ids(...)` lets `sync_accounts` refuse the reverse 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`.
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; resending the same version does nothing more. Supply `SubscriptionOptions.end_account_configs(principal, account_id, ids, *, at)` together with `account_delivery_configs`; without them the flag is refused with `UNSUPPORTED_FEATURE`. Periods that started before the handover stay owed by the account-level configuration, and the subscription owes the rest: run `kit.assert_agreement` in the [test kit](reporting-subscriptions-testing.md#testing-today) across a handover before you ship it.
Loading