Skip to content

docs(proposals): ProposalManager v1.5 design — session cache, finalize, expires_at, capability validation, recipe lifecycle#538

Merged
bokelley merged 5 commits into
mainfrom
bokelley/docs-proposal-manager-v15-design
May 4, 2026
Merged

docs(proposals): ProposalManager v1.5 design — session cache, finalize, expires_at, capability validation, recipe lifecycle#538
bokelley merged 5 commits into
mainfrom
bokelley/docs-proposal-manager-v15-design

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 4, 2026

Summary

This is a DESIGN DRAFT for review before implementation. Lands the blueprint for v1.5 — the post-Protocol-skeleton work that gives adopters real proposal lifecycle support. After review, a python-expert agent in the next phase writes the implementation against this spec.

Builds on:

This PR is the gate before v1.5 implementation work — the design doc anchors the spec the python-expert builds against.

Key Decisions for reviewer attention

  • D1. Session cache shape — adopter-supplied ProposalStore Protocol with InMemoryProposalStore reference impl. Mirrors MediaBuyStore (already shipped); separate from TaskRegistry because the lifecycle states differ. Per-tenant binding via PlatformRouter.
  • D2. Finalize lifecycle ships both sync and async surfaces (finalize_proposal_sync and finalize_proposal_async), capability-declared on ProposalCapabilities. Compliance scenarios cover both (proposal_finalize.yaml is sync-flavoured; sales-guaranteed/index.yaml shows the HITL pattern).
  • D3. Recipe persistence splits across two stores: ProposalStore for draft/committed; MediaBuyStore.persist_recipes / hydrate_recipes (extended) for consumed proposals through the buy lifecycle. Hand-off at create_media_buy(proposal_id=...).
  • D4. Capability-overlap declaration on Recipe (new CapabilityOverlap dataclass); framework validates buyer requests pre-adapter, projecting INVALID_REQUEST with field paths matching wire schema.
  • D5. Tenant binding extends PlatformRouter with two new optional kwargs (proposal_stores=, media_buy_stores=); cross-store consistency check at construction (a tenant declaring finalize_* MUST wire a store).
  • D6. Backward compatibility — v1.5 is purely additive; v1 adopters not touching new APIs see zero behavioural change. Compatibility matrix in the doc.
  • D7. expires_at enforcement is framework-owned (no adapter participation); introduces new PROPOSAL_EXPIRED / PROPOSAL_NOT_FOUND error codes (spec coordination noted as parallel workstream).

Storyboard pass criteria

The end-state v1.5 unlocks: an adopter writes a mock seller that passes proposal_finalize.yaml + refine_products.yaml end-to-end through the SDK in ≤ 350 LOC (anchor: examples/hello_proposal_manager.py is 279 LOC for the v1 demo). LOC budget breakdown in § "Storyboard pass criteria + adopter LOC budget".

Open questions flagged for review (8)

The doc explicitly flags items the architect couldn't resolve alone — most notably:

  1. Auto-allocate InMemoryProposalStore when missing (D1 vs. D5 recommendations conflict)
  2. Sync-vs-async finalize hint inference (lacks an explicit wire surface)
  3. PROPOSAL_EXPIRED spec coordination timing
  4. MediaBuyStore extension in-place vs. new RecipeStore Protocol
  5. Capability-overlap re-validation cost on update_media_buy
  6. Whether to keep multi_decisioning capability field in v1.5 or defer
  7. Optional Protocol method detection consistency
  8. InMemoryProposalStore location

Full text + recommendations in § "Open questions for review."

Salesagent gap citations (real, verified)

Surveyed Developer/salesagent/.conductor/tallahassee-v8 — no Proposal table, no proposal_id anywhere, buying_mode widened to str | None with no dispatch, implementation_config stored on products but no proposal lifecycle persistence. Confirms v1.5 is the missing piece.

Test plan

  • Reviewers (Brian + parent agent) walk the seven Decisions and the eight open questions
  • Confirm the LOC budget (≤ 350) is realistic — challenge with a sketch implementation if uncertain
  • Confirm Decision recommendations on the open questions before python-expert build phase
  • Cross-check spec coordination plan for PROPOSAL_EXPIRED error code with adcp-1 maintainers
  • Once approved, this PR merges; next phase opens an implementation PR against the design

🤖 Generated with Claude Code

bokelley added a commit that referenced this pull request May 4, 2026
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.
bokelley added a commit that referenced this pull request May 4, 2026
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.
bokelley added a commit that referenced this pull request May 4, 2026
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.
bokelley and others added 5 commits May 3, 2026 22:17
…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>
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.
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.
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.
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.
@bokelley bokelley force-pushed the bokelley/docs-proposal-manager-v15-design branch from 6e4d85c to 691a9cd Compare May 4, 2026 02:17
@bokelley bokelley merged commit 8ea85ad into main May 4, 2026
15 checks passed
bokelley added a commit that referenced this pull request May 4, 2026
…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>
bokelley added a commit that referenced this pull request May 4, 2026
… 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>
bokelley added a commit that referenced this pull request May 4, 2026
…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 added a commit that referenced this pull request May 4, 2026
… 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.
bokelley added a commit that referenced this pull request 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
…dation, lifecycle helpers (#538-impl) (#550)

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

* test(decisioning): unit tests for ProposalStore + lifecycle helpers + 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>

* feat(decisioning): proposal lifecycle dispatch wiring + sales-proposal-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>

* feat(decisioning): wire TaskHandoff finalize path — single-ledger via 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.

* feat(decisioning): TOCTOU consumption fix + expert-review fix-pack (#538-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.

---------

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