Skip to content

Commit 46c464a

Browse files
authored
fix(decisioning): disable sync task webhooks by default (#1020)
1 parent e27c6a7 commit 46c464a

23 files changed

Lines changed: 339 additions & 174 deletions

MIGRATION_v6_to_v7.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,23 @@ AdCP 3.0 is upgraded on reads and downgraded on writes. AdCP 3.1 requires the
4444
`media_buy.features.canonical_creatives` capability or unambiguous
4545
request-local evidence. AdCP 3.2 will be canonical by contract once supported;
4646
advertising `canonical_creatives: false` there will be an error.
47+
48+
## Synchronous completion webhooks
49+
50+
`auto_emit_completion_webhooks` now defaults to `False`. AdCP forbids a task
51+
webhook when the initial response is already terminal: the result is available
52+
inline and no registry task exists for a webhook `task_id`.
53+
54+
If an existing buyer depends on receiving both copies, temporarily pass
55+
`auto_emit_completion_webhooks=True` to `serve()` or
56+
`create_adcp_server_from_platform()`. This retains the former behavior as a
57+
non-conformant compatibility extension with a synthetic, unpollable `sync-*`
58+
task ID. Update the buyer to consume the inline result, then remove the opt-in.
59+
60+
This setting only controls synthetic synchronous-completion delivery. Terminal
61+
webhooks for real `TaskHandoff` requests remain enabled when the request supplies
62+
`push_notification_config` and a webhook sender or supervisor is configured. The
63+
framework rejects a push-configured handoff before task creation when no transport
64+
is available, rather than returning `submitted` and silently dropping the callback.
65+
Adopters that deliver terminal task webhooks themselves can set the independent
66+
`auto_emit_task_webhooks=False` ownership flag.

docs/handler-authoring.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,16 +1277,28 @@ MCP for production agents.
12771277

12781278
## Webhooks
12791279

1280-
When `auto_emit_completion_webhooks=True` (the default), the framework fires a
1281-
sync-completion webhook after every successfully-dispatched tool call whose task
1282-
type is in the spec's webhook-eligible set (`create_media_buy`, `activate_signal`,
1283-
and their siblings). Buyers who register `push_notification_config.url` receive
1284-
these notifications automatically.
1285-
1286-
The framework requires a sender or supervisor at boot — it raises `AdcpError`
1287-
rather than silently dropping notifications if neither is wired and auto-emit is on.
1288-
Set `auto_emit_completion_webhooks=False` only if you emit webhooks manually inside
1289-
your platform methods.
1280+
Synchronous terminal responses do not emit task webhooks. This is the default:
1281+
`auto_emit_completion_webhooks=False`. The buyer already has the result inline,
1282+
and no registry task exists for a webhook `task_id`.
1283+
1284+
`TaskHandoff` is different. When the initial response is `submitted` and the
1285+
request includes `push_notification_config`, the framework delivers the required
1286+
terminal completion or failure webhook through the configured sender or supervisor.
1287+
The sync-completion compatibility flag does not disable that async delivery. A
1288+
push-configured handoff is rejected before task creation when no delivery transport
1289+
is configured, so the server cannot return `submitted` and then silently drop the
1290+
required callback.
1291+
1292+
`auto_emit_task_webhooks=True` controls framework ownership of these real task
1293+
notifications. Set it to `False` only when adopter code owns terminal webhook
1294+
delivery itself. This is separate from `auto_emit_completion_webhooks`, which only
1295+
controls the legacy synthetic sync behavior.
1296+
1297+
Existing integrations that relied on duplicate inline and webhook delivery can
1298+
temporarily set `auto_emit_completion_webhooks=True`. This is a non-conformant
1299+
compatibility extension: it synthesizes an unpollable `sync-*` task ID. The
1300+
framework requires a sender or supervisor at boot when this mode is enabled.
1301+
Migrate buyers to consume the inline terminal response, then remove the opt-in.
12901302

12911303
### Sender constructors
12921304

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Related skills: `build-seller-agent`, `build-generative-seller-agent`,
8383

8484
## Webhooks
8585

86-
- [`hello_seller_with_webhooks.py`](hello_seller_with_webhooks.py)wire an `InMemoryWebhookDeliverySupervisor` so completion webhooks reach buyers who register `push_notification_config.url`; uses `WebhookSender.from_bearer_token`.
86+
- [`hello_seller_with_webhooks.py`](hello_seller_with_webhooks.py)legacy, non-conformant sync-completion compatibility example using `InMemoryWebhookDeliverySupervisor`; normal `TaskHandoff` notifications require no sync opt-in.
8787

8888
See `docs/handler-authoring.md#webhooks` for the full `WebhookSender`
8989
constructor comparison (bearer vs RFC 9421 JWK signing).

examples/hello_seller.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,10 +373,8 @@ def _get_packages(req: Any) -> list[dict[str, Any]]:
373373
# server. Default port 3001 over streamable-http; override via
374374
# ``serve(seller, port=...)``.
375375
#
376-
# ``auto_emit_completion_webhooks=False`` opts out here because this
377-
# example has no signing key. Production sellers want webhooks on so
378-
# buyers who register ``push_notification_config.url`` get sync-
379-
# completion notifications. Pick a constructor and pass
376+
# Synchronous terminal responses do not emit task webhooks. For real
377+
# TaskHandoff completion notifications, pick a constructor and pass
380378
# ``webhook_supervisor=`` (retry + circuit breaker, recommended) or
381379
# ``webhook_sender=`` (transport only):
382380
#
@@ -399,4 +397,4 @@ def _get_packages(req: Any) -> list[dict[str, Any]]:
399397
# serve(HelloSeller(), name="hello-seller", webhook_supervisor=supervisor)
400398
#
401399
# See docs/handler-authoring.md#webhooks for the full wiring recipe.
402-
serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False)
400+
serve(HelloSeller(), name="hello-seller")

examples/hello_seller_async_handoff.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,9 @@ def _echo_packages(req: Any) -> list[dict[str, Any]]:
288288
serve(
289289
HelloSellerHybrid(),
290290
name="hello-seller-hybrid",
291-
# Opt out of F12 auto-emit so the example boots without a
292-
# ``webhook_sender``. Production sellers wire ``webhook_sender=``
293-
# so buyers who register ``push_notification_config.url`` get
294-
# completion notifications when their TaskHandoff finishes.
291+
# Pin the conformant sync-completion default explicitly.
292+
# Production sellers wire ``webhook_sender=`` so buyers who
293+
# register ``push_notification_config.url`` get completion
294+
# notifications when their TaskHandoff finishes.
295295
auto_emit_completion_webhooks=False,
296296
)

examples/hello_seller_audience.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,8 @@ def sync_audiences(
6464
def main() -> None:
6565
"""Boot the seller on http://localhost:3001/mcp.
6666
67-
``auto_emit_completion_webhooks=False`` opts out so this example
68-
boots without a ``webhook_sender``. In production, wire
69-
``webhook_sender=`` for buyer notification.
67+
Synchronous terminal responses remain inline-only by default. Wire
68+
``webhook_sender=`` when adding ``TaskHandoff`` support.
7069
"""
7170
serve(HelloAudienceSeller(), auto_emit_completion_webhooks=False)
7271

examples/hello_seller_brand_rights.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,8 @@ def acquire_rights(
8787
def main() -> None:
8888
"""Boot the seller on http://localhost:3001/mcp.
8989
90-
``auto_emit_completion_webhooks=False`` opts out so this example
91-
boots without a ``webhook_sender``. In production, wire
92-
``webhook_sender=`` for buyer notification.
90+
Synchronous terminal responses remain inline-only by default. Wire
91+
``webhook_sender=`` when adding ``TaskHandoff`` support.
9392
"""
9493
serve(HelloBrandRightsSeller(), auto_emit_completion_webhooks=False)
9594

examples/hello_seller_catalog.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,8 @@ def sync_catalogs(self, req: Any, ctx: RequestContext[Any]) -> list[dict[str, An
9393
def main() -> None:
9494
"""Boot the seller on http://localhost:3001/mcp.
9595
96-
``auto_emit_completion_webhooks=False`` opts out so this example
97-
boots without a ``webhook_sender``. In production, wire
98-
``webhook_sender=`` for buyer notification.
96+
Synchronous terminal responses remain inline-only by default. Wire
97+
``webhook_sender=`` when adding ``TaskHandoff`` support.
9998
"""
10099
serve(HelloCatalogSeller(), auto_emit_completion_webhooks=False)
101100

examples/hello_seller_creative.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,15 @@ def main() -> None:
102102
governance tools (per-specialism filter).
103103
* ``tools/call build_creative`` returns the synthesized manifest.
104104
105-
The ``auto_emit_completion_webhooks=False`` opt-out keeps this
106-
example minimal. In production, wire ``webhook_sender=`` so
107-
buyers who register ``push_notification_config.url`` get
108-
completion notifications:
105+
Synchronous terminal responses remain inline-only. If this seller
106+
returns a ``TaskHandoff``, wire ``webhook_sender=`` so buyers who
107+
register ``push_notification_config.url`` get terminal notifications:
109108
110109
from adcp.webhook_sender import WebhookSender
111110
sender = WebhookSender.from_jwk(...)
112111
serve(HelloCreativeSeller(), webhook_sender=sender)
113112
"""
114-
serve(HelloCreativeSeller(), auto_emit_completion_webhooks=False)
113+
serve(HelloCreativeSeller())
115114

116115

117116
if __name__ == "__main__":

examples/hello_seller_signals.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,13 @@ async def _async_activation(self, task_ctx: Any) -> dict[str, Any]:
137137
def main() -> None:
138138
"""Boot the seller on http://localhost:3001/mcp.
139139
140-
``auto_emit_completion_webhooks=False`` opts out of the sync
141-
completion-webhook auto-emit so this example boots without a
142-
``webhook_sender``. In production, wire ``webhook_sender=`` so
140+
Synchronous terminal responses remain inline-only by default. In
141+
production, wire ``webhook_sender=`` so
143142
buyers who register ``push_notification_config.url`` on
144143
``activate_signal`` get notifications when a TaskHandoff
145144
completes.
146145
"""
147-
serve(HelloSignalsSeller(), auto_emit_completion_webhooks=False)
146+
serve(HelloSignalsSeller())
148147

149148

150149
if __name__ == "__main__":

0 commit comments

Comments
 (0)