Skip to content

Commit b9d683e

Browse files
bokelleyclaude
andcommitted
feat(decisioning): boot-time validator for declared idempotency vs wired @Wrap
Catches the silent-lie configuration: a platform that advertises capabilities.adcp.idempotency.supported=True on the AdCP get_adcp_capabilities response while never applying @IdempotencyStore.wrap to any handler method. Buyers reading the capabilities envelope assume retries are deduped; without the decorator every retry re-executes side effects. * IdempotencyStore.wrap registers the wrapper in a private WeakSet — defense-in-depth over a public sentinel attr (which adopters could spoof and silently defeat the validator). * validate_idempotency_wiring fires from create_adcp_server_from_platform next to the existing property_list / webhook_sender / capabilities boot gates. AdcpError(recovery="terminal") on misconfig. * Loose check by design: "any method wrapped" passes. Tightening to per-method coverage requires the spec to expose a canonical mutating-tool enum, which AdCP #2315 doesn't yet. * Escape hatch: _adcp_idempotency_external = True on the platform class for adopters with upstream gateway dedup or BYO middleware the SDK can't introspect. * Error details carry candidate_methods, decorator_import path, and both opt-out flags so coding agents can auto-resolve the misconfig. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2733423 commit b9d683e

5 files changed

Lines changed: 579 additions & 1 deletion

File tree

src/adcp/decisioning/serve.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,16 @@ def create_adcp_server_from_platform(
342342

343343
validate_capabilities_response_shape(handler)
344344

345+
# Boot-time fail-fast: idempotency advertised but no @wrap applied.
346+
# Buyers reading IdempotencySupported(supported=True) on the
347+
# capabilities envelope assume retries dedupe; without the
348+
# decorator, every retry re-executes side effects.
349+
from adcp.decisioning.validate_idempotency import (
350+
validate_idempotency_wiring,
351+
)
352+
353+
validate_idempotency_wiring(platform)
354+
345355
return handler, executor, registry
346356

347357

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Boot-time validator: declared idempotency capability vs. wired decorator.
2+
3+
Catches the silent-lie configuration: a platform that advertises
4+
``capabilities.adcp.idempotency.supported=True`` on the AdCP
5+
``get_adcp_capabilities`` response while never applying
6+
:meth:`adcp.server.idempotency.IdempotencyStore.wrap` to any handler
7+
method. Buyers reading the capabilities envelope assume retries are
8+
deduped; without the decorator, every retry re-executes side effects.
9+
10+
The check is loose by design — "any wrapped method on the platform"
11+
passes. A platform that wraps ``create_media_buy`` but forgets
12+
``update_media_buy`` slips through here. Tightening to per-method
13+
coverage requires the spec to expose a canonical mutating-tool enum,
14+
which AdCP #2315 doesn't yet. The loose check still catches the
15+
dominant failure mode (capability declared, decorator never applied).
16+
17+
**Escape hatch.** Adopters who terminate idempotency upstream of the
18+
SDK — gateway-tier dedup (Kong, Envoy, ASGI middleware), or a
19+
bring-your-own decorator that doesn't go through
20+
:meth:`IdempotencyStore.wrap` — set ``_adcp_idempotency_external = True``
21+
on the platform class to opt out of this check. They still publish
22+
``IdempotencySupported`` to buyers; the validator just trusts that the
23+
dedup is wired somewhere the SDK can't see.
24+
25+
Mirrors :func:`adcp.decisioning.property_list.validate_property_list_config`
26+
and :func:`adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform` —
27+
boot-time fail-fast with a structured :class:`AdcpError`.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from typing import Any
33+
34+
from adcp.server.idempotency import is_wrapped
35+
36+
37+
def idempotency_capability_supported(platform: Any) -> bool:
38+
"""Return True if ``platform.capabilities.adcp.idempotency.supported`` is True.
39+
40+
Walks the three-level attribute chain defensively — adopters may set
41+
capabilities to ``None`` or omit nested fields entirely.
42+
"""
43+
caps = getattr(platform, "capabilities", None)
44+
if caps is None:
45+
return False
46+
adcp = getattr(caps, "adcp", None)
47+
if adcp is None:
48+
return False
49+
idempotency = getattr(adcp, "idempotency", None)
50+
if idempotency is None:
51+
return False
52+
return getattr(idempotency, "supported", None) is True
53+
54+
55+
def _candidate_method_names(platform: Any) -> list[str]:
56+
"""Return public method names on the platform.
57+
58+
Uses ``dir()`` + per-name ``getattr`` with try/except so a
59+
boot-side-effect property (DB lookup, config fetch) on the platform
60+
class doesn't blow up the validator. ``inspect.getmembers`` would
61+
fire every descriptor.
62+
"""
63+
out: list[str] = []
64+
for name in dir(platform):
65+
if name.startswith("_"):
66+
continue
67+
try:
68+
attr = getattr(platform, name)
69+
except Exception: # noqa: BLE001 — descriptor side effects are out of our control
70+
continue
71+
if callable(attr):
72+
out.append(name)
73+
return out
74+
75+
76+
def _platform_has_wrapped_method(platform: Any) -> bool:
77+
"""True if any callable on the platform is registered as wrapped.
78+
79+
Walks instance attributes so adopters who bind a wrapped function
80+
in ``__init__`` (``self.create_media_buy = wrapped_fn``) are
81+
recognized too — not just class-level ``@`` decoration.
82+
"""
83+
for name in dir(platform):
84+
if name.startswith("_"):
85+
continue
86+
try:
87+
attr = getattr(platform, name)
88+
except Exception: # noqa: BLE001
89+
continue
90+
if not callable(attr):
91+
continue
92+
if is_wrapped(attr):
93+
return True
94+
return False
95+
96+
97+
def validate_idempotency_wiring(platform: Any) -> None:
98+
"""Boot-time fail-fast: idempotency advertised but no method wrapped.
99+
100+
Honors the ``_adcp_idempotency_external = True`` opt-out for
101+
adopters with upstream gateway dedup or a BYO decorator the SDK
102+
can't introspect.
103+
104+
:raises AdcpError: ``recovery='terminal'`` when the platform
105+
declares ``IdempotencySupported(supported=True)`` but no method
106+
on the platform is decorated with
107+
:meth:`adcp.server.idempotency.IdempotencyStore.wrap` AND the
108+
external opt-out is not set.
109+
"""
110+
if not idempotency_capability_supported(platform):
111+
return
112+
if getattr(platform, "_adcp_idempotency_external", False):
113+
return
114+
if _platform_has_wrapped_method(platform):
115+
return
116+
117+
from adcp.decisioning.types import AdcpError
118+
119+
raise AdcpError(
120+
"INVALID_REQUEST",
121+
message=(
122+
"capabilities.adcp.idempotency.supported=True is declared but "
123+
"no method on the platform is decorated with @IdempotencyStore.wrap. "
124+
"Buyers reading the capabilities envelope expect retries to be "
125+
"deduped; without the decorator every retry re-executes side "
126+
"effects. Wrap your mutating handlers — typically "
127+
"create_media_buy, update_media_buy, sync_creatives, "
128+
"activate_signal — with the decorator:\n\n"
129+
" from adcp.server.idempotency import IdempotencyStore, MemoryBackend\n"
130+
" idempotency = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)\n\n"
131+
" class MySeller(DecisioningPlatform):\n"
132+
" @idempotency.wrap\n"
133+
" async def create_media_buy(self, params, context=None):\n"
134+
" ...\n\n"
135+
"Alternatively: set IdempotencySupported(supported=False) to opt "
136+
"out, OR — if dedup is wired upstream of the SDK (gateway tier, "
137+
"BYO middleware) — set _adcp_idempotency_external = True on the "
138+
"platform class."
139+
),
140+
recovery="terminal",
141+
details={
142+
"missing": "@IdempotencyStore.wrap",
143+
"decorator_import": "from adcp.server.idempotency import IdempotencyStore",
144+
"candidate_methods": _candidate_method_names(platform),
145+
"opt_out": "IdempotencySupported(supported=False)",
146+
"external_opt_out": "_adcp_idempotency_external = True",
147+
},
148+
)
149+
150+
151+
__all__ = [
152+
"idempotency_capability_supported",
153+
"validate_idempotency_wiring",
154+
]

src/adcp/server/idempotency/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def get_adcp_capabilities(self, params, context=None):
6363
canonical_json_sha256,
6464
strip_excluded_fields,
6565
)
66-
from adcp.server.idempotency.store import IdempotencyStore
66+
from adcp.server.idempotency.store import IdempotencyStore, is_wrapped
6767
from adcp.server.idempotency.webhook_dedup import WebhookDedupStore
6868

6969
__all__ = [
@@ -75,5 +75,6 @@ async def get_adcp_capabilities(self, params, context=None):
7575
"PgBackend",
7676
"WebhookDedupStore",
7777
"canonical_json_sha256",
78+
"is_wrapped",
7879
"strip_excluded_fields",
7980
]

src/adcp/server/idempotency/store.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import logging
3333
import time
3434
import warnings
35+
import weakref
3536
from collections.abc import Awaitable, Callable
3637
from functools import wraps
3738
from typing import Any
@@ -44,6 +45,33 @@
4445

4546
logger = logging.getLogger(__name__)
4647

48+
# Registry of functions returned by IdempotencyStore.wrap. Read by
49+
# adcp.decisioning.validate_idempotency.is_wrapped() to reconcile the
50+
# adopter's declared IdempotencySupported capability against actual
51+
# decorator application. WeakSet so wrapper functions garbage-collect
52+
# normally when the platform method holding them goes away — the
53+
# registry doesn't pin them in memory.
54+
#
55+
# Defense-in-depth choice over a public attribute on the wrapper: a
56+
# plain attr can be set by any caller (test fixture, monkeypatch) and
57+
# silently defeat the validator. Membership in this private set is
58+
# only granted by IdempotencyStore.wrap itself.
59+
_WRAPPED_FUNCTIONS: weakref.WeakSet[Callable[..., Any]] = weakref.WeakSet()
60+
61+
62+
def is_wrapped(fn: Any) -> bool:
63+
"""Return True if ``fn`` was produced by :meth:`IdempotencyStore.wrap`.
64+
65+
Accepts bound methods (resolves to the underlying function before
66+
the membership check) and plain callables. Used by the boot-time
67+
validator at :mod:`adcp.decisioning.validate_idempotency`.
68+
"""
69+
if fn is None:
70+
return False
71+
target = fn.__func__ if hasattr(fn, "__func__") else fn
72+
return target in _WRAPPED_FUNCTIONS
73+
74+
4775
# Spec bounds from capabilities.idempotency.replay_ttl_seconds (1h-7d).
4876
_MIN_TTL_SECONDS = 3600
4977
_MAX_TTL_SECONDS = 604800
@@ -189,6 +217,21 @@ async def _wrapped(
189217
)
190218
return response
191219

220+
# Register the wrapper for the boot-time validator at
221+
# adcp.decisioning.validate_idempotency. WeakSet membership —
222+
# not a public attribute — so adopters can't spoof "wrapped"
223+
# by stamping an attr on a plain function. The wrapper is
224+
# registered, not the original handler: re-decorating a forked
225+
# copy of `handler` would otherwise falsely flag both.
226+
#
227+
# Contract for future maintainers: ``is_wrapped()`` checks
228+
# WeakSet membership of the closure object directly. Do NOT
229+
# change it to ``inspect.unwrap()``-then-check — the
230+
# ``@functools.wraps(handler)`` decorator above sets
231+
# ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap``
232+
# would return the original handler (not in the WeakSet) and
233+
# the validator would silently regress.
234+
_WRAPPED_FUNCTIONS.add(_wrapped)
192235
return _wrapped
193236

194237
def _prepare(self, params: Any, context: Any) -> tuple[str | None, str | None, dict[str, Any]]:

0 commit comments

Comments
 (0)