Skip to content

feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager forwarder + tenant binding#504

Merged
bokelley merged 2 commits into
mainfrom
bokelley/feat-proposal-manager-v1
May 4, 2026
Merged

feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager forwarder + tenant binding#504
bokelley merged 2 commits into
mainfrom
bokelley/feat-proposal-manager-v1

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

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 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.

After this PR, the on-ramp for new adopters is: point a MockProposalManager at a running bin/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)

  • ProposalManager Protocolget_products (required) + refine_products (optional, capability-gated). Sync or async, detected via asyncio.iscoroutinefunction. Same convention as SalesPlatform.
  • ProposalCapabilities dataclasssales_specialism: Literal["sales-guaranteed", "sales-non-guaranteed"] + capability flags (refine, dynamic_products, rate_card_pricing, availability_reservations, multi_decisioning).
  • Recipe Pydantic base with recipe_kind: str discriminator. Adopter subclasses declare typed fields. v1 treats Product.implementation_config as opaque dict[str, Any] from the framework; adopters who want typed recipes round-trip via model_dump() / model_validate() themselves.
  • MockProposalManager — v1 default forwarder. POSTs get_products / refine_products requests to a configured mock_upstream_url (running bin/adcp.js mock-server <specialism>). Symmetric with Phase 2's DecisioningPlatform.upstream_for(ctx) mock-mode pattern. Reuses UpstreamHttpClient.
  • Tenant binding — new proposal_manager= kwarg on serve() / create_adcp_server_from_platform(). The dispatcher in PlatformHandler.get_products routes to the wired manager when present; falls through to platform.get_products otherwise.
  • Refine routingbuying_mode='refine' + capabilities.refine + refine_products method present → dispatch to refine_products; any condition missing → falls through to get_products.
  • Backward-compatible by construction — every existing example keeps working unchanged. ProposalManager is opt-in.
  • Teststests/test_proposal_manager.py (16 tests).
  • Exampleexamples/hello_proposal_manager.py.

Out of scope (subsequent PRs — flagged in module docstring)

  • Session cache for in-flight proposals
  • finalize transition handling
  • expires_at enforcement
  • capability_overlap declaration on Recipe + framework validation
  • Recipe persistence + hydration through buy lifecycle
  • Per-tenant ProposalManager via PlatformRouter
  • MIGRATION.md updates for v3 reference seller (separate doc PR)

Module layout

Picked flat files (proposal_manager.py, recipe.py directly under decisioning/) over a proposal/ submodule. Existing decisioning module is flat; adding a submodule for two files would break local convention.

Dispatcher routing (pseudocode)

# PlatformHandler.get_products(params, context)
if proposal_manager is not None:
    target = proposal_manager
    method_name = self._select_proposal_method(params)
    # _select_proposal_method returns 'refine_products' iff:
    #   - params.buying_mode == 'refine'
    #   - manager.capabilities.refine is True
    #   - hasattr(manager, 'refine_products')
    # otherwise returns 'get_products'
else:
    target = platform              # back-compat fall-through
    method_name = 'get_products'

response = await _invoke_platform_method(target, method_name, params, ctx, ...)
if params.fields:
    response = _project_product_fields(response, params.fields)
return response

Tenant binding

Wired on serve() — a single ProposalManager per serve() call applies to every tenant. Per-tenant managers via PlatformRouter are deferred (flagged in the architecture doc § "Open questions").

Notes during implementation

  • The architecture doc's sketch said refine_products(req: RefineProductsRequest, ...). Per the spec, refine is a buying_mode value on GetProductsRequest — there's no separate RefineProductsRequest type. Updated the Protocol to use GetProductsRequest for both methods and route by buying_mode inside the dispatcher.
  • AdcpError exposes message via args[0], not .message.
  • GetProductsRequest.buying_mode is a generated BuyingMode enum; the dispatcher normalizes via getattr(buying_mode, 'value', buying_mode).

References

Test plan

  • ruff check on new + modified files clean
  • mypy on new + modified files clean
  • New tests/test_proposal_manager.py: 16/16 pass
  • Full regression: 3718 passed, 32 skipped, 1 xfailed
  • examples/hello_proposal_manager.py imports + composes without error

🤖 Generated with Claude Code

@bokelley bokelley force-pushed the bokelley/feat-proposal-manager-v1 branch from 85dceb5 to 7e28a6a Compare May 3, 2026 23:44
…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>
@bokelley bokelley force-pushed the bokelley/feat-proposal-manager-v1 branch from 7e28a6a to 9d68a80 Compare May 3, 2026 23:50
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>
@bokelley bokelley force-pushed the bokelley/feat-proposal-manager-v1 branch from 9d68a80 to d50c64e Compare May 3, 2026 23:58
@bokelley bokelley merged commit fe8e5b0 into main May 4, 2026
15 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant