Skip to content

fix: send quote_id on negotiation messages and assert the booked price equals the agreed price - #133

Merged
Sirajmx merged 2 commits into
mainfrom
fix/negotiation-quote-id-and-price-assertion
Oct 1, 2026
Merged

Sirajmx merged 2 commits into
mainfrom
fix/negotiation-quote-id-and-price-assertion

Conversation

@atc964

@atc964 atc964 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

The defect

A buyer negotiates a price down, books, and is billed the seller's standard price, with "Negotiation succeeded" in the log. Two independent faults combine. This PR fixes both buyer-side halves.

1. Send the quote id on negotiation messages

DealBookingRequest carries a quote_id and no negotiation_id, so the quote is the only key the booking contract offers for tying an agreed price to the deal being struck. The buyer's outbound NegotiationMessage sent only proposal_id/negotiation_id, so the seller had nothing to index the negotiation against for the buyer's own flow, which is the flow that matters.

The quote being negotiated is now threaded through NegotiationSession and sent as quote_id on the open, every counter, the accept and the reject.

Checked before assuming: the shared NegotiationMessage validator rejects only the all-None case (iab_agentic_primitives/protocol/negotiation.py), so negotiation_id/proposal_id/quote_id are not mutually exclusive. Sending quote_id alongside proposal_id validates cleanly and leaves the seller's proposal-first key derivation untouched, so this is additive with no behaviour change for the seller today. The wire builder's docstring, which claimed "exactly one of", was wrong and is corrected.

Tests assert on the real serialized request body (captured through httpx.MockTransport, with a genuine httpx.AsyncClient doing the serialization) and then re-validate that body with the shared NegotiationMessage, so a field rename on either side fails the test instead of passing it.

2. Assert the booked price equals the agreed price

The only post-negotiation guard was final_cpm is None or final_cpm > max_cpm: a ceiling test that passes for any price under the buyer's limit, including the seller's undiscounted list price. A negotiation whose agreed price was silently dropped therefore booked at list price and reported success. This is the change that converts that whole class of defect from silent to loud.

The buyer now also asserts the bookable quote's final_cpm equals the agreed price. The assertion sits before the quote enters the ranked list, and therefore before anything can book it, so a mismatch refuses to book rather than surfacing once the money is committed.

The two guards stay separate and are reported separately, requote_above_ceiling vs requote_price_mismatch: one answers "never pay more than our limit", the other "never pay a price we did not agree to". They are not collapsed into one check. The ceiling check runs first because it is the more specific diagnosis and because it also disposes of the unpriced (final_cpm is None) case; the code says so, so a future reorder is not silent.

Equality is compared in integer micros, with no tolerance window. Both sides are quantized with the buyer's existing _money_from_float mapper and the resulting amount_micros are compared as integers, so two prices are equal if and only if they are the same number of micros, which is the same resolution the contract itself uses.

An earlier revision of this PR used a $0.005 float tolerance, on the theory that money crossing the wire as micros and returning as floats would trip a bit-exact comparison. That was measured rather than assumed, and it does not hold. Both sides convert with round(v * 1e6) out and m / 1e6 back, the seller assigns an accepted target_cpm verbatim with no arithmetic on it, and seller counters are already rounded to two decimals at the source. The round trip is lossless across every two-decimal CPM from $0.01 to $200: delta exactly 0.0. The largest delta the path can produce at all is half a micro, $0.0000005, from the micros quantization itself. The window was four orders of magnitude wider than the noise it was defending against.

That gap was not free. Cost is computed from the booked price, not the agreed price, so a difference inside the window moved real money, $5 per million impressions, while the negotiation record stored the agreed price and nothing reported the disagreement. A silent band is exactly what this guard exists to eliminate.

Micros rather than a bit-exact != on floats, because != is correct today only because both sides happen to use the same two operations, and it would refuse a legitimate booking if the agreed price ever carried more than six decimal places: the quantization then produces a real ~1e-7 delta, and the buyer would walk away from a good deal in a way that looks like a seller outage. Quantizing first absorbs that one genuinely representational case and nothing else.

Deliberately NOT in this PR: stop the target_cpm re-quote

The buyer currently books a negotiated price by re-quoting with target_cpm=<agreed> and relying on the seller honouring an acceptable target as the quote's final_cpm. The right end state is to delete that re-quote and book the original quote, letting the seller strike the agreed price at booking time, which is what the seller's own code always intended.

That change is held back on purpose. It depends on the seller being able to resolve a negotiation from a quote (recording the quote_id on the stored negotiation and indexing by it) being deployed, not merely merged. Shipping it ahead of the seller would book at the pre-negotiation price, which is a worse failure than today's. So it is a separate PR, sequenced behind the seller's.

This PR is the safe half, and it is the half that is worth landing on its own merits: the two changes here are additive, and the equality assertion is exactly the tripwire that would catch the sequencing going wrong.

Testing

  • New tests/unit/test_negotiation_quote_correlation.py: 16 tests. Wire-body assertions for open/counter/accept/reject plus the full auto-negotiate loop, the additive no-quote case, the agreed-price equality assertion, and the ceiling guard's own independence.
  • The equality assertion is green against the current seller's behaviour: the re-quote sends target_cpm=agreed and the seller honours it, so the price comes back at the agreed price and equality holds.
  • The micros rule is pinned from both sides. test_ragged_agreed_price_round_tripped_through_micros_books agrees at 34/3 (11.333333333333334, 11_333_333 micros) and has the seller return the micros round trip 11.333333: not equal as floats, the same money, so it books. test_one_micro_price_difference_is_a_mismatch returns 11.333334, one micro up and well under the ceiling, and it is refused. test_a_tenth_of_a_cent_difference_is_a_mismatch covers the case the old tolerance waved through.
  • test_sub_cent_float_noise_is_not_a_mismatch is gone, deliberately. It asserted that AGREED + 0.001 must book, calling it representation noise. A tenth of a cent is a thousand micros, two thousand times the largest delta this path can produce, so the test asserted that a genuine price difference must be accepted, which is the opposite of the guard's purpose. Its replacement asserts that input is refused.
  • Negative controls, run then reverted: restoring the $0.005 tolerance fails both mismatch tests; switching to a bit-exact float != fails the ragged-price test. Each of the three tests therefore discriminates against a specific wrong implementation, and none remains in the tree.
  • Full suite: uv run pytest tests/ -q → 3641 passed, 80 skipped.
  • Lint: ruff check src/ tests/ and ruff format --check src/ tests/ both clean.

…e equals the agreed price

Two independent halves of one defect: a buyer negotiates a price down,
books, and is billed the seller's standard price with "Negotiation
succeeded" in the log.

Correlation on the wire. DealBookingRequest carries a quote_id and no
negotiation_id, so the quote is the only key the booking contract offers
for tying an agreed price to the deal being struck. The buyer's outbound
NegotiationMessage sent only proposal_id/negotiation_id, so the seller had
nothing to index the negotiation against for the buyer's own flow. The
quote being negotiated is now threaded through NegotiationSession and sent
as quote_id on the open, every counter, the accept and the reject. The
shared model's validator rejects only the all-None case, so the three ids
are not mutually exclusive: sending quote_id alongside proposal_id is
valid and leaves the seller's proposal-first key derivation untouched. The
stale "exactly one of" comment on the wire builder is corrected.

Assertion, not a bound, on the booked price. The only post-negotiation
guard was `final_cpm is None or final_cpm > max_cpm`, a ceiling test that
passes for ANY price under the buyer's limit including the seller's
undiscounted list price, so a negotiation whose agreed price was silently
dropped booked at list price and reported success. The buyer now also
asserts the bookable quote's final_cpm EQUALS the agreed price, before the
quote enters the ranked list and therefore before anything can book it, so
a mismatch refuses to book rather than surfacing once money is committed.

The two guards stay separate and are reported separately
(requote_above_ceiling vs requote_price_mismatch): one answers "never pay
more than our limit", the other "never pay a price we did not agree to".
The ceiling check runs first because it is the more specific diagnosis and
because it also disposes of the unpriced case.

Equality is compared with a half-a-cent tolerance. CPMs cross the wire as
Money micros and come back as floats, so a bit-exact comparison would trip
on representation noise; half a cent is orders of magnitude below any real
pricing discrepancy.
…lerance

The post-negotiation price assertion compared CPMs as floats with a $0.005
tolerance. The mechanism that window was sized against has since been
measured: money crosses the wire as integer micros, both sides convert with
round(v * 1e6) out and m / 1e6 back, and the round trip is lossless across
every two-decimal CPM from $0.01 to $200 -- delta exactly 0.0. The largest
delta the path can produce at all is half a micro, so the window was four
orders of magnitude wider than the noise it defended against.

That gap was not free. Cost is computed from the booked price, so a
difference inside the window moved real money ($5 per million impressions)
with no warning, inside a guard whose entire purpose is that silence is the
bug.

Compare integer micros instead, via the existing _money_from_float mapper.
Two prices are equal if and only if they are the same number of micros --
the same resolution the contract itself uses. This absorbs the only delta
the path can actually create (the sub-micro quantization of a ragged float,
which a bit-exact != would turn into a spurious walk-away) while rejecting a
real difference of one micro and upward.

test_sub_cent_float_noise_is_not_a_mismatch tested the opposite of what it
claimed: AGREED + 0.001 is a tenth of a cent, two thousand times the largest
achievable representation delta, so it asserted that a genuine price
difference must be booked. Replaced with three tests that discriminate:
a ragged agreed price returning micros-quantized still books, one micro of
difference is refused, and a tenth of a cent is refused.

@Sirajmx Sirajmx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently verified the micros-equality math rather than trusting the write-up: swept every
2-decimal CPM from $0.01 to $200.00 through _money_from_float's round trip — max delta exactly
0.0, confirming "lossless" precisely, not approximately. The $34/3 ragged-price example lands
on 11333333 micros on both sides and books; one-micro-off and tenth-of-a-cent-off are both
correctly refused. Negative control, run myself: restoring the old $0.005 tolerance fails
exactly the two tests the PR predicts (test_one_micro_price_difference_is_a_mismatch,
test_a_tenth_of_a_cent_difference_is_a_mismatch), 14/16 pass otherwise — matches exactly.

Confirmed the validator claim directly too: NegotiationMessage(proposal_id=..., quote_id=...)
together validates fine, an all-None message is genuinely rejected — the old "exactly one of"
docstring was wrong, the correction is accurate.

New test file: 16/16 passed. Full suite: 3638 passed, 83 skipped (you report 3641/80 — the small
gap is environmental on my end, no Redis service / Python 3.12 vs your 3.11+Redis CI, zero
failures either way). Ruff clean. Fast-forwards cleanly onto current main, zero conflicts.

The check that matters most: real cross-repo interop against seller #96, not assumed
compatibility. Built the exact wire JSON to_wire_negotiation_message produces and ran it through
a real quote → real proposal → counter → accept flow against #96's actual handler. Result: 200
throughout, and the stored negotiation correctly retains the quote_id, with the
negotiation_by_quote pointer correctly resolving to the proposal id at accept time. Also
confirmed additive safety against current seller main (pre-#96): the same message with
quote_id present doesn't break anything there either, it's just inert until #96 lands.

The held-back re-quote removal is correctly scoped out — shipping it ahead of #96 being deployed,
not merely merged, would book at the pre-negotiation price, a worse failure than today's.
Approving — recommend merging this together with seller #96, since neither alone closes the real
mispricing bug.

@Sirajmx
Sirajmx merged commit 47ac319 into main Oct 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants