Skip to content

feat(decisioning): implementation_config lookup helper for create_media_buy #497

@bokelley

Description

@bokelley

Motivation

Every seller adapter (GAM, Kevel, Xandr, prebid-server, …) carries seller-side configuration on each Product that the buyer doesn't see and isn't part of the AdCP wire schema — line item template, ad unit IDs, key-value targeting templates, default placements, etc. AdCP's Product.ext (extra='allow') is the wire surface, but every adopter stores it under Product.implementation_config by convention (salesagent, the prebid example, the GAM reference adapter all do it the same way).

The pattern on create_media_buy is universal: enumerate the requested products, look up implementation_config per product_id, hand it to the adapter alongside the wire CreateMediaBuyRequest. Today every adopter writes this loop. The SDK can ship the helper.

Parent tracker: #491.

Current state

Salesagent: does this inline at src/core/tools/media_buy_create.py:2423-2466:

  • Looks up products via get_product_catalog(tenant_id=…) then filters by req.get_product_ids() (lines 2425-2429)
  • For GAM specifically, auto-generates a default implementation_config if missing (lines 2438-2455)
  • Validates the config via GAMProductConfigService.validate_config (lines 2468-2491)

The model field: src/core/database/models.py:256 (implementation_config: Mapped[dict | None] = mapped_column(JSONType)) and src/core/database/models.py:428-448 (effective_implementation_config accessor).

SDK: no helper. MediaBuyHandler.create_media_buy (src/adcp/decisioning/handler.py:1035-1052) hands the raw CreateMediaBuyRequest to the adopter; the adopter has to do the lookup itself. No implementation_config references anywhere in src/adcp/decisioning/.

Proposed API

A pluggable lookup protocol the adopter wires once, then framework injects the resolved configs into the platform method via a typed wrapper around the request:

# src/adcp/decisioning/implementation_config.py (new)

class ProductConfigStore(Protocol):
    \"\"\"Adopter-supplied lookup. Framework calls once per create_media_buy
    with the product_ids from the request; adopter returns the configs.\"\"\"

    async def lookup_implementation_configs(
        self,
        product_ids: list[str],
        ctx: RequestContext,
    ) -> dict[str, dict[str, Any]]:
        ...


# SalesPlatform method signature gains an optional configs kwarg the
# framework populates before calling — adopters that don't wire a
# ProductConfigStore see configs={} (current behavior).

class SalesPlatform(Protocol):
    def create_media_buy(
        self,
        req: CreateMediaBuyRequest,
        ctx: RequestContext,
        configs: dict[str, dict[str, Any]] = ...,  # framework-injected
    ) -> SalesResult[CreateMediaBuySuccessResponse]:
        ...

Acceptance criteria

  • ProductConfigStore is optional; when not wired, framework injects configs={} (back-compat)
  • When wired, framework calls lookup_implementation_configs with deduped product_ids extracted from req.packages[*].product_id (or wherever they live in the v3 wire shape — verify against current CreateMediaBuyRequest)
  • Lookup runs once per create_media_buy, before the platform method, results passed via kwarg
  • Lookup failure → AdcpError(IMPLEMENTATION_CONFIG_LOOKUP_FAILED, recovery='transient')
  • Missing product_id in returned dict → adopter handles (framework doesn't fabricate)
  • validate_platform warns when adopter declares the sales-* specialism but doesn't wire a ProductConfigStore AND uses configs kwarg
  • Test: 3 product_ids, store returns all 3 → adopter sees configs={pid1: {...}, pid2: {...}, pid3: {...}}
  • Test: store returns only 2 of 3 → adopter sees the partial dict (framework doesn't fail)
  • Test: store raises → translates to wire IMPLEMENTATION_CONFIG_LOOKUP_FAILED

Out of scope

  • Auto-generation of default configs (salesagent has GAM-specific defaults at media_buy_create.py:2440-2455 — that's adapter-specific, not framework-portable)
  • Config validation (also adapter-specific — salesagent uses GAMProductConfigService.validate_config)
  • Persisting configs back to a store on creation (write-side; only reading is universal)
  • Threading config to other tools (update_media_buy, sync_creatives) — open question, possibly follow-up

Cross-references

Open question (for the implementer)

Should ProductConfigStore be a standalone protocol or fold into a broader inventory_store primitive? Brian's note in the original triage prompt flagged signals/inventory store as an architectural question. Recommendation: ship narrow first (ProductConfigStore only, scoped to create_media_buy); generalize when a second tool needs the same lookup.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions