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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
33 changes: 33 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,36 @@ updates:
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"

# Frozen Python environments for the same interop replay. Each requirements
# file pins the installed set an interop control verifies, so it changes only
# with its control.
- package-ecosystem: pip
directory: "/scripts/ci/reporting_interop/python"
schedule:
interval: monthly
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"

# The SDK's own Python dependencies. Version updates stay off, so these
# entries exist only for their ignore rules, which also apply to security
# updates. datamodel-code-generator is pinned exactly because its class
# numbering shifts between releases: an upgrade must regenerate
# src/adcp/types/ in the same change, which is an SDK 10 decision
# (docs/v10-codegen-plan.md, #1649). Dependabot has filed this manifest as
# pip, and uv.lock makes it a uv project too, so both carry the rule.
- package-ecosystem: pip
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 0
ignore:
- dependency-name: "datamodel-code-generator"
- package-ecosystem: uv
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 0
ignore:
- dependency-name: "datamodel-code-generator"
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ jobs:
tests/test_decisioning_lazy_pg_stores.py \
tests/conformance/decisioning/test_pg_task_webhook_outbox.py \
tests/conformance/decisioning/test_pg_notification_outbox.py \
tests/conformance/decisioning/test_pg_webhook_delivery_supervisor.py \
tests/test_notification_outbox.py \
tests/test_notification_outbox_artifacts.py \
tests/conformance/decisioning/test_pg_reference_workflow_queue.py \
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
".": "9.2.0",
".": "9.3.0",
"packages/adcp-reporting-storage": "0.1.0-beta.4"
}
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,21 @@
change public consumer defaults. See
[format-kind migration](docs/canonical-format-kinds-migration.md).

## [9.3.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.2.0...v9.3.0) (2026-10-27)


### Features

* **decisioning:** export media-buy terminal statuses and the observed transition graph ([#1664](https://github.com/adcontextprotocol/adcp-client-python/issues/1664)) ([4b1554e](https://github.com/adcontextprotocol/adcp-client-python/commit/4b1554e14de0e72bf21af8a6a2b3f102a1227a5b)), closes [#1604](https://github.com/adcontextprotocol/adcp-client-python/issues/1604)
* **webhooks:** add PgNotificationOutbox.enqueue_prepared_sync for synchronous business transactions ([#1665](https://github.com/adcontextprotocol/adcp-client-python/issues/1665)) ([e081fe4](https://github.com/adcontextprotocol/adcp-client-python/commit/e081fe431d83c733648921d34ead9420d0cbce4b)), closes [#1607](https://github.com/adcontextprotocol/adcp-client-python/issues/1607)


### Bug Fixes

* **protocol:** adopt signed AdCP 3.2.7 on SDK 9 ([#1657](https://github.com/adcontextprotocol/adcp-client-python/issues/1657)) ([95ed163](https://github.com/adcontextprotocol/adcp-client-python/commit/95ed163d61c40a851997280c647ef8627ada17b0))
* **types:** drop the empty-model arm codegen emits beside a constrained array ([#1658](https://github.com/adcontextprotocol/adcp-client-python/issues/1658)) ([adaf29b](https://github.com/adcontextprotocol/adcp-client-python/commit/adaf29bcb040860d8d4047741735002ea527016f)), closes [#1616](https://github.com/adcontextprotocol/adcp-client-python/issues/1616)
* **webhooks:** send PgWebhookDeliverySupervisor task webhooks and deliver the ones already stuck in the queue ([#1651](https://github.com/adcontextprotocol/adcp-client-python/issues/1651)) ([83a2fe3](https://github.com/adcontextprotocol/adcp-client-python/commit/83a2fe37e8517950c4aa465ad0d5b92a955bf16c))

## [9.2.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.1.1...v9.2.0) (2026-10-26)


Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ recursive-include src/adcp/reporting/materializer/assets *.json
recursive-include src/adcp/_compliance/3.2.1 *.json *.jsonl *.yaml *.md
recursive-include src/adcp/_compliance/3.2.4 *.json *.jsonl *.yaml *.md
recursive-include src/adcp/_compliance/3.3.0-beta.0 *.json *.jsonl *.yaml *.md
recursive-include src/adcp/_compliance/3.2.7 *.json *.jsonl *.yaml *.md
# build_py copies the tracked cache into wheels. Do not duplicate those
# inputs with ignored, generated package copies in the source distribution.
prune src/adcp/_schemas
Expand All @@ -29,5 +30,6 @@ prune schemas/cache/3.2.0-rc.7
prune schemas/releases
include schemas/releases/3.2.1.json
include schemas/releases/3.2.4.json
include schemas/releases/3.2.7.json
include schemas/releases/3.1.27.json
include schemas/releases/3.3.0-beta.0.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ the SDK's supported versions in the error details.
- **[Durable legacy purchase continuations](docs/legacy-purchase-continuations.md)** - Safe products-only compatibility redemption and crash recovery
- **[Media-buy lifecycle coordinator](docs/media-buy-lifecycle-coordinator.md)** - Negotiated catalog-to-purchase workflow across AdCP 3.0, 3.1, and 3.2; remaining buyer parity is tracked in [#1154](https://github.com/adcontextprotocol/adcp-client-python/issues/1154)
- **[Media-buy action rights](docs/media-buy-action-rights.md)** - Assess product possibilities, accepted change rights, and currently available actions
- **[Media-buy state machine](docs/media-buy-state-machine.md)** - Requested and observed lifecycle graphs, and terminal statuses
- **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading
- **[Universal macro translation](docs/universal-macro-translation.md)** - Producer-side pixel URL translation, trust boundary, and diagnostics
- **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy
Expand Down
6 changes: 2 additions & 4 deletions SCHEMA_DELTAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@

## Field changes

- `core/hold_policy.py`
- `PositiveDuration`: `+__slots__`, `+_constraints`, `+_json_schema_extra` `-root`
- `trust/v1/trust.py`
- `Domain`: `+__slots__`, `+_constraints`, `+_json_schema_extra` `-root`
- `brand/search_brands_request.py`
- `SearchBrandsRequest`: `-adcp_major_version`
3 changes: 2 additions & 1 deletion docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,8 @@ if event_type in subscription.event_types:

`WebhookSender` is the transport layer — it constructs and signs one HTTP POST.
`InMemoryWebhookDeliverySupervisor` adds best-effort retries and circuit breakers
for local development. Neither it nor `PgWebhookDeliverySupervisor` implements
for local development. Neither it nor
[`PgWebhookDeliverySupervisor`](webhooks/pg-delivery-supervisor.md) implements
the atomic terminal-state/outbox contract required for TaskHandoff publication;
use `PgTaskWebhookOutbox` with `PgTaskRegistry` for that lifecycle. The older
supervisors remain explicit/manual delivery utilities.
Expand Down
75 changes: 75 additions & 0 deletions docs/media-buy-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Media-buy state machine

`adcp.decisioning` publishes the AdCP media-buy lifecycle as data, so a
seller does not keep its own copy of the graph. There are two graphs,
because a seller changes a buy's status in two different ways:

| | Graph | Use it for |
|---|---|---|
| **Requested** | `MEDIA_BUY_TRANSITIONS` | A handler changing status on a buyer's request (`update_media_buy`, `control_media_buy`). `assert_media_buy_transition` refuses an illegal move with `INVALID_STATE`. |
| **Observed** | `OBSERVED_MEDIA_BUY_TRANSITIONS` | Recording a status the seller's own platform (ad server, OMS) reports. `is_observable_media_buy_transition` answers whether the move is on the published graph. |

The observed graph is a strict superset of the requested one. It adds the
three AdCP 3.2 edges that follow from the seller's lifecycle rather than from a
status a buyer asks for:

| Observed edge | AdCP 3.2 source |
|---|---|
| `pending_creatives → active` | Creative coverage completed after the flight started. [Lifecycle, "Creative sync timing"](https://docs.adcontextprotocol.org/docs/media-buy/media-buys/lifecycle#creative-sync-timing): the buy "moves to `pending_start` when the flight is in the future or `active` when delivery can begin." |
| `pending_creatives → paused` | Same, on a buy created with `paused: true`. Lifecycle transition diagram, "sync_creatives assignment (buy held)". |
| `pending_start → paused` | The flight started on a buy created with `paused: true`. Lifecycle transition diagram, "flight date reached (create held)"; the [media-buy specification](https://docs.adcontextprotocol.org/docs/media-buy/specification#media-buy-state-transitions) says the hold is latent while setup blockers are visible and "once creatives are present and the flight can start, the buy enters `paused` instead of `active`." |

It is the same graph the `@adcp/client` storyboard runner's `status.monotonic`
invariant enforces for AdCP 3.2, so a status history this graph accepts also
passes that invariant.

## Terminal statuses

`MEDIA_BUY_TERMINAL_STATUSES` is `{"completed", "canceled", "rejected"}`. It is
derived from the graph, not listed by hand. `is_terminal_media_buy_status`
returns `False` for a status the spec does not define, so an internal
pre-lifecycle status such as `draft` or `pending_approval` is simply
non-terminal.

```python
from adcp.decisioning import is_terminal_media_buy_status

if is_terminal_media_buy_status(stored.status):
return # nothing more to sync from the ad server
```

The creative graph has no terminal status: a rejected creative returns to
`processing` when resubmitted, and an archived one can be unarchived. There is
no `CREATIVE_TERMINAL_STATUSES`.

## Mirroring an ad server

```python
from adcp.decisioning import is_observable_media_buy_transition

def record_platform_status(buy, reported: str) -> None:
if reported == buy.status:
return
if is_observable_media_buy_transition(buy.status, reported):
buy.status = reported
return
# Not on the AdCP 3.2 graph: keep the reported status, log the platform state.
log.warning("ad server moved %s %s -> %s", buy.id, buy.status, reported)
```

`is_observable_media_buy_transition` returns `False` for an unchanged status,
for an unknown status, and for any move out of a terminal status.

## Platform moves AdCP 3.2 does not define

Some ad-server moves have no edge in AdCP 3.2, and the SDK does not add one:

- `completed → active` or `completed → paused`, when an order's end date is
extended or a finished order is reopened. The specification says terminal
states "allow no further transitions".
- `active → pending_start`, when the flight start is moved later. The graph has
no edge back to an earlier phase.

A seller that sees one of these should keep the status it reports to buyers on
the published graph, and track the platform's state separately, rather than
send a history that `status.monotonic` rejects.
2 changes: 1 addition & 1 deletion docs/validation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ it, and the list only shrinks:
| class | what happens | where it stands |
| --- | --- | --- |
| `default_emitted` | the dump materializes a schema default the document omitted | #1613; `model_dump_as_received` recovers the bytes. Two digest-covered schemas (`canonical-proposal`, `commercial-terms`) are affected under the default dump and are listed in the suite. |
| `field_dropped` | `extra='ignore'` drops an unknown or sibling-arm property | #1614; not recoverable by any dump. No digest-covered schema is affected at pin 3.2.4. |
| `field_dropped` | `extra='ignore'` drops an unknown or sibling-arm property | #1614; not recoverable by any dump. No digest-covered schema is affected at pin 3.2.7. |
| `alias_emitted` | `model_dump()` writes `pass_` for `pass`, `field_schema` for `$schema` | #1615; `model_dump_as_received` writes the wire name. |
| `null_dropped` | `exclude_none` drops an admitted explicit `null` | #1615; `model_dump_as_received` keeps it. |

Expand Down
4 changes: 2 additions & 2 deletions docs/webhooks/migration-from-fragmented-senders.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ await supervisor.deliver(WebhookDeliveryRequest(url=..., payload=..., ...))

The in-memory supervisor handles retry policy and circuit-breaker state per buyer,
but it is process-local and cannot support the AdCP 3.2 retry-horizon capability.
`PgWebhookDeliverySupervisor` persists pending attempts but removes
final rows, so it also does not satisfy the beta.5 retention or atomic terminal
[`PgWebhookDeliverySupervisor`](pg-delivery-supervisor.md) persists pending
attempts but removes final rows, so it also does not satisfy the beta.5 retention or atomic terminal
state/outbox contract. Use these APIs for explicit best-effort delivery only.
For framework-managed task webhooks, pair `PgTaskWebhookOutbox` with
`PgTaskRegistry` on the same pool, supply a stable 32-byte outbox encryption
Expand Down
36 changes: 36 additions & 0 deletions docs/webhooks/notification-outbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,39 @@ old keys through `decryption_keys={"old-key-id": old_key}`. Existing rows retain
their original key ID and remain readable and deduplicated. Retain each old key
until all rows encrypted under it have been purged. Unknown key IDs or wrong key
material quarantine rows; restoring a key does not automatically release them.

## Synchronous business transactions

A seller whose business writes run on a synchronous driver uses
`enqueue_prepared_sync` with the same arguments. It accepts a psycopg 3
`Connection`, a psycopg2 `connection`, or a SQLAlchemy `Connection` on either
driver, and shares every check, the encryption and the duplicate-binding rule
with `enqueue_prepared`. The worker is unchanged: it stays async, on the pool
you passed to the constructor, and delivers rows written by either method.

```python
from sqlalchemy.orm import Session

with Session(engine) as session, session.begin():
account.status = "active"
outbox.enqueue_prepared_sync(
session.connection(), # the unit of work's connection
prepared,
notification_type="account.status_changed",
account_id="acct_1",
caller_scope_id="seller-tenant-1",
)
# Commit publishes the account change and its notification together;
# a rollback publishes neither.
```

The connection must be inside a transaction, or the call raises `ValueError`
before any SQL:

| Connection | Accepted when |
| --- | --- |
| psycopg 3 `Connection` | The session is in a transaction block: after a statement on a non-autocommit connection, or inside `conn.transaction()`. Same rule as `enqueue_prepared`. |
| psycopg2 `connection` | The session is in a transaction block: after a statement with `autocommit` off. psycopg2 opens the block on the first statement, so a fresh connection is refused. |
| SQLAlchemy `Connection` | `conn.in_transaction()` is true (`engine.begin()`, `conn.begin()`, `session.connection()` inside `session.begin()`) and the isolation level is not `AUTOCOMMIT`. SQLAlchemy starts the driver transaction on the first statement, so the notification may be the first write. |

Any other object, including a psycopg `AsyncConnection`, raises `TypeError`.
91 changes: 91 additions & 0 deletions docs/webhooks/pg-delivery-supervisor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Best-effort task webhooks with PostgreSQL

`PgWebhookDeliverySupervisor` is a retry queue for task webhooks you send
explicitly. It shares circuit-breaker state across processes and keeps queued
jobs through a restart. It does not keep delivery proofs for the AdCP 3.2 retry
horizon, and it does not commit task state in the same transaction as the
queue, so it cannot back SDK-managed TaskHandoff push. For that, pair
`PgTaskWebhookOutbox` with `PgTaskRegistry`. For non-task notifications, use
[`PgNotificationOutbox`](notification-outbox.md).

Install `adcp[pg]`. `send_mcp` only enqueues a job; `run_worker` sends it, so
start the worker or nothing is delivered.

```python
import asyncio

from psycopg_pool import AsyncConnectionPool

from adcp.decisioning import serve
from adcp.webhook_supervisor_pg import PgWebhookDeliverySupervisor

pool = AsyncConnectionPool("postgresql://...", min_size=4, max_size=20, open=False)
await pool.open()
supervisor = PgWebhookDeliverySupervisor(pool, sender)
await supervisor.create_schema() # idempotent; also migrates older tables
asyncio.create_task(supervisor.run_worker())

serve(my_handler, webhook_supervisor=supervisor, auto_emit_task_webhooks=False)
```

## Queue columns

Each row in `adcp_webhook_delivery_queue` keeps two separate statuses:

| Column | Holds | Values |
| --- | --- | --- |
| `status_str` | The task status the payload carries | `submitted`, `working`, `input-required`, `completed`, `canceled`, `failed`, `rejected`, `auth-required`, `unknown` |
| `delivery_state` | Whether the worker should claim the row | `pending`, `retry` |

Workers claim rows with `FOR UPDATE SKIP LOCKED`, so each job goes to one
worker at a time. A successful or finally failed job is deleted from the
queue. Each attempt is recorded in `adcp_webhook_delivery_log`. A retry after an
HTTP failure replays the first attempt's bytes. A retry after a transport error
has no bytes to replay, so it rebuilds the payload with the same
`idempotency_key`. In both cases the receiver can dedupe.

## Upgrading from 9.2.0 and earlier: stuck rows are delivered

Through 9.2.0, `status_str` held both the task status and the queue state.
`send_mcp` wrote the task status (for example `completed`), but the worker only
claimed rows whose `status_str` was `pending` or `retry`. No task webhook
enqueued through `send_mcp` was ever sent
([#1606](https://github.com/adcontextprotocol/adcp-client-python/issues/1606)).
The bug has been present since at least 9.0.0b4.

From 9.2.1, `create_schema()` migrates the table in place:

```sql
ALTER TABLE adcp_webhook_delivery_queue
ADD COLUMN IF NOT EXISTS delivery_state TEXT NOT NULL DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS adcp_webhook_delivery_queue_delivery_idx
ON adcp_webhook_delivery_queue (delivery_state, scheduled_at)
WHERE delivery_state IN ('pending', 'retry');
DROP INDEX IF EXISTS adcp_webhook_delivery_queue_work_idx;
```

**Rows already stuck in the queue are delivered automatically.** The new
column's default marks every existing row `pending`, and the next worker poll
sends it with the task status it was enqueued with. Rows go out oldest first.
No worker ever claimed these rows, so none has been sent before, and each is
delivered once. On PostgreSQL 11 and later, adding a column with a constant
default does not rewrite the table.

Rolling deploys are safe in both directions. A 9.2.0 process that enqueues
during the rollout omits `delivery_state`, so its rows take the default and new
workers deliver them. A 9.2.0 worker still claims on `status_str`, so it never
picks up a row written by 9.2.1.

The stuck rows may be old, and the buyer may already have read the task state
by polling. To see what will go out, run this before upgrading:

```sql
SELECT id, task_id, task_type, status_str, created_at
FROM adcp_webhook_delivery_queue
WHERE status_str NOT IN ('pending', 'retry')
ORDER BY created_at;
```

To discard some or all of those webhooks instead, delete the rows before the
first 9.2.1 process calls `create_schema()`. If you manage the schema with a
migration tool, apply the three statements above in your own migration.
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "adcp"
version = "9.2.0"
version = "9.3.0"
description = "Official Python client for the Ad Context Protocol (AdCP)"
authors = [
{name = "AdCP Community", email = "maintainers@adcontextprotocol.org"}
Expand Down Expand Up @@ -289,6 +289,10 @@ adcp = [
"_compliance/3.3.0-beta.0/**/*.jsonl",
"_compliance/3.3.0-beta.0/**/*.yaml",
"_compliance/3.3.0-beta.0/**/*.md",
"_compliance/3.2.7/**/*.json",
"_compliance/3.2.7/**/*.jsonl",
"_compliance/3.2.7/**/*.yaml",
"_compliance/3.2.7/**/*.md",
# Vendored canonical-formats reference fixtures (14 v2 products +
# 50-entry v1 catalog) so :mod:`adcp.canonical_formats.fixtures`
# can serve them to adopter test suites without forcing each
Expand Down
Loading
Loading