diff --git a/examples/multi_platform_seller/src/app.py b/examples/multi_platform_seller/src/app.py index 31ac5eded..bce61b563 100644 --- a/examples/multi_platform_seller/src/app.py +++ b/examples/multi_platform_seller/src/app.py @@ -41,7 +41,7 @@ from adcp.decisioning.capabilities import Account as CapabilitiesAccount from adcp.decisioning.capabilities import ( Adcp, - IdempotencySupported, + IdempotencyUnsupported, MediaBuy, SupportedProtocol, ) @@ -76,7 +76,11 @@ def build_router() -> PlatformRouter: specialisms=["sales-guaranteed", "sales-non-guaranteed"], adcp=Adcp( major_versions=[3], - idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400), + # Router union over two mock platforms — neither wires + # in-memory dedup, so the union honestly advertises + # unsupported. Real adopters wrap mutating handlers with + # @IdempotencyStore.wrap and declare supported=True. + idempotency=IdempotencyUnsupported(supported=False), ), account=CapabilitiesAccount(supported_billing=["operator"]), media_buy=MediaBuy(supported_pricing_models=["cpm"]), diff --git a/examples/multi_platform_seller/src/mock_guaranteed.py b/examples/multi_platform_seller/src/mock_guaranteed.py index bf8064f0f..b37a988cc 100644 --- a/examples/multi_platform_seller/src/mock_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_guaranteed.py @@ -30,7 +30,7 @@ from adcp.decisioning.capabilities import Account as CapabilitiesAccount from adcp.decisioning.capabilities import ( Adcp, - IdempotencySupported, + IdempotencyUnsupported, MediaBuy, SupportedProtocol, ) @@ -131,7 +131,12 @@ class MockGuaranteedPlatform(DecisioningPlatform, SalesPlatform): specialisms=["sales-guaranteed"], adcp=Adcp( major_versions=[3], - idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400), + # Mock platform: no in-memory dedup wired. Honest declaration + # over a silent-lie supported=True (the SDK's boot-time + # validator at adcp.decisioning.validate_idempotency catches + # the latter). Real adopters wrap mutating handlers with + # @IdempotencyStore.wrap and declare supported=True. + idempotency=IdempotencyUnsupported(supported=False), ), account=CapabilitiesAccount(supported_billing=["operator"]), media_buy=MediaBuy(supported_pricing_models=["cpm"]), diff --git a/examples/multi_platform_seller/src/mock_non_guaranteed.py b/examples/multi_platform_seller/src/mock_non_guaranteed.py index 73dae7022..f672f91d7 100644 --- a/examples/multi_platform_seller/src/mock_non_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_non_guaranteed.py @@ -32,7 +32,7 @@ from adcp.decisioning.capabilities import Account as CapabilitiesAccount from adcp.decisioning.capabilities import ( Adcp, - IdempotencySupported, + IdempotencyUnsupported, MediaBuy, SupportedProtocol, ) @@ -118,7 +118,12 @@ class MockNonGuaranteedPlatform(DecisioningPlatform, SalesPlatform): specialisms=["sales-non-guaranteed"], adcp=Adcp( major_versions=[3], - idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400), + # Mock platform: no in-memory dedup wired. Honest declaration + # over a silent-lie supported=True (the SDK's boot-time + # validator at adcp.decisioning.validate_idempotency catches + # the latter). Real adopters wrap mutating handlers with + # @IdempotencyStore.wrap and declare supported=True. + idempotency=IdempotencyUnsupported(supported=False), ), account=CapabilitiesAccount(supported_billing=["operator"]), media_buy=MediaBuy(supported_pricing_models=["cpm"]), diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index 93a90cfa0..f58e10129 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -342,6 +342,16 @@ def create_adcp_server_from_platform( validate_capabilities_response_shape(handler) + # Boot-time fail-fast: idempotency advertised but no @wrap applied. + # Buyers reading IdempotencySupported(supported=True) on the + # capabilities envelope assume retries dedupe; without the + # decorator, every retry re-executes side effects. + from adcp.decisioning.validate_idempotency import ( + validate_idempotency_wiring, + ) + + validate_idempotency_wiring(platform) + return handler, executor, registry diff --git a/src/adcp/decisioning/validate_idempotency.py b/src/adcp/decisioning/validate_idempotency.py new file mode 100644 index 000000000..b944b1d07 --- /dev/null +++ b/src/adcp/decisioning/validate_idempotency.py @@ -0,0 +1,154 @@ +"""Boot-time validator: declared idempotency capability vs. wired decorator. + +Catches the silent-lie configuration: a platform that advertises +``capabilities.adcp.idempotency.supported=True`` on the AdCP +``get_adcp_capabilities`` response while never applying +:meth:`adcp.server.idempotency.IdempotencyStore.wrap` to any handler +method. Buyers reading the capabilities envelope assume retries are +deduped; without the decorator, every retry re-executes side effects. + +The check is loose by design — "any wrapped method on the platform" +passes. A platform that wraps ``create_media_buy`` but forgets +``update_media_buy`` slips through here. Tightening to per-method +coverage requires the spec to expose a canonical mutating-tool enum, +which AdCP #2315 doesn't yet. The loose check still catches the +dominant failure mode (capability declared, decorator never applied). + +**Escape hatch.** Adopters who terminate idempotency upstream of the +SDK — gateway-tier dedup (Kong, Envoy, ASGI middleware), or a +bring-your-own decorator that doesn't go through +:meth:`IdempotencyStore.wrap` — set ``_adcp_idempotency_external = True`` +on the platform class to opt out of this check. They still publish +``IdempotencySupported`` to buyers; the validator just trusts that the +dedup is wired somewhere the SDK can't see. + +Mirrors :func:`adcp.decisioning.property_list.validate_property_list_config` +and :func:`adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform` — +boot-time fail-fast with a structured :class:`AdcpError`. +""" + +from __future__ import annotations + +from typing import Any + +from adcp.server.idempotency import is_wrapped + + +def idempotency_capability_supported(platform: Any) -> bool: + """Return True if ``platform.capabilities.adcp.idempotency.supported`` is True. + + Walks the three-level attribute chain defensively — adopters may set + capabilities to ``None`` or omit nested fields entirely. + """ + caps = getattr(platform, "capabilities", None) + if caps is None: + return False + adcp = getattr(caps, "adcp", None) + if adcp is None: + return False + idempotency = getattr(adcp, "idempotency", None) + if idempotency is None: + return False + return getattr(idempotency, "supported", None) is True + + +def _candidate_method_names(platform: Any) -> list[str]: + """Return public method names on the platform. + + Uses ``dir()`` + per-name ``getattr`` with try/except so a + boot-side-effect property (DB lookup, config fetch) on the platform + class doesn't blow up the validator. ``inspect.getmembers`` would + fire every descriptor. + """ + out: list[str] = [] + for name in dir(platform): + if name.startswith("_"): + continue + try: + attr = getattr(platform, name) + except Exception: # noqa: BLE001 — descriptor side effects are out of our control + continue + if callable(attr): + out.append(name) + return out + + +def _platform_has_wrapped_method(platform: Any) -> bool: + """True if any callable on the platform is registered as wrapped. + + Walks instance attributes so adopters who bind a wrapped function + in ``__init__`` (``self.create_media_buy = wrapped_fn``) are + recognized too — not just class-level ``@`` decoration. + """ + for name in dir(platform): + if name.startswith("_"): + continue + try: + attr = getattr(platform, name) + except Exception: # noqa: BLE001 + continue + if not callable(attr): + continue + if is_wrapped(attr): + return True + return False + + +def validate_idempotency_wiring(platform: Any) -> None: + """Boot-time fail-fast: idempotency advertised but no method wrapped. + + Honors the ``_adcp_idempotency_external = True`` opt-out for + adopters with upstream gateway dedup or a BYO decorator the SDK + can't introspect. + + :raises AdcpError: ``recovery='terminal'`` when the platform + declares ``IdempotencySupported(supported=True)`` but no method + on the platform is decorated with + :meth:`adcp.server.idempotency.IdempotencyStore.wrap` AND the + external opt-out is not set. + """ + if not idempotency_capability_supported(platform): + return + if getattr(platform, "_adcp_idempotency_external", False): + return + if _platform_has_wrapped_method(platform): + return + + from adcp.decisioning.types import AdcpError + + raise AdcpError( + "INVALID_REQUEST", + message=( + "capabilities.adcp.idempotency.supported=True is declared but " + "no method on the platform is decorated with @IdempotencyStore.wrap. " + "Buyers reading the capabilities envelope expect retries to be " + "deduped; without the decorator every retry re-executes side " + "effects. Wrap your mutating handlers — typically " + "create_media_buy, update_media_buy, sync_creatives, " + "activate_signal — with the decorator:\n\n" + " from adcp.server.idempotency import IdempotencyStore, MemoryBackend\n" + " idempotency = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)\n\n" + " class MySeller(DecisioningPlatform):\n" + " @idempotency.wrap\n" + " async def create_media_buy(self, params, context=None):\n" + " ...\n\n" + "Alternatively: set IdempotencySupported(supported=False) to opt " + "out, OR — if dedup is wired upstream of the SDK (gateway tier, " + "BYO middleware) — set _adcp_idempotency_external = True on the " + "platform class." + ), + recovery="terminal", + details={ + "missing": "@IdempotencyStore.wrap", + "decorator_import": "from adcp.server.idempotency import IdempotencyStore", + "candidate_methods": _candidate_method_names(platform), + "opt_out": "IdempotencySupported(supported=False)", + "external_opt_out": "_adcp_idempotency_external = True", + }, + ) + + +__all__ = [ + "idempotency_capability_supported", + "validate_idempotency_wiring", +] diff --git a/src/adcp/server/idempotency/__init__.py b/src/adcp/server/idempotency/__init__.py index 92201878d..a20f5af15 100644 --- a/src/adcp/server/idempotency/__init__.py +++ b/src/adcp/server/idempotency/__init__.py @@ -63,7 +63,7 @@ async def get_adcp_capabilities(self, params, context=None): canonical_json_sha256, strip_excluded_fields, ) -from adcp.server.idempotency.store import IdempotencyStore +from adcp.server.idempotency.store import IdempotencyStore, is_wrapped from adcp.server.idempotency.webhook_dedup import WebhookDedupStore __all__ = [ @@ -75,5 +75,6 @@ async def get_adcp_capabilities(self, params, context=None): "PgBackend", "WebhookDedupStore", "canonical_json_sha256", + "is_wrapped", "strip_excluded_fields", ] diff --git a/src/adcp/server/idempotency/store.py b/src/adcp/server/idempotency/store.py index 63927f16c..b6173fc88 100644 --- a/src/adcp/server/idempotency/store.py +++ b/src/adcp/server/idempotency/store.py @@ -32,6 +32,7 @@ import logging import time import warnings +import weakref from collections.abc import Awaitable, Callable from functools import wraps from typing import Any @@ -44,6 +45,33 @@ logger = logging.getLogger(__name__) +# Registry of functions returned by IdempotencyStore.wrap. Read by +# adcp.decisioning.validate_idempotency.is_wrapped() to reconcile the +# adopter's declared IdempotencySupported capability against actual +# decorator application. WeakSet so wrapper functions garbage-collect +# normally when the platform method holding them goes away — the +# registry doesn't pin them in memory. +# +# Defense-in-depth choice over a public attribute on the wrapper: a +# plain attr can be set by any caller (test fixture, monkeypatch) and +# silently defeat the validator. Membership in this private set is +# only granted by IdempotencyStore.wrap itself. +_WRAPPED_FUNCTIONS: weakref.WeakSet[Callable[..., Any]] = weakref.WeakSet() + + +def is_wrapped(fn: Any) -> bool: + """Return True if ``fn`` was produced by :meth:`IdempotencyStore.wrap`. + + Accepts bound methods (resolves to the underlying function before + the membership check) and plain callables. Used by the boot-time + validator at :mod:`adcp.decisioning.validate_idempotency`. + """ + if fn is None: + return False + target = fn.__func__ if hasattr(fn, "__func__") else fn + return target in _WRAPPED_FUNCTIONS + + # Spec bounds from capabilities.idempotency.replay_ttl_seconds (1h-7d). _MIN_TTL_SECONDS = 3600 _MAX_TTL_SECONDS = 604800 @@ -189,6 +217,21 @@ async def _wrapped( ) return response + # Register the wrapper for the boot-time validator at + # adcp.decisioning.validate_idempotency. WeakSet membership — + # not a public attribute — so adopters can't spoof "wrapped" + # by stamping an attr on a plain function. The wrapper is + # registered, not the original handler: re-decorating a forked + # copy of `handler` would otherwise falsely flag both. + # + # Contract for future maintainers: ``is_wrapped()`` checks + # WeakSet membership of the closure object directly. Do NOT + # change it to ``inspect.unwrap()``-then-check — the + # ``@functools.wraps(handler)`` decorator above sets + # ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap`` + # would return the original handler (not in the WeakSet) and + # the validator would silently regress. + _WRAPPED_FUNCTIONS.add(_wrapped) return _wrapped def _prepare(self, params: Any, context: Any) -> tuple[str | None, str | None, dict[str, Any]]: diff --git a/tests/test_validate_idempotency_wiring.py b/tests/test_validate_idempotency_wiring.py new file mode 100644 index 000000000..d2874b30c --- /dev/null +++ b/tests/test_validate_idempotency_wiring.py @@ -0,0 +1,370 @@ +"""Tests for the idempotency-wiring boot-time validator. + +Catches the silent-lie configuration: a platform that advertises +``capabilities.adcp.idempotency.supported=True`` while never applying +``@IdempotencyStore.wrap`` to any handler method. +""" + +from __future__ import annotations + +import pytest + +from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts +from adcp.decisioning.capabilities import ( + Adcp, + IdempotencySupported, + IdempotencyUnsupported, +) +from adcp.decisioning.types import AdcpError +from adcp.decisioning.validate_idempotency import ( + idempotency_capability_supported, + validate_idempotency_wiring, +) +from adcp.server.idempotency import IdempotencyStore, MemoryBackend, is_wrapped + + +def _store() -> IdempotencyStore: + return IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400) + + +def _caps_with_idempotency(*, supported: bool) -> DecisioningCapabilities: + if supported: + idempotency = IdempotencySupported(supported=True, replay_ttl_seconds=86400) + else: + idempotency = IdempotencyUnsupported(supported=False) + return DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=("operator",), + adcp=Adcp(major_versions=[3], idempotency=idempotency), + ) + + +# --------------------------------------------------------------------------- +# is_wrapped helper +# --------------------------------------------------------------------------- + + +class TestIsWrapped: + def test_unwrapped_function_returns_false(self) -> None: + async def plain_handler(self, params, context=None): + return {} + + assert is_wrapped(plain_handler) is False + + def test_wrapped_function_returns_true(self) -> None: + store = _store() + + @store.wrap + async def handler(self, params, context=None): + return {} + + assert is_wrapped(handler) is True + + def test_none_returns_false(self) -> None: + assert is_wrapped(None) is False + + def test_attribute_spoofing_does_not_register(self) -> None: + """Setting a sentinel attr on a plain function must NOT + register it — defense-in-depth against the previous-design + attr-based sentinel.""" + + async def plain_handler(self, params, context=None): + return {} + + plain_handler.__adcp_idempotency_wrapped__ = True # type: ignore[attr-defined] + assert is_wrapped(plain_handler) is False + + def test_bound_method_resolves(self) -> None: + """A wrapped function used as a bound method still returns + True — ``is_wrapped`` resolves ``__func__``.""" + store = _store() + + @store.wrap + async def handler(self, params, context=None): + return {} + + class Holder: + create_media_buy = handler + + instance = Holder() + assert is_wrapped(instance.create_media_buy) is True + + +# --------------------------------------------------------------------------- +# idempotency_capability_supported +# --------------------------------------------------------------------------- + + +class TestIdempotencyCapabilitySupported: + def test_no_capabilities_attr_returns_false(self) -> None: + class P: + pass + + assert idempotency_capability_supported(P()) is False + + def test_capabilities_none_returns_false(self) -> None: + class P: + capabilities = None + + assert idempotency_capability_supported(P()) is False + + def test_no_adcp_arm_returns_false(self) -> None: + class P: + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=("operator",), + ) + + assert idempotency_capability_supported(P()) is False + + def test_unsupported_arm_returns_false(self) -> None: + class P: + capabilities = _caps_with_idempotency(supported=False) + + assert idempotency_capability_supported(P()) is False + + def test_supported_arm_returns_true(self) -> None: + class P: + capabilities = _caps_with_idempotency(supported=True) + + assert idempotency_capability_supported(P()) is True + + +# --------------------------------------------------------------------------- +# validate_idempotency_wiring — direct unit tests +# --------------------------------------------------------------------------- + + +class _MinimalPlatform: + """Bare minimum platform shape — no DecisioningPlatform inheritance + so unrelated boot validation doesn't run. The validator only needs + ``capabilities`` and method introspection.""" + + def __init__(self, capabilities, methods=None): + self.capabilities = capabilities + for name, fn in (methods or {}).items(): + setattr(self, name, fn) + + +async def _stub_create_media_buy(self, params, context=None): + return {"media_buy_id": "x"} + + +async def _stub_update_media_buy(self, mid, p, context=None): + return {"media_buy_id": mid} + + +class TestValidateIdempotencyWiring: + def test_no_capability_passes(self) -> None: + platform = _MinimalPlatform( + capabilities=DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=("operator",), + ), + ) + validate_idempotency_wiring(platform) + + def test_unsupported_arm_passes(self) -> None: + platform = _MinimalPlatform( + capabilities=_caps_with_idempotency(supported=False), + ) + validate_idempotency_wiring(platform) + + def test_supported_with_wrap_passes(self) -> None: + store = _store() + + @store.wrap + async def create_media_buy(self, params, context=None): + return {"media_buy_id": "x"} + + platform = _MinimalPlatform( + capabilities=_caps_with_idempotency(supported=True), + methods={"create_media_buy": create_media_buy}, + ) + validate_idempotency_wiring(platform) + + def test_supported_with_async_but_unwrapped_methods_raises(self) -> None: + """Real async handlers that lack the wrap registration → raise. + Distinct from earlier lambda-based test which couldn't exercise + the wrapped-vs-unwrapped discriminator.""" + platform = _MinimalPlatform( + capabilities=_caps_with_idempotency(supported=True), + methods={ + "create_media_buy": _stub_create_media_buy, + "update_media_buy": _stub_update_media_buy, + }, + ) + with pytest.raises(AdcpError) as exc_info: + validate_idempotency_wiring(platform) + + err = exc_info.value + assert err.recovery == "terminal" + assert "@IdempotencyStore.wrap" in str(err) + assert err.details["missing"] == "@IdempotencyStore.wrap" + assert err.details["decorator_import"].startswith("from adcp.server.idempotency") + assert "create_media_buy" in err.details["candidate_methods"] + assert "update_media_buy" in err.details["candidate_methods"] + assert err.details["external_opt_out"] == "_adcp_idempotency_external = True" + + def test_external_opt_out_passes_without_wrap(self) -> None: + """``_adcp_idempotency_external = True`` on the platform class + opts out of the wrap-coverage check. For adopters with + gateway-tier dedup or a BYO decorator the SDK can't introspect.""" + + class P: + capabilities = _caps_with_idempotency(supported=True) + _adcp_idempotency_external = True + + async def create_media_buy(self, params, context=None): + return {} + + validate_idempotency_wiring(P()) + + def test_property_with_boot_side_effect_does_not_blow_up(self) -> None: + """Platforms with a ``@property`` that raises (e.g., DB lookup + before connection is open) must not trip the validator's method + scan. ``inspect.getmembers`` would fire every descriptor; the + validator uses ``dir`` + try/except per name.""" + + class P: + capabilities = _caps_with_idempotency(supported=True) + + @property + def db_session(self): + raise RuntimeError("DB not connected at boot") + + # The platform has no wrapped method → expect the validator to + # raise the wiring AdcpError (not the property's RuntimeError). + with pytest.raises(AdcpError) as exc_info: + validate_idempotency_wiring(P()) + assert exc_info.value.recovery == "terminal" + + def test_dynamic_bind_in_init(self) -> None: + """Adopters may bind a wrapped function in ``__init__``. The + introspection walks the instance, so this still passes.""" + store = _store() + + @store.wrap + async def handler(self, params, context=None): + return {} + + class P: + capabilities = _caps_with_idempotency(supported=True) + + def __init__(self): + self.create_media_buy = handler + + validate_idempotency_wiring(P()) + + +# --------------------------------------------------------------------------- +# Boot-time integration: validator runs from create_adcp_server_from_platform +# --------------------------------------------------------------------------- + + +def _sales_methods(): + """Stubs for the SalesPlatform required surface — none wrapped.""" + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "x", "status": "active"} + + def update_media_buy(self, mid, p, ctx): + return {"media_buy_id": mid, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def get_media_buys(self, req, ctx): + return {"media_buys": []} + + def list_creative_formats(self, req, ctx): + return {"creative_formats": []} + + def list_creatives(self, req, ctx): + return {"creatives": []} + + def provide_performance_feedback(self, req, ctx): + return {"acknowledged": True} + + return { + "get_products": get_products, + "create_media_buy": create_media_buy, + "update_media_buy": update_media_buy, + "sync_creatives": sync_creatives, + "get_media_buy_delivery": get_media_buy_delivery, + "get_media_buys": get_media_buys, + "list_creative_formats": list_creative_formats, + "list_creatives": list_creatives, + "provide_performance_feedback": provide_performance_feedback, + } + + +def test_boot_time_raises_when_idempotency_advertised_without_wrap() -> None: + """End-to-end: ``create_adcp_server_from_platform`` invokes the + validator and the boot fails with a structured AdcpError.""" + from adcp.testing import build_asgi_app + + methods = _sales_methods() + + class LyingPlatform(DecisioningPlatform): + capabilities = _caps_with_idempotency(supported=True) + accounts = SingletonAccounts(account_id="t") + + for name, fn in methods.items(): + setattr(LyingPlatform, name, fn) + + with pytest.raises(AdcpError) as exc_info: + build_asgi_app(LyingPlatform()) + + err = exc_info.value + assert err.recovery == "terminal" + assert "@IdempotencyStore.wrap" in str(err) + + +def test_boot_time_passes_when_idempotency_advertised_and_wrapped() -> None: + """The same shape passes once at least one method is wrapped.""" + from adcp.testing import build_asgi_app + + store = _store() + methods = _sales_methods() + + @store.wrap + async def create_media_buy_wrapped(self, params, context=None): + return {"media_buy_id": "x", "status": "active"} + + methods["create_media_buy"] = create_media_buy_wrapped + + class HonestPlatform(DecisioningPlatform): + capabilities = _caps_with_idempotency(supported=True) + accounts = SingletonAccounts(account_id="t") + + for name, fn in methods.items(): + setattr(HonestPlatform, name, fn) + + app = build_asgi_app(HonestPlatform()) + assert app is not None + + +def test_boot_time_passes_with_external_opt_out() -> None: + """End-to-end: external opt-out lets a platform advertise + idempotency support without the SDK seeing any wrapped method.""" + from adcp.testing import build_asgi_app + + methods = _sales_methods() + + class GatewayDedupPlatform(DecisioningPlatform): + capabilities = _caps_with_idempotency(supported=True) + accounts = SingletonAccounts(account_id="t") + _adcp_idempotency_external = True + + for name, fn in methods.items(): + setattr(GatewayDedupPlatform, name, fn) + + app = build_asgi_app(GatewayDedupPlatform()) + assert app is not None