Skip to content

A second authenticated buyer can accept another buyer's proposal-led negotiation and apply its price to their own quote #130

Open

Description

@jaanijuk

Summary

Buyer B can accept a negotiation that buyer A opened on A's own proposal, by naming A's proposal_id. Nothing checks that B is the negotiation's buyer. We observed this on v2.4.2 plus the #72 approval-resume patch, and on main.

On main (902e3f57), the consequence is larger. B names its own quote_id in the accept, and the quote-binding check passes because B owns that quote. B then books its quote at the price A negotiated, and the stored deal is linked through the quote index to A's proposal.

#96 stopped a buyer binding another buyer's quote to its own negotiation. This is the other direction: a buyer binding its own quote to someone else's negotiation.

Setup

  • seller-agent commit 902e3f57e6c5c55dd2c00956132f1fd44e70d513 (the tested main snapshot);
  • CSV ad server, SQLite and a fresh database for each run;
  • APPROVAL_GATE_ENABLED=true, APPROVAL_REQUIRED_FLOWS=proposal_decision;
  • a synthetic product with base CPM US$45.00 and floor US$38.25;
  • a deterministic stand-in answering the proposal review; subsequent steps run the seller's own code;
  • buyer A and buyer B each have their own agency-tier API key.

The older comparison target was v2.4.2 at e5b367d2b780aa4d0e03b6363d744ba3adf2b221, plus the #72 approval-resume patch.

The test supplied buyer A's proposal ID to buyer B. It did not test how B might discover that identifier. All data is synthetic.

Steps and observed result on main (identifiers from our run)

  1. Buyer A: POST /proposals at US$42.08 → prop-57cae977, accept, pending_approval. The operator approves it.
  2. Buyer A: POST /proposals/prop-57cae977/counter at US$40.00. The seller counters at US$40.25 and opens neg-514d30e5. The stored negotiation records buyer_tier: agency and no buyer identity.
  3. Buyer B: POST /api/v1/quotes at target_cpm US$40.00 → qt-1699a95cbb20, issued to B.
  4. Buyer B: POST /api/v1/negotiations/messages with action: accept, proposal_id: prop-57cae977, quote_id: qt-1699a95cbb20 → 200. neg-514d30e5 becomes accepted at US$40.25 with B's quote bound to it, and negotiation_by_quote:qt-1699a95cbb20 points to prop-57cae977.
  5. Buyer B: POST /api/v1/deals with qt-1699a95cbb20 → 200. A deal is stored with status: proposed at US$40.25, rationale "… | Negotiated to $40.25 CPM (neg-514d30e5)". B's quote becomes booked.

The seller's idempotency records for steps 3 to 5 are scoped to buyer B.

Control: with buyer A making steps 3 to 5, the result is the same: a linked deal at US$40.25.

At v2.4.2 plus #72: step 4 also returns 200 and marks A's negotiation accepted. At that version, booking doesn't read a proposal-led negotiation, so B's deal is stored at its quote's US$40.00 and isn't linked to A's proposal. So the missing ownership check predates main. What's new on main is that it lets B take A's negotiated price.

Nothing was executed externally, and the price stayed above the floor.

Questions

  1. Should accepting a negotiation (and countering it) be limited to the buyer it belongs to, as booking is limited to the buyer the quote was issued to (fix: booking accepted anonymous callers - require verified buyer matching the quote #77)?
  2. Should the stored negotiation record that buyer's identity, so there is something to check against?

Activity

  1. jaanijuk commented on Nov 4, 2026

    @jaanijuk
    ContributorAuthor

    Related: #129 covers the approval decision on the linked negotiation-and-booking path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions