04 — Auction Engine
04 — Auction Engine
Section titled “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)
1. Purpose & scope
Section titled “1. Purpose & scope”In scope
Section titled “In scope”AuctionDO: one Durable Object per future date in the 30-day rolling window, addressed byidFromName("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_actualvia DO alarm; emission ofauction.closedonto theeventsQueue with the ranked bid IDs (top 10). - Pre-creation cron: every day at 00:05 UTC, create the DO for
today + 30dso the rolling window is always provisioned.
Out of scope
Section titled “Out of scope”- 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).
Honoured master decisions
Section titled “Honoured master decisions”- 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).
2. AuctionDO state shape
Section titled “2. AuctionDO state shape”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).
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.
3. Lifecycle
Section titled “3. Lifecycle”opened → accepting_bids → closing_window → closed → settled → archivedopened: header written, no bids yet. Alarm set tocloses_at_planned.accepting_bids: first valid bid accepted.closing_window(UX label only — not gating): inside the lastanti_snipe.window_ms(if enabled) or last 60 s (default). Used by 06 to flip a UI tier.closed: alarm fired, ranking computed,auction.closedenqueued. No further bids accepted.settled: 05 has reported eithersaga.activated(winner paid) or terminalsaga.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— seeprocesses/04-bid-placement.mdand04-bid-increase.md) is a Hono route inapps/api(ADR-0001 / D40) that forwards toenv.AUCTIONS.idFromName(auction_id).fetch(c.req.raw). The DO’s ownfetch()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 } };Validation order (cheapest first)
Section titled “Validation order (cheapest first)”- Window check —
auction_idmust be within[today, today+30d]UTC; rejectbid.out-of-window. - Status check —
header.status ∈ {opened, accepting_bids, closing_window}. Elsebid.auction-closed. - Clock check —
Date.now() < Date.parse(closes_at_actual). Elsebid.auction-closed(covers alarm-fired-but-DO-hibernating; see §10). - Amount check —
amount_cents ≥ 100andamount_cents % 100 === 0. Elsebid.below-min/bid.invalid-increment. - Caller-supplied invariants (api worker pre-validated, but DO re-checks): user not banned, ad approved. Else
bid.user-banned/bid.ad-not-approved. - Idempotency — if
(user_id, idempotency_key)already maps to a bid_id within 24 h, return that result. Stored inidem:KV-style entries inside DO storage. - No-op rule — if the user has an active bid at
amount_centsalready, returnbid.same-amount(no insert). - Strict-monotonic-per-user rule — if the user has an active bid at
≥ amount_cents, rejectbid.not-an-increase. Usebid.increaseto 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).
Anti-snipe trigger
Section titled “Anti-snipe trigger”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), emitauction.extendedevent onto Queue.
5. Bid increase / supersede chain
Section titled “5. Bid increase / supersede chain”Invariants (D5; master §4.2)
Section titled “Invariants (D5; master §4.2)”- Bids are immutable once inserted. No
UPDATE bids SET amount_cents = …ever. - A bid increase inserts a new row
B'withsupersedes = B.id; in the same DO transaction,Bis updated only to setsuperseded_by = B'.idandstatus = 'cancelled'. (statusandsuperseded_byare the only legal mutations on an existing row.) - A bid may be superseded at most once. Enforced by
WHERE superseded_by IS NULLin the update. - The chain is linear per user. The “current bid” for
(auction_id, user_id)is the unique row withstatus='active'.
Ranking after supersede
Section titled “Ranking after supersede”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.
6. Anti-snipe
Section titled “6. Anti-snipe”Per D6: configurable per auction, default OFF.
| Field | Default | Bounds |
|---|---|---|
enabled | false | bool |
window_ms | 120_000 | 30_000 … 600_000 |
extension_ms | 120_000 | 30_000 … 600_000 |
max_total_extension_ms | 1_800_000 | 0 … 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).
7. Close mechanics
Section titled “7. Close mechanics”- DO sets
storage.setAlarm(Date.parse(closes_at_actual))whenever that timestamp changes. - Alarm fires →
alarm()handler: a. Re-readcloses_at_actualfrom header; ifnow < closes_at_actual(extension landed during the alarm delivery race), reschedule and return. b. Setstatus = 'closed'. No further bids will pass §4’s check 3. c. Run ranking SQL; take top 10 (or fewer); compileRankedBid[] = { bid_id, user_id, ad_id, amount_cents, rank }. d. Markbids.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 theeventsQueue. f. POST a broadcast envelope{ type: "auction.closed", auction_id, winner_user_id }to 06 over the WS service binding. - 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.) - Cascade case (§10) is handled by 05, not here. AuctionDO exposes a read-only
getRankedBids()RPC so 05 can walk down the list.
8. Storage
Section titled “8. Storage”- SQLite-backed DO (
new_sqlite_classesmigration inwrangler.jsonc). Per research §3, this gives us indexed reads without the 128 KB KV-storage row limit. - Header lives at storage key
headeras JSON; bids in thebidsSQL 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 byforest-counter-v2.tsfor its multi-key writes). - Idempotency keys live as
idem:<user_id>:<key>rows with TTL 24 h. - On
archivetransition: drop thebidstable 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).
9. Discovery & pre-creation
Section titled “9. Discovery & pre-creation”- 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 at00:05 UTCand POSTs aninitRPC to the DO fortoday + 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
D1tableauctions(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 smallapps/apiwriter route. This is a read-side mirror only — never the source of truth (master §10.4 pattern parallel to the Workflows D1 mirror).
10. Edge cases
Section titled “10. Edge cases”| # | Case | Handling |
|---|---|---|
| E1 | Clock skew between callers and DO | DO is the only authority for closes_at_actual. Callers’ clocks are advisory only. |
| E2 | Alarm fires but DO is hibernating | CF guarantees alarm wakes the DO. No special handling needed (research §3). |
| E3 | Simultaneous bids from different users | DO single-threaded; the second bid sees the first committed and ranks accordingly. |
| E4 | Same-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. |
| E5 | Bid arrives after closes_at_actual | Rejected at §4 check 3 with bid.auction-closed. |
| E6 | Winner has zero approved ads at close | At 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. |
| E7 | Cascade target also fails | 05 walks getRankedBids(); AuctionDO stays in closed until 05 reports settled. If the list is exhausted, 05 declares “house-ad fallback” terminal. |
| E8 | Anti-snipe at max extension boundary | min(... , closes_at_planned + max_total) caps. Further triggers are no-ops; bid still accepted. |
| E9 | Admin toggles anti-snipe OFF during closing window | New bids no longer extend; already-applied extensions persist. |
| E10 | DO delete() while a bid is in flight | Pre-creation guarantees the DO exists. Archival deletion is gated on status === 'archived'. |
| E11 | Queue delivery of auction.closed fails | Worker 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(). |
11. Acceptance criteria
Section titled “11. Acceptance criteria”- Creating an
AuctionDOfor anyYYYY-MM-DDin[today, today+30d]is idempotent and exposesheaderviagetHeader(). placeBidandincreaseBidenforce all rules in §4–§5; bids are never mutated except for thesuperseded_by/statusaudit trail.- Ranking is deterministic:
(amount_cents DESC, placed_at ASC)overstatus='active'. - Default-OFF anti-snipe: a bid placed in the last second of the auction does not extend.
- Admin-enabled anti-snipe: a bid in the last 2 min extends by exactly 2 min, capped at +30 min total.
- At alarm time,
auction.closedis emitted exactly once to the Queue with up to 10 ranked bids. getRankedBids()is callable by 05 afterclosed.- A simulated 10 K-bid auction closes in ≤ 1 s of DO CPU time (SQLite-backed index makes this trivial).
- DO survives hibernation between bids without state loss (replay test mirrors
forest-show.tstest patterns).
12. Agent-team hints
Section titled “12. Agent-team hints”- Package layout (research §2): put
AuctionDOinpackages/auction-engine/src/auction-do.ts, re-export frompackages/shared.apps/ws/src/index.tsexports it as{ AuctionDO } from "@nowyourlink/shared"exactly asworld-forest/apps/forest-ws/src/index.tsexportsForestCounterV2. - Wrangler binding lives on
apps/ws(per master §4.8; research §3);apps/apicalls 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.tsas pure functions, tested in isolation (parallelsworld-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
instrumentDurableObjectWithSentrywiring (see comment header inforest-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 sameplaceBid/increaseBidmatrix. - Routing (D40 / ADR-0001): the
apps/apiside that fronts these RPCs uses Hono — handlers do nothing more than forwardc.req.rawto the DO stub. DO internals stay framework-free.