Skip to content

Commit e5346eb

Browse files
bokelleyclaude
andcommitted
fix(decisioning): address review feedback on auto-commit capability (#725)
- Loud-fail ``auto_commit_on_put_draft=True`` on ``sales-guaranteed`` specialism. Product-expert catch: a 7-day default TTL inventory hold issued silently from every ``get_products`` call would burn guaranteed-direct inventory across thousands of catalog probes per day. GAM / ad-server proposal lifecycles require explicit buyer-driven reservation precisely because trafficking ops won't accept silent holds. Loud-fail with a clear migration path (switch to ``sales-non-guaranteed`` or set ``finalize=True``). - Implement the >30-day TTL soft-cap warning the docstring promised. The framework permits longer TTLs (long-running RFPs legitimately need them) but warns at boot so the choice is visible at declaration time. Boundary check pins that exactly 30 days does not trigger. - Move ``from datetime import timedelta`` to the module-level import (was inline inside the per-proposal loop). - 3 new tests: ``sales-guaranteed`` rejection, >30d warning + 30d boundary, catalog-mode (store wired but manager unwired stays in DRAFT regardless). - Rename local variable ``_SOFT_CAP_SECONDS`` → ``_soft_cap_seconds`` per ruff N806 (function-local should be lowercase). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c93cc4f commit e5346eb

3 files changed

Lines changed: 111 additions & 3 deletions

File tree

src/adcp/decisioning/proposal_dispatch.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
import contextvars
5252
import functools
5353
import logging
54-
from datetime import datetime, timezone
54+
from datetime import datetime, timedelta, timezone
5555
from typing import TYPE_CHECKING, Any, cast
5656

5757
from adcp.decisioning.proposal_lifecycle import (
@@ -475,8 +475,6 @@ async def maybe_persist_draft_after_get_products(
475475
# next call's ``try_reserve_consumption`` finds a COMMITTED
476476
# record. The manager's ``auto_commit_ttl_seconds`` sets
477477
# the expires_at horizon.
478-
from datetime import timedelta
479-
480478
expires_at = datetime.now(timezone.utc) + timedelta(seconds=auto_commit_ttl)
481479
await _await_maybe(
482480
store.commit(

src/adcp/decisioning/proposal_manager.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,33 @@ def __post_init__(self) -> None:
229229
recovery="terminal",
230230
field="auto_commit_on_put_draft",
231231
)
232+
# #723 product safety: auto-commit on guaranteed-direct issues
233+
# a silent inventory hold on every ``get_products`` call. GAM /
234+
# ad-server proposal lifecycles require explicit reservation
235+
# precisely because trafficking ops won't accept silent holds
236+
# — a 7-day default TTL would burn inventory across thousands
237+
# of catalog probes per day. Loud-fail; adopters who need
238+
# auto-commit on guaranteed-direct can re-evaluate the
239+
# commercial commitment by wiring the explicit ``finalize``
240+
# path instead.
241+
if self.auto_commit_on_put_draft and self.sales_specialism == "sales-guaranteed":
242+
raise AdcpError(
243+
"INVALID_REQUEST",
244+
message=(
245+
"ProposalCapabilities: auto_commit_on_put_draft=True is "
246+
"not permitted on sales_specialism='sales-guaranteed'. "
247+
"Auto-commit issues a silent inventory hold on every "
248+
"get_products call (7-day default TTL); guaranteed-"
249+
"direct flows require explicit buyer-driven reservation "
250+
"via the finalize=True lifecycle to avoid unintended "
251+
"commitments. Either switch to "
252+
"sales_specialism='sales-non-guaranteed' (catalog / "
253+
"spot-market flows where auto-commit is appropriate) "
254+
"or set finalize=True instead."
255+
),
256+
recovery="terminal",
257+
field="auto_commit_on_put_draft",
258+
)
232259
if self.auto_commit_ttl_seconds <= 0:
233260
raise AdcpError(
234261
"INVALID_REQUEST",
@@ -242,6 +269,25 @@ def __post_init__(self) -> None:
242269
recovery="terminal",
243270
field="auto_commit_ttl_seconds",
244271
)
272+
# Soft-cap warning: a TTL longer than 30 days holds inventory
273+
# for an entire month per catalog probe. Operators can extend
274+
# for long-running RFP flows, but the SDK surfaces a heads-up
275+
# so the default doesn't drift past what the adopter intended.
276+
_soft_cap_seconds = 30 * 24 * 3600
277+
if self.auto_commit_on_put_draft and self.auto_commit_ttl_seconds > _soft_cap_seconds:
278+
import warnings as _warnings
279+
280+
_warnings.warn(
281+
f"ProposalCapabilities.auto_commit_ttl_seconds="
282+
f"{self.auto_commit_ttl_seconds} exceeds the soft cap of "
283+
f"{_soft_cap_seconds} (30 days). Auto-committed proposals "
284+
"hold inventory for the full TTL — verify your commercial "
285+
"model supports holds this long. The framework permits "
286+
"it; this warning fires once per declaration site so the "
287+
"choice is visible at boot.",
288+
UserWarning,
289+
stacklevel=3,
290+
)
245291

246292

247293
@dataclass(frozen=True)

tests/test_proposal_auto_commit.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,67 @@ def test_auto_commit_default_is_false() -> None:
222222
guard so the default isn't accidentally flipped."""
223223
caps = ProposalCapabilities(sales_specialism="sales-non-guaranteed")
224224
assert caps.auto_commit_on_put_draft is False
225+
226+
227+
def test_auto_commit_rejected_on_sales_guaranteed() -> None:
228+
"""Product safety guard (raised by review): auto-commit on
229+
``sales-guaranteed`` issues a silent inventory hold on every
230+
``get_products`` call. GAM / ad-server proposal lifecycles
231+
require explicit buyer-driven reservation precisely because
232+
trafficking ops won't accept silent holds — a 7-day default TTL
233+
would burn inventory across thousands of catalog probes per day.
234+
Loud-fail with a clear migration path."""
235+
with pytest.raises(AdcpError, match="sales-guaranteed"):
236+
ProposalCapabilities(
237+
sales_specialism="sales-guaranteed",
238+
auto_commit_on_put_draft=True,
239+
)
240+
241+
242+
def test_auto_commit_long_ttl_emits_soft_cap_warning() -> None:
243+
"""A TTL longer than 30 days holds inventory for an entire month
244+
per catalog probe. The framework permits it (long-running RFPs
245+
can legitimately need it) but warns at boot so the choice is
246+
visible. Adopters silence per-process via the warnings filter."""
247+
import warnings
248+
249+
with pytest.warns(UserWarning, match="soft cap of"):
250+
ProposalCapabilities(
251+
sales_specialism="sales-non-guaranteed",
252+
auto_commit_on_put_draft=True,
253+
auto_commit_ttl_seconds=45 * 24 * 3600, # 45 days
254+
)
255+
256+
# Boundary check: exactly 30 days = no warning (cap is "exceeds",
257+
# not "meets").
258+
with warnings.catch_warnings():
259+
warnings.simplefilter("error", UserWarning)
260+
ProposalCapabilities(
261+
sales_specialism="sales-non-guaranteed",
262+
auto_commit_on_put_draft=True,
263+
auto_commit_ttl_seconds=30 * 24 * 3600, # exactly 30 days
264+
)
265+
266+
267+
@pytest.mark.asyncio
268+
async def test_catalog_mode_store_wired_manager_unwired_no_auto_commit() -> None:
269+
"""Catalog-mode adopter: ``ProposalStore`` wired but no
270+
``ProposalManager`` (no proposal-lifecycle dispatch). The
271+
auto-commit branch must be off in this configuration regardless
272+
of what any other manager's capabilities say — we read the
273+
capability off the tenant's own manager, which here is ``None``.
274+
Explicit pin so future refactors that resolve the capability via
275+
a different path (e.g. a router-level default) don't accidentally
276+
enable auto-commit in catalog mode."""
277+
store = InMemoryProposalStore()
278+
platform = _RouterLike(manager=None, store=store)
279+
280+
await maybe_persist_draft_after_get_products(
281+
platform,
282+
{"products": [], "proposals": [{"proposal_id": "p1"}]},
283+
_ctx(),
284+
)
285+
286+
record = await store.get("p1")
287+
assert record is not None
288+
assert record.state is ProposalState.DRAFT

0 commit comments

Comments
 (0)