feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager forwarder + tenant binding#504
Merged
Merged
Conversation
85dceb5 to
7e28a6a
Compare
…r forwarder + tenant binding Foundation for the two-platform composition established in the product architecture design doc (PR #502, docs/proposals/product-architecture.md). Adopters can now wire a separate ProposalManager that handles get_products / refine, while DecisioningPlatform handles create_media_buy and lifecycle. The recipe (typed implementation_config) is the contract between them. v1 surface: - ProposalManager Protocol (sync/async, capability-gated refine) - ProposalCapabilities dataclass (sales_specialism + flags) - Recipe Pydantic base with recipe_kind discriminator - MockProposalManager v1 default forwarder (symmetric with Phase 2's upstream_for mock-mode dispatch on DecisioningPlatform) - proposal_manager= kwarg on serve() / create_adcp_server_from_platform - Dispatcher routing in PlatformHandler.get_products: routes to ProposalManager when wired (with refine/get_products selection by buying_mode + capability + method-presence); falls through to platform.get_products otherwise — backward-compat by construction Out of scope (deferred to subsequent PRs, called out in module doc): - Session cache for in-flight proposals - finalize transition (buying_mode='refine' + action='finalize') - expires_at enforcement - capability_overlap declaration on Recipe + framework validation - Recipe persistence through buy lifecycle (hydration in create_media_buy / update_media_buy / get_delivery) - Per-tenant ProposalManager binding via PlatformRouter - MIGRATION.md updates for v3 reference seller Tests: 16 new in tests/test_proposal_manager.py covering Protocol conformance, capability validation, MockProposalManager forwarding (respx-mocked), dispatcher routing with/without proposal_manager, adopter subclass dispatch, sync+async support, and refine routing across all four (capability, method, buying_mode, fall-through) cases. Full regression suite (3718 tests) green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7e28a6a to
9d68a80
Compare
v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on
serve()/PlatformHandler. Reviewer feedback (Brian, architect):
ProposalManager has to be per-tenant. Multi-tenant deployments
(salesagent, agentic-adapters social) need different proposal logic per
tenant — a GAM tenant has different products from a Kevel tenant; a Meta
tenant has different proposal assembly from a TikTok tenant.
Single-tenant binding doesn't fit.
Changes:
- PlatformRouter accepts ``proposal_managers={tenant_id:
ProposalManager}``. Validates keys are a subset of platforms keys
at construction (orphan tenants raise ValueError). The router
overrides its synthesized ``get_products`` delegation with an
explicit method that does per-tenant manager lookup, refine-mode
selection (capability + method-presence gated), and per-tenant
fall-through to ``platforms[tenant_id].get_products`` when no
manager is wired — back-compat per tenant.
- ``serve(proposal_manager=)`` and
``create_adcp_server_from_platform(proposal_manager=)`` kwargs
removed. Single-tenant adopters wire a one-entry router:
``PlatformRouter(platforms={"default": ...},
proposal_managers={"default": ...})``. Same pattern as v3 reference
seller adopted in PR #488.
- PlatformHandler's ``proposal_manager=`` field and
``_select_proposal_method`` helper removed. The router's
``get_products`` does its own dispatch and the handler delegates
uniformly via ``_invoke_platform_method``.
- ``examples/hello_proposal_manager.py`` rewritten to demonstrate the
per-tenant binding: tenant_acme has a wired MockProposalManager;
tenant_globex falls through to its platform's get_products.
- Tests rewritten to cover per-tenant routing: orphan-key validation,
per-tenant isolation, per-tenant fall-through, sync+async manager
dispatch, refine routing across all four conditions. 18 tests
total, all green; full regression suite (3720 tests) green; no
existing example modified (v3 reference seller, hello_seller,
hello_mock_seller, multi_platform_seller — back-compat preserved
since none ever used the removed ``proposal_manager=`` kwarg).
- docs/proposals/product-architecture.md § "Open questions" updated:
the tenant binding model question is now resolved (v1 ships
per-tenant via PlatformRouter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9d68a80 to
d50c64e
Compare
This was referenced May 4, 2026
bokelley
added a commit
that referenced
this pull request
May 4, 2026
…e, expires_at, capability validation, recipe lifecycle Design draft for the post-Protocol-skeleton work that gives adopters real proposal lifecycle support. Anchors v1.5 implementation: session cache (ProposalStore Protocol), sync + async finalize surfaces with expires_at hold window, capability-overlap declaration on Recipe with framework validation pre-adapter, and recipe persistence through the buy lifecycle via MediaBuyStore extension. Builds on PR #502 (product-architecture.md) and PR #504 (ProposalManager v1 Protocol). Cites the spec compliance scenarios proposal_finalize.yaml + refine_products.yaml as the storyboard contracts v1.5 enables adopters to satisfy. Quantifies the adopter LOC budget (≤ 350 LOC for a working proposal-mode mock) against the v1 hello example anchor. DRAFT for review before implementation; flags eight open questions the architect couldn't resolve alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 4, 2026
…e, expires_at, capability validation, recipe lifecycle (#538) * docs(proposals): ProposalManager v1.5 design — session cache, finalize, expires_at, capability validation, recipe lifecycle Design draft for the post-Protocol-skeleton work that gives adopters real proposal lifecycle support. Anchors v1.5 implementation: session cache (ProposalStore Protocol), sync + async finalize surfaces with expires_at hold window, capability-overlap declaration on Recipe with framework validation pre-adapter, and recipe persistence through the buy lifecycle via MediaBuyStore extension. Builds on PR #502 (product-architecture.md) and PR #504 (ProposalManager v1 Protocol). Cites the spec compliance scenarios proposal_finalize.yaml + refine_products.yaml as the storyboard contracts v1.5 enables adopters to satisfy. Quantifies the adopter LOC budget (≤ 350 LOC for a working proposal-mode mock) against the v1 hello example anchor. DRAFT for review before implementation; flags eight open questions the architect couldn't resolve alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(proposals): bake Brian's resolutions into v1.5 design Resolutions from review: 1. Auto-allocate InMemoryProposalStore — OPEN; default to explicit wiring, revisit on adopter feedback 2. Sync-vs-async finalize — DROP time_budget inference; explicit ProposalCapabilities.finalize_sync/finalize_async declaration 3. PROPOSAL_EXPIRED / PROPOSAL_NOT_FOUND — ship via KNOWN_NON_SPEC_CODES allowlist; ask spec for 3.1 4. MediaBuyStore extension — IN PLACE 5. update_media_buy capability re-validation — RE-VALIDATE every call; drop recipe-identity-cache complexity 6. multi_decisioning capability field — REMOVE for v1.5 7. Optional Protocol method detection — mirror v1's hasattr pattern 8. InMemoryProposalStore location — src/adcp/decisioning/ D2 finalize section + § Out-of-scope spec-error coordination updated to reflect new posture. Open-questions section restructured as Resolutions with the source quotes captured. * docs(proposals): apply DX review SHOULD-FIX to v1.5 design Round 2 review (dx-expert) on PR #538. No architectural blockers; 8 naming/typing/semantic-clarity fixes the python-expert needs answered before writing implementation code, plus 5 nice-to-haves. Applied: - Rename methods finalize_proposal_sync/_async → finalize_proposal/_with_handoff (and capability flags finalize_sync/_async → finalize/_with_handoff). Disambiguates from Python "async def". - ctx.recipes typed as Mapping[str, Recipe], not dict[str, Any]. Adapters down-cast to their concrete subclass; mypy + Cursor stay in the typed lane. - CapabilityOverlap fields are frozenset[str] | None (None = no gate; frozenset() = deny-all). Matches Python set intuition. - D5 cross-store consistency: explicit-wiring with hard error (resolves Resolutions §1 from OPEN); error message names exact kwarg to add. Auto-allocate-with-warning was the multi-worker silent-data-loss footgun. - Default sync-on-both-declared: framework dispatches to inline finalize_proposal; HUMAN_REVIEW_REQUIRED escalation re-dispatches to handoff path. Boring default; matches storyboard. - PROPOSAL_NOT_COMMITTED replaces INVALID_REQUEST for state-mismatch errors; PROPOSAL_NOT_FOUND covers absent records. Codes ship via KNOWN_NON_SPEC_CODES allowlist (Resolutions §3). - ProposalRecord typed dataclass spelled out (not just prose); recipe_schema_version captured for durable-store migrations. - Hello-skeleton ~50 LOC sketch for examples/hello_proposal_manager_v15.py to validate the LOC budget before implementation. - Multi-tenant mixed-version row added to compatibility matrix. - Testing primitives + observability hooks one-paragraph notes. - _await_maybe helper pointer for MaybeAsync await-bridging. * docs(proposals): apply round 3 adopter feedback to v1.5 design Adopter review on PR #538 from someone writing the salesagent integration. Six items, all clean improvements: 1. D2 — collapse two finalize methods into one with union return type. Mirrors the SDK precedent (create_media_buy returns Result | TaskHandoff[Result]; dispatch projects). Half the surface, no phantom "is this sync or async?" question. Capability collapses to a single `finalize: bool`. Drop the HUMAN_REVIEW_REQUIRED escalation pattern (no longer needed — adopter branches internally on its own state, returns the right shape, framework projects). 2. D1 — explicit ProposalState StrEnum on ProposalRecord, not implicit-from-which-method-was-called. Clear contract for adopter ProposalStore implementations (especially durable SQL/Redis backings). Framework guards transitions in commit() and mark_consumed(). 3. D4 — drop CapabilityOverlap.extras dict entirely. Adopters with custom gating subclass and add typed fields. Subclass leaves a paper trail; dict bag does not. The "file an issue" social pressure doesn't survive contact with deadlines. 4. D7 — one-paragraph note on clock posture. Framework now is process-local; multi-worker deployments need NTP sync within the grace window or workers grace-or-reject the same proposal inconsistently at the boundary. 5. New "Adopter migration: v1 → v1.5 in ~50 LOC" section showing the salesagent path: Recipe subclass + ProposalManager + router wiring. Three ~30-LOC chunks. 6. Resolutions §1 already shipped explicit-wiring — confirms. Net effect: simpler shape, clearer contracts, less surface. * docs(proposals): apply round 4 TS feedback to v1.5 design TS-side reviewer feedback on PR #538. Net effect: simpler again. D3 race fix — collapse to single ledger. The original two-store split (ProposalStore for draft/committed + MediaBuyStore for consumed) had a two-write race at the create_media_buy hand-off (persist_recipes + mark_consumed across stores). idempotency_key covers buyer retries, not framework crash mid-handoff. Collapses to: ProposalStore is single source of truth throughout the lifecycle. New get_by_media_buy_id reverse-index method services post-acceptance lookups. MediaBuyStore stays unchanged from v1. Drops the media_buy_stores= router kwarg too. Supersedes Brian's earlier "in place" resolution (§4) with TS-flagged race rationale. D4 wire-derived overlap — validate at put_draft. Recipe.capability_overlap.pricing_models is a subset of what Product.pricing_options[*].pricing_model already declares on the wire. Two declarations risk drift. Framework now checks overlap ⊆ wire-capabilities at put_draft time; mismatches raise INTERNAL_ERROR (adopter bug, not buyer error). Future v1.6 may derive overlap entirely from wire once all axes are first-class. D1 MaybeAsync — Python-only wart, doc note. Cross-language alignment table flags this as Python-only ergonomics. TS uses Promise<T> consistently; the equivalent semantic is "always async" with no opt-out. Don't port to TS. D2 cross-language — keep single-method-with-union for Python. TS author claimed JS established createMediaBuy + createMediaBuyTask (two methods). Verified Python's SDK established the opposite at types.py:187 — single method returning Success | TaskHandoff. Python stays consistent with its own precedent. New "Cross-language alignment" section documents both shapes are wire-equivalent; TS may keep its two-method JS convention, project at the protocol layer. D7 spec issue paper trail. Already filed adcp#4043; round 2 already required PR description to cite the URL. Confirmed. Open Q1 — dev-mode helper. Add create_dev_proposal_store() factory that wraps in-memory and warns on construction. Doesn't change explicit-wiring posture (D5 still rejects finalize-capable manager without wired store); reads as deliberate dev-mode choice in storyboard wiring. refine_products.yaml caveat. Was claimed as "strict subset of proposal_finalize.yaml" — not true. Refine multi-turn touches state finalize doesn't (draft overwrite across N iterations, abandon-without-commit). Adds pass criteria for refine-only flows. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Foundation for the two-platform composition established in the product architecture design doc (#502,
docs/proposals/product-architecture.md). Adopters can now wire a separateProposalManagerthat handlesget_products/refine, whileDecisioningPlatformhandlescreate_media_buyand lifecycle. The recipe (typedimplementation_config) is the contract between them.After this PR, the on-ramp for new adopters is: point a
MockProposalManagerat a runningbin/adcp.js mock-server <specialism>and ship a working seller agent with zero adopter code on the proposal side. Real proposal logic lands incrementally as a subclass that replaces mock-served slices.What this PR ships (v1 scope)
ProposalManagerProtocol —get_products(required) +refine_products(optional, capability-gated). Sync or async, detected viaasyncio.iscoroutinefunction. Same convention asSalesPlatform.ProposalCapabilitiesdataclass —sales_specialism: Literal["sales-guaranteed", "sales-non-guaranteed"]+ capability flags (refine,dynamic_products,rate_card_pricing,availability_reservations,multi_decisioning).RecipePydantic base withrecipe_kind: strdiscriminator. Adopter subclasses declare typed fields. v1 treatsProduct.implementation_configas opaquedict[str, Any]from the framework; adopters who want typed recipes round-trip viamodel_dump()/model_validate()themselves.MockProposalManager— v1 default forwarder. POSTsget_products/refine_productsrequests to a configuredmock_upstream_url(runningbin/adcp.js mock-server <specialism>). Symmetric with Phase 2'sDecisioningPlatform.upstream_for(ctx)mock-mode pattern. ReusesUpstreamHttpClient.proposal_manager=kwarg onserve()/create_adcp_server_from_platform(). The dispatcher inPlatformHandler.get_productsroutes to the wired manager when present; falls through toplatform.get_productsotherwise.buying_mode='refine'+capabilities.refine+refine_productsmethod present → dispatch torefine_products; any condition missing → falls through toget_products.tests/test_proposal_manager.py(16 tests).examples/hello_proposal_manager.py.Out of scope (subsequent PRs — flagged in module docstring)
finalizetransition handlingexpires_atenforcementcapability_overlapdeclaration on Recipe + framework validationProposalManagerviaPlatformRouterMIGRATION.mdupdates for v3 reference seller (separate doc PR)Module layout
Picked flat files (
proposal_manager.py,recipe.pydirectly underdecisioning/) over aproposal/submodule. Existing decisioning module is flat; adding a submodule for two files would break local convention.Dispatcher routing (pseudocode)
Tenant binding
Wired on
serve()— a singleProposalManagerperserve()call applies to every tenant. Per-tenant managers viaPlatformRouterare deferred (flagged in the architecture doc § "Open questions").Notes during implementation
refine_products(req: RefineProductsRequest, ...). Per the spec, refine is abuying_modevalue onGetProductsRequest— there's no separateRefineProductsRequesttype. Updated the Protocol to useGetProductsRequestfor both methods and route bybuying_modeinside the dispatcher.AdcpErrorexposesmessageviaargs[0], not.message.GetProductsRequest.buying_modeis a generatedBuyingModeenum; the dispatcher normalizes viagetattr(buying_mode, 'value', buying_mode).References
docs/proposals/product-architecture.md)DecisioningPlatform.upstream_for)Test plan
ruff checkon new + modified files cleanmypyon new + modified files cleantests/test_proposal_manager.py: 16/16 passexamples/hello_proposal_manager.pyimports + composes without error🤖 Generated with Claude Code