Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Bid lifecycle

From a tap or a spoken bid to an accepted or rejected bid, with the confirm gate in the middle.

Code-checkedread against the code it describes; nothing was run

Verified againstRead against packages/server/src/room/room.ts, room/confirm.ts, packages/server/src/voice/bridge.ts and packages/engine/src/rules/validate-bid.ts (PR #1954). Nothing was run to produce the diagram.

Page last changed 7 Nov 2026 (from the git history)

A bid in GAVEL is a small message with an amount in cents. It can come from a tap on a phone or from speech in the room. Either way it passes the same short chain: the room decides whether it needs a human yes, the engine decides whether the bid is legal, and only then does it reach the event log and the other screens.

Verified against the code, not measured

This page describes code paths that were read in the repository. It makes no speed or accuracy claim. Speech accuracy is a separate, still partial gate: see the note on G5 below.

In plain words

  1. You tap a number, or you say "paddle twelve, three hundred".
  2. The room checks that it is really you and that you are not sending too fast.
  3. If the bid came from speech and the system is not sure, it holds the bid and asks you "Did you mean $300?" for up to 15 seconds. Nothing has happened to the auction yet.
  4. The engine checks the rules: is the lot open, are you registered, is the amount under your ceiling, is it high enough.
  5. The result is written to the log first. Only then are you told, and everyone else sees the new price.

Step through it

Bid lifecycle: from a tap or a spoken bid to an accepted or rejected bid

Read from code
Bid lifecycle: from a tap or a spoken bid to an accepted or rejected bidSequence diagram with five lanes: bidder, voice bridge, room, engine and event log. A tap or a voice bid arrives at the room. The room rate-limits it, checks the message id, and holds a low-confidence or ambiguous voice bid for a 15 second confirm. An accepted confirm, or a confident bid, reaches the engine, which applies its checks in a fixed order. The result is appended to the event log before the sender is told, and an accepted bid is broadcast as a price and an audit event.held: engine has not seen itBidderphone or consoleVoice bridgeroom mic, /audioRoomWebSocket hostEnginepure rulesEvent logroom storerate limit, role, identity from the tokensame mid? send the old reply againvalidateBid: first failing check winsbid { mid, amount, source: 'qr' } (a tap)1bid: voice + confidencedrop: voice_dropped, no bidappend confirm_requestedconfirm_request (this bidder only, 15 s)confirm_response { accept }PlaceBid confirmed: trueaccepted | rejected(reason)append, fenced by the expected seqbid_accepted | bid_rejected (sender only)price + audit_event to everyone (redacted)

Scroll the figure sideways, or open it full size.

Step 1 of 8. Arrow keys work too.

1. Two ways in. A tap sends a bid frame with source qr. Speech goes through the voice bridge: the pipeline classifies each utterance as accept, confirm or drop. Accept and confirm become a bid frame with source voice (a confirm sets ambiguous); a drop is recorded as voice_dropped and never becomes a bid. As the bridge is wired today no second parser is attached, so a clean reading scores at most 0.82, below the 0.85 auto line: every spoken bid arrives as a confirm.

Source: packages/server/src/room/room.ts (submitBid, handleBid, holdForConfirm, respondConfirm), room/confirm.ts, packages/server/src/voice/bridge.ts, packages/voice/src/decide/policy.ts and packages/engine/src/rules/validate-bid.ts. This is a map of code paths, not a measurement. Tap bids and confident voice bids skip steps 4 and 5. Hatched band: held, the engine has not seen the bid.
Text version of this diagram

Actors, left to right: bidder, voice bridge, room, engine, event log.

  1. A tap sends a bid frame with source qr. A spoken bid is classified by the voice pipeline as accept, confirm or drop; accept and confirm become a bid frame with source voice, a drop becomes a voice_dropped record.
  2. The room rate-limits the sender and takes the bidder identity from the token.
  3. A repeat of the same message id gets the original reply; the same id with another amount is a conflict.
  4. A voice bid that is ambiguous or under the room threshold (0.85 in the demo config) is held. confirm_requested is logged and only that bidder is asked, for 15 seconds. The engine is not touched.
  5. The bidder accepts, rejects, or the hold expires.
  6. On accept the room sends PlaceBid with confirmed set; the resolution and the bid are one append.
  7. The engine checks in order: lot open, registered, ceiling, amount, voice confirmed, over ceiling, reserve, increment, already leading, limits.
  8. The result is appended with the expected seq, then the sender is told. Accepted bids broadcast price and a redacted audit event.

Two ways in

The web app sends a bid frame with source: "qr", a fresh message id (mid) and the amount in cents. The bidder is whoever the session token says it is. A tap is never held: it goes straight to the engine.

If the network drops before an answer, the client marks the bid as unknown after four seconds and lets the bidder send it again with the same mid, so a retry cannot place the bid twice.

The confirm gate

The gate sits in the room, before the engine. A frame is held when it is voice-like (source voice, or it carries a transcript, a confidence or an ambiguity flag) and either the parser flagged it ambiguous or its confidence is under the room threshold. A missing confidence counts as zero. Labelling a spoken bid as qr does not escape the gate.

Prop

Type

A held bid is not seen by the engine. The auctioneer's console never sees its amount or transcript either, because the bid might still be refused as over the bidder's ceiling and the amount would leak it.

What the engine checks

The engine runs these checks in a fixed order and reports the first failure. The wire code is what the sender sees; several engine reasons share one code.

OrderEngine reasonWire codeRetryable
1lot_not_open (also: lot paused, deadline passed)closedno
2unregisteredunregisteredno
3ceiling_revokedunregisteredno
4bad_amountbad_amountno
5unconfirmedbad_messageno
6over_ceilingover_ceilingno
7below_reservebelow_reserveno
8below_incrementbelow_incrementno
9already_leading, not_higher_than_own_max, already_bidalready_leadingno
10limit_exceededroom_capno

Before the engine, the room can also answer rate_limited (retryable), forbidden, auction_full, conflict and not_found. While the clock is paused the room answers auction_paused, which is retryable with the same mid.

Manual bids are narrower

An auctioneer can enter a bid for a bidder, but only up to the next minimum, the bidder must confirm it first, and one auctioneer can enter at most 25 per lot. A refusal over the bidder's ceiling is reported as limit_exceeded so the amounts cannot be used to probe a ceiling.

After the engine accepts

  1. The room builds bid_accepted with a fresh bid id and appends it to the log with the expected sequence number. If another host already owns the room, the append fails and this host stops serving it.
  2. The sender gets bid_accepted once the log holds it. If persisting fails the sender gets a store error instead, never an acknowledgement that is ahead of the log.
  3. Everyone gets a price frame and a redacted audit_event under the same sequence number. The end-of-lot timer is re-armed, because a bid in the soft-close window can move the end. See Auction states and soft close.
Edit on GitHub

Last updated on