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
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 backingUpstream ships
adcp.decisioning.InMemoryProposalStore(non-durable reference) + theProposalStoreProtocol. Every production adopter writes the same Postgres-backed implementation:put_draft/get/commit/try_reserve_consumption/finalize_consumption/release_consumption/mark_consumed/discard/get_by_media_buy_id).SELECT … FOR UPDATErow-lock pattern for the CAS intry_reserve_consumption(Protocol contract).(tenant_id, proposal_id)for multi-tenant scoping; partial unique on(tenant_id, media_buy_id) WHERE media_buy_id IS NOT NULLforget_by_media_buy_id.commitfrom non-DRAFT raises INTERNAL_ERROR,try_reserve_consumptionfrom non-COMMITTED raises PROPOSAL_NOT_COMMITTED, etc.).The Prebid salesagent fork shipped this as
SalesAgentProposalStorein bokelley/salesagent#390 (~430 LOC core + tests). The implementation is mostly mechanical — same shape asIdempotencyStore.PgBackendadopters get for free.Proposed API
Same shape as
IdempotencyStore.PgBackendships today.(2) Framework-level package derivation from proposal allocations
The framework already:
try_reserve_consumption(proposal_dispatch.py:585-588)ctx.recipes(line 607)But then it punts: the seller's
platform.create_media_buyis handed an emptyreq.packagesand is expected to do the derivation. Every adopter writes essentially the same percentage math:The salesagent had to ship this seller-side just to pass the
proposal_finalize/create_media_buystoryboard. So will every other seller declaringcapabilities.refine=Trueor wiring a ProposalStore.Proposed API — framework-level auto-injection
In
proposal_dispatch.maybe_hydrate_recipes_for_create_media_buy, after reserving the proposal: ifreq.packagesis empty AND the proposal hasallocations[], mutatereq.packagesto the derived list before dispatching toplatform.create_media_buy.The seller's adapter sees a normal
create_media_buywith populated packages — never needs to know it came from a proposal. Existingpackages-based paths (noproposal_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 onProposalManager:Adopters with default behavior (even percentage split) leave the method off; the framework falls through to its built-in even-split derivation.
SalesAgentProposalManagerand 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 outsideproposal_dispatch's auto-injection path can still call it. Mirror the shapeIdempotencyStore.wrapexposes — 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
SalesAgentProposalStorePR (collapses to ~10 LOC once PgProposalStore ships upstream)🤖 Generated with Claude Code