Skip to content

04 — Auction Engine

Status: Draft Date: 2026-05-23 Owner: backend (DO specialist) Parent: 2026-05-23-nowyourlink-master-design.md (binding — §4.2 Bid, §4.3 Auction key, §4.4 Events; decisions D5, D6, D12)


  • AuctionDO: one Durable Object per future date in the 30-day rolling window, addressed by idFromName("YYYY-MM-DD").
  • Bid acceptance, validation, and immutable bid persistence with the supersede chain (master §4.2).
  • Ranking of active bids on demand and at close.
  • Anti-snipe extension logic (D6: admin-configurable per auction, default OFF).
  • Auction close at closes_at_actual via DO alarm; emission of auction.closed onto the events Queue with the ranked bid IDs (top 10).
  • Pre-creation cron: every day at 00:05 UTC, create the DO for today + 30d so the rolling window is always provisioned.
  • Payment, charging, invoicing, cascade execution (→ 05 payment-saga; AuctionDO only emits the event).
  • WebSocket broadcasts and presence (→ 06 real-time-hub; AuctionDO posts a payload, HubDO/WS DOs broadcast).
  • Creative validation, ad-status checks beyond an “is this ad_id approved for this user_id?” RPC call (→ 03 ad-moderation).
  • Saga orchestration (→ 05 payment-saga).
  • Public archive pages, OG images (→ 07 public-site-archive).
  • D5 sealed-bid, 1 € min, 1 € flat increments.
  • D6 anti-snipe is per-auction config, default OFF.
  • D12 one DO per YYYY-MM-DD; DO destroyed after the ad day completes (handover to 07/archive).

SQLite-backed DO (new_sqlite_classes, per research §10). Storage is split between an in-memory-cached header blob and a SQL table for bids so ranking can use ORDER BY amount_cents DESC without scanning a JSON blob (mirror of the forest-counter-v2.ts SQLite-backed pattern — world-forest/packages/shared/src/forest-counter-v2.ts).

packages/shared/src/auction-types.ts
export type AuctionStatus =
| "opened" // created, no bids yet OR has bids, accepting
| "accepting_bids" // ≥1 active bid, not yet in closing window
| "closing_window" // within last 2 min if anti_snipe.enabled OR last 60s otherwise (UX only)
| "closed" // alarm fired, ranked list emitted, awaiting saga
| "settled" // saga reported terminal outcome (paid or cascade-final)
| "archived"; // ad-day complete; DO scheduled for deletion
export interface AntiSnipeConfig {
enabled: boolean; // D6: default false
window_ms: number; // default 120_000 (2 min)
extension_ms: number; // default 120_000 (2 min)
max_total_extension_ms: number; // default 1_800_000 (30 min)
}
export interface AuctionHeader {
auction_id: string; // "YYYY-MM-DD" — also the DO name
opened_at: string; // ISO; set on DO first init
closes_at_planned: string; // ISO; 00:00 UTC of auction_id
closes_at_actual: string; // ISO; == planned unless anti-snipe extended
total_extension_ms: number; // 0..max_total_extension_ms
status: AuctionStatus;
anti_snipe: AntiSnipeConfig;
schema: 1;
}
// SQLite table `bids` (immutable, master §4.2):
// id TEXT PRIMARY KEY,
// auction_id TEXT NOT NULL,
// user_id TEXT NOT NULL,
// ad_id TEXT NOT NULL,
// amount_cents INTEGER NOT NULL,
// placed_at TEXT NOT NULL,
// supersedes TEXT, -- nullable; points to predecessor bid.id
// superseded_by TEXT, -- nullable; set when this bid is replaced
// status TEXT NOT NULL -- 'active' | 'won' | 'lost' | 'cancelled'
// Indexes:
// idx_active_rank (status, amount_cents DESC, placed_at ASC)
// idx_user_active (user_id, status)
// idx_supersedes (supersedes)

hub_subscribers is not stored in AuctionDO — that responsibility belongs to 06 (HubDO + per-auction WS DO sibling). AuctionDO only POSTs broadcast payloads to the WS surface via service binding.


opened → accepting_bids → closing_window → closed → settled → archived
  • opened: header written, no bids yet. Alarm set to closes_at_planned.
  • accepting_bids: first valid bid accepted.
  • closing_window (UX label only — not gating): inside the last anti_snipe.window_ms (if enabled) or last 60 s (default). Used by 06 to flip a UI tier.
  • closed: alarm fired, ranking computed, auction.closed enqueued. No further bids accepted.
  • settled: 05 has reported either saga.activated (winner paid) or terminal saga.cascaded → ... (final fallback to house ad). Set via inbound RPC from 05.
  • archived: the ad-day has elapsed; 07 took its archive snapshot; DO scheduled for deletion by the maintenance cron.

State transitions are header-only writes; bids are append-only and survive every transition.


4. Bid placement — happy path & validation

Section titled “4. Bid placement — happy path & validation”

RPC contract (called by apps/api service-binding only)

Section titled “RPC contract (called by apps/api service-binding only)”

The api-worker side that exposes these RPCs as HTTP (POST /bid, POST /bid/increase — see processes/04-bid-placement.md and 04-bid-increase.md) is a Hono route in apps/api (ADR-0001 / D40) that forwards to env.AUCTIONS.idFromName(auction_id).fetch(c.req.raw). The DO’s own fetch() contract is untouched — Hono lives in front of, not inside, AuctionDO.

type PlaceBidInput = {
user_id: string;
ad_id: string; // creative — must be approved (caller verifies with 03)
amount_cents: number; // ≥100; must be integer multiple of 100
idempotency_key: string; // user-scoped
};
type PlaceBidResult =
| { ok: true; bid_id: string; current_rank: number; total_active: number }
| { ok: false; error: { code: string; message: string; retryable: boolean } };
  1. Window check — auction_id must be within [today, today+30d] UTC; reject bid.out-of-window.
  2. Status check — header.status ∈ {opened, accepting_bids, closing_window}. Else bid.auction-closed.
  3. Clock check — Date.now() < Date.parse(closes_at_actual). Else bid.auction-closed (covers alarm-fired-but-DO-hibernating; see §10).
  4. Amount check — amount_cents ≥ 100 and amount_cents % 100 === 0. Else bid.below-min / bid.invalid-increment.
  5. Caller-supplied invariants (api worker pre-validated, but DO re-checks): user not banned, ad approved. Else bid.user-banned / bid.ad-not-approved.
  6. Idempotency — if (user_id, idempotency_key) already maps to a bid_id within 24 h, return that result. Stored in idem: KV-style entries inside DO storage.
  7. No-op rule — if the user has an active bid at amount_cents already, return bid.same-amount (no insert).
  8. Strict-monotonic-per-user rule — if the user has an active bid at ≥ amount_cents, reject bid.not-an-increase. Use bid.increase to raise (see §5).

A valid PlaceBid always inserts a new row with supersedes = NULL only when no prior active bid for the user. Otherwise the call is rerouted to the supersede path (§5).

If anti_snipe.enabled and bid is accepted within closes_at_actual − window_ms:

  • new_close = min(closes_at_actual + extension_ms, closes_at_planned + max_total_extension_ms)
  • If new_close > closes_at_actual: update header, storage.setAlarm(new_close), emit auction.extended event onto Queue.

  • Bids are immutable once inserted. No UPDATE bids SET amount_cents = … ever.
  • A bid increase inserts a new row B' with supersedes = B.id; in the same DO transaction, B is updated only to set superseded_by = B'.id and status = 'cancelled'. (status and superseded_by are the only legal mutations on an existing row.)
  • A bid may be superseded at most once. Enforced by WHERE superseded_by IS NULL in the update.
  • The chain is linear per user. The “current bid” for (auction_id, user_id) is the unique row with status='active'.

Ranking always reads the materialised view “latest active bid per user”, which is just SELECT * FROM bids WHERE status='active' ORDER BY amount_cents DESC, placed_at ASC. Ties broken by earliest placed_at (rewards early action — matches the cinematic narrative).

type IncreaseBidInput = {
user_id: string;
ad_id: string; // may differ from previous bid — bidder can swap creative
amount_cents: number; // > existing active bid amount; integer 100s
idempotency_key: string;
};

Validation as in §4, plus: existing active bid must exist; amount_cents > existing.amount_cents; delta ≥100. Errors: bid.no-existing-bid, bid.not-an-increase.


Per D6: configurable per auction, default OFF.

FieldDefaultBounds
enabledfalsebool
window_ms120_00030_000 … 600_000
extension_ms120_00030_000 … 600_000
max_total_extension_ms1_800_0000 … 3_600_000

Admin (10) flips enabled and tunes window/extension via authenticated RPC to AuctionDO (setAntiSnipeConfig). Effective immediately. Toggling OFF mid-auction does not rewind closes_at_actual already extended.

Algorithm on each accepted bid is the simple form in §4. We don’t attempt VCG/Vickrey or fancy stochastic anti-snipe — the master locks the sealed-bid + flat-increment model (D5).


  1. DO sets storage.setAlarm(Date.parse(closes_at_actual)) whenever that timestamp changes.
  2. Alarm fires → alarm() handler: a. Re-read closes_at_actual from header; if now < closes_at_actual (extension landed during the alarm delivery race), reschedule and return. b. Set status = 'closed'. No further bids will pass §4’s check 3. c. Run ranking SQL; take top 10 (or fewer); compile RankedBid[] = { bid_id, user_id, ad_id, amount_cents, rank }. d. Mark bids.status = 'won' for rank 1, bids.status = 'lost' for ranks 2..N. Losers’ bids are already-cancelled chain ancestors → unchanged. e. POST { type: "auction.closed", auction_id, top_bids: RankedBid[] } to the events Queue. f. POST a broadcast envelope { type: "auction.closed", auction_id, winner_user_id } to 06 over the WS service binding.
  3. 05 consumes from the Queue, calls env.SAGA.create({ id: \${auction_id}:${winner_user_id}` }). (Idempotent — auction_id` collisions are by definition same-auction, same-winner.)
  4. Cascade case (§10) is handled by 05, not here. AuctionDO exposes a read-only getRankedBids() RPC so 05 can walk down the list.

  • SQLite-backed DO (new_sqlite_classes migration in wrangler.jsonc). Per research §3, this gives us indexed reads without the 128 KB KV-storage row limit.
  • Header lives at storage key header as JSON; bids in the bids SQL table.
  • DO output gate guarantees all storage writes inside a single handler commit atomically (no transaction() wrapper needed for the typical bid insert + header touch — same property leveraged by forest-counter-v2.ts for its multi-key writes).
  • Idempotency keys live as idem:<user_id>:<key> rows with TTL 24 h.
  • On archive transition: drop the bids table contents (after handing the snapshot to 07 / D1 archive), deleteAll() the storage namespace, and let the eviction proceed (research §10 anti-pitfall: don’t accumulate forever).

  • Lookup: any caller (public site, dashboard, admin, api) resolves an auction with env.AUCTIONS.get(env.AUCTIONS.idFromName(auctionIdYYYYMMDD)). The DO is created on first reference, but we don’t rely on that for the public path.
  • Pre-creation cron: a scheduled Worker (apps/cron, separate spec under 01-foundation) runs daily at 00:05 UTC and POSTs an init RPC to the DO for today + 30d. The handler is idempotent; if the header already exists, it returns 200 no-op.
  • Backfill: on first deploy and after any prolonged outage, the cron walks the full 30-day window once.
  • Listing: a thin D1 table auctions(auction_id, status, opened_at, closes_at_planned, anti_snipe_enabled) is the materialised index for “list all upcoming auctions” (used by 08 dashboard and 07 home page). Updates are emitted from AuctionDO on every status change via a fire-and-forget service binding to a small apps/api writer route. This is a read-side mirror only — never the source of truth (master §10.4 pattern parallel to the Workflows D1 mirror).

#CaseHandling
E1Clock skew between callers and DODO is the only authority for closes_at_actual. Callers’ clocks are advisory only.
E2Alarm fires but DO is hibernatingCF guarantees alarm wakes the DO. No special handling needed (research §3).
E3Simultaneous bids from different usersDO single-threaded; the second bid sees the first committed and ranks accordingly.
E4Same-user concurrent bids (two tabs)Idempotency key dedupes if the client sent the same one; else second bid runs through §4/§5 — if it’s strictly greater, it supersedes; if equal, bid.same-amount; if lesser, bid.not-an-increase.
E5Bid arrives after closes_at_actualRejected at §4 check 3 with bid.auction-closed.
E6Winner has zero approved ads at closeAt alarm time, the elected winner’s ad_id was already approved when bid was placed (validated in §4). If 03 has since revoked approval, AuctionDO surfaces the top rank anyway; 05 is responsible for verifying ad approval before charging and cascading immediately if revoked. AuctionDO does not re-validate creatives at close — that’s a 05 invariant.
E7Cascade target also fails05 walks getRankedBids(); AuctionDO stays in closed until 05 reports settled. If the list is exhausted, 05 declares “house-ad fallback” terminal.
E8Anti-snipe at max extension boundarymin(... , closes_at_planned + max_total) caps. Further triggers are no-ops; bid still accepted.
E9Admin toggles anti-snipe OFF during closing windowNew bids no longer extend; already-applied extensions persist.
E10DO delete() while a bid is in flightPre-creation guarantees the DO exists. Archival deletion is gated on status === 'archived'.
E11Queue delivery of auction.closed failsWorker retries Queue delivery up to N times; AuctionDO retains top_bids in storage so the saga can re-pull on its first step via getRankedBids().

  1. Creating an AuctionDO for any YYYY-MM-DD in [today, today+30d] is idempotent and exposes header via getHeader().
  2. placeBid and increaseBid enforce all rules in §4–§5; bids are never mutated except for the superseded_by/status audit trail.
  3. Ranking is deterministic: (amount_cents DESC, placed_at ASC) over status='active'.
  4. Default-OFF anti-snipe: a bid placed in the last second of the auction does not extend.
  5. Admin-enabled anti-snipe: a bid in the last 2 min extends by exactly 2 min, capped at +30 min total.
  6. At alarm time, auction.closed is emitted exactly once to the Queue with up to 10 ranked bids.
  7. getRankedBids() is callable by 05 after closed.
  8. A simulated 10 K-bid auction closes in ≤ 1 s of DO CPU time (SQLite-backed index makes this trivial).
  9. DO survives hibernation between bids without state loss (replay test mirrors forest-show.ts test patterns).

  • Package layout (research §2): put AuctionDO in packages/auction-engine/src/auction-do.ts, re-export from packages/shared. apps/ws/src/index.ts exports it as { AuctionDO } from "@nowyourlink/shared" exactly as world-forest/apps/forest-ws/src/index.ts exports ForestCounterV2.
  • Wrangler binding lives on apps/ws (per master §4.8; research §3); apps/api calls AuctionDO over a service binding, never instantiates a second namespace (research anti-pitfall §10).
  • Pure ranking + close logic belongs to packages/auction-engine/src/close.ts as pure functions, tested in isolation (parallels world-forest/packages/forest-geo/).
  • No WS code in AuctionDO; broadcasts are sent by 06’s WS DO siblings.
  • Sentry: instrument the host Worker per master §4.6; DO method errors require instrumentDurableObjectWithSentry wiring (see comment header in forest-show.ts).
  • Hibernation: AuctionDO has no WS connections itself, so hibernation reduces to “alarm wakes it” — no Hibernation-API ceremony needed here.
  • Tests: unit-test the close logic and ranking pure functions; integration-test the DO with unstable_dev + the same placeBid/increaseBid matrix.
  • Routing (D40 / ADR-0001): the apps/api side that fronts these RPCs uses Hono — handlers do nothing more than forward c.req.raw to the DO stub. DO internals stay framework-free.