Skip to content

docs(proposals): product architecture — layered model + two-platform composition#502

Merged
bokelley merged 3 commits into
mainfrom
bokelley/docs-product-architecture
May 3, 2026
Merged

docs(proposals): product architecture — layered model + two-platform composition#502
bokelley merged 3 commits into
mainfrom
bokelley/docs-product-architecture

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

Foundational design doc establishing the layered model for products across the SDK + the two-platform composition (ProposalManager + DecisioningPlatform).

Anchors the missing prerequisite that the salesagent migration guide (#489) currently has no answer for: what is a product, actually, in this framework?

Key conceptual moves

  • Four-layer model with explicit ownership per layer:

    • Layer 1: Wire (AdCP spec)
    • Layer 2: Product internal-config (the recipe — typed-per-decisioning-platform JSON)
    • Layer 3: Capability overlap (which wire capabilities this product exposes for buyer config)
    • Layer 4: Supporting tables (account, rate cards, audiences, properties, placements, availability)
  • Two-platform composition: ProposalManager + DecisioningPlatform, with the recipe as the typed contract between them.

  • Three concrete shapes: tight coupling (LinkedIn), sophisticated multi-decisioning (Prebid salesagent), naive (SimpleCatalogProposalManager).

  • Path B recipe binding: discriminated-union with recipe_kind tag + explicit tenant binding + boot-time validation.

  • ProposalCapabilities is sales-axis-scoped — guaranteed vs non-guaranteed flavors, not generic across specialisms.

Status

DRAFT — conceptual scaffold. Sections marked [citations pending] await a follow-up pass against salesagent and agentic-adapters reference codebases.

Python-first; ports to JS @adcp/sdk once settled. Reverses the typical direction because the gap surfaced during the Python migration story.

Refs

Open questions

  1. Tenant binding model — Path B's recipe_kind dict vs PlatformRouter (feat(examples): multi-platform-per-process proof — mock platforms with PlatformRouter, refactor v3 reference seller along the way #477)?
  2. Default recipe_kind for legacy adopters?
  3. Dynamic-product assembly: where in the SDK contract?
  4. Reporting capability declaration: per-product or per-platform?

🤖 Generated with Claude Code

bokelley added 3 commits May 3, 2026 17:43
…composition

Foundational design doc capturing the layered architecture for products
across the SDK: wire / internal-config (recipe) / capability overlap /
supporting tables. Anchors the missing prerequisite that the salesagent
migration guide (PR #489) currently has no answer for.

Key conceptual moves:

- Four-layer model with explicit ownership per layer.
- Two-platform composition: ProposalManager + DecisioningPlatform,
  with the recipe (implementation_config) as the typed contract
  between them.
- Three concrete shapes: tight coupling (LinkedIn), sophisticated
  multi-decisioning (Prebid salesagent), naive (programmatic
  non-guaranteed).
- Path B recipe binding: discriminated-union with recipe_kind tag,
  explicit tenant binding, boot-time validation.
- ProposalCapabilities is sales-axis-scoped (not generic to all
  specialisms) — guaranteed vs non-guaranteed flavors.

Status: DRAFT (conceptual scaffold). Concrete examples sections marked
[citations pending] for follow-up agent pass once budget allows.

Python-first; ports back to JS once settled. Reverses the typical
direction (lifecycle-state proposal went JS-to-Python) because the
conceptual gap surfaced during the Python migration story.

Refs:
- PR #489 (migration guide reviewer feedback that prompted this)
- Issues #491-#497 (already-filed buyer-side request-shape helpers)
- #477 (multi-platform proof — interacts with tenant binding model)
- proposals/decisioning-platform-dispatch-design.md (current
  DecisioningPlatform design)
…w catalog impl

Brian's review correction: the mock seller backend (bin/adcp.js
mock-server) already implements the naive case via product fixtures.
SDK doesn't need a separate SimpleCatalogProposalManager.

v1 of ProposalManager is just the wiring that forwards to the mock
backend — symmetric with DecisioningPlatform's upstream_for(ctx)
mock-mode dispatch from Phase 2. Adopters declare a mock_upstream_url;
framework forwards get_products / refine; recipes flow back.

Updates:
- Shape 3 reframed: Naive → Mock-backed (v1 default)
- MockProposalManager sketch with parallel pattern to upstream_for
- Independent-modes table: ProposalManager and DecisioningPlatform
  can each be mock or live independently
- Future-issues list: SimpleCatalogProposalManager replaced with
  MockProposalManager (the v1 work is wiring, not a new impl)
- §What ships in v1 framing: framework wiring, not catalog content
…n, finalize

Four corrections from Brian's review:

1. Recipe lifecycle (Layer 2): never on the wire. Lives in framework
   session cache during negotiation; persists alongside the committed
   proposal after finalize; the framework hydrates it for every
   subsequent operation in the buy's lifecycle. Adopters do NOT store
   recipes themselves — the SDK is the system of record.

2. ProposalManager flavors: don't lock the formal taxonomy. Two
   extremes today (simple catalog vs complex proposal); useful
   variants will emerge between. sales_specialism + capability flags
   let adopters declare their actual shape; naming variants is
   future-state work.

3. Proposal hydration + capability validation upstream of adapter:
   buyer may reference packages by proposal_id OR product_id;
   framework hydrates from session cache or persisted store and
   validates the buyer's request against capability_overlap BEFORE
   invoking adapter. Buyer asking for geo-metro targeting on a
   product that doesn't expose it gets a structured error without
   adapter code participating. Layer 3 capability-overlap seam
   gets concrete.

4. Acceptance IS in the spec — buying_mode='refine' with refine[]
   action='finalize' transitions a draft proposal to committed
   with locked pricing, expires_at hold window, optional HITL.
   I incorrectly said the spec had no proposal lifecycle.
   Rewrote the section to walk the actual wire flow:
   get_products → refine → finalize → create_media_buy. SDK
   responsibilities at the seam: session cache, finalize transition,
   expires_at enforcement, persistence through the buy lifecycle.
@bokelley bokelley merged commit 69c585c into main May 3, 2026
14 checks passed
bokelley added a commit that referenced this pull request May 3, 2026
Per #502's "Migration impact" directive: §3.3 introduces the
proposal/decisioning seam adopters will split along long-term;
§3.5 resolves the inventory_store/signal_store open question to
"ProposalManager concern". §3.7 (governance) is unaffected by the
split. Cross-link added to See also.

Adopters land the migration on SalesPlatform today; ProposalManager
arrives as a follow-up Protocol that splits the class along the
recipe seam without re-porting either side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 3, 2026
…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 added a commit that referenced this pull request May 3, 2026
…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 added a commit that referenced this pull request May 3, 2026
Major changes addressing self-review on PR #506:

1. Two-phase structure. Phase 1 (~1 day): falsify recipe model on
   dynamic_products.py in isolation, no infrastructure. Phase 2
   (3-5 days, gated on Phase 1): full side-car runtime with GAM
   live + HITL + webhooks. The cheapest available falsifier of #502
   runs first; full experiment only if Phase 1 finds the recipe
   shape can carry signal-driven variants without escape hatches.

2. Wrap target corrected. Wrap salesagent's _impl seams
   (_create_media_buy_impl, _update_media_buy_impl,
   _add_creative_assets_impl, _get_products_impl), NOT adapter
   bodies. Self-review correctly flagged that wrapping
   google_ad_manager.py forces re-implementing principal
   resolution, tenant config, currency validation, signal lookup,
   audit logs — a port disguised as a wrap.

3. Routing strategy: separate process + nginx tenant-header
   routing, NOT in-process dispatch. Avoids dual MCP+A2A transport
   mounts, FastMCP registry collisions, ResolvedIdentity
   construction conflicts. Killable, independently restartable.

4. Step 0 with concrete prereqs: pin SHAs (SDK, both storyboards,
   GAM Network ID); identify _impl seams; enumerate AST guards
   that fire on src/sdk_runtime/; enumerate cross-tenant
   background services to disable; validate _already_approved
   sentinel under extra='forbid'; verify webhook signing parity
   against subscribed test buyer; pre-register falsification
   signals.

5. Multi-criteria exit (not binary): both storyboards pass,
   recipe carries implementation_config without extra: dict
   escape hatches, glue LOC under 60% ratio, zero structural-guard
   allowlist additions, at least one finding contradicts a #502
   prior, webhook signature verified by real subscribed buyer.

6. HITL marker decision decoupled. Salesagent's pattern is N=1
   and unrepresentative; experiment informs but doesn't settle
   the Protocol seam. Spec PR comes after as separate work.

7. Q1.5 added: does recipe model allow proposal-time assembly,
   not just lookup? implementation_config lives on Product row;
   dynamic products generate signal-driven variants at brief time.
   #502's session-cache model may be too restrictive.

8. Three HITL re-entry surfaces in scope (create/update/
   add_creative_assets). Creative-specific re-entry through
   order_approval_service.py out of scope for v1.

9. Both storyboards pinned: media_buy_seller for happy path +
   media_buy_guaranteed_approval for HITL. Test-controller hybrid
   mode for delivery simulation alongside real GAM mutations.

10. Auth shim sized correctly per schema audit (~150 LOC total —
    Principal is bearer-token only, Account is already AdCP-shaped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
Salesagent's multi-adapter abstraction is vestigial: GAM is the only
deployed backend (~99% of clients per migration guide §"Migration
order"); Kevel/Broadstreet/Triton/Xandr are scaffolding with no
client traffic; mock is a fixture. Treating salesagent as a GAM
agent that ships dead code simplifies the experiment in three
concrete ways:

1. Wrap target is unconditional GAM. The if-adapter-class switches
   in _impl (e.g., media_buy_create.py:2431-2464) collapse to
   unconditional logic; no compatibility surface to preserve.
2. Single recipe type — salesagent contributes only the GAM shape
   to #502's typed-recipe model. Phase 1 falsification narrows.
3. MockAdServer (~1,800 LOC) deletion joins post-experiment cleanup.

Updates:
- New "Reframing" section after Two phases
- Out of scope clarified: other adapters slated for deletion, not
  preserved
- Next steps adds adapter deprecation roadmap (~3,500-4,000 LOC
  deletion across 4 sequenced PRs) and side-car-to-runtime
  promotion path
- Note that #489 §3.1 needs a "single-adapter adopters skip
  PlatformRouter" addition (tracked separately)

Doesn't change experiment shape: two-platform composition seam,
recipe falsification target, HITL/webhook/auth shim work all stay.
Reframing simplifies the success path; doesn't shrink the questions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
…r forwarder + tenant binding (#504)

* feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager 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>

* fix(decisioning): per-tenant ProposalManager binding via PlatformRouter

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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
Read dynamic_products.py end-to-end (505 LOC, src/services/
dynamic_products.py). Two of three pre-registered Q1.5 falsifiers
fire from reading alone, no harness needed.

Five structural facts:
1. Variants are persistent DB rows in the products table — not
   session-scoped state
2. Variants share the template's implementation_config verbatim
   (line 303); recipe shape doesn't differ
3. Variant identity is globally deterministic via md5 hash of
   activation_key — dedup crosses sessions/buyers
4. Signal-derived data lives on the Product row (signal_metadata,
   activation_key, parent_product_id, expires_at,
   is_dynamic_variant), NOT in implementation_config
5. Variants have an independent lifecycle (TTL + archival) with no
   relationship to proposal acceptance/finalization

Falsifiers fired (Q1.5):
- "Variant Products require new schema rows" — confirmed
- "Hash-dedup state crosses sessions" — confirmed
- "Recipe schema requires proposal_id lookup" — not strictly fired,
  but related: recipe is Product-scoped not proposal-scoped, so
  #502's framework-managed-recipe-state model is wrong shape

Implication for #502:
- Recipe is adopter-owned data on the Product row (or equivalent)
- Framework's job at the seam is TYPING the recipe contract, not
  CACHING it
- Proposal-time assembly (generating new Product rows that share a
  template's recipe) is adopter logic; framework shouldn't try to
  cache "proposal recipes" because proposals don't own them

Exit criterion (5) — at least one finding contradicts a #502 prior
— satisfied early, pre-registered, before 1B harness runs.

Q1 prediction (still pre-1B): wrapper is small (~50-80 LOC) for the
dynamic-products subset. Wrapper sketch included in doc.

Q2 still pending — needs 1B run projecting actual
implementation_config through a typed Pydantic recipe.

Phase 1B harness setup documented (worktree, fixtures,
signals_agent_registry mock, wrapper module, typed GAMRecipe model,
test harness). ~2 hours in a salesagent worktree. Next session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
Adds examples/recipe_falsification/ — the pytest harness for Q2 of
the salesagent side-car experiment (PR #506):

> Does the recipe shape carry GAM's implementation_config without
> escape hatches?

Files:
- gam_recipe.py — typed Pydantic GAMRecipe model with sub-models
  (CreativePlaceholder, FrequencyCap), Literal-typed enums for
  every documented GAM API value, extra="forbid" on every model
- fixtures/gam_impl_config_examples.json — five fixture shapes
  derived from salesagent's GAMProductConfigService:
    guaranteed_default, non_guaranteed_default,
    video_with_targeting, native_with_discount, minimal
- test_recipe_round_trip.py — runs the four pre-registered Q2
  falsifiers from PR #506:
    (a) any extra: dict[str, Any] field
    (b) any # type: ignore needed to construct
    (c) lossy round-trip dict → recipe → dict
    extra-forbid: smuggled fields rejected
- README.md — what's here, how to run, results, caveats

Result: 8 tests pass. All Q2 falsifiers refuse to fire.

- Round-trip is lossless across all 5 fixture shapes
- Zero Any-typed fields; only dict-typed field is custom_targeting_keys
  typed strictly as dict[str, str | list[str]] per GAM's API contract,
  NOT an escape hatch
- Direct construction with sub-models needs no # type: ignore
- Unknown fields are rejected by extra="forbid"

Q2 prior holds: a typed Pydantic recipe carries the full GAM
implementation_config shape without escape hatches.

Combined with Q1.5 (Phase 1A — recipe is adopter-owned, not
framework-managed; corrected in this PR's revision of #502), the
architecture story is now:
- Recipe is typed at framework boundary (Q2 confirmed)
- Recipe storage is adopter-owned (Q1.5 confirmed)
- Framework's job: type the contract, route transitions, dispatch

Caveats documented in README:
- Fixtures derived from service code paths, not production DB dumps;
  dev-DB validation pass would tighten the result
- custom_targeting_keys typing follows GAM's documented API; deeper-
  nested salesagent data would reject (correct against GAM, may
  surface migration edges)
- Literal[...] enums need versioning when GAM adds enum values

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
The architectural claim (recipe is adopter-owned; framework types,
doesn't cache) is general. The empirical evidence in
examples/recipe_falsification/ is GAM-specific by construction —
validates GAMRecipe against GAM's implementation_config, not other
adopter shapes.

Added explicit scope note to the top-level revision callout so
reviewers don't read the change as GAM-only thinking. Multi-adopter
validation (LinkedIn, Meta, TikTok, Prebid multi-decisioning) is
future work that would tighten Q2 across more shapes; the general
architecture claim doesn't wait on it.

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

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