Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/adcp/decisioning/proposal_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
import contextvars
import functools
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, cast

from adcp.decisioning.proposal_lifecycle import (
Expand Down Expand Up @@ -417,8 +417,17 @@ async def maybe_persist_draft_after_get_products(
* Response carries no ``proposals[]`` (catalog mode).
* No recipes attached to products (v1 ``implementation_config``
flow without typed recipes).

Per #723: when the manager declares
:attr:`ProposalCapabilities.auto_commit_on_put_draft`, the
framework calls :meth:`ProposalStore.commit` immediately after
:meth:`put_draft` to promote ``DRAFT → COMMITTED`` so the
subsequent ``create_media_buy(proposal_id=X)`` can call
``try_reserve_consumption`` without a separate finalize step.
Used by managers issuing directly-consumable proposals from
``get_products``.
"""
_, store = _resolve_manager_and_store(platform, ctx)
manager, store = _resolve_manager_and_store(platform, ctx)
if store is None:
return

Expand All @@ -428,6 +437,14 @@ async def maybe_persist_draft_after_get_products(

products = _extract_list(response, "products") or []

# #723: cache the capability flag once per call — avoid attribute
# lookups inside the per-proposal loop. ``manager`` may legitimately
# be ``None`` (catalog-mode adopter wired a ProposalStore without
# a ProposalManager); in that case auto-commit is off by default.
caps = getattr(manager, "capabilities", None) if manager is not None else None
auto_commit = bool(getattr(caps, "auto_commit_on_put_draft", False))
auto_commit_ttl = int(getattr(caps, "auto_commit_ttl_seconds", 7 * 24 * 3600))

for proposal in proposals:
proposal_id = _read(proposal, "proposal_id")
if proposal_id is None:
Expand All @@ -453,6 +470,19 @@ async def maybe_persist_draft_after_get_products(
account_id=ctx.account.id,
recipes_count=len(recipes),
)
if auto_commit:
# Promote DRAFT → COMMITTED in the same dispatch so the
# next call's ``try_reserve_consumption`` finds a COMMITTED
# record. The manager's ``auto_commit_ttl_seconds`` sets
# the expires_at horizon.
expires_at = datetime.now(timezone.utc) + timedelta(seconds=auto_commit_ttl)
await _await_maybe(
store.commit(
str(proposal_id),
expires_at=expires_at,
proposal_payload=proposal_payload,
)
)


def _collect_recipes_from_products(
Expand Down
103 changes: 103 additions & 0 deletions src/adcp/decisioning/proposal_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ class ProposalCapabilities:
Kevel for non-guaranteed-remnant in the same proposal).
Informational in v1; the per-recipe-kind routing lands in
a subsequent PR alongside the typed-recipe registry.
:param auto_commit_on_put_draft: Opt-in shortcut for managers
that issue directly-consumable proposals from ``get_products``
without a separate ``finalize_proposal`` step. When ``True``,
the framework calls :meth:`ProposalStore.commit` immediately
after :meth:`ProposalStore.put_draft` on every proposal
returned, promoting ``DRAFT → COMMITTED`` so that
``create_media_buy(proposal_id=X)`` can call
``try_reserve_consumption`` without a separate buyer round-trip.
Mutually exclusive with ``finalize=True`` (finalize is the
explicit lifecycle; auto-commit is the bypass). Adopters
wiring their own commit lifecycle (e.g. webhook-driven
approval) leave this ``False``. See #723.
:param auto_commit_ttl_seconds: TTL applied to the auto-committed
proposal's ``expires_at``. Used only when
:attr:`auto_commit_on_put_draft` is ``True``. Defaults to
``604800`` (7 days), matching the salesagent's adopter
choice. Tune up for long-running RFPs; tune down for
spot-market flows. Cap is enforced soft (a warning fires for
values > 30 days) — buyers retrying past the TTL get
``PROPOSAL_EXPIRED`` and re-request the brief.
"""

sales_specialism: SalesSpecialism
Expand All @@ -152,6 +172,8 @@ class ProposalCapabilities:
# the framework no longer reads this field. Stops appearing on new
# adopter declarations; v1.6+ removes it entirely.
multi_decisioning: bool = False
auto_commit_on_put_draft: bool = False
auto_commit_ttl_seconds: int = 7 * 24 * 3600 # 7 days, salesagent default

def __post_init__(self) -> None:
# Spec only allows the two slugs at v1. Adopter passing a
Expand Down Expand Up @@ -185,6 +207,87 @@ def __post_init__(self) -> None:
recovery="terminal",
field="expires_at_grace_seconds",
)
# #723: auto-commit and finalize are mutually exclusive
# lifecycles. ``finalize=True`` says "buyer drives DRAFT →
# COMMITTED explicitly"; ``auto_commit_on_put_draft=True`` says
# "framework promotes on put_draft so no explicit step is
# needed." Both on at once produces a state-machine race
# (the framework auto-commits, then the buyer's finalize call
# rejects because the proposal is no longer DRAFT). Loud-fail
# at construction.
if self.auto_commit_on_put_draft and self.finalize:
raise AdcpError(
"INVALID_REQUEST",
message=(
"ProposalCapabilities: auto_commit_on_put_draft=True and "
"finalize=True are mutually exclusive. auto-commit "
"skips the explicit finalize step (proposals from "
"get_products are committed-on-issuance); finalize "
"requires the buyer to drive the transition. Pick one. "
"See #723."
),
recovery="terminal",
field="auto_commit_on_put_draft",
)
# #723 product safety: auto-commit on guaranteed-direct issues
# a silent inventory hold on every ``get_products`` call. GAM /
# ad-server proposal lifecycles require explicit reservation
# precisely because trafficking ops won't accept silent holds
# — a 7-day default TTL would burn inventory across thousands
# of catalog probes per day. Loud-fail; adopters who need
# auto-commit on guaranteed-direct can re-evaluate the
# commercial commitment by wiring the explicit ``finalize``
# path instead.
if self.auto_commit_on_put_draft and self.sales_specialism == "sales-guaranteed":
raise AdcpError(
"INVALID_REQUEST",
message=(
"ProposalCapabilities: auto_commit_on_put_draft=True is "
"not permitted on sales_specialism='sales-guaranteed'. "
"Auto-commit issues a silent inventory hold on every "
"get_products call (7-day default TTL); guaranteed-"
"direct flows require explicit buyer-driven reservation "
"via the finalize=True lifecycle to avoid unintended "
"commitments. Either switch to "
"sales_specialism='sales-non-guaranteed' (catalog / "
"spot-market flows where auto-commit is appropriate) "
"or set finalize=True instead."
),
recovery="terminal",
field="auto_commit_on_put_draft",
)
if self.auto_commit_ttl_seconds <= 0:
raise AdcpError(
"INVALID_REQUEST",
message=(
"ProposalCapabilities.auto_commit_ttl_seconds must be "
f"> 0; got {self.auto_commit_ttl_seconds!r}. Zero or "
"negative TTL would mark proposals expired on commit, "
"making every consumption attempt fail with "
"PROPOSAL_EXPIRED."
),
recovery="terminal",
field="auto_commit_ttl_seconds",
)
# Soft-cap warning: a TTL longer than 30 days holds inventory
# for an entire month per catalog probe. Operators can extend
# for long-running RFP flows, but the SDK surfaces a heads-up
# so the default doesn't drift past what the adopter intended.
_soft_cap_seconds = 30 * 24 * 3600
if self.auto_commit_on_put_draft and self.auto_commit_ttl_seconds > _soft_cap_seconds:
import warnings as _warnings

_warnings.warn(
f"ProposalCapabilities.auto_commit_ttl_seconds="
f"{self.auto_commit_ttl_seconds} exceeds the soft cap of "
f"{_soft_cap_seconds} (30 days). Auto-committed proposals "
"hold inventory for the full TTL — verify your commercial "
"model supports holds this long. The framework permits "
"it; this warning fires once per declaration site so the "
"choice is visible at boot.",
UserWarning,
stacklevel=3,
)


@dataclass(frozen=True)
Expand Down
Loading
Loading