Skip to content

ProposalStore: ship PgProposalStore + framework-level package derivation from allocations #727

Description

@bokelley

Summary

Two adopter-side pieces every production seller wires today that belong upstream. Same upstream-vs-local-workaround pattern as #712 (WWW-Authenticate), #714 (replayed: true), and #720 (multi-header auth) — proven productive this week (all three shipped in 5.4.0 and adopter code shrunk).

(1) PgProposalStore — concrete durable backing

Upstream ships adcp.decisioning.InMemoryProposalStore (non-durable reference) + the ProposalStore Protocol. Every production adopter writes the same Postgres-backed implementation:

  • The same nine methods (put_draft / get / commit / try_reserve_consumption / finalize_consumption / release_consumption / mark_consumed / discard / get_by_media_buy_id).
  • The same SELECT … FOR UPDATE row-lock pattern for the CAS in try_reserve_consumption (Protocol contract).
  • The same composite primary key shape: (tenant_id, proposal_id) for multi-tenant scoping; partial unique on (tenant_id, media_buy_id) WHERE media_buy_id IS NOT NULL for get_by_media_buy_id.
  • The same state-machine guards (commit from non-DRAFT raises INTERNAL_ERROR, try_reserve_consumption from non-COMMITTED raises PROPOSAL_NOT_COMMITTED, etc.).

The Prebid salesagent fork shipped this as SalesAgentProposalStore in bokelley/salesagent#390 (~430 LOC core + tests). The implementation is mostly mechanical — same shape as IdempotencyStore.PgBackend adopters get for free.

Proposed API

from adcp.decisioning.proposal_store import PgProposalStore
from psycopg_pool import AsyncConnectionPool

pool = AsyncConnectionPool(DATABASE_URL, ...)
store = PgProposalStore(
    pool=pool,
    # Adopter chooses the table name to avoid collisions with existing
    # ``proposal_drafts`` / ``proposals`` tables. Defaults to the canonical
    # name; explicit prefix lets adopters with one Postgres serving multiple
    # adcp instances keep them scoped.
    table_name="adcp_proposal_drafts",
)
PgProposalStore.MIGRATION  # alembic-compatible upgrade/downgrade strings

Same shape as IdempotencyStore.PgBackend ships today.

(2) Framework-level package derivation from proposal allocations

The framework already:

  • Reserves the proposal via try_reserve_consumption (proposal_dispatch.py:585-588)
  • Hydrates ctx.recipes (line 607)
  • Documents that "the spec allows the seller to derive packages from the proposal's allocations" (line 590-593)

But then it punts: the seller's platform.create_media_buy is handed an empty req.packages and is expected to do the derivation. Every adopter writes essentially the same percentage math:

for allocation in proposal_payload["allocations"]:
    pkg_budget = total_budget.amount * (allocation["allocation_percentage"] / 100.0)
    packages.append(PackageRequest(
        product_id=allocation["product_id"],
        budget=pkg_budget,
        pricing_option_id=allocation.get("pricing_option_id"),
    ))

The salesagent had to ship this seller-side just to pass the proposal_finalize/create_media_buy storyboard. So will every other seller declaring capabilities.refine=True or wiring a ProposalStore.

Proposed API — framework-level auto-injection

In proposal_dispatch.maybe_hydrate_recipes_for_create_media_buy, after reserving the proposal: if req.packages is empty AND the proposal has allocations[], mutate req.packages to the derived list before dispatching to platform.create_media_buy.

The seller's adapter sees a normal create_media_buy with populated packages — never needs to know it came from a proposal. Existing packages-based paths (no proposal_id) unchanged.

Override hook for non-default strategies

Some sellers will want custom derivation logic — auction pricing options need bid_price, multi-currency proposals need explicit currency, capability-overlap filtering may shape the package count. Expose a method on ProposalManager:

class MyProposalManager:
    def derive_packages(
        self,
        proposal_payload: dict,
        total_budget: TotalBudget,
        recipes: Mapping[str, Recipe],
    ) -> list[PackageRequest]:
        \"\"\"Custom derivation. Framework calls this when proposal_id is set
        and req.packages is empty. Default implementation (when this method
        is absent) does even-percentage distribution per allocation.\"\"\"

Adopters with default behavior (even percentage split) leave the method off; the framework falls through to its built-in even-split derivation. SalesAgentProposalManager and most other minimal adopters delete their custom code; sellers with auction / multi-currency / complex inventory shaping override.

Standalone helper for sellers that need it

Even if (2) ships, expose adcp.decisioning.derive_packages_from_proposal(...) as a public helper so adopters with workflows outside proposal_dispatch's auto-injection path can still call it. Mirror the shape IdempotencyStore.wrap exposes — auto-applied via decorator, also callable directly.

Why now

Five days, four PRs (#712, #714, #720, plus the implicit #722/#723 wave), every one collapsed adopter code by 50-500 LOC. The pattern is reliable: defects and patterns in adopter decisioning/ wiring are good candidates for the library. This issue closes two more.

Related

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions