Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This README serves both sides of an AdCP integration. Jump to what you're doing:

- **Connect as a buyer** → [Quick Start: Test Helpers](#quick-start-test-helpers) and [Quick Start: Distributed Operations](#quick-start-distributed-operations). Entry point: `from adcp import ADCPClient, AgentConfig`; start with the `client.simple.*` API.
- **Build a seller / agent** → [Building an AdCP Agent](#building-an-adcp-agent). Entry point: `from adcp.server import ADCPHandler, serve`; use the [production seller path](docs/production-seller.md) when adding tenants, durable tasks, and webhooks.
- **Run Reliable Reporting** → [Account currencies](docs/reporting-currency.md) and [ledger migrations](docs/reporting-ledger-migration.md).
- **Understand the type system & imports** → [Type Safety](#type-safety) (import surface, partial modules, cold-start note).
- **Test against reference agents** → [Quick Start: Test Helpers](#quick-start-test-helpers) and [Test Helpers](#test-helpers). Entry point: `from adcp.testing import test_agent, creative_agent`.

Expand Down
164 changes: 164 additions & 0 deletions docs/reporting-currency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Currency on reporting obligations

One `ReportingProducer` can serve USD and EUR accounts concurrently. Currency
is resolved once, from trusted seller state, when the SDK creates an obligation.
It is stored before source acquisition and reused for retries, process restarts,
snapshot restatements, official publications and subsequent adjustments.

Pass a synchronous or asynchronous `CurrencyResolver` to the producer:

```python
import asyncio

from adcp.reporting.ledger import (
ProducerOfferings,
ReportingConfiguration,
ReportingObligationRecord,
ReportingProducer,
require_single_currency,
)

# Illustrative historical seller records, keyed by account and media buy.
# In production, read their accepted value as of candidate.scope_resolved_at.
accepted_currencies = {
("us-account", "us-buy"): "USD",
("eu-account", "eu-buy"): "EUR",
}

def resolve_currency(
configuration: ReportingConfiguration,
candidate: ReportingObligationRecord,
) -> str:
return require_single_currency(
accepted_currencies[(candidate.account_id, buy_id)]
for buy_id in candidate.media_buy_ids
)

producer = ReportingProducer(
store=ledger,
source=source,
object_reader=staging,
offerings=ProducerOfferings(snapshot_offering_id="ACCOUNT_CURRENCY_SNAPSHOT"),
currency_resolver=resolve_currency,
)
# Accepted us-account/daily@1 and eu-account/daily@1 configurations each
# contain their own media buy. The #1169 account-qualified keys isolate them.
await asyncio.gather(producer.run_worker(), producer.run_worker())
```

`ledger`, `source` and `staging` are the seller's existing reporting components;
the offering must support the accepted report definition and source scope.
The resolver receives SDK-owned configuration/obligation records, including the
account-qualified generation, period-end timestamp and frozen media-buy/package
scope. The candidate's currency is initially `None`. An async resolver can load
the same historical account context that a future `ReliableReportingService`
uses. A resolver must establish **one currency for the entire scope** and raise
on unknown or mixed currencies. `require_single_currency` validates each code
and rejects empty/mixed input without aggregating it.

Never derive this value from a buyer's request `context`, a live mutable default,
or an adapter response. A resolver should perform bounded, read-only historical
lookups. It can run more than once if workers race before commit; both workers
use the store's immutable winning obligation. An existing obligation never
calls the resolver again. Resolver failure occurs before obligation creation
and leaves source work untouched; it must be surfaced by the worker supervisor.

The existing `ProducerOfferings(currency="EUR")` option remains a convenient
single-currency configuration, implemented by `FixedCurrencyResolver`. Its
default remains USD. A custom resolver takes precedence. This convenience is
appropriate only when every scope it serves has that currency. Codes must match
three uppercase ASCII letters exactly: `EUR` is accepted; `eur`, whitespace,
non-ASCII letters, and non-string values fail with `INVALID_CURRENCY`. Validation
checks ISO 4217 **shape**, not membership in a periodically changing registry.

## Pinned monetary semantics

`ReportingDefinitionBinding` can retain immutable monetary unit declarations
projected by trusted seller code from its verified, content-addressed definition:

```python
from dataclasses import replace

binding = replace(
verified_binding,
monetary_metric_units=(("spend", "EUR"),),
monetary_control_total_units=(("spend", "EUR"),),
)
```

These are tuples, copied into immutable pairs on construction and persisted
with the obligation. They are not an alternate definition download or evidence
obtained from the source. Verify the definition bytes against the retained
digest before projecting them. Do not supply a unit that its pinned definition
does not establish. If the definition leaves currency account-scoped, the
trusted resolver establishes it; unitless values inherit the frozen currency.

Conflicting pinned currencies fail at freeze time. Both ledger stores check
flat monetary columns and same-name additive totals using exact decimal values.
The existing `spend` column is treated as monetary even without extra declarations;
declare other monetary columns and totals explicitly. This convenience check
is for flat additive reporting, matching `InlineReportingSource`; it is not a
general evaluator for arbitrary definition expressions or nested row schemas.
Non-additive/custom metrics require an appropriate seller/source validator.
Nonmonetary control-total units are preserved; three capital letters alone do
not make a unit a currency.

A metric that some rows omit has no honest sum, because an omitted cell and a
measured zero are different facts. That is exactly when `InlineReportingSource`
declines to publish a control total for it, and those rows still publish: each
reported value is checked and the column is left unsummed. A control total that
*is* published must reconcile against every row; a column every row carries must
come with one. A period with no rows carries no money column, so it owes no
derived total -- `monetary_control_total_units` is the declaration meaning
"always present", and it applies rows or none. `null` reads as "not reported"
for both money columns and row `currency` labels.

Source slice requests use only the stored currency. A manifest's currency and
explicit monetary total units must match; its definition binding must also
match any retained pin before staging is read or a revision is committed. The
source cannot override the obligation. `InlineFetchResult(currency="EUR", rows=...)`
supplies optional corroboration. Any explicit row `currency` must match too.
When a fetch returns `GetMediaBuyDeliveryResponse`, response, media-buy and
package currency labels are checked before flattening. Mixed/mismatched rows
are rejected before inline aggregation or staging; no conversion to USD occurs.
The adapter copies rows before validation so a shared cache cannot change the
checked money during staging.
Absent unit labels inherit the obligation/definition, preserving existing
unitless low-level row and total formats. Inline `spend` totals explicitly carry
the frozen currency in their source manifests.

`ReportingCurrencyError.code` distinguishes `CURRENCY_UNRESOLVED`,
`CURRENCY_MISMATCH`, `MIXED_CURRENCY_SCOPE`, `INVALID_CURRENCY`, and
`MONETARY_TOTAL_MISMATCH`. An inline adapter returns currency failures as terminal
`INTEGRITY_FAILED` with that reason in `safe_message`.

## Low-level use and retained history

Low-level writers can still construct records and call `commit_obligation`,
`commit_revision` and `commit_adjustment` directly. Set `currency="EUR"` on each
new obligation, using trusted historical evidence. `currency=None` remains
representable for loading old records, but new writes without currency fail.
Public producer acquisition/manifest commit calls reload the stored obligation;
passing a modified copy cannot substitute its currency. Exact legacy record
replays remain idempotent. Adjustments inherit units through their official
revision's obligation and cannot request a different currency.

No new currency field is invented on the AdCP status wire schema. Status and
exact content reads retain the original definition binding, rows and digests;
audits can inspect the obligation's currency through the store. The consumer's
existing `currency_mismatch` classification remains available for contradictory
observed metric/control-total units.

Upgrading from beta.15 or #1169 preserves unknown legacy currencies as `NULL`,
blocks new acquisition/publication/adjustment for them, and projects
`HISTORY_UNAVAILABLE` / `action_required` while keeping their history readable.
See [the migration policy and deployment instructions](reporting-ledger-migration.md).

The shared memory/Postgres scenarios in
[`test_reporting_currency.py`](../tests/conformance/reporting/test_reporting_currency.py)
publish USD and EUR through one producer, change its resolver/default between
attempts, and reconcile the frozen results with the buyer-side SDK. The
[strict adopter fixture](../tests/type_checks/reporting_currency.py) demonstrates
both callback forms and low-level writes without typing suppressions.
`adcp.reporting.fixtures.redacted_multi_currency_requests()` supplies USD/EUR
slice requests for adopter replay-conformance tests.
85 changes: 80 additions & 5 deletions docs/reporting-ledger-migration.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Account-qualified reporting generations
# Reporting ledger migrations

The fix for [#1169](https://github.com/adcontextprotocol/adcp-client-python/issues/1169)
changes a reporting configuration generation's identity to
Expand Down Expand Up @@ -49,22 +49,24 @@ obligation IDs, the named obligations must exist in the requested account.
mixing old and new workers is unsafe once accounts reuse a config ID.
2. With the upgraded SDK, run `await store.create_schema()` before starting
reporting work. It creates missing tables and applies the bundled
`reporting_ledger_account_generations.sql` migration in one transaction.
`reporting_ledger_account_generations.sql` and
`reporting_ledger_obligation_currency.sql` migrations in one transaction.
3. Restart reporting work with the upgraded SDK on every instance.

For deployments managed by a migration tool, the standalone migration is
[`reporting_ledger_account_generations.sql`](../src/adcp/reporting/ledger/reporting_ledger_account_generations.sql).
It upgrades an existing beta.15 ledger by itself, including in autocommit mode.
For a combined bootstrap and upgrade, run both bundled files in one transaction:
For a combined bootstrap and upgrade, run all three bundled files in one transaction:

```sh
psql "$REPORTING_DATABASE_URL" --set=ON_ERROR_STOP=1 --single-transaction \
-f src/adcp/reporting/ledger/reporting_ledger.sql \
-f src/adcp/reporting/ledger/reporting_ledger_account_generations.sql
-f src/adcp/reporting/ledger/reporting_ledger_account_generations.sql \
-f src/adcp/reporting/ledger/reporting_ledger_obligation_currency.sql
```

Use the ledger's existing `search_path` and a role that owns its tables. Both
SQL files are also included as resources in the installed SDK's
SQL migrations are also included as resources in the installed SDK's
`adcp.reporting.ledger` package. Running only `CREATE TABLE IF NOT EXISTS`
leaves the old primary key in place and does not perform this upgrade.

Expand Down Expand Up @@ -98,3 +100,76 @@ account-qualified implementation.

The in-memory store needs no schema migration. Restart it with the upgraded
SDK and reload accepted configurations from the adopter's source of truth.

## Frozen currency: upgrading beta.15 or the #1169 schema

[#1171](https://github.com/adcontextprotocol/adcp-client-python/issues/1171)
adds `reporting_obligations.currency`. The migration runs after #1169 under the
same advisory lock and transaction. It is an idempotent, in-place nullable
column addition, with uppercase three-letter validation and a trigger that
rejects changes to a stored currency, including `NULL` to a guessed value.
The account-qualified keys and their migration remain intact.

**Legacy policy: preserve unknown, fail closed.** Neither beta.15 nor #1169
persisted the process currency on obligations. A definition URI/digest does not
contain its definition bytes; retained Core totals contain name/value pairs
without units. Row data may include a currency, but it is adapter-supplied
corroboration rather than proof of the originally accepted scope. Empty rows
and unfulfilled obligations carry even less evidence. Therefore the migration
leaves **every existing obligation's currency `NULL`**, including apparently
USD rows. It does not copy a current account setting, use a new resolver, read
buyer context, infer from an adapter row, or default historical obligations to USD.

The options considered are:

1. Backfill USD or today's account currency: rejected. Either can mislabel
non-USD history, and even historically USD deployments require evidence
beyond the retained ledger to prove that choice.
2. Recover from external, authenticated historical configuration/definition
evidence: possible only through a separate, reviewed adopter migration.
Corroborate the entire frozen account/media-buy/package scope, including
every existing revision and adjustment; retain the provenance and audit
trail. The SDK does not perform that repair or supply an unchecked backfill
API. Its immutability trigger deliberately requires explicit operator work.
3. Retain `NULL`, prevent new monetary work and preserve readable history:
**the implemented recommendation**. This makes no lossy assumption and is
safe when historical evidence is unavailable.

Upgraded workers refuse acquisition, snapshot restatement, new revision and
new adjustment writes for unknown obligations with `CURRENCY_UNRESOLVED`. An
obligation that is already satisfied or officially closed has no acquisition
work to refuse, so it stays the no-op it was before the upgrade. Inside
`run_worker`, an unresolved obligation is reported in `WorkerTurn.slices_failed`
and escalated like any other stuck slice: every *other* period under the same
configuration still closes and publishes on that same turn. A direct
`acquire_obligation` call still raises, so an operator driving one period by
hand sees the failure.
Exact legacy obligation/revision/adjustment replays still return the retained
record without appending evidence. Status and content reads remain available;
unknown obligations project `HISTORY_UNAVAILABLE`, `action_required` and
`contact_seller`. Reads never resolve or backfill currency. A new accepted
generation can support **future** work with a proven currency; it must not be
used to relabel old periods or erase a missing historical obligation.

No existing rows, hashes, statuses, leases, revisions, adjustments or change-feed
sequences are rewritten. The new column has **no database default**. Existing
indexes/constraints are preserved; a currency check and immutability trigger
are added. The currency migration locks `reporting_obligations` exclusively
while adding/validating the column and check. Plan a maintenance window for
large tables; production-sized lock time has not been benchmarked. Unexpected
adopter column types/defaults fail and roll back rather than silently adapting.

Stop and drain **all** beta.15 and #1169-only reporting writers before upgrading;
they do not supply frozen currency. Run `create_schema()` or the three-file SQL
command above, then start upgraded writers. If #1169 is already installed, its
primary-key migration recognizes the account-qualified key without rebuilding
it. The standalone currency SQL also runs atomically on an installed #1169
schema, including with autocommit. Do not run older code against this schema:
it can ignore the new invariant or create unresolved obligations.

New low-level `ReportingObligationRecord` writes must explicitly include a
trusted `currency`. The optional Python field exists to deserialize legacy
history, not to authorize an unfrozen new publication. High-level users of
`ProducerOfferings(currency=...)` keep their fixed-currency convenience; new
obligations freeze that value. See [multi-account currency resolution and
monetary validation](reporting-currency.md) for the resolver and type examples.
3 changes: 2 additions & 1 deletion src/adcp/reporting/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@
if TYPE_CHECKING:
from adcp.reporting import canonical_json as canonical_json
from adcp.reporting import conformance as conformance
from adcp.reporting import currency as currency
from adcp.reporting import fixtures as fixtures
from adcp.reporting import inline_source as inline_source
from adcp.reporting import ledger as ledger
from adcp.reporting import source as source

_LAZY_SUBMODULES = frozenset(
{"canonical_json", "conformance", "fixtures", "inline_source", "ledger", "source"}
{"canonical_json", "conformance", "currency", "fixtures", "inline_source", "ledger", "source"}
)


Expand Down
8 changes: 8 additions & 0 deletions src/adcp/reporting/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,14 @@ def _validate_manifest_against_request(
f"({actual!r} != {expected!r})",
)

for total in manifest.control_totals:
# Other monetary names need the ledger's trusted definition binding;
# a three-letter unit alone does not establish monetary semantics.
if total.unit is not None and total.name == "spend" and total.unit != request.currency:
raise _fail(
"MANIFEST_MISMATCH", "monetary control total unit contradicts frozen currency"
)

requested_metrics = set(request.requested_metrics)
requested_constituents = {item.constituent_id for item in request.coverage.constituents}
declared = {metric.name: metric for metric in offering.metrics}
Expand Down
Loading
Loading