Summary
PlatformRouter (the eager router) accepts proposal_stores: Mapping[str, ProposalStore] and exposes proposal_store_for_tenant(tenant_id) — the accessor the framework's proposal_dispatch duck-types via hasattr(platform, "proposal_store_for_tenant"). LazyPlatformRouter doesn't accept the kwarg and doesn't expose the accessor, so adopters wiring LazyPlatformRouter (the multi-tenant lazy-build pattern the docstring recommends for 50+ tenants) can't plug a ProposalStore into the framework's proposal lifecycle.
Where it bites
adcp/decisioning/platform_router.py:
PlatformRouter.__init__ accepts proposal_stores= (line ~331) and proposal_store_for_tenant is defined (line ~579).
LazyPlatformRouter.__init__ accepts only proposal_managers= (line ~736); there's no proposal_stores= and no proposal_store_for_tenant method.
adcp/decisioning/proposal_dispatch.py:109:
if hasattr(platform, "proposal_store_for_tenant"):
store = platform.proposal_store_for_tenant(tenant_id)
→ on LazyPlatformRouter, hasattr returns False, store=None, and maybe_persist_draft_after_get_products / maybe_hydrate_recipes_for_create_media_buy both short-circuit. Adopters get the v1 (no-proposal) path silently.
Adopter workaround (we shipped this)
Subclass LazyPlatformRouter and add the accessor:
class _LazyPlatformRouterWithStore(LazyPlatformRouter):
def __init__(self, *args, proposal_store: ProposalStore | None = None, **kwargs):
super().__init__(*args, **kwargs)
self._proposal_store = proposal_store
def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
return self._proposal_store
Works because the framework's dispatch is duck-typed (hasattr), but every adopter wiring LazyPlatformRouter will hit the same wall and write the same subclass. Strict parity with PlatformRouter would resolve.
Proposed fix
Mirror PlatformRouter's shape on LazyPlatformRouter:
- Add
proposal_stores: Mapping[str, ProposalStore] | None = None to __init__
- Persist as
self._proposal_stores: dict[str, ProposalStore]
- Add
proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None: return self._proposal_stores.get(tenant_id)
- Match the eager router's cross-store consistency check (
finalize_supported and tenant_id not in self._proposal_stores → ValueError)
Single-store-across-tenants adopters can still pass {tid: shared_store for tid in tenants} or — better — accept a proposal_store_factory: Callable[[str], ProposalStore] (the same shape as factory= for platforms). The factory shape composes more naturally with the lazy router's per-tenant-on-first-request philosophy.
Context
Hit while wiring a Postgres-backed ProposalStore in prebid/salesagent (PR #390) to unblock the media_buy_seller/proposal_finalize/create_media_buy storyboard step. Filed concurrent issue on the lifecycle gap that storyboard exposes (separate from this).
Summary
PlatformRouter(the eager router) acceptsproposal_stores: Mapping[str, ProposalStore]and exposesproposal_store_for_tenant(tenant_id)— the accessor the framework'sproposal_dispatchduck-types viahasattr(platform, "proposal_store_for_tenant").LazyPlatformRouterdoesn't accept the kwarg and doesn't expose the accessor, so adopters wiringLazyPlatformRouter(the multi-tenant lazy-build pattern the docstring recommends for 50+ tenants) can't plug aProposalStoreinto the framework's proposal lifecycle.Where it bites
adcp/decisioning/platform_router.py:PlatformRouter.__init__acceptsproposal_stores=(line ~331) andproposal_store_for_tenantis defined (line ~579).LazyPlatformRouter.__init__accepts onlyproposal_managers=(line ~736); there's noproposal_stores=and noproposal_store_for_tenantmethod.adcp/decisioning/proposal_dispatch.py:109:→ on
LazyPlatformRouter,hasattrreturns False,store=None, andmaybe_persist_draft_after_get_products/maybe_hydrate_recipes_for_create_media_buyboth short-circuit. Adopters get the v1 (no-proposal) path silently.Adopter workaround (we shipped this)
Subclass
LazyPlatformRouterand add the accessor:Works because the framework's dispatch is duck-typed (
hasattr), but every adopter wiringLazyPlatformRouterwill hit the same wall and write the same subclass. Strict parity withPlatformRouterwould resolve.Proposed fix
Mirror
PlatformRouter's shape onLazyPlatformRouter:proposal_stores: Mapping[str, ProposalStore] | None = Noneto__init__self._proposal_stores: dict[str, ProposalStore]proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None: return self._proposal_stores.get(tenant_id)finalize_supported and tenant_id not in self._proposal_stores → ValueError)Single-store-across-tenants adopters can still pass
{tid: shared_store for tid in tenants}or — better — accept aproposal_store_factory: Callable[[str], ProposalStore](the same shape asfactory=for platforms). The factory shape composes more naturally with the lazy router's per-tenant-on-first-request philosophy.Context
Hit while wiring a Postgres-backed
ProposalStoreinprebid/salesagent(PR #390) to unblock themedia_buy_seller/proposal_finalize/create_media_buystoryboard step. Filed concurrent issue on the lifecycle gap that storyboard exposes (separate from this).