feat(decisioning): ProposalManager v1.5 — primitives, capability validation, lifecycle helpers (#538-impl)#550
Merged
Conversation
…pe, 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>
… cross-store router (#538-impl) 55 new tests covering the v1.5 primitives landed in the previous commit: - tests/test_proposal_store.py (25 tests): Protocol conformance, state-machine guards, idempotency on commit/mark_consumed, eviction (24h draft TTL + 7d post-expiry committed grace), cross-tenant safety on get / get_by_media_buy_id, discard idempotency, create_dev_proposal_store warns. - tests/test_proposal_lifecycle.py (24 tests): enforce_proposal_expiry (PROPOSAL_NOT_FOUND / PROPOSAL_NOT_COMMITTED / PROPOSAL_EXPIRED with correct recovery), validate_capability_overlap (4 axes + None=no-op + frozenset()=deny-all + field-path-prefix override), validate_overlap_subset_of_wire drift catch, detect_finalize_action. - tests/test_platform_router_proposal_stores.py (6 tests): cross-store consistency hard error, orphan-key check, mixed-version tenants, proposal_stores= optionality. - examples/hello_proposal_manager_v15.py: 161 effective LOC of source (well under the design's ≤350 budget). Demonstrates the full v1.5 surface: typed Recipe with CapabilityOverlap, finalize- capable ProposalManager, ctx.recipes-reading DecisioningPlatform, cross-store-checked PlatformRouter wiring. All 3452 tests passing (55 new + 3397 baseline). ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l-mode storyboard adopter (#538-impl) Lands the load-bearing v1.5 dispatch seam: framework intercepts the buyer's get_products / create_media_buy / update_media_buy / get_media_buy_delivery calls, runs the proposal-lifecycle work between the wire and the adopter's ProposalManager / DecisioningPlatform. ## What ships * **proposal_dispatch.py** — five integration helpers wired into the PlatformHandler shims: - maybe_intercept_finalize: intercepts refine[i].action='finalize'; hydrates draft, calls manager.finalize_proposal, commits via proposal_store.commit (D2 + D7). - maybe_persist_draft_after_get_products: walks proposals[] from get_products / refine_products responses, persists drafts via proposal_store.put_draft (D3 single-ledger), validates overlap subset of wire (D4 round-4). - maybe_hydrate_recipes_for_create_media_buy: when proposal_id is set, validates expiry + capability overlap, populates ctx.recipes; returns ProposalRecord for mark_consumed. - mark_proposal_consumed: single-write hand-off after platform's create_media_buy returns successfully. - maybe_hydrate_recipes_for_media_buy_id: reverse-index hydration on update_media_buy / get_media_buy_delivery (per Resolutions §5). * **handler.py** — surgical edits to four existing shims (get_products, create_media_buy, update_media_buy, get_media_buy_delivery). Each edit is one helper call before/after the existing _invoke_platform_method; the v1 path is unchanged when no proposal store is wired. * **proposal_manager.py** — finalize_proposal removed from Protocol body per Resolutions §7. Framework detects via hasattr + capabilities.finalize. MockProposalManager's default-raising stub removed. * **examples/sales_proposal_mode_seller/** — concrete adopter mock that passes the proposal_finalize.yaml setup + brief_with_proposals storyboard phases end-to-end. ~480 effective LOC. * **tests/test_proposal_lifecycle_e2e.py** — 11 integration tests exercising the full pipeline (PlatformHandler -> dispatch helpers -> manager + platform + store). Covers brief / refine / finalize / create_media_buy / update_media_buy / get_delivery + capability overlap rejection + expiry boundaries + restart safety + lifecycle log assertions + wire-overlap drift detection. * **.github/workflows/ci.yml** — new storyboard-sales-proposal-mode job asserts proposal_finalize/setup + proposal_finalize/brief_with_proposals pass. Blocking gate (no continue-on-error). ## Test status 3952 passed, 32 skipped (was 3941 + 11 new). ruff check src/ clean. mypy src/adcp/ clean (782 source files). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5b95fdb to
df97cc8
Compare
… on_complete hook (#538-impl) The HITL finalize path that the v1.5 design promised but the previous commits left as a follow-up. Adopters returning ctx.handoff_to_task(...) from finalize_proposal now get the expected behaviour: Submitted envelope to the buyer immediately, handoff fn runs in the background, proposal commits to the store on completion. Single ledger per § D3. Mechanism: _project_handoff gains an optional on_complete callback that runs with the typed result before registry.complete. maybe_intercept_finalize builds a closure capturing proposal_store + proposal_id and threads it through. If the handoff fn raises, on_complete raises, or store.commit raises, the framework calls registry.fail and the proposal stays DRAFT — no half-committed state. 5 new E2E tests cover: happy-path commit-on-completion; handoff path log emission; AdcpError from handoff fn; wrong return type; commit failure under transient store error. 3957 passed, 32 skipped (was 3952). Zero regressions.
This was referenced May 4, 2026
-impl) Round-5 fix-pack consolidating findings from three expert reviews (security, code, DX) on PR #550. Wave 3 — TOCTOU consumption race (security HIGH): Two-phase commit prevents inventory double-spend. Concurrent create_media_buy(proposal_id=X) calls under the same authenticated principal previously both passed the COMMITTED state check, both called the upstream adapter, and double-consumed the inventory hold. Now: ProposalState.CONSUMING intermediate state + atomic CAS via try_reserve_consumption / finalize_consumption / release_consumption. handler.py wraps adapter dispatch in try/except — adapter failure rolls back the reservation so the buyer can retry without PROPOSAL_NOT_COMMITTED blocking them. Wave 1 — small fixes (security MEDIUM + code review): * _media_buy_index keyed by (account_id, media_buy_id) — adopter- controlled IDs can collide across tenants. * get_by_media_buy_id requires expected_account_id (no default). * assert ctx.recipes in example platform → raise AdcpError. * detect_finalize_action returns (index, proposal_id, ask) for indexed wire-field paths (refine[3].proposal_id). * FinalizeProposalSuccess.proposal docstring flags JSON-serializability for durable stores. Wave 2 — DX (dx-expert SHOULD-FIX): * HelloProposalManager.finalize_proposal includes commented HITL branch so adopters discover the pattern from the canonical example. * Examples switched from raw InMemoryProposalStore() to create_dev_proposal_store() — the warned factory should be the path adopters copy from. * Storyboard CI failure message emits per-scenario likely-cause hints + design-doc anchors. Deferred to v1.5.1 follow-up: * TaskHandoff create_media_buy on_complete hook — currently the HITL accept-proposal path leaves the proposal in CONSUMING until eviction. Same on_complete pattern as the finalize path (c94802d); needs the parallel for create_media_buy. Tests: 3966 passed, 32 skipped (was 3957). +9 new tests covering the race regression, adapter rollback, three new store methods, and cross-tenant media_buy_id collision. ruff + mypy clean on changed files.
bokelley
added a commit
that referenced
this pull request
May 4, 2026
…ure hooks Closes the v1.5.1 follow-up flagged in PR #550. Adopters returning ctx.handoff_to_task(...) from create_media_buy(proposal_id=...) now get the same single-ledger D3 guarantee as the inline path: the proposal transitions CONSUMING → CONSUMED via the framework's on_complete hook when the bg task lands; failure paths release the reservation so the buyer can retry. Previous gap: HITL accept-proposal flows left the proposal in CONSUMING until eviction. No data leak, but reverse-index lookup couldn't hydrate, and a second create_media_buy(proposal_id=X) after a HITL rejection would hit PROPOSAL_NOT_COMMITTED until eviction. Mechanism: * _project_handoff gets on_failure paired with on_complete; inner _fail() helper centralizes 'invoke on_failure (best effort) then registry.fail' across all four failure branches. * _invoke_platform_method forwards both kwargs into _project_handoff on the handoff path; fires on_complete inline before sync return; fires on_failure on every adapter exception path. * handler.py:create_media_buy replaces the local try/except + post-call mark_consumed branches with two closures passed to _invoke_platform_method. Single source of truth for inline + HITL. 3 new tests: handoff happy path, handoff failure releases reservation, end-to-end retry-after-release. All 5 existing finalize-handoff tests still pass. 4014 passed (was 3966 on main + concurrent CI work). Zero regressions. ruff + mypy clean.
6 tasks
bokelley
added a commit
that referenced
this pull request
May 4, 2026
…ure hooks (#561) Closes the v1.5.1 follow-up flagged in PR #550. Adopters returning ctx.handoff_to_task(...) from create_media_buy(proposal_id=...) now get the same single-ledger D3 guarantee as the inline path: the proposal transitions CONSUMING → CONSUMED via the framework's on_complete hook when the bg task lands; failure paths release the reservation so the buyer can retry. Previous gap: HITL accept-proposal flows left the proposal in CONSUMING until eviction. No data leak, but reverse-index lookup couldn't hydrate, and a second create_media_buy(proposal_id=X) after a HITL rejection would hit PROPOSAL_NOT_COMMITTED until eviction. Mechanism: * _project_handoff gets on_failure paired with on_complete; inner _fail() helper centralizes 'invoke on_failure (best effort) then registry.fail' across all four failure branches. * _invoke_platform_method forwards both kwargs into _project_handoff on the handoff path; fires on_complete inline before sync return; fires on_failure on every adapter exception path. * handler.py:create_media_buy replaces the local try/except + post-call mark_consumed branches with two closures passed to _invoke_platform_method. Single source of truth for inline + HITL. 3 new tests: handoff happy path, handoff failure releases reservation, end-to-end retry-after-release. All 5 existing finalize-handoff tests still pass. 4014 passed (was 3966 on main + concurrent CI work). Zero regressions. ruff + mypy clean.
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
Lands the v1.5 ProposalManager surface — primitives AND the dispatch wiring + storyboard adopter that proves the design works end-to-end.
This is the load-bearing v1.5 work: an adopter writes ~50 LOC of substantive lifecycle logic (manager + recipe + platform); the framework intercepts at the seam (get_products / create_media_buy / update_media_buy / get_media_buy_delivery), runs the proposal-lifecycle work, and dispatches.
What ships
v1.5 primitives (initial PR scope)
proposal_store.py—ProposalStoreProtocol +InMemoryProposalStorereference impl with state machine guards (DRAFT → COMMITTED → CONSUMED).get_by_media_buy_idreverse-index for the single-ledger D3 design.recipe.py—CapabilityOverlapdataclass with four typed axes (pricing_models,targeting_dimensions,delivery_types,signal_types).proposal_manager.py—ProposalCapabilities+FinalizeProposalRequest/FinalizeProposalSuccesstyped dataclasses. Per Resolutions §7,finalize_proposalis intentionally NOT in theruntime_checkableProtocol body — framework detects viahasattr(manager, "finalize_proposal")ANDmanager.capabilities.finalize is True. TheMockProposalManagerdefault-raising stub has been removed.proposal_lifecycle.py—enforce_proposal_expiry(D7),validate_capability_overlap(D4),validate_overlap_subset_of_wire(D4 round-4),detect_finalize_action, structured-log helpers.platform_router.py—proposal_stores=kwarg + cross-store consistency check (D5).context.py—RequestContext.recipes: Mapping[str, Recipe]field per § D3.v1.5 dispatch wiring (this round)
proposal_dispatch.py(NEW) — five integration helpers wired into thePlatformHandlershims:maybe_intercept_finalize— interceptsrefine[i].action='finalize'; hydrates draft, callsmanager.finalize_proposal, commits viaproposal_store.commit(D2 + D7). File:line atsrc/adcp/decisioning/handler.py:1146-1163.maybe_persist_draft_after_get_products— walksproposals[]from get_products / refine_products responses, persists drafts viaproposal_store.put_draft(D3 single-ledger), validatesoverlap ⊆ wire(D4 round-4). File:line atsrc/adcp/decisioning/handler.py:1247andsrc/adcp/decisioning/handler.py:1188-1192.maybe_hydrate_recipes_for_create_media_buy— whenproposal_idis set, validates expiry + capability overlap, populatesctx.recipes. File:line atsrc/adcp/decisioning/handler.py:1294-1299.mark_proposal_consumed— single-write hand-off aftercreate_media_buyreturns (per § D3 — eliminates the two-write race). File:line atsrc/adcp/decisioning/handler.py:1318-1326.maybe_hydrate_recipes_for_media_buy_id— reverse-index hydration onupdate_media_buy/get_media_buy_delivery(Resolutions §5). File:line atsrc/adcp/decisioning/handler.py:1346-1353andsrc/adcp/decisioning/handler.py:1383-1387.Storyboard adopter mock
examples/sales_proposal_mode_seller/— concrete adopter that passesproposal_finalize.yamlsetup + brief_with_proposals phases through the storyboard runner. Files:src/recipe.py(41 LOC) —ProposalModeRecipe(Recipe)with typedCapabilityOverlap.src/proposal_manager.py(256 LOC effective; ~60 LOC of substantive logic — rest is the wire-conformant catalog dict literals).src/platform.py(280 LOC) —ProposalModeDecisioningPlatformreadingctx.recipes.src/app.py(139 LOC) — boot script with_SingleTenantAccountsimpl that satisfies bothAccountStore+AccountStoreUpsert.E2E integration tests (11 new)
tests/test_proposal_lifecycle_e2e.py:pricing_modeloutside overlap → INVALID_REQUEST + correct field path)PROPOSAL_EXPIRED)INTERNAL_ERRORraised at put_draft time)proposal.draft_persisted,proposal.finalized,proposal.consumed)CI integration
.github/workflows/ci.ymladdsstoryboard-sales-proposal-modejob. Blocking gate (nocontinue-on-error). Assertsproposal_finalize/setup+proposal_finalize/brief_with_proposalspass — the v1.5 dispatch-path scenarios that exercise the framework's intercept seam end-to-end.Spec-issue citations
PROPOSAL_EXPIREDis already canonical in 3.0 (src/adcp/types/generated_poc/enums/error_code.py:18).PROPOSAL_NOT_COMMITTEDis already canonical in 3.0 (src/adcp/types/generated_poc/enums/error_code.py:48).PROPOSAL_NOT_FOUNDlands in 3.1 — currently allowlisted viaKNOWN_NON_SPEC_CODES(tests/test_error_code_conformance.py:99). Spec issue: adcontextprotocol/adcp#4043.Test status
(11 new E2E + 55 v1.5 unit + 3886 baseline.)
ruff check src/clean.mypy src/adcp/clean (782 source files).multi_platform_sellerstoryboard regression test unchanged.Storyboard scenarios (verified locally)
Run via
adcp storyboard run http://127.0.0.1:3003/mcp media_buy_seller/proposal_finalize:media_buy_seller/proposal_finalize/setup— sync_accounts (skip-tolerant)media_buy_seller/proposal_finalize/brief_with_proposals— manager.get_products round-trip + framework draft persistencemedia_buy_seller/proposal_finalize/refine_proposal— runner skips due to "prior stateful step sync_accounts skipped"; the framework's refine + draft-overwrite paths pass via the E2E unit tests (test_refine_overwrites_draft).media_buy_seller/proposal_finalize/finalize_proposal— same upstream chain dependency; framework finalize seam validated viatest_finalize_commits_proposal.media_buy_seller/proposal_finalize/accept_proposal— same; framework hydration + consume validated viatest_create_media_buy_hydrates_and_consumes.The storyboard runner's stateful chain depends on
sync_accountsmaterializing state in the runner's controller; that's an orthogonal framework gap (the existingmulti_platform_sellerexample has the same issue). The CI assertion targets the scenarios that exercise the v1.5 dispatch-path seam end-to-end.Design caveats / follow-ups
proposal_dispatch.maybe_intercept_finalizeraisesINTERNAL_ERRORif a manager returnsTaskHandoff[FinalizeProposalSuccess]. The inlineFinalizeProposalSuccesspath (D2's spec-default route) is fully wired. HITL handoff finalize is a v1.5+ follow-up — needs the dispatch surface to expose a hook for the post-completion commit.multi_platform_sellerhas the same shape. Not v1.5's blocker, but flagged for the runner's next pass.Reviewer reading order
docs/proposals/proposal-manager-v15-design.md(merged on main) — re-read end to end if not already.src/adcp/decisioning/proposal_dispatch.py— the load-bearing seam.src/adcp/decisioning/handler.py(diff) — the four shim integration points.src/adcp/decisioning/proposal_lifecycle.py— D4 + D7 framework helpers.examples/sales_proposal_mode_seller/— the adopter proof.tests/test_proposal_lifecycle_e2e.py— behavioural confirmation.Test plan
ruff check src/cleanmypy src/adcp/clean (782 source files)pytest tests/ -v— 3952 passingmulti_platform_sellerstoryboard regression unchangedPROPOSAL_NOT_FOUNDallowlisted; conformance test passesproposal_finalize/setup+proposal_finalize/brief_with_proposalspass via storyboard runner (locally verified; CI asserts)🤖 Generated with Claude Code