Skip to content

Commit 68682ff

Browse files
bokelleyclaude
andcommitted
feat(decisioning): ProposalManager v1.5 core primitives — store, recipe, capabilities, lifecycle helpers (#538-impl)
Lands the foundation for v1.5 proposal lifecycle work per docs/proposals/proposal-manager-v15-design.md (PR #538): - proposal_store.py: ProposalStore Protocol + InMemoryProposalStore reference impl with state-machine guards (DRAFT → COMMITTED → CONSUMED). Includes get_by_media_buy_id reverse-index for the single-ledger recipe-persistence design (D1, D3). create_dev_proposal_store() factory warns on construction. is_durable=False for the in-memory ref mirrors TaskRegistry posture. - recipe.py: CapabilityOverlap dataclass with four typed axes (pricing_models, targeting_dimensions, delivery_types, signal_types). No extras dict per § D4 — adopters subclass. Recipe gains optional capability_overlap field. - proposal_manager.py: ProposalCapabilities adds finalize + expires_at_grace_seconds. ProposalManager Protocol adds finalize_proposal returning union (FinalizeProposalSuccess | TaskHandoff[FinalizeProposalSuccess]) — single method per § D2, matching the create_media_buy precedent. Adds typed FinalizeProposalRequest / FinalizeProposalSuccess dataclasses. multi_decisioning retained for v1 source-compat but framework no longer reads it (Resolutions §6). - proposal_lifecycle.py: framework intercept seam parallel to refine.py / webhook_emit.py. Carries enforce_proposal_expiry (D7), validate_capability_overlap (D4 buyer-request gate), validate_overlap_subset_of_wire (D4 round-4 drift check), detect_finalize_action helper, and structured log emit helpers per § Observability (proposal.draft_persisted, proposal.finalized, proposal.expired, proposal.consumed). - platform_router.py: adds proposal_stores= constructor kwarg with orphan-key validation + cross-store consistency check (D5). Hard error at construction when finalize=True without a wired store — the error message names the exact kwarg to add. - context.py: RequestContext.recipes field typed as Mapping[str, Recipe] per § D3. - tests/test_error_code_conformance.py: PROPOSAL_NOT_FOUND added to KNOWN_NON_SPEC_CODES (PROPOSAL_EXPIRED + PROPOSAL_NOT_COMMITTED already in canonical 3.0 enum). Cites adcp#4043 spec issue. Out of this commit (follow-up): full dispatch wiring for finalize interception in get_products and create_media_buy(proposal_id=...); unit tests; the v1.5 hello example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b16d18f commit 68682ff

8 files changed

Lines changed: 1430 additions & 22 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,21 @@ def create_media_buy(
123123
validate_property_list_config,
124124
)
125125
from adcp.decisioning.proposal_manager import (
126+
FinalizeProposalRequest,
127+
FinalizeProposalSuccess,
126128
MockProposalManager,
127129
ProposalCapabilities,
128130
ProposalManager,
129131
SalesSpecialism,
130132
)
131-
from adcp.decisioning.recipe import Recipe
133+
from adcp.decisioning.proposal_store import (
134+
InMemoryProposalStore,
135+
ProposalRecord,
136+
ProposalState,
137+
ProposalStore,
138+
create_dev_proposal_store,
139+
)
140+
from adcp.decisioning.recipe import CapabilityOverlap, Recipe
132141
from adcp.decisioning.refine import (
133142
RefinementOutcome,
134143
RefinementStatus,
@@ -331,6 +340,14 @@ def __init__(self, *args: object, **kwargs: object) -> None:
331340
"Proposal",
332341
"ProposalCapabilities",
333342
"ProposalManager",
343+
"ProposalRecord",
344+
"ProposalState",
345+
"ProposalStore",
346+
"InMemoryProposalStore",
347+
"FinalizeProposalRequest",
348+
"FinalizeProposalSuccess",
349+
"CapabilityOverlap",
350+
"create_dev_proposal_store",
334351
"PropertyList",
335352
"PropertyListFetcher",
336353
"PropertyListReference",

src/adcp/decisioning/context.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from adcp.server.base import ToolContext
2929

3030
if TYPE_CHECKING:
31+
from adcp.decisioning.recipe import Recipe
3132
from adcp.decisioning.registry import (
3233
BuyerAgent,
3334
Credential,
@@ -534,6 +535,12 @@ class RequestContext(ToolContext, Generic[TMeta]):
534535
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
535536
state: StateReader = field(default_factory=_make_default_state_reader)
536537
resolve: ResourceResolver = field(default_factory=_make_default_resolver)
538+
# ``recipes`` — populated by the framework on dispatch paths that
539+
# hydrate a proposal (post-finalize ``create_media_buy`` /
540+
# ``update_media_buy`` / ``get_media_buy_delivery``). Empty mapping
541+
# by default so legacy / non-proposal flows see the v1 shape. See
542+
# ``docs/proposals/proposal-manager-v15-design.md`` § D3.
543+
recipes: Mapping[str, Recipe] = field(default_factory=dict)
537544

538545
def handoff_to_task(
539546
self,

src/adcp/decisioning/platform_router.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
from adcp.decisioning.accounts import AccountStore
122122
from adcp.decisioning.context import RequestContext
123123
from adcp.decisioning.proposal_manager import ProposalManager
124+
from adcp.decisioning.proposal_store import ProposalStore
124125

125126

126127
# Every specialism Protocol the framework knows about. New Protocol
@@ -325,6 +326,7 @@ def __init__(
325326
platforms: Mapping[str, DecisioningPlatform],
326327
capabilities: DecisioningCapabilities,
327328
proposal_managers: Mapping[str, ProposalManager] | None = None,
329+
proposal_stores: Mapping[str, ProposalStore] | None = None,
328330
) -> None:
329331
if not platforms:
330332
raise ValueError(
@@ -351,6 +353,36 @@ def __init__(
351353
f"orphan tenant_id(s): {sorted(orphans)}"
352354
)
353355

356+
# Per-tenant ProposalStore binding (v1.5 § D5). Same orphan
357+
# validation; no auto-allocation — finalize-capable managers
358+
# without a wired store are a hard error so multi-worker
359+
# deployments don't silently lose proposals at the first
360+
# worker that didn't see put_draft.
361+
self._proposal_stores: dict[str, ProposalStore] = dict(proposal_stores or {})
362+
if self._proposal_stores:
363+
orphans = set(self._proposal_stores) - set(self._platforms)
364+
if orphans:
365+
raise ValueError(
366+
f"proposal_stores keys must be a subset of platforms keys; "
367+
f"orphan tenant_id(s): {sorted(orphans)}"
368+
)
369+
370+
# Cross-store consistency check: a tenant declaring
371+
# finalize=True needs a wired store. The error message names
372+
# the exact kwarg to add — adopters get a 30-second copy-paste
373+
# rather than a debugging session at first finalize request.
374+
for tenant_id, manager in self._proposal_managers.items():
375+
caps = getattr(manager, "capabilities", None)
376+
finalize_supported = bool(getattr(caps, "finalize", False))
377+
if finalize_supported and tenant_id not in self._proposal_stores:
378+
raise ValueError(
379+
f"Tenant {tenant_id!r} wired a ProposalManager declaring "
380+
f"finalize=True, but no ProposalStore was registered for "
381+
f"that tenant. Wire one via "
382+
f"proposal_stores={{{tenant_id!r}: InMemoryProposalStore()}}, "
383+
"or remove the finalize capability."
384+
)
385+
354386
self.accounts = accounts
355387
self.capabilities = capabilities
356388

@@ -585,5 +617,17 @@ def proposal_manager_for_tenant(self, tenant_id: str) -> ProposalManager | None:
585617
"""
586618
return self._proposal_managers.get(tenant_id)
587619

620+
def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
621+
"""Return the :class:`ProposalStore` for ``tenant_id``, or
622+
``None`` when the tenant has no store wired.
623+
624+
Tenants without a wired store cannot dispatch finalize / expiry
625+
/ consume paths — the cross-store consistency check at
626+
construction prevents declaring ``finalize=True`` without a
627+
store, but tenants running pure-catalog mode with no finalize
628+
legitimately have no store.
629+
"""
630+
return self._proposal_stores.get(tenant_id)
631+
588632

589633
__all__ = ["PlatformRouter"]

0 commit comments

Comments
 (0)