Skip to content

Commit 162fd7f

Browse files
atc964claude
andauthored
Stage 1 — Security & Guardrail Hardening (#110)
* Add deterministic budget/CPM spend ceiling to the booking path Nothing on the buyer's money-committing path previously compared the final price to the campaign's limits: max_cpm reached the selection LLM only as prose and the seller only as an advisory search filter, so a seller quoting basePrice 200.0 against a target CPM of 20 was still issued a Deal ID at ~8.5x the ceiling. Add a pure, deterministic guard (enforce_spend_ceiling / SpendCeilingExceeded in ad_buyer.booking.spend_ceiling) and call it at both money-commit points: - RequestDealTool._create_deal_response: the computed final CPM is checked against the buyer's max_cpm BEFORE a Deal ID is minted; a breach returns a structured DEAL REJECTED error. BuyerDealFlow threads state.max_cpm onto the tool deterministically (not via an LLM-visible argument). - DealBookingFlow._execute_bookings: the total cost of LLM-parsed approved recommendations is checked against the campaign budget before any line is booked; a breach rejects the booking and marks the flow FAILED. Missing limits (max_cpm/budget is None) fail open with a warning log — an explicit choice to preserve current demo behavior; a supplied limit is always enforced and there is no flag to disable the guard. MultiSellerOrchestrator is intentionally untouched: evaluate_and_rank already filters by max_cpm and select_and_book already skips quotes whose minimum spend exceeds the remaining budget. bead: ar-70eh Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fail-closed delivery for audit-class events Event emission was fail-open across the board: emit_event and emit_event_sync swallowed every exception, so audit-trail events for money decisions (deal booked/cancelled, booking submitted, budget allocation, negotiation rounds, approval decisions) could silently drop when the event bus failed. Surgical hotfix, not a bus redesign: - AUDIT_EVENT_TYPES (events/models.py): the money-relevant event types whose loss would break the audit trail. - events/audit_fallback.py: append-only JSONL fallback writer with fsync-on-write; path from settings (audit_fallback_path, default data/audit_fallback.jsonl). - emit_event / emit_event_sync (events/helpers.py): on bus failure for an audit-class event, write the event to the fallback JSONL and continue; if the fallback write also fails, re-raise so the transaction surfaces the error (fail-closed). The fire-and-forget running-loop path in emit_event_sync gets a done-callback that writes the fallback on publish failure (best effort; cannot raise into a caller that already moved on). Non-audit events keep the existing fail-open behavior unchanged. bead: ar-z64h Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: restore default event loop after audit fail-closed tests emit_event_sync consumes the thread default loop; test_event_bus.py's deprecated asyncio.get_event_loop() then finds none when the files run adjacently. Fresh loop on fixture teardown removes the order dependency. bead: ar-z64h Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPyrrnxpTatTciiNo7eK2y --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0823518 commit 162fd7f

13 files changed

Lines changed: 1017 additions & 15 deletions

File tree

src/ad_buyer/booking/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
QuoteFlowClient - Quote-then-book flow for deal creation
1414
QuoteNormalizer - Normalizes quotes from different sellers for comparison
1515
TemplateFlowClient - Template-based booking (stub)
16+
enforce_spend_ceiling - Deterministic budget/CPM ceiling guard
17+
SpendCeilingExceeded - Raised when a spend limit would be breached
1618
"""
1719

1820
from .deal_id import generate_deal_id
1921
from .pricing import PricingCalculator, PricingResult
2022
from .quote_flow import QuoteFlowClient
2123
from .quote_normalizer import NormalizedQuote, QuoteNormalizer, SupplyPathInfo
24+
from .spend_ceiling import SpendCeilingExceeded, enforce_spend_ceiling
2225
from .template_flow import TemplateFlowClient
2326

2427
__all__ = [
@@ -27,7 +30,9 @@
2730
"PricingResult",
2831
"QuoteFlowClient",
2932
"QuoteNormalizer",
33+
"SpendCeilingExceeded",
3034
"SupplyPathInfo",
3135
"TemplateFlowClient",
36+
"enforce_spend_ceiling",
3237
"generate_deal_id",
3338
]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Author: Green Mountain Systems AI Inc.
2+
# Donated to IAB Tech Lab
3+
4+
"""Deterministic spend-ceiling guard for money-committing paths.
5+
6+
Nothing on the buyer's booking path previously compared the final price
7+
to the campaign's limits: ``max_cpm`` reached the selection LLM only as
8+
prose and the seller only as an advisory search filter, so a seller
9+
quoting far above the buyer's ceiling would still be issued a Deal ID.
10+
11+
``enforce_spend_ceiling`` closes that hole. It is pure, deterministic
12+
code — no LLM involvement, no configuration flag to disable it — and is
13+
called at every point where the buyer commits money:
14+
15+
* ``RequestDealTool._create_deal_response`` (before a Deal ID is minted)
16+
* ``DealBookingFlow._execute_bookings`` (before booked lines are created)
17+
18+
Note: ``MultiSellerOrchestrator`` already enforces its own bounds
19+
(``evaluate_and_rank`` filters quotes by ``max_cpm``; ``select_and_book``
20+
skips quotes whose minimum spend exceeds the remaining budget), so it is
21+
intentionally not routed through this guard.
22+
23+
Fail-open policy: when a limit is None (the caller never supplied a
24+
max_cpm or budget), the corresponding check is skipped and a warning is
25+
logged. This is an explicit choice to preserve current demo behavior for
26+
callers that never configure limits; it is NOT a bypass for configured
27+
limits — a supplied limit is always enforced.
28+
"""
29+
30+
import logging
31+
32+
logger = logging.getLogger(__name__)
33+
34+
__all__ = ["SpendCeilingExceeded", "enforce_spend_ceiling"]
35+
36+
37+
class SpendCeilingExceeded(Exception):
38+
"""Raised when a deal or booking would breach a spend limit.
39+
40+
Deliberately does NOT subclass ValueError/RuntimeError so that broad
41+
``except (OSError, ValueError, RuntimeError)`` handlers on the tool
42+
paths cannot swallow it by accident — call sites must handle the
43+
rejection explicitly.
44+
45+
Attributes:
46+
final_cpm: Computed final CPM that was checked (or None).
47+
max_cpm: CPM ceiling that was breached (or None).
48+
total_cost: Computed total cost that was checked (or None).
49+
budget: Budget ceiling that was breached (or None).
50+
"""
51+
52+
def __init__(
53+
self,
54+
message: str,
55+
*,
56+
final_cpm: float | None = None,
57+
max_cpm: float | None = None,
58+
total_cost: float | None = None,
59+
budget: float | None = None,
60+
) -> None:
61+
super().__init__(message)
62+
self.final_cpm = final_cpm
63+
self.max_cpm = max_cpm
64+
self.total_cost = total_cost
65+
self.budget = budget
66+
67+
68+
def enforce_spend_ceiling(
69+
final_cpm: float | None = None,
70+
total_cost: float | None = None,
71+
max_cpm: float | None = None,
72+
budget: float | None = None,
73+
) -> None:
74+
"""Enforce CPM and budget ceilings before money is committed.
75+
76+
Pure deterministic guard: compares actuals against limits and raises
77+
when a limit would be breached. At-or-under a limit is allowed.
78+
79+
Args:
80+
final_cpm: The computed final CPM about to be committed.
81+
total_cost: The total cost about to be committed.
82+
max_cpm: The campaign's maximum acceptable CPM (limit).
83+
budget: The campaign's budget (limit).
84+
85+
Raises:
86+
SpendCeilingExceeded: When final_cpm > max_cpm or
87+
total_cost > budget (both comparisons only run when both
88+
sides are present).
89+
"""
90+
if max_cpm is None and budget is None:
91+
# Fail-open (explicit choice): no limits were supplied, so there
92+
# is nothing to enforce. Allow the spend but log a warning so
93+
# unbounded money paths are visible in logs. This preserves
94+
# current demo behavior for callers without configured limits.
95+
logger.warning(
96+
"Spend ceiling check skipped: no max_cpm or budget limit supplied "
97+
"(final_cpm=%s, total_cost=%s). Spend is unbounded.",
98+
final_cpm,
99+
total_cost,
100+
)
101+
return
102+
103+
if max_cpm is not None and final_cpm is not None and final_cpm > max_cpm:
104+
raise SpendCeilingExceeded(
105+
f"Final CPM ${final_cpm:.2f} exceeds max CPM ceiling ${max_cpm:.2f}",
106+
final_cpm=final_cpm,
107+
max_cpm=max_cpm,
108+
total_cost=total_cost,
109+
budget=budget,
110+
)
111+
112+
if budget is not None and total_cost is not None and total_cost > budget:
113+
raise SpendCeilingExceeded(
114+
f"Total cost ${total_cost:,.2f} exceeds budget ${budget:,.2f}",
115+
final_cpm=final_cpm,
116+
max_cpm=max_cpm,
117+
total_cost=total_cost,
118+
budget=budget,
119+
)

src/ad_buyer/config/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ def get_seller_endpoints(self) -> list[str]:
7474
postgres_pool_min: int = 2
7575
postgres_pool_max: int = 10
7676

77+
# Durable fallback for audit-class events (see events/audit_fallback.py).
78+
# When the event bus fails for an audit-class event, the event is appended
79+
# to this JSONL file (fsynced per write) instead of being dropped.
80+
audit_fallback_path: str = "data/audit_fallback.jsonl"
81+
7782
# CrewAI Settings
7883
crew_memory_enabled: bool = True
7984
crew_verbose: bool = True

src/ad_buyer/events/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
"""Event bus for buyer workflow observability and control."""
55

6+
from .audit_fallback import get_audit_fallback_path, write_audit_fallback
67
from .bus import EventBus, InMemoryEventBus, close_event_bus, get_event_bus
78
from .helpers import emit_event, emit_event_sync
8-
from .models import Event, EventType
9+
from .models import AUDIT_EVENT_TYPES, Event, EventType
910

1011
__all__ = [
12+
"AUDIT_EVENT_TYPES",
1113
"Event",
1214
"EventType",
1315
"EventBus",
@@ -16,4 +18,6 @@
1618
"close_event_bus",
1719
"emit_event",
1820
"emit_event_sync",
21+
"get_audit_fallback_path",
22+
"write_audit_fallback",
1923
]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Author: Green Mountain Systems AI Inc.
2+
# Donated to IAB Tech Lab
3+
4+
"""Durable fallback writer for audit-class events.
5+
6+
When the event bus fails while emitting an audit-class event (see
7+
AUDIT_EVENT_TYPES in models.py), the event is appended to a local JSONL
8+
file so the audit trail survives bus outages. Each write is flushed and
9+
fsynced so the record is on disk before the calling transaction proceeds.
10+
11+
This is an interim safety net, not an event store: a later refactor of
12+
the event architecture replaces it with durable bus semantics.
13+
"""
14+
15+
import json
16+
import os
17+
from pathlib import Path
18+
from typing import Any
19+
20+
DEFAULT_AUDIT_FALLBACK_PATH = "data/audit_fallback.jsonl"
21+
22+
23+
def get_audit_fallback_path() -> Path:
24+
"""Resolve the fallback JSONL path from settings, with a sane default."""
25+
try:
26+
from ..config import get_settings
27+
28+
path = getattr(get_settings(), "audit_fallback_path", DEFAULT_AUDIT_FALLBACK_PATH)
29+
except Exception: # noqa: BLE001 - settings failure must not block the fallback write
30+
path = DEFAULT_AUDIT_FALLBACK_PATH
31+
return Path(path)
32+
33+
34+
def write_audit_fallback(record: dict[str, Any]) -> None:
35+
"""Append one event record to the fallback JSONL file, fsync-on-write.
36+
37+
Raises on failure -- the caller (emit_event) treats a fallback-write
38+
failure as fatal for audit-class events (fail-closed).
39+
"""
40+
path = get_audit_fallback_path()
41+
path.parent.mkdir(parents=True, exist_ok=True)
42+
line = json.dumps(record, default=str)
43+
with open(path, "a", encoding="utf-8") as f:
44+
f.write(line + "\n")
45+
f.flush()
46+
os.fsync(f.fileno())

0 commit comments

Comments
 (0)