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
217 changes: 186 additions & 31 deletions MIGRATION_v6_to_v7.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,86 @@
# Migrating from Python SDK 6 to 7

Python SDK 7 makes canonical creatives the default application contract. The
package release (`7.0.0-rc`) is distinct from the negotiated AdCP protocol
version (`3.0` or `3.1`). AdCP 3.2 is not advertised until the SDK ships its
validator bundle.
This guide applies to applications upgrading from any Python SDK 6.x release
to 7.x. SDK 7 makes canonical creatives the primary application contract,
updates the bundled protocol schemas to AdCP 3.1.14, and tightens several
security and concurrency boundaries.

The SDK package version and negotiated AdCP protocol version are independent.
Installing `adcp==7.0.0` does not force peers to use a particular wire version;
SDK 7 continues to interoperate with AdCP 3.0 and 3.1 agents.

## Upgrade checklist

1. Install SDK 7 in a branch and run your test suite with deprecation warnings
visible:

```bash
pip install --upgrade "adcp>=7,<8"
python -W default::DeprecationWarning -m pytest
```

2. Replace legacy creative identity in normal application code with canonical
`Format` declarations and `format_options`.
3. Move any intentionally legacy creative calls and types to the explicit
`Legacy*` and `*_legacy` APIs.
4. Add an authorization callback to every `create_roster_account_store` call.
5. If you provide a custom decisioning executor, set
`timed_sync_get_products_limit`.
6. If you use `PgBackend`, create a separate connection pool for advisory-lock
transactions and pass it as `lock_pool`.
7. Confirm that synchronous callers consume terminal results inline instead of
waiting for a duplicate completion webhook.
8. Exercise callback validation and multi-tenant isolation in staging before
production rollout.

## Canonical creatives are the primary API

Use `Format`, `Product.format_options`, `format_kind`, and
`format_option_refs` in normal application code. `Format` now means a
canonical declaration. Products, packages, creatives, filters, delivery
reads, callbacks, generic task execution, multi-agent clients, server
handlers, response builders, and asset helpers enforce this boundary.
`format_option_refs` in normal application code. Products, packages,
creatives, filters, delivery reads, callbacks, generic task execution,
multi-agent clients, server handlers, response builders, and asset helpers all
enforce the canonical boundary.

Legacy named-format identity is explicit:
The main renames for code that still needs named-format compatibility are:

| SDK 6 surface | SDK 7 compatibility surface |
|---|---|
| `FormatId` | `LegacyFormatId` |
| `BuildCreativeRequest` | `LegacyBuildCreativeRequest` |
| `PreviewCreativeRequest` | `LegacyPreviewCreativeRequest` |
| `ListCreativeFormatsRequest` | `LegacyListCreativeFormatsRequest` |
| `client.get_products(...)` for raw legacy rows | `client.get_products_legacy(...)` |
| `client.build_creative(...)` | `client.build_creative_legacy(...)` |
| `client.preview_creative(...)` | `client.preview_creative_legacy(...)` |
| `client.list_creative_formats(...)` | `client.list_creative_formats_legacy(...)` |
| legacy server handler names | handler names ending in `_legacy` |

`Product` and creative filters no longer expose `format_ids`. Use canonical
`format_options`, `format_kind`, and `format_option_refs` instead. Do not import
from `adcp.types._generated` or `adcp.types.generated_poc`; those modules are
internal and regenerated from the protocol schemas.

If a workflow must continue using the legacy wire shape during migration, make
that boundary explicit:

```python
from adcp.types.legacy import LegacyGetProductsRequest

raw = await client.get_products_legacy(LegacyGetProductsRequest(...))
```

`FormatId` and `list_creative_formats` are no longer normal root surfaces.
Use `LegacyFormatId`, `adcp.types.legacy`, and methods ending in `_legacy`
only for migration or conformance tooling. These methods emit
`DeprecationWarning` and are scheduled for removal with AdCP 4.0.
Legacy methods emit `DeprecationWarning` and are scheduled for removal with
AdCP 4.0. Treat them as a temporary interoperability layer rather than the
default API for new code.

### Converting between legacy and canonical formats

For seller-owned named formats, configure `legacy_format_converter`. For
canonical selections persisted across a JSON/process boundary, configure a
separate `canonical_format_legacy_resolver`; the SDK never reverse-guesses a
legacy tuple. Catalog snapshots can build both:
canonical selections persisted across a JSON or process boundary, configure a
separate `canonical_format_legacy_resolver`; the SDK deliberately does not
reverse-guess a legacy tuple.

Catalog snapshots can build both adapters:

```python
from adcp.canonical_formats import projection_adapters_from_catalog_snapshots
Expand All @@ -40,27 +93,129 @@ client = ADCPClient(
)
```

AdCP 3.0 is upgraded on reads and downgraded on writes. AdCP 3.1 requires the
`media_buy.features.canonical_creatives` capability or unambiguous
request-local evidence. AdCP 3.2 will be canonical by contract once supported;
advertising `canonical_creatives: false` there will be an error.
AdCP 3.0 payloads are upgraded on reads and downgraded on writes. For AdCP 3.1,
the framework advertises `media_buy.features.canonical_creatives: true` for
canonical-capable sellers. If you build capability responses yourself, include
that feature or provide unambiguous request-local canonical evidence.

## Synchronous completion webhooks
## Roster account stores require authorization

`create_roster_account_store` no longer treats possession of an account ID as
authorization. Every store must receive a synchronous or asynchronous callback
that binds the verified `AuthInfo` principal to the candidate account.

```python
from adcp.decisioning import create_roster_account_store

async def authorize(account, auth_info):
return await access_policy.can_access(
principal=auth_info,
account_id=account.id,
)

accounts = create_roster_account_store(
roster=roster,
authorize=authorize,
)
```

Return exactly `True` to allow access. Missing authentication, `False`, or an
exception denies access. Avoid a blanket allow callback outside isolated test
fixtures; it defeats the security boundary this change introduces.

## Custom executors require an admission limit

When a decisioning server receives a custom `executor=`, it cannot safely infer
the executor's capacity. SDK 7 therefore requires an explicit positive
`timed_sync_get_products_limit`:

```python
from concurrent.futures import ThreadPoolExecutor
from adcp.decisioning import serve

executor = ThreadPoolExecutor(max_workers=16)
serve(
platform,
executor=executor,
timed_sync_get_products_limit=8,
)
```

Choose a value that leaves capacity for other tools. If the SDK creates the
pool through `thread_pool_size=`, the admission limit defaults to half the
worker count, with a minimum of one, so no change is required.

The caller still owns the lifecycle of a custom executor and must shut it down
cleanly.

## PostgreSQL idempotency requires a distinct lock pool

`PgBackend` now requires both `pool` and `lock_pool`. They must be different
pool objects: the lock pool holds advisory-lock transactions while adopter code
runs, and sharing it with business or cache queries can deadlock under
saturation.

```python
from psycopg_pool import AsyncConnectionPool
from adcp.server.idempotency import IdempotencyStore, PgBackend

pool = AsyncConnectionPool(database_url, min_size=2, max_size=10)
lock_pool = AsyncConnectionPool(database_url, min_size=2, max_size=10)

backend = PgBackend(pool=pool, lock_pool=lock_pool)
await backend.create_schema()
store = IdempotencyStore(backend=backend, ttl_seconds=86_400)
```

Open and close both caller-owned pools with the application lifecycle. The
same requirement applies when constructing `PgBackend` through `LazyBackend`.
Passing the same object for both arguments fails at construction.

## Synchronous completion webhooks default to off

`auto_emit_completion_webhooks` now defaults to `False`. AdCP forbids a task
webhook when the initial response is already terminal: the result is available
inline and no registry task exists for a webhook `task_id`.
inline, and no registry task exists for a webhook `task_id`.

If an existing buyer depends on receiving both copies, temporarily pass
If an existing buyer temporarily depends on receiving both copies, pass
`auto_emit_completion_webhooks=True` to `serve()` or
`create_adcp_server_from_platform()`. This retains the former behavior as a
`create_adcp_server_from_platform()`. This preserves the SDK 6 behavior as a
non-conformant compatibility extension with a synthetic, unpollable `sync-*`
task ID. Update the buyer to consume the inline result, then remove the opt-in.

This setting only controls synthetic synchronous-completion delivery. Terminal
webhooks for real `TaskHandoff` requests remain enabled when the request supplies
`push_notification_config` and a webhook sender or supervisor is configured. The
framework rejects a push-configured handoff before task creation when no transport
is available, rather than returning `submitted` and silently dropping the callback.
Adopters that deliver terminal task webhooks themselves can set the independent
`auto_emit_task_webhooks=False` ownership flag.
This setting controls only synthetic synchronous-completion delivery. Terminal
webhooks for real `TaskHandoff` requests remain enabled when the request
supplies `push_notification_config` and a webhook sender or supervisor is
configured. Adopters that deliver terminal task webhooks themselves can set
the independent `auto_emit_task_webhooks=False` ownership flag.

## Callback and tenant boundaries are stricter

SDK 7 validates and canonicalizes A2A callback destinations with a fail-closed
default policy. Deployments that accept dynamic callback destinations or use
DNS pinning must provide a custom sender and enforce resolution at connection
time. A push-configured handoff with no available delivery transport is now
rejected before task creation instead of being accepted and silently dropped.

Account registries, sessions, proposals, notification stores, and reference
seller state now enforce tenant ownership. Test fixtures or application code
that relied on a cross-tenant fallback must be updated to carry the authenticated
tenant/account scope explicitly. Notification credentials are no longer
returned through typed or generic response paths.

## Recommended rollout

1. Deploy SDK 7 to staging with the legacy creative adapters only where they
are still required.
2. Run buyer and seller storyboards for every supported wire version.
3. Test two separate tenants using the same external identifiers and verify
that neither can read the other's accounts, sessions, proposals, or tasks.
4. Exercise allowed and denied callback destinations, including redirects and
DNS changes if your sender supports them.
5. Load-test timed synchronous `get_products` calls and PostgreSQL idempotency
under pool saturation.
6. Remove temporary legacy adapters and
`auto_emit_completion_webhooks=True` after all callers have migrated.

See the [7.0.0 release](https://github.com/adcontextprotocol/adcp-client-python/releases/tag/v7.0.0)
and [full changelog](CHANGELOG.md) for the complete change list.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ async with ADCPMultiAgentClient(

## AdCP version support

The 7.x line is built against **AdCP 3.1.13 stable**, makes canonical creatives
The 7.x line is built against **AdCP 3.1.14 stable**, makes canonical creatives
the primary Python contract, and negotiates AdCP 3.0, 3.1, and 3.2 wire
dialects. The SDK package version and protocol version are intentionally
independent:
Expand All @@ -285,7 +285,7 @@ independent:
import adcp

adcp.get_adcp_sdk_version() # SDK package version, e.g. "7.0.0rc1"
adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.13"
adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.14"
```

If you talk to an agent on a newer spec than this SDK validates, the response
Expand All @@ -298,7 +298,7 @@ forward traffic degrades gracefully rather than failing.
- **[API Reference](https://adcontextprotocol.github.io/adcp-client-python/)** - Complete API documentation with type signatures and examples
- **[Protocol Spec](https://github.com/adcontextprotocol/adcp)** - Ad Context Protocol specification
- **[Handler authoring](docs/handler-authoring.md)** - Building an AdCP-compliant agent on `adcp.server`
- **[Migrating from SDK 6 to 7](MIGRATION_v6_to_v7.md)** - Canonical creative replacements and legacy escape hatches
- **[Migrating from SDK 6 to 7](MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes
- **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading
- **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy
- **[Examples](examples/)** - Code examples and usage patterns
Expand Down
12 changes: 11 additions & 1 deletion SCHEMA_DELTAS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Generated-types delta

_No field-shape changes detected._
## Field changes

- `bundled/protocol/get_adcp_capabilities_response.py`
- `SupportedCatalogType`: `+promotion`
- `Surface`: `+linear_tv`
- `core/registry_event.py`
- `BadgeRole`: `+root` `-brand`, `-creative`, `-governance`, `-media_buy`, `-signals`, `-sponsored_intelligence`
- `formats/canonical/sponsored_placement.py`
- **classes removed**: SupportedCatalogType
- `protocol/get_adcp_capabilities_response.py`
- **classes removed**: Surface
14 changes: 7 additions & 7 deletions schemas/cache/3.1/adagents.json
Original file line number Diff line number Diff line change
Expand Up @@ -786,12 +786,12 @@
],
"examples": [
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"authoritative_location": "https://cdn.example.com/adagents/v2/adagents.json",
"last_updated": "2025-01-15T10:00:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"properties": [
{
"property_id": "example_site",
Expand Down Expand Up @@ -872,7 +872,7 @@
"last_updated": "2025-01-10T12:00:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"contact": {
"name": "Meta Advertising Operations",
"email": "adops@meta.com",
Expand Down Expand Up @@ -981,7 +981,7 @@
"last_updated": "2025-01-10T15:30:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"contact": {
"name": "Tumblr Advertising"
},
Expand Down Expand Up @@ -1020,7 +1020,7 @@
"last_updated": "2025-01-10T16:00:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"contact": {
"name": "Example Third-Party Sales Agent",
"email": "sales@agent.example",
Expand Down Expand Up @@ -1085,7 +1085,7 @@
"last_updated": "2025-01-10T17:00:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"contact": {
"name": "Premium News Publisher",
"email": "adops@news.example.com",
Expand Down Expand Up @@ -1160,7 +1160,7 @@
"last_updated": "2025-01-10T18:00:00Z"
},
{
"$schema": "/schemas/3.1.13/adagents.json",
"$schema": "/schemas/3.1.14/adagents.json",
"contact": {
"name": "Polk Automotive Data",
"email": "partnerships@polk.com",
Expand Down
Loading
Loading