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: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,40 @@ All notable changes to the IAB Tech Lab Seller Agent are documented here.
was never called and `test_self_asserted_advertiser_identity_is_floored`
raised on a `None` `await_args`. CI never caught it because every job
starts from a clean checkout.
- **Information disclosure:** the chat interface and every counter-offer
no longer hand the buyer the seller's floor price. Two separate
surfaces were disclosing it. (1) On a negotiation reject, the chat
interface stated the floor outright in prose — "Our floor for this
inventory is $X CPM", formatted from the negotiation's `floor_price` —
so the seller told its counterparty its own floor mid-negotiation. The
reject is now a walk-away that discloses no number, and it no longer
interpolates the engine's reject rationale, whose max-rounds variant
read "Maximum N rounds reached" and published the concession budget.
(2) `counter_terms` carried `floor_price` (the product's `floor_cpm`)
and is returned to the buyer on **every** counter-offer, so the floor
crossed the wire on the normal negotiation path rather than on an
obscure read. That key is removed; `proposed_price`, `negotiation_id`
and the rest of the counter terms are unchanged. Operators running an
earlier build should assume their product floors were readable by any
buyer who received a counter-offer, and by any buyer who was rejected
in chat. A regression test asserts that neither path carries
`floor_price`, `base_price`, `strategy` or `max_rounds` as a field
name at any nesting depth, so a future addition of a guardrail to
either payload fails rather than shipping. (3) The prose leak on the
same class is closed with **two rationales**: the negotiation engine's
`rationale` used to state the floor, the strategy and the round budget
in a sentence (for example "Countering at the floor price $X CPM ...
(round 1/5)") which shipped to the buyer as `counter_terms["reason"]`,
in the chat counter/final-offer text, and on the REST negotiation
responses. The engine now also emits a deliberately constructed
`buyer_rationale` that explains the action without stating the
seller's price in prose (the structured `seller_price` carries the
number unlabeled) and without naming the floor, the strategy, the
concession budget or the round limit — and every outbound surface
sends only that one. The internal `rationale` is unchanged and stays
in logs and the stored negotiation history for audit. Engine pricing
arithmetic (concession math, floor clamping, gap-split, acceptance
conditions) is untouched.

### Docs

Expand Down
42 changes: 42 additions & 0 deletions src/ad_seller/engines/negotiation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ def evaluate_buyer_offer(

Returns:
NegotiationRound with the action and counter price

Every round carries two rationales (information-disclosure fix):
the internal ``rationale`` keeps naming the floor, the strategy and
the round budget for logs and stored history, while the
``buyer_rationale`` is authored deliberately and never states the
seller's price in prose (the structured ``seller_price`` carries the
number), never names the floor by value or by label, and never
discloses the strategy, the concession budget or the round limit.
Outbound surfaces must send only ``buyer_rationale``.
"""
round_number = len(history.rounds) + 1
limits = history.limits
Expand All @@ -157,6 +166,7 @@ def evaluate_buyer_offer(
concession_pct=0.0,
cumulative_concession_pct=cumulative_concession,
rationale="Buyer price meets or exceeds seller target. Deal accepted.",
buyer_rationale=(f"We accept your offer of ${buyer_price:.2f} CPM."),
)

# 2. Reject nonpositive offers — not a valid price to negotiate.
Expand All @@ -174,6 +184,10 @@ def evaluate_buyer_offer(
rationale=(
f"Buyer price ${buyer_price:.2f} is not a valid offer. Cannot negotiate."
),
buyer_rationale=(
f"An offer of ${buyer_price:.2f} is not a valid price, "
f"so we cannot negotiate on it."
),
)

# 3. Reject if max rounds exceeded (bounds BOTH the gap-split path
Expand All @@ -190,6 +204,12 @@ def evaluate_buyer_offer(
f"Maximum {limits.max_rounds} rounds reached. "
f"Negotiation concluded without agreement."
),
# No round count: "Maximum N rounds reached" publishes the
# seller's round budget to the counterparty.
buyer_rationale=(
"We were unable to reach agreement, so we are ending "
"this negotiation. Thank you for the discussion."
),
)

# 3.5 Below-floor offer: counter AT the floor — the seller's minimum
Expand Down Expand Up @@ -225,6 +245,19 @@ def evaluate_buyer_offer(
f"(round {round_number}/{limits.max_rounds})."
)
),
# The counter here IS the floor, so the buyer-facing string
# must not state the seller's price in prose at all — the
# structured seller_price carries the number without labeling
# it. No "floor", no "minimum", no round budget.
buyer_rationale=(
f"We cannot accept ${buyer_price:.2f} CPM for this inventory, "
f"and we have moved as far as we can on price."
if is_last_round
else (
f"We cannot accept ${buyer_price:.2f} CPM for this inventory. "
f"We have countered at a price that reflects its value."
)
),
)

# Generate counter price
Expand Down Expand Up @@ -264,6 +297,7 @@ def evaluate_buyer_offer(
concession_pct=incremental_concession,
cumulative_concession_pct=new_cumulative,
rationale="Buyer price is acceptable within concession limits.",
buyer_rationale=(f"We accept your offer of ${buyer_price:.2f} CPM."),
)

return NegotiationRound(
Expand All @@ -278,6 +312,9 @@ def evaluate_buyer_offer(
f"This represents our maximum concession of "
f"{limits.total_concession_cap * 100:.0f}%."
),
# No concession percentage, no price in prose: final_price can
# equal the floor, and the concession cap is a guardrail.
buyer_rationale=("We have moved as far as we can on price for this inventory."),
)

# 5. Counter with gap-split
Expand All @@ -293,6 +330,11 @@ def evaluate_buyer_offer(
f"({history.strategy.value} strategy, "
f"round {round_number}/{limits.max_rounds})."
),
# No strategy name, no round budget, no price in prose (the
# gap-split counter can land exactly on the floor).
buyer_rationale=(
"We have countered at a price that reflects the value of this inventory."
),
)

def record_round(
Expand Down
34 changes: 27 additions & 7 deletions src/ad_seller/flows/proposal_handling_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,21 +835,28 @@ async def generate_counter_terms(self) -> None:
f"the available volume."
)
if round_result.action == NegotiationAction.ACCEPT:
# The volume note discloses nothing (availability is the
# truthful subject of the counter), so it goes on BOTH
# rationales; the agreed price is the buyer's own offer.
agreeable = (
f"Price ${round_result.seller_price:.2f} CPM is agreeable. {volume_note}"
)
round_result = round_result.model_copy(
update={
"action": NegotiationAction.COUNTER,
"rationale": (
f"Price ${round_result.seller_price:.2f} CPM is "
f"agreeable. {volume_note}"
),
"rationale": agreeable,
"buyer_rationale": agreeable,
}
)
elif round_result.action in (
NegotiationAction.COUNTER,
NegotiationAction.FINAL_OFFER,
):
round_result = round_result.model_copy(
update={"rationale": f"{round_result.rationale} {volume_note}"}
update={
"rationale": f"{round_result.rationale} {volume_note}",
"buyer_rationale": f"{round_result.buyer_rationale} {volume_note}",
}
)

history = neg_engine.record_round(history, round_result)
Expand All @@ -858,11 +865,24 @@ async def generate_counter_terms(self) -> None:
# only, the buyer's next round could never continue it.
self.state.negotiation_history = history.model_dump(mode="json")

# counter_terms is returned to the buyer on EVERY counter-offer (it is
# where the buyer reads proposed_price and negotiation_id), so it is an
# outbound payload. Information disclosure fix: it used to carry
# "floor_price": product.floor_cpm, handing the counterparty the
# seller's absolute floor on the normal negotiation path rather than on
# some obscure read. A buyer who knows the floor concedes nothing above
# it. The seller's floor, base_price, strategy and max_rounds are
# internal guardrails -- the shared Negotiation primitive excludes all
# four deliberately -- and must not be added back here.
self.state.counter_terms = {
"proposed_price": round_result.seller_price,
"floor_price": product.floor_cpm,
"max_impressions": self.state.evaluation.available_impressions,
"reason": round_result.rationale,
# buyer_rationale, never rationale: the internal rationale names
# the floor, the strategy and the round budget in prose, which is
# the same disclosure as the removed floor_price key, moved into
# a sentence. The buyer-facing rationale is authored in the
# engine to explain the action without any of the four.
"reason": round_result.buyer_rationale,
"negotiation_id": history.negotiation_id,
"round_number": round_result.round_number,
"action": round_result.action.value,
Expand Down
7 changes: 6 additions & 1 deletion src/ad_seller/interfaces/api/contract_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,12 @@ def terminal_round_response(
buyer_price=float_to_money(buyer_p) or Money(amount_micros=0),
seller_price=float_to_money(seller_p) or Money(amount_micros=0),
action=action,
rationale=last.get("rationale", ""),
# buyer_rationale, never rationale: this response is built from
# STORED rounds, whose internal rationale names the seller's
# guardrails (e.g. "Maximum N rounds reached" when the last round
# was an engine walk-away). Older stored rounds without a
# buyer_rationale fall back to empty, never to the internal one.
rationale=last.get("buyer_rationale", ""),
),
rounds_remaining=0,
)
Expand Down
33 changes: 26 additions & 7 deletions src/ad_seller/interfaces/chat/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,15 @@ def _format_negotiation_response(
round_result: Any,
history: Any,
) -> str:
"""Format a negotiation round result as human-readable text."""
"""Format a negotiation round result as human-readable text.

This text goes straight to the counterparty, so it is an outbound
surface: ``history`` carries the seller's internal guardrails
(``floor_price``, ``base_price``, ``strategy``, ``limits.max_rounds``)
and none of them may be interpolated into what is returned here. The
shared ``Negotiation`` primitive excludes those same four fields
deliberately, for the same reason.
"""
from ...models.negotiation import NegotiationAction

action = round_result.action
Expand All @@ -538,25 +546,36 @@ def _format_negotiation_response(
f"Would you like me to generate a Deal ID for DSP activation?"
)
elif action == NegotiationAction.REJECT:
# Information disclosure fix: this branch used to state the
# seller's floor outright -- "Our floor for this inventory is
# $X CPM" -- formatted from history.floor_price, mid-negotiation,
# to the counterparty. A buyer who knows the floor concedes
# nothing above it, so the walk-away now discloses no number at
# all. The engine's REJECT rationale is not interpolated either:
# its max-rounds variant reads "Maximum N rounds reached", which
# publishes the concession budget in prose.
return (
f"I'm sorry, but we can't go that low. "
f"{round_result.rationale}\n\n"
f"Our floor for this inventory is **${history.floor_price:.2f} CPM**. "
f"Would you like to explore other packages that might fit your budget?"
"I'm sorry, but we can't make that work for this inventory.\n\n"
"We've gone as far as we can on price here. "
"Would you like to explore other packages that might fit your budget?"
)
elif action == NegotiationAction.FINAL_OFFER:
# buyer_rationale, never rationale: the internal rationale labels
# the price as the floor and states the concession cap.
return (
f"Here's our **best and final offer**: **${round_result.seller_price:.2f} CPM**.\n\n"
f"{round_result.rationale}\n\n"
f"{round_result.buyer_rationale}\n\n"
f"This is the lowest we can go. Would you like to accept?"
)
else:
# COUNTER
# buyer_rationale, never rationale: the internal rationale names
# the strategy, the round budget and (below floor) the floor.
rounds_left = history.limits.max_rounds - round_result.round_number
return (
f"We appreciate your offer of ${round_result.buyer_price:.2f} CPM. "
f"How about **${round_result.seller_price:.2f} CPM**?\n\n"
f"{round_result.rationale}\n\n"
f"{round_result.buyer_rationale}\n\n"
f"{'We have room for further discussion.' if rounds_left > 1 else 'This is close to our limit.'}"
)

Expand Down
18 changes: 16 additions & 2 deletions src/ad_seller/models/negotiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,29 @@ class NegotiationLimits(BaseModel):


class NegotiationRound(BaseModel):
"""A single round in a negotiation."""
"""A single round in a negotiation.

Carries TWO rationales, and the distinction is load-bearing:

- ``rationale`` is the INTERNAL explanation. It may (and does) name the
seller's guardrails — the floor, the strategy, the round budget, the
concession caps — because it exists for logs, stored history and audit.
It must never be interpolated into anything the buyer receives.
- ``buyer_rationale`` is the BUYER-FACING explanation. It is authored
deliberately in the engine and never names the floor (by value or by
label), the strategy, the concession budget or the round limit. Every
outbound surface (counter_terms["reason"], the chat text, the REST
negotiation responses) must use this one.
"""

round_number: int
buyer_price: float # What buyer offered
seller_price: float # What seller countered (or accepted at)
action: NegotiationAction
concession_pct: float = 0.0 # How much seller conceded this round (0-1)
cumulative_concession_pct: float = 0.0 # Total concession so far (0-1)
rationale: str = ""
rationale: str = "" # Internal-only: logs, stored history, audit
buyer_rationale: str = "" # Outbound: the only rationale the buyer may see
timestamp: datetime = Field(default_factory=datetime.utcnow)


Expand Down
14 changes: 13 additions & 1 deletion src/ad_seller/services/negotiation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,12 @@ async def counter_proposal(
"seller_price": round_result.seller_price,
"concession_pct": round_result.concession_pct,
"cumulative_concession_pct": round_result.cumulative_concession_pct,
"rationale": round_result.rationale,
# This dict is returned to the buyer verbatim on the legacy counter
# route and mapped into the shared NegotiationRoundResponse on the
# canonical route, so it carries the buyer-facing rationale. The
# internal rationale (floor, strategy, round budget) stays in the
# stored history persisted above.
"rationale": round_result.buyer_rationale,
"status": history.status,
"rounds_remaining": history.limits.max_rounds - round_result.round_number,
}
Expand Down Expand Up @@ -345,6 +350,13 @@ async def apply_terminal_action(
if action == "accept"
else "Buyer declined (walk-away)."
),
# Same text on both: the buyer's own terminal move
# discloses no seller guardrail.
buyer_rationale=(
f"Buyer accepted at ${seller_price:.2f} CPM."
if action == "accept"
else "Buyer declined (walk-away)."
),
)
)
history.status = "accepted" if action == "accept" else "rejected"
Expand Down
Loading
Loading