Skip to content

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

@bokelley

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions