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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,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`
- **[Production seller path](docs/production-seller.md)** - Choose the server abstraction and wire durable multi-tenant tasks, idempotency, and webhook delivery
- **[Production seller path](docs/production-seller.md)** - Choose the server abstraction and wire durable multi-tenant tasks and webhook delivery
- **[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
- **[Migrating from AdCP 3.1 to 3.2 beta](MIGRATION_ADCP_3.1_TO_3.2.md)** - Compact lifecycle adoption and old/new compatibility matrix
Expand Down Expand Up @@ -1058,6 +1058,12 @@ class MySeller(ADCPHandler):
serve(MySeller(), name="my-seller")
```

The advertised capability applies globally, not just to the decorated example
method. Declare it only after every mutating operation the seller supports is
covered. Production integrations also need operation-aware handling for both
inline responses and durable task handoffs; a partial set of method wrappers
must leave `adcp.idempotency.supported` false.

**What the middleware does for you:**

- Extracts `idempotency_key` from `params`, scopes lookups by `context.caller_identity` (per-principal — a security requirement from AdCP §2315)
Expand Down
70 changes: 35 additions & 35 deletions docs/production-seller.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ and webhook delivery so adopters can replace one boundary at a time.
buyer request
auth → tenant router → buyer registry → idempotency lock
auth → tenant router → buyer registry
DecisioningPlatform method → upstream ad server
├─ terminal result ───────────────► return inline and cache result
├─ terminal result ───────────────► return inline
└─ TaskHandoff / WorkflowHandoff ─► persist submitted task
Expand Down Expand Up @@ -58,31 +58,29 @@ not make arbitrary in-process work durable. The reference includes a leased
PostgreSQL queue and restart-recovery test; adopters still provide the
business-specific approval handler.

The reference's `IdempotencyStore` wrapping is intentionally paired with its
inline terminal responses. Do not put the same method-level wrapper around a
method that returns a raw `TaskHandoff` or `WorkflowHandoff`: the wrapper runs
before framework task issuance and therefore cannot cache the projected
`{status: "submitted", task_id}` envelope. A durable workflow adapter must not
advertise method-level idempotency for that method unless an external durable
request-to-task mapping can reuse the prior task id. The reference queue's
uniqueness constraint is only on the framework-issued `task_id`, not the
buyer's idempotency key. A web-process
crash after enqueue commits but before the `submitted` response reaches the
buyer can therefore cause a retried request to issue a second task and queue
row. Fully closing that window requires SDK support for looking up or reusing
a workflow task id by buyer idempotency key.

For a mixed adapter, make the split explicit with
`method_level_idempotency_methods`: include only methods that return terminal
responses from the method-level cache, and implement the workflow method's
deduplication in the durable queue. The default reference includes
`create_media_buy` because its mock path is inline.
The reference intentionally advertises `adcp.idempotency.supported=false` and
does not install request-idempotency middleware. That capability is global:
wrapping only selected methods would overstate support while mutations such as
`sync_accounts` remain uncovered. A production implementation needs a future
operation-aware integration that covers every supported mutating operation,
including both inline results and task handoffs, before it can truthfully
advertise request idempotency.

Workflow business effects still require independent idempotency. The reference
queue's uniqueness constraint is only on the framework-issued `task_id`, not
the buyer's idempotency key. A web-process crash after enqueue commits but
before the `submitted` response reaches the buyer can therefore cause a
retried request to issue a second task and queue row. Closing that window
requires a durable request-to-task mapping that can look up or reuse the prior
task id by buyer idempotency key. The workflow worker must also deduplicate
every external write using the stored buyer key because a crash can occur
after the write but before the handler outcome is staged.

## Task transitions

| Handler outcome | Work owner | Persistence owner | What the buyer does next |
|---|---|---|---|
| Return a result | Request handler | Idempotency backend caches the terminal response | Consume the inline result |
| Return a result | Request handler | The reference does not cache request results | Consume the inline result |
| Raise `AdcpError` | Request handler | Framework projects the structured error; no task is created | Follow its recovery guidance |
| `ctx.handoff_to_task(fn)` | SDK runs `fn` in the web process | `PgTaskRegistry` records submitted, progress, and terminal state | Poll `tasks/get` or await a webhook |
| `ctx.handoff_to_workflow(enqueue)` | The adopter's queue/worker/HITL system | The enqueue callback stores the task id; that system later calls `registry.complete()` or `registry.fail()` | Poll `tasks/get` or await a webhook |
Expand Down Expand Up @@ -137,13 +135,16 @@ await run_with_signals(workflow_handler=handle_approval)
```

The queue completes the original `PgTaskRegistry` record only after the
handler returns. A crash after an external side effect but before queue
acknowledgement causes deliberate re-execution after lease expiry, so the
business effect must be independently idempotent. The PostgreSQL conformance
handler returns. A crash after an external side effect but before the handler
outcome is staged causes deliberate re-execution after lease expiry, so the
business effect must be independently idempotent. Once staging commits, a
replacement retries only registry publication. The PostgreSQL conformance
test kills the first logical worker after claim, creates fresh queue/registry
objects, and verifies that the replacement completes the same task id.
Queue payloads are ordinary JSONB: store only minimal continuation state and
never copy push-notification credentials or other secrets into them.
never copy push-notification credentials or other secrets into them. Scrubbed
terminal results are staged there until registry finalization succeeds, then
the queue clears that staged payload.

## Run the reference deployment

Expand Down Expand Up @@ -210,10 +211,10 @@ bootstrap. The workflow example performs the one additive upgrade shown here,
but these runtime DDL calls are not a general schema migration system and do
not detect or safely evolve an arbitrarily mismatched table.
For production, copy the SDK-owned SQL files (`decisioning_tasks.sql` and
`task_webhook_outbox.sql`), the `PgBackend.create_schema()` DDL, and the
reference workflow-queue DDL into reviewed, versioned migrations and apply
them before either process starts. Runtime bootstrap can remain a safety net,
but migrations own schema evolution and rollback.
`task_webhook_outbox.sql`) and the reference workflow-queue DDL into reviewed,
versioned migrations and apply them before either process starts. Runtime
bootstrap can remain a safety net, but migrations own schema evolution and
rollback.

The worker installs `SIGTERM` and `SIGINT` handlers, cancels its polling loops,
awaits their cleanup, and then closes the sender and PostgreSQL pools. This is
Expand All @@ -231,11 +232,10 @@ the shutdown path used by ordinary container and process supervisors.
- Route human or long-running approval work through `WorkflowHandoff` and a
durable queue; reserve `TaskHandoff` for bounded work that may safely fail on
web-process restart.
- Put a uniqueness constraint on business effects keyed by the buyer's
idempotency key. The SDK cache cannot make a separate upstream transaction
atomic.
- Schedule `PgBackend.delete_expired()` (or equivalent SQL/pg_cron cleanup) so
expired idempotency rows do not accumulate.
- Make every external workflow effect independently idempotent using the
stored buyer key; queue lease recovery deliberately permits re-execution.
- Keep request idempotency unadvertised until an operation-aware integration
covers every supported mutation and durable task handoff.
- Apply URL challenge and SSRF validation before accepting durable callback
destinations.
- Run the in-process tests and the media-buy seller storyboard before deploy.
Expand Down
21 changes: 13 additions & 8 deletions examples/v3_reference_seller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ salesagent), see [MIGRATION.md](MIGRATION.md).
| Durable task state | `src/durable_tasks.py` | `adcp.decisioning.PgTaskRegistry` |
| Restart-safe external workflows | `src/workflow_queue.py` | `WorkflowHandoff` + leased PostgreSQL queue |
| Atomic signed webhook delivery | `src/durable_tasks.py` + `src/worker.py` | `PgTaskWebhookOutbox` |
| Durable request idempotency | `src/durable_tasks.py` | `adcp.server.idempotency.PgBackend` |
| Account v3 projection on read | `src/platform.py::list_accounts` | `adcp.types.project_account_for_response` |

## Architecture
Expand Down Expand Up @@ -88,22 +87,28 @@ The seller binds `0.0.0.0:3001` and serves both transports.

The commands above use the lightweight local path: async tasks are pollable
but intentionally in-memory, and push-configured handoffs are rejected. For
the production-shaped PostgreSQL registry, idempotency cache, signed atomic
outbox, and separately supervised worker, follow the
the production-shaped PostgreSQL registry, signed atomic outbox, and
separately supervised worker, follow the
[production seller path](../../docs/production-seller.md#run-the-reference-deployment).

The bundled mock upstream resolves approvals quickly, so
`create_media_buy` completes inline. When adapting the example to a human or
long-running approval system, return `ctx.handoff_to_workflow(...)` and let a
durable queue consumer call `registry.complete()` or `registry.fail()`; do not
move a long polling loop into an in-process `TaskHandoff`. Leave method-level
idempotency unadvertised for that method unless an external durable
request-to-task mapping can reuse the prior task id. The reference's method
wrapper is for its inline terminal-response path.
move a long polling loop into an in-process `TaskHandoff`.

The reference advertises `adcp.idempotency.supported=false` and does not claim
durable request idempotency. The capability is global, so wrapping only a few
methods would be misleading while other mutations such as `sync_accounts`
remain uncovered. Truthful support requires a future operation-aware
integration across every supported mutation, including task handoffs.

The reference queue deduplicates only the framework-issued `task_id`. It does
not close the crash window between committing enqueue and returning
`submitted`, because a buyer retry can receive a newly issued task id. That
requires SDK support for reusing a workflow task id by buyer idempotency key.
requires a durable request-to-task mapping that reuses the prior task id by
buyer idempotency key. External workflow effects must independently
deduplicate on that stored key because lease recovery may re-execute a job.
Worker failures use capped exponential backoff and a bounded attempt count;
exhausted or account-mismatched jobs move to `dead_lettered` for operations.
The queue and restart-recovery test are runnable reference infrastructure;
Expand Down
1 change: 0 additions & 1 deletion examples/v3_reference_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,6 @@ def main() -> None:
webhook_retry_horizon_seconds=(
task_wiring.retry_horizon_seconds if task_wiring else 86_400
),
idempotency=task_wiring.idempotency if task_wiring else None,
)

logger.info(
Expand Down
41 changes: 1 addition & 40 deletions examples/v3_reference_seller/src/durable_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from typing import TYPE_CHECKING

from adcp.decisioning import PgTaskRegistry, PgTaskWebhookOutbox
from adcp.server.idempotency import IdempotencyStore, PgBackend
from adcp.webhook_sender import WebhookSender

from .workflow_queue import PgWorkflowQueue
Expand Down Expand Up @@ -78,13 +77,10 @@ class DurableTaskWiring:
"""One process's validated durable task/webhook resources."""

pool: AsyncConnectionPool
lock_pool: AsyncConnectionPool | None
sender: WebhookSender
outbox: PgTaskWebhookOutbox
registry: PgTaskRegistry
workflow_queue: PgWorkflowQueue
idempotency_backend: PgBackend | None
idempotency: IdempotencyStore | None
retry_horizon_seconds: int
signing_algorithm: str

Expand All @@ -94,7 +90,6 @@ def from_env(
environ: Mapping[str, str] | None = None,
*,
required: bool | None = None,
include_idempotency: bool = True,
) -> DurableTaskWiring | None:
"""Validate configuration and build resources without opening sockets.

Expand Down Expand Up @@ -132,18 +127,6 @@ def from_env(
max_size=10,
open=False,
)
# Advisory-lock operations reserve a connection for the whole handler
# call, so the SDK requires a distinct pool to prevent self-deadlock.
lock_pool = (
AsyncConnectionPool(
values["ADCP_TASK_DATABASE_URL"],
min_size=1,
max_size=10,
open=False,
)
if include_idempotency
else None
)
outbox = PgTaskWebhookOutbox(
pool=pool,
sender=sender,
Expand All @@ -152,27 +135,12 @@ def from_env(
)
registry = PgTaskRegistry(pool=pool, task_webhook_outbox=outbox)
workflow_queue = PgWorkflowQueue(pool=pool, registry=registry)
idempotency_backend = (
PgBackend(pool=pool, lock_pool=lock_pool) if lock_pool is not None else None
)
idempotency = (
IdempotencyStore(
backend=idempotency_backend,
ttl_seconds=retry_horizon,
raise_on_persist_error=True,
)
if idempotency_backend is not None
else None
)
return cls(
pool=pool,
lock_pool=lock_pool,
sender=sender,
outbox=outbox,
registry=registry,
workflow_queue=workflow_queue,
idempotency_backend=idempotency_backend,
idempotency=idempotency,
retry_horizon_seconds=retry_horizon,
signing_algorithm=signing_algorithm,
)
Expand All @@ -181,13 +149,9 @@ async def startup(self) -> None:
"""Open the process-local pool and idempotently create SDK tables."""
try:
await self.pool.open()
if self.lock_pool is not None:
await self.lock_pool.open()
await self.registry.create_schema()
await self.outbox.create_schema()
await self.workflow_queue.create_schema()
if self.idempotency_backend is not None:
await self.idempotency_backend.create_schema()
except asyncio.CancelledError:
cleanup = asyncio.create_task(self.shutdown())
# Once startup owns resources, repeated cancellation must not
Expand Down Expand Up @@ -215,10 +179,7 @@ async def startup(self) -> None:
async def shutdown(self) -> None:
"""Best-effort close every resource owned by this process."""
first_error: BaseException | None = None
closers = [self.sender.aclose]
if self.lock_pool is not None:
closers.append(self.lock_pool.close)
closers.append(self.pool.close)
closers = [self.sender.aclose, self.pool.close]
for close in closers:
try:
await close()
Expand Down
Loading
Loading