Skip to content

feat(decisioning): ProposalManager v1.5 — primitives, capability validation, lifecycle helpers (#538-impl)#550

Merged
bokelley merged 5 commits into
mainfrom
claude/issue-538-proposal-manager-v15-impl
May 4, 2026
Merged

feat(decisioning): ProposalManager v1.5 — primitives, capability validation, lifecycle helpers (#538-impl)#550
bokelley merged 5 commits into
mainfrom
claude/issue-538-proposal-manager-v15-impl

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 4, 2026

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.pyProposalStore Protocol + InMemoryProposalStore reference impl with state machine guards (DRAFT → COMMITTED → CONSUMED). get_by_media_buy_id reverse-index for the single-ledger D3 design.
  • recipe.pyCapabilityOverlap dataclass with four typed axes (pricing_models, targeting_dimensions, delivery_types, signal_types).
  • proposal_manager.pyProposalCapabilities + FinalizeProposalRequest / FinalizeProposalSuccess typed dataclasses. Per Resolutions §7, finalize_proposal is intentionally NOT in the runtime_checkable Protocol body — framework detects via hasattr(manager, "finalize_proposal") AND manager.capabilities.finalize is True. The MockProposalManager default-raising stub has been removed.
  • proposal_lifecycle.pyenforce_proposal_expiry (D7), validate_capability_overlap (D4), validate_overlap_subset_of_wire (D4 round-4), detect_finalize_action, structured-log helpers.
  • platform_router.pyproposal_stores= kwarg + cross-store consistency check (D5).
  • context.pyRequestContext.recipes: Mapping[str, Recipe] field per § D3.

v1.5 dispatch wiring (this round)

  • proposal_dispatch.py (NEW) — 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). File:line at src/adcp/decisioning/handler.py:1146-1163.
    • 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 ⊆ wire (D4 round-4). File:line at src/adcp/decisioning/handler.py:1247 and src/adcp/decisioning/handler.py:1188-1192.
    • maybe_hydrate_recipes_for_create_media_buy — when proposal_id is set, validates expiry + capability overlap, populates ctx.recipes. File:line at src/adcp/decisioning/handler.py:1294-1299.
    • mark_proposal_consumed — single-write hand-off after create_media_buy returns (per § D3 — eliminates the two-write race). File:line at src/adcp/decisioning/handler.py:1318-1326.
    • maybe_hydrate_recipes_for_media_buy_id — reverse-index hydration on update_media_buy / get_media_buy_delivery (Resolutions §5). File:line at src/adcp/decisioning/handler.py:1346-1353 and src/adcp/decisioning/handler.py:1383-1387.

Storyboard adopter mock

  • examples/sales_proposal_mode_seller/ — concrete adopter that passes proposal_finalize.yaml setup + brief_with_proposals phases through the storyboard runner. Files:
    • src/recipe.py (41 LOC) — ProposalModeRecipe(Recipe) with typed CapabilityOverlap.
    • 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) — ProposalModeDecisioningPlatform reading ctx.recipes.
    • src/app.py (139 LOC) — boot script with _SingleTenantAccounts impl that satisfies both AccountStore + AccountStoreUpsert.
    • Total ~480 effective LOC (under the design's 500 LOC fail-stop; over the 350 LOC ideal budget — see "Design caveats" below).

E2E integration tests (11 new)

tests/test_proposal_lifecycle_e2e.py:

  • Phase 1 — brief persists drafts to store
  • Phase 2 — refine overwrites draft in place
  • Phase 3 — finalize commits proposal
  • Phase 4 — create_media_buy hydrates recipes + marks consumed
  • Phase 5 — update_media_buy / get_delivery hydrate from reverse-index
  • Capability-overlap rejection (pricing_model outside overlap → INVALID_REQUEST + correct field path)
  • Expired proposal rejection (PROPOSAL_EXPIRED)
  • Within-grace-window acceptance
  • Restart safety (fresh handler picks up COMMITTED state from durable store)
  • Wire-overlap subset drift (INTERNAL_ERROR raised at put_draft time)
  • Lifecycle log emission (proposal.draft_persisted, proposal.finalized, proposal.consumed)

CI integration

.github/workflows/ci.yml adds storyboard-sales-proposal-mode job. Blocking gate (no continue-on-error). Asserts proposal_finalize/setup + proposal_finalize/brief_with_proposals pass — the v1.5 dispatch-path scenarios that exercise the framework's intercept seam end-to-end.

Spec-issue citations

  • PROPOSAL_EXPIRED is already canonical in 3.0 (src/adcp/types/generated_poc/enums/error_code.py:18).
  • PROPOSAL_NOT_COMMITTED is already canonical in 3.0 (src/adcp/types/generated_poc/enums/error_code.py:48).
  • PROPOSAL_NOT_FOUND lands in 3.1 — currently allowlisted via KNOWN_NON_SPEC_CODES (tests/test_error_code_conformance.py:99). Spec issue: adcontextprotocol/adcp#4043.

Test status

3952 passed, 32 skipped, 9 deselected, 1 xfailed

(11 new E2E + 55 v1.5 unit + 3886 baseline.) ruff check src/ clean. mypy src/adcp/ clean (782 source files). multi_platform_seller storyboard 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 persistence
  • ⚠️ media_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 via test_finalize_commits_proposal.
  • ⚠️ media_buy_seller/proposal_finalize/accept_proposal — same; framework hydration + consume validated via test_create_media_buy_hydrates_and_consumes.

The storyboard runner's stateful chain depends on sync_accounts materializing state in the runner's controller; that's an orthogonal framework gap (the existing multi_platform_seller example 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

  1. TaskHandoff finalize path not wired. proposal_dispatch.maybe_intercept_finalize raises INTERNAL_ERROR if a manager returns TaskHandoff[FinalizeProposalSuccess]. The inline FinalizeProposalSuccess path (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.
  2. Sales-proposal-mode mock LOC at 480, design budget 350. Most of the overage is wire-conformant catalog dict literals + the platform's create_media_buy/update_media_buy boilerplate (~70 LOC of the catalog alone). Adopter migration (existing platform → wired with finalize) is still ~50 LOC per the design — the storyboard adopter is greenfield.
  3. Storyboard runner sync_accounts state seeding. The runner's controller seeds state via sync_accounts, but proposal-mode adopters who don't implement sync_accounts can't exercise the full stateful chain. Orthogonal gap — multi_platform_seller has the same shape. Not v1.5's blocker, but flagged for the runner's next pass.

Reviewer reading order

  1. docs/proposals/proposal-manager-v15-design.md (merged on main) — re-read end to end if not already.
  2. src/adcp/decisioning/proposal_dispatch.py — the load-bearing seam.
  3. src/adcp/decisioning/handler.py (diff) — the four shim integration points.
  4. src/adcp/decisioning/proposal_lifecycle.py — D4 + D7 framework helpers.
  5. examples/sales_proposal_mode_seller/ — the adopter proof.
  6. tests/test_proposal_lifecycle_e2e.py — behavioural confirmation.

Test plan

  • ruff check src/ clean
  • mypy src/adcp/ clean (782 source files)
  • pytest tests/ -v — 3952 passing
  • multi_platform_seller storyboard regression unchanged
  • Hello example imports + builds router cleanly
  • Cross-store consistency check raises with correct remediation message
  • PROPOSAL_NOT_FOUND allowlisted; conformance test passes
  • proposal_finalize/setup + proposal_finalize/brief_with_proposals pass via storyboard runner (locally verified; CI asserts)
  • All 11 E2E lifecycle tests pass through the full PlatformHandler dispatch path

🤖 Generated with Claude Code

bokelley and others added 3 commits May 3, 2026 22:43
…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>
@bokelley bokelley force-pushed the claude/issue-538-proposal-manager-v15-impl branch from 5b95fdb to df97cc8 Compare May 4, 2026 03:20
… 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.
-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 bokelley merged commit c7c317e into main May 4, 2026
16 checks passed
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.
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.
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