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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ All notable changes to the IAB Tech Lab Seller Agent are documented here.
orphaned worker thread (CrewAI has no cancellation API). Budget <= 0
still means "no bound" and always runs the crew; wire answers on the
timeout path are unchanged.
- **A negotiated price now actually books.** Booking looked for an accepted
negotiation with `get_negotiation(quote_id)`, which only ever hit for a
quote-led negotiation, since that one happens to be stored under the quote
id. A proposal-led negotiation is stored under its `prop-` id, so the
lookup missed and the deal was booked at the seller's standard price with
the negotiation reported as successful. `NegotiationHistory` now retains
the `quote_id` that already arrived on `NegotiationMessage` and was being
dropped, an accepted negotiation indexes itself as
`negotiation_by_quote:{quote_id}`, and booking resolves through that index
(falling back to the direct read, so quote-led negotiations recorded before
the index existed are still honored). **Behavior change:** bookings that
silently ignored an accepted negotiation now honor it, so a booked
`final_cpm` can differ from the quoted one — it carries the agreed price
and says so in its rationale. An accepted negotiation carrying no agreed
price is now refused (`negotiation_price_unresolved`) rather than quietly
booked at the un-negotiated quoted price. Relatedly, the negotiation
storage key is now resolved against the store instead of being read off
whichever ids a given message happened to carry, so a continuation leading
with a different id continues the same negotiation rather than silently
restarting it at round one.

### Fixed

Expand Down
23 changes: 19 additions & 4 deletions src/ad_seller/interfaces/api/routers/negotiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,17 @@ async def post_negotiation_message(

# The seller keys negotiations by proposal_id; negotiation_id doubles as
# that key for continuation, and quote-led opens key off the quote_id
# (the service resolves the stored quote to its product).
proposal_id = message.proposal_id or message.negotiation_id or message.quote_id
# (the service resolves the stored quote to its product). Which of those
# is the key is resolved against the STORE rather than read off this
# message: taking `proposal_id or negotiation_id or quote_id` made the key
# depend on the ids a given message happened to carry, so a continuation
# leading with a different id resolved to a different record and silently
# restarted the negotiation.
proposal_id = await negotiation_service.resolve_negotiation_key(
proposal_id=message.proposal_id,
negotiation_id=message.negotiation_id,
quote_id=message.quote_id,
)
if proposal_id is None: # unreachable: the shared model requires one key
raise HTTPException(
status_code=400,
Expand Down Expand Up @@ -226,15 +235,21 @@ async def post_negotiation_message(
proposal_id=proposal_id,
buyer_price=buyer_price,
buyer_context=buyer_context,
quote_id=message.quote_id,
)
response = cm.negotiation_round_to_response(result)
else:
# accept / reject — terminal moves off the recorded history; the
# price engine is not run (it stays untouched). The move is
# PERSISTED onto the stored negotiation so downstream booking sees
# the agreed state.
# the agreed state, and an accept indexes the negotiation by its
# quote so booking that quote can find the agreed price.
status_data = await negotiation_service.apply_terminal_action(
proposal_id, message.action.value, buyer_price
proposal_id,
message.action.value,
buyer_price,
quote_id=message.quote_id,
buyer_context=buyer_context,
)
response = cm.terminal_round_response(status_data, message.action, buyer_price)

Expand Down
5 changes: 5 additions & 0 deletions src/ad_seller/models/negotiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ class NegotiationHistory(BaseModel):

negotiation_id: str = Field(default_factory=lambda: f"neg-{uuid.uuid4().hex[:8]}")
proposal_id: str
# The quote this negotiation concerns, when one was named. It arrives on
# the wire (``NegotiationMessage.quote_id``) and used to be dropped here,
# so nothing could correlate a negotiation to the quote a buyer books —
# booking then struck the stale quoted price and logged success.
quote_id: Optional[str] = None
product_id: str
buyer_tier: AccessTier
strategy: NegotiationStrategy
Expand Down
88 changes: 70 additions & 18 deletions src/ad_seller/services/deal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,34 @@ def match_agentic_audience(ref: dict[str, Any]) -> dict[str, Any]:
# =============================================================================


async def _resolve_accepted_negotiation(storage: Any, quote_id: str) -> Optional[dict[str, Any]]:
"""Return the ACCEPTED negotiation about ``quote_id``, or None.

``DealBookingRequest`` carries only the quote id, so the quote is the only
handle the protocol offers for correlating a booking to a negotiation.
An accepted negotiation indexes itself under
``negotiation_by_quote:{quote_id}`` (see
``negotiation_service._persist_negotiation``); that pointer names the key
its record is stored under, whichever id the buyer led with.

The direct read by quote id is retained as a fallback: a quote-led
negotiation is itself stored under the quote id, and records written
before the index existed have no pointer.
"""
from ..storage.base import negotiation_by_quote_key

negotiation = None
pointer = await storage.get(negotiation_by_quote_key(quote_id))
if isinstance(pointer, str) and pointer:
negotiation = await storage.get_negotiation(pointer)
if not negotiation:
negotiation = await storage.get_negotiation(quote_id)

if isinstance(negotiation, dict) and negotiation.get("status") == "accepted":
return negotiation
return None


async def book_deal(request: Any) -> dict[str, Any]:
"""Book a deal from a previously issued quote (``DealBookingRequestModel``).

Expand Down Expand Up @@ -238,27 +266,51 @@ async def book_deal(request: Any) -> dict[str, Any]:
},
)

# Honor ACCEPTED negotiation state on this quote: a
# quote-led negotiation is keyed by the quote_id; when it concluded
# accepted, the booking strikes the AGREED price, not the stale quoted
# price. A re-quote at the agreed price (the buyer's historical
# workaround) carries no negotiation and books unchanged.
negotiation = await storage.get_negotiation(request.quote_id)
if negotiation and negotiation.get("status") == "accepted":
# Honor ACCEPTED negotiation state on this quote: when a negotiation about
# this quote concluded accepted, the booking strikes the AGREED price, not
# the stale quoted price. A quote with no accepted negotiation (including a
# re-quote at the agreed price, the buyer's historical workaround) books
# unchanged.
#
# Resolution goes through the quote index an accepted negotiation writes
# (`negotiation_by_quote:{quote_id}` -> the key its record is stored
# under). Passing the quote id straight to get_negotiation only ever
# worked for a QUOTE-LED negotiation, which happens to be stored under the
# quote id; a proposal-led one is stored under its `prop-` id, so the
# lookup missed every time and the agreed price was silently dropped. The
# direct read is kept as a fallback so quote-led negotiations recorded
# before this index existed are still honored.
negotiation = await _resolve_accepted_negotiation(storage, request.quote_id)
if negotiation:
rounds = negotiation.get("rounds") or []
agreed_cpm = rounds[-1].get("seller_price") if rounds else None
if agreed_cpm is not None:
quote = {
**quote,
"pricing": {
**quote["pricing"],
"final_cpm": agreed_cpm,
"rationale": (
f"{quote['pricing'].get('rationale', '')} | Negotiated to "
f"${agreed_cpm:.2f} CPM ({negotiation.get('negotiation_id')})"
).strip(" |"),
if agreed_cpm is None:
# A negotiation that concluded accepted with no price is a corrupt
# record. Booking it at list price is the worst available outcome —
# it is exactly the silent overpayment this whole path exists to
# prevent — so refuse instead.
raise HTTPException(
status_code=500,
detail={
"error": "negotiation_price_unresolved",
"message": (
f"Negotiation '{negotiation.get('negotiation_id')}' for quote "
f"'{request.quote_id}' is accepted but carries no agreed price. "
"Refusing to book at the un-negotiated quoted price."
),
},
}
)
quote = {
**quote,
"pricing": {
**quote["pricing"],
"final_cpm": agreed_cpm,
"rationale": (
f"{quote['pricing'].get('rationale', '')} | Negotiated to "
f"${agreed_cpm:.2f} CPM ({negotiation.get('negotiation_id')})"
).strip(" |"),
},
}

# Pre-flight: if the buyer sent an audience_plan with this booking, validate
# it against the seller's capability block. Per proposal §5.7 layer 3, any
Expand Down
Loading
Loading