DJ backend architecture

Updated Oct 14, 2026 · py-mkt and mono-volt · the built app on the screens page · source DJ/ARCHITECTURE.md

How DJ runs inside CrowdVolt. It covers what reads, what decides, what moves money, how fast it has to happen, and where each piece plugs into code that already exists. The rule under all of it: the model reads, the engine acts, and money only moves under terms the user approved.

What's built (October 14)

DJ runs end to end on an isolated test system, with test cards and test sellers. Nothing is merged yet: it is a stack of draft pull requests (the work). It ships dark, to staff, over the air: no native change and no Face ID. The design further down is the original architecture; where it differs from this section, this section is what was built.

PermissionWhat the person meansWhat runs
Once“get those now”DJ quotes the exact show, ticket type and quantity at checkout's own price. Buy now re-quotes, then opens CrowdVolt checkout. List and Change price open CrowdVolt's own listing editor at DJ's price.
Notify“lmk under 80”An alert at their price for their quantity, shown as a live row. A group alert counts seats as listings line up.
Buy for me“get me 2 under 80”A plan, approved on a sheet with one required consent box. DJ buys the whole quantity from one listing through checkout's own path. When no one listing has the quantity under the max, it buys in parts: several sellers, each an ordinary CrowdVolt order, one after another. It never splits tickets they said must sit together. CrowdVolt's per-show ticket limit holds: over it there is no sheet, only CrowdVolt's line and Change plan to what's left. A platform they haven't linked never holds a plan: at approval DJ fixes the one delivery platform that fills the whole quantity under the max for the least, every part comes from it, and the sheet offers one optional row, Link <Platform> for instant delivery, that never blocks Approve. Up to $1,000 a plan, with no daily cap, and Buy for me is only offered within those caps.
DJ prices it for me“sell my pair by Friday, at least 80 back”The autopricer keeps their listing first in line and never goes under the minimum they approved. One sell sweep per pass: offers first, then pricing. A buyer's offer within $1 of DJ's price that still earns the minimum sells the whole listing as one CrowdVolt sale. Selling to offers has its own switch.

One card per plan. A plan bought in one purchase posts one receipt in the thread. A plan bought in parts posts nothing per part: its own card fills in place (“2 of 6 bought”), and each part is a row in the plan's activity, with its seller and a link to the order.

Sheets open complete. The approval sheet arrives with the draft: the show's art with ticket type, date and venue, the terms as rows, and one consent line. Approve works as soon as the box is ticked.

No backward compatibility. Nothing has shipped, so the DJ pull requests carry no compatibility code and no compatibility migrations. The server and the app deploy together, and the first merge is the first contract.

The capability layer. DJ reads the person's whole account (wallets on every platform, each listing's delivery state, each sale's next step) and the catalog (artists, venues, events, search), and answers with composed cards: artist and venue cards with one hero, details behind folds, and names that open CrowdVolt's pages.

Delivery in CrowdVolt's words. An order or a sale is one card: CrowdVolt's own status badge, one line on what is happening, the steps folded under it, and one button only when a step is the person's to do. An order or sale waiting on them takes the dock with that step. Each plan has its own page: the terms, what DJ bought or sold, and the activity, with the orders one tap down.

Evaluation results stay in the repository.

  • The agent reads and drafts, never acts. A tool loop on one strong model every turn looks up the market and the user's own plans, answers questions, finds events, and drafts several plans at once. No tool can approve, charge, list or raise a limit, and every number it says has to come from a tool. It uses the kit, gives evidence-backed recommendations and distinguishes observed facts from inference. It never guarantees future prices.
  • Approval is the only authority. An immutable consent row is hashed to the exact terms and text the user saw. The person ticks one consent line on a sheet the server rendered. The engine re-checks the consent in the same transaction that moves money.
  • React after commit, never inside it. The 23 places that already feed the autopricer also emit a DJ signal, coalesced to each listing's latest state. A matcher per event picks it up. Target: listing to charge in under 5 s at p95; the budget below adds to 4.1 s.
  • Buying waits smartly. DJ buys when a listing beats the expected cost of waiting, and that bar rises to the max as the deadline nears. Plans run until carting closes, past doors.
  • No second path to money. DJ buys through calc_buy, cart_buy_order and buy_approved, the chain checkout uses, with an off-session charge on the card pinned at approval.
  • Selling extends the autopricer. It adds two goals and pools DJ sellers so they never undercut each other. It matches the lowest early, leads by $1 in the last 6 h, anchors on recent sales when there's little to match, and sells to an offer within $1 or one that crosses.
  • Ships dark. A kill switch row starts off. Breakers trip it on their own. Two weeks of shadow mode run before any real charge.

Four boundaries

Each layer can do one kind of thing. Nothing below a line can widen what the line above allowed.

The model untrusted

Does
Runs a tool loop: reads the market and the user's own plans, answers, drafts, pauses, lowers a limit.
Never
Approves, charges, lists, raises a limit. Those tools don't exist. Says a number no tool returned, or sees a real id.
Worst case
A wrong draft, a paused plan or a wrong answer.

The consent authority

Does
Grants one plan version's terms up to a cap or down to a minimum, on one card, and for a buy, one delivery platform.
Is
Immutable. Hashed to the terms and to the server-rendered text. Single-use challenge and a ticked box.
Changes
More power needs a new consent. Less power applies at once.

The engine deterministic

Does
Matches, holds a cart, charges, reprices, sells to offers.
Checks
In one transaction: consent hash, plan state, spend left, user pause, kill switch.
Never
Widens its own limits or retries a hard decline.

The market existing

Does
Everything a person's checkout or listing does, with the same rules: ticket limits, both-sides, fees, holds.
Changes
Emits a signal after commit. Six fixes first (below).

What the evals changed

Backtests on past events, agent evals and a market simulation changed these decisions. Where they disagree with the first version of this design, the evals win, and the rest of this page already reflects them.

AreaFirst versionNow
Last callTransfer close, and plans end at doorsWhen carting closes, doors_close_time. Buy and sell plans run past doors, because a big share of trading happens after doors open
BuyingThe first listing under the maxSmart waiting. DJ buys when a listing beats the expected cost of waiting, and that bar rises to the max as the deadline nears
Recommending a maxThree odds-based chipsCalibrated options phrased as facts, plus buy now. The suggested discount shrinks as DJ's share of waiting demand grows, because a discount everyone follows stops trades
SellingAn expected-payout curveThe simple rule: match early, lead by $1 in the last 6 h. The curve did no better and is harder to explain. A demand anchor gives a book of mostly DJ sellers something to price against
Price movesUp to 6 an hourA move budget that loosens toward doors. Raises are capped per move and wait until cheaper competition has stayed gone, which stops prices bouncing up and down
Fair shareDJ takes any listing at onceA short hold-off on brand-new listings once DJ holds a large share of an event's waiting demand, so people buying by hand still get a turn
OffersSell when an offer beats waitingSell to an offer within $1 of DJ's price, or one that crosses. Kept for speed and certainty, since an offer is a committed buyer. Built after the core pricer
The agentAnother model round after every tool callThe server looks up the event before the first call, replies ride inside the final tool call, and the model sees short aliases instead of real ids
CardsComposed by the agentA designed card for every common question. Composing from the kit is the fallback
ScaleOne worker per eventSignals coalesce per listing, executors run in parallel within an event, and a pool's price is stored once
Market infoRecent sale rangesFacts with a frame. No forecasts, no verdicts, no sale-by-sale history, no one's private limits

The system

new extended existing external, moves money
flowchart LR
  app["DJ thread, bar, page"]
  subgraph API["py-mkt, /api/dj"]
    direction LR
    routes["DjRouteManager
auth, rate limits"] look["Event lookup
before the model"] llm["Agent loop
GPT-6 Luna, up to 16 rounds"] val["Grounding check
every number from a tool"] plans["Plan service
states, versions, edits"] consent["Consent
challenge, hashes, ticked box"] end stripe[("Stripe
SetupIntent")] idx[("dj_buy_scope
match index")] pol[("pricing_policy
sell goals")] log[("dj_event
dj_thread_message")] app -->|"POST messages"| routes routes -->|"text"| look look -->|"text, market"| llm llm -->|"reply, drafts, card"| val llm <-->|"read tools"| reads[("Market and my data
book, plans, quotes, events")] val -->|"draft or needs_info"| plans routes -->|"edit, pause, stop"| plans routes -->|"approval, approve"| consent consent -->|"card for off-session"| stripe consent -->|"active"| plans plans -->|"buy plans"| idx plans -->|"sell plans"| pol plans -->|"every change"| log classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__ classDef ext fill:__INFOSOFT__,stroke:__INFO__,color:__INK__ classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__ classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__ class app,routes,look,val,plans,consent,idx,log nw class reads ex class llm bad class pol ext class stripe money
Commands, what the user does. One route manager, one plan service. The server finds the event before the model runs, so a simple ask is one model call. The agent can read anything the user could see and draft anything, but its reply and card are checked before they're shown, and only a consent can make a plan active.
flowchart LR
  subgraph MKT["market commits, same rules as today"]
    direction TB
    l1["new listing
QR approval"] l2["price edit
cart released"] l3["new bid"] end stream[("Redis DJ stream
latest per listing")] pq[("Pricing signal queue
5 s coalesce")] matcher["Buy matcher
beats waiting, oldest first"] exec["Executors
parallel, locked per listing"] recon["Reconciler
every 30 s"] pricer["Pool pricer
match early, lead late"] sugg["Suggestions
1 a day, 3 a week"] chain["Checkout chain
calc_buy, cart_buy_order,
buy_approved, sell_approved"] apply["ListingPriceChangeService"] stripe[("Stripe
off-session charge")] log[("dj_event
dj_thread_message")] sock["cvsocket
ids only"] push["notify
Expo push"] app["App
refetches over REST"] l1 -->|"after commit"| stream l2 -->|"after commit"| stream l1 --> pq l2 --> pq l3 -->|"after commit"| pq stream -->|"blocking pop"| matcher matcher -->|"match"| exec matcher -.->|"alert lost, offer would fill"| sugg exec -->|"cart, buy_approved"| chain exec -->|"charge"| stripe recon -->|"same key"| stripe pq -->|"pool"| pricer pricer -->|"move, within budget"| apply pricer -->|"sell to offer"| chain exec -->|"writes"| log pricer -->|"writes"| log sugg -->|"writes"| log log -->|"on commit"| sock log -->|"on commit"| push sock --> app push --> app classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__ classDef ext fill:__INFOSOFT__,stroke:__INFO__,color:__INK__ classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__ classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__ class stream,matcher,exec,recon,sugg,log,app nw class pq,pricer,sock,push,apply ext class l1,l2,l3,chain ex class stripe money
Reactions, what the market does. The seller's request has already committed when DJ starts. Only two things write to the market, the executors and the pricer, and both use the same functions a person's checkout or edit does.
PieceLives inBuilt like
new Plan serviceshared/dj/_async, repos in shared/db/_async, tables tktx.dj_*Async first, sync by unasync
new RoutesDjRouteManager, @requires_auth_uow on every routevd_chat_resource.py, agent_action_resource.py
extended AgentThe ops_copilot loop made async and streaming, on the Responses API with gpt-6-luna. An event lookup runs before the first callops_copilot_agent_service.py, llm_tool_client.py
extended DJ signaluow.add_dj_match_after_commit(event, ask) at the 23 pricing sites, keeping only each listing's latest stateunit_of_work.py:955, flushed :813
new Buy matcherAsync worker lifespan loop, blocking pop instead of a 1 s sleep. One consumer per event, split by ticket type when an event gets busypricing_worker_service.py:58
new ExecutorSync py_mkt code on a dj-worker fleet, in parallel within an event, locked per listing and per user and event. The unique dj_execution row is the idempotency guardOps CV-fill chain
new Reconciler, last callHatchet cron every 30 s. Last call at doors_close_time, scheduled like cart expiry at T−30 min and T, plus a 1 min sweepms:2157
extended Seller pricershared/pricing: goals, evaluator, apply. Writes through ListingPriceChangeService. A pool's price is stored once and its DJ listings reference itManual edits still pause it
extended DiscoveryA discover tool on the recs stack py-mkt already has: taste vectors, event embeddings, the "More for you" blendrecs/
new Live ActivitiesAn ActivityKit widget target via expo-widgets. py-mkt sends to APNs directlyThe target the bank-check activity already needs
extended Push, socketdj-* notif types written in the executor's transaction. dj_plan_updated, dj_message framesnotif_policy.py, cvsocket/models.py
new Kill switchdj_control rows read FOR SHARE inside every executionpricing_control_repo.py:12

Plan states

Every plan is one row with a state, a version and a revision. Pausing everything and the kill switch are not states. The API returns a computed effective_state, and the executor checks all three.

flowchart LR
  msg(["message"]) --> draft
  draft -->|"missing a field"| needs_info
  needs_info -->|"pick"| draft
  draft -->|"POST approval"| pending_approval
  pending_approval -->|"POST approve"| active
  active -->|"match"| executing
  executing -->|"lost race, partial"| active
  executing -->|"filled or sold"| done(["done"])
  WAIT["waits on the user

needs_auth
needs_payment
blocked
paused"] executing -->|"bank check, decline"| WAIT active -->|"limit, minimum, pause"| WAIT WAIT -->|"fixed or approved"| active active -->|"stop"| stopped(["stopped"]) WAIT -->|"stop"| stopped active -->|"deadline"| expired(["expired"]) classDef live fill:__GOODSOFT__,stroke:__GOOD__,color:__INK__ classDef attn fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ classDef pre fill:__INFOSOFT__,stroke:__INFO__,color:__INK__ classDef fin fill:__SURF2__,stroke:__LINE__,color:__INK__ class active,executing live class WAIT attn class draft,needs_info,pending_approval pre class done,stopped,expired,msg fin
Green runs, amber waits on the user, blue is before approval, grey is final. An execution already in flight always finishes before a stop lands.
StateIn whenOut when
draftA draft passes validationApproval requested. Expires when carting closes
needs_infoEvent, type or a number is missingThe user picks
pending_approvalPOST /approval issues a challengeApproved, or the challenge lapses after 10 min
activeA verified consentA match, a wait, a stop, the deadline. Plans can run until carting closes, past doors
executingMatch found, preflight passesBought or sold, lost race, bank check, decline
needs_authStripe authentication_required, cart keptUser confirms, or the hold expires
needs_paymentHard decline, or the card expires before the deadlineA new card, approved
blockedThe pricer is at NET_FLOOR, or a buy is at CrowdVolt's per-show ticket limitA lower minimum approved, last call, or Change plan to what's left
pausedUser, a manual price edit, a dispute, opsOnly the same kind resumes it
done, stopped, expiredFilled or sold, stopped by the user or ops, deadline passedFinal
More power: approve againLess power: applies now
Raise or remove the maxLower the max
Lower or remove the minimumRaise the minimum
More ticketsFewer tickets
Later deadline, new ticket typeEarlier deadline
A different cardOffer off
Show an offer, best offer at last callPause, stop

How an edit lands

The server classifies every edit, never the client. A more-power edit makes a pending version, and the plan keeps running on the approved terms until the new one is approved. A less-power edit bumps the version at once and posts a receipt. Edits carry base_revision, so two devices can't overwrite each other.

Approve: the only way authority is created

sequenceDiagram
  autonumber
  participant A as App
  participant API as /api/dj
  participant ST as Stripe
  participant DB as Postgres
  A->>API: POST approval
  API->>ST: SetupIntent, off_session
  API-->>A: challenge, terms, hashes
  Note over A: box starts unticked
button says Approve $160 A->>A: box ticked A->>ST: confirm, bank check over $250 A->>API: POST approve with challenge rect __MONEYRECT__ API->>DB: lock plan, check owner, challenge, hashes API->>ST: SetupIntent succeeded? API->>DB: insert dj_consent, plan active end API-->>A: Approved up to $160
The server renders the consent sentence and stores its hash, so the record is what the user read. A retry with the same client_request_id returns the first result. Approve is never optimistic in the app.

Several plans, one sheet

Several buys share one sheet and several sells share another. A buy and a sell asked together get two sheets back to back. Each side has its own headline number, the most you'll pay or the least you'll earn, and one total can't show both. The agent opens a sheet only once every plan in it is drafted.

One consent per plan

A sheet lists each plan with its own amount, above one box. The server writes one dj_consent per plan, and the plans on a sheet share one challenge. Removing a plan from the sheet drops it from the batch. One sheet per side is decided.

Buying: listing to charge in under 5 seconds

The hero case: Four Tet, Sat Oct 10. Max $80 each, all in. A $73 listing ($76 all in) appears Thu 11:48 PM. It beats the expected cost of waiting, so DJ buys it 3 seconds later.

sequenceDiagram
  autonumber
  participant S as Seller
  participant MS as market_service
  participant R as DJ stream
  participant M as Matcher
  participant X as Executor
  participant ST as Stripe
  participant B as Buyer app
  S->>MS: list 2 GA at $73
  MS-->>R: after commit, event and ask
  R-->>M: pop
  M->>M: scan index, oldest first
beats waiting, preflight from cache M->>X: execute, 2 tickets X->>X: insert dj_execution X->>MS: calc_buy, cart_buy_order MS-->>X: cart held, $152 all in X->>X: $152 within 2 × $80 rect __MONEYRECT__ X->>ST: off-session charge, idempotency key ST-->>X: requires_capture X->>MS: buy_approved end X-->>B: dj-bought push, socket id alt bank check needed X-->>B: keep the cart, needs_auth else hard decline X->>MS: release the cart, needs_payment else lost the lock X->>M: next listing end
The seller's request finishes before DJ starts. The match reads an index, not the book, and takes the listing only if it beats waiting. Money only moves in the highlighted block, and only after the cart total is checked against the approved max.

When DJ buys: smart waiting

  • Fitting the max isn't enough. DJ estimates how often listings arrive and at what prices from the event's last 24 h. It buys when a listing beats the expected cost of waiting for a better one.
  • The bar rises to the max. As the deadline nears there's less time left to wait, so the bar climbs until anything under the max qualifies. The aim is the same chance of getting tickets at a lower price.
  • Plans run until carting closes. A buy plan's deadline is doors_close_time, past doors, because people keep buying after doors open.
  • Speed still matters. People cart a good listing soon after it appears, so the race is won at cart time. That's why the target is under 5 s from commit.

Latency budget, p95 from commit

Max all in to a list-price cap. Fees round up per ticket

def list_cap(M, cfg, buyer, linked):
    def fee(p):
        return 100 * calc_fee_with_qty(
            cfg, price=p / 100, qty=1, is_buy=True,
            user=buyer, linked_users=linked)
    p = floor_dollar(M / (1 + pct(cfg, buyer)))
    while p + fee(p) > M:
        p -= 100
    while p + 100 + fee(p + 100) <= M:
        p += 100
    return p

# stored per plan: cap_normal_cents, cap_surge_cents
# the column switches when resolve_fee_config does
# binding check: charge_now_amount <= q * M

Matcher, one consumer per event partition. A listing has to beat waiting

def on_signal(event, ask, now):
    avail = ask.qty_open_to_cart
    cap = cap_col(event)
    for plan in eligible(event, ask.tt, ask.price, cap):
        if not beats_waiting(plan, ask, now):
            continue
        q = fill_qty(plan, ask, avail)
        if q == 0 or not preflight(plan, q):
            continue
        r = execute(plan, ask, q)
        if r == LOST_RACE:
            return
        if r == BOUGHT:
            avail -= q
        if avail == 0:
            return

def fill_qty(plan, ask, avail):
    if ask.allow_partial:
        q = min(plan.remaining, avail)
    else:
        q = avail if avail <= plan.remaining else 0
    if ask.no_single and avail - q == 1:
        q -= 1
    if plan.together_only and q != plan.remaining:
        return 0
    return q

def beats_waiting(plan, ask, now):
    rate, prices = last_24h(plan.event, plan.tt)
    wait = expected_best(rate, prices, now, plan.deadline)
    return ask.all_in <= min(plan.max_all_in, wait)

# wait rises toward the max as the deadline nears
TriggerHook
New listing live, QR approvalms:5974, ms:2331
Price edit by a user or the pricerms:7885, ms:8031, price_change_service.py:36
Cart released or expiredms:7039, ms:1431 (new hook)
Plan approved, resumed, max raisedMatch against the whole book once
SweepLowest per ticket type vs the top cap, so a listing DJ waited on is bought once the rising bar reaches it. Every 60 s, and every 15 s from 6 h before doors
Not like price alerts.

Price alerts run inside the seller's transaction and swallow errors (price_alert_service.py:20). A Stripe call there would hold the seller's locks for seconds, so DJ always waits for the commit.

  • Fairness. First approved, first served. queue_at only moves when a version raises the max or the quantity. Public bids fill inside the seller's own calc_sell, so they win ties by construction.
  • Fair share with people. While DJ's share of an event's waiting demand is low, there's no hold-off. Once DJ holds more than about a quarter of it, brand-new listings wait 30 to 60 s before DJ can take them, so people buying by hand still get a turn.
  • Retries. Stripe timeouts retry twice with the same key. Anything stuck past 30 s goes to the reconciler, which gets the original intent back by key and finishes or releases. buy_approved is already idempotent on the intent.
  • Bank checks. The app confirms the intent with the user present, then calls auth_complete. Doing it in that order avoids buy_approved cancelling intents that still need action (ms:3117).
  • Exemptions. DJ carts skip the cart-hogger policy. The bank check at approval satisfies the forced check over $250 (sus_buyer_service.py:32).

Selling: a pool, not a race

The hero case: Jamie xx, Fri Oct 9. Listed at $110, 11th of 11. DJ moves it to $98, 1st of 11, which earns $93. The minimum is $80 earned, which means listing at $85.

sequenceDiagram
  autonumber
  participant O as Market
  participant Q as Pricing queue
  participant P as Pool pricer
  participant MS as market_service
  participant SL as Seller app
  O-->>Q: listing, edit or bid committed
  Q-->>P: the event's pool
  P->>P: lowest non-DJ price, best bid
match early, lead by $1 in the last 6 h
under 3 others, anchor on recent sales loop each DJ listing, oldest approval first alt offer within $1 or crossing, earns the minimum rect __MONEYRECT__ P->>MS: lock bid, re-check, calc_sell P->>MS: sell_approved end P-->>SL: Sold 2 at $98, you earn $186 else a move the budget allows P->>MS: new pool price through
ListingPriceChangeService P-->>SL: 1st of 11 at $98 else at the minimum P-->>SL: blocked end end Note over P,SL: last call, when carting closes: an offer at the minimum or above sells
below it asks the seller, no offer ends the plan
DJ sellers in one pool sit at the same price, queued by approval time, so they never undercut each other. The pool's price is stored once, so a move doesn't rewrite every listing. A manual edit pauses the plan through the existing override path.

Inside the spread

The seller wants to earn at least $80, which means listing at $85. The best offer is $78 and the lowest other listing is $90. Nothing can trade yet, because $78 earns $74.

  • Early, match $90. Far from doors, DJ ties the lowest other listing instead of undercutting it, which keeps the seller's margin.
  • In the last 6 h, lead by $1 at $89. That's when buyers arrive, so first in line is worth a dollar. It earns $84. Plans keep leading after doors, until carting closes.
  • Keep $85 private. It's the floor, not the price.
  • Anchor when there's little to match. With fewer than 3 other listings live, DJ prices at the median of the last 20 sales, capped at the lowest other listing. From 3 h after doors, it takes the best offer at or above the floor. Without an anchor, a book of mostly DJ sellers has nothing to price against.
  • Take an offer within $1. Leading at $89, an offer at $88 earns $83, above the minimum, so it sells. An offer that crosses DJ's next price sells too. An offer is a committed buyer, which makes the sale faster and surer.
  • Pull the other side up. The $78 bidders get the existing new-supply message that a listing is at $89. A DJ buyer whose max covers it gets it in seconds. That's how a spread becomes a trade.
  • At last call, when carting closes, an offer of $85 or more sells. A lower one asks the seller: "An offer at $82 earns you $77, $3 under your minimum. Take it?" No offer ends the plan.

Match early, lead by $1 late, anchor on sales when the book is thin

def sell_target(a, pool, now):
    others = pool.non_dj_asks()
    lo = others.lowest
    if len(others) < 3:
        tgt = median(pool.last_sales(20))
        if lo:
            tgt = min(tgt, lo)
    elif hours_to_doors(now) <= 6:
        tgt = lo - 100
    else:
        tgt = lo
    return max(a.floor, tgt)

# hours_to_doors goes negative after doors,
# so DJ keeps leading until carting closes

The pool pass, oldest approval first. Offers within $1 or crossing sell

def pool_prices(pool, now):
    b = best_bid(pool)
    late = hours_after_doors(now) >= 3
    for a in pool.dj_asks_by_queue_at():
        if a.goal == DJ_HOLD:
            tgt = hold_target(a, pool)
        else:
            tgt = sell_target(a, pool, now)
        near = b and b.price >= min(a.price, tgt) - 100
        if b and b.fits(a) and net(a, b) >= a.min_net \
                and (near or late):
            yield a, SellToBid(b)
            b = next_bid(pool, b)
        elif tgt != a.price and move_ok(a, tgt, now):
            yield a, tgt

def hold_target(a, pool):
    lo = pool.lowest_other or a.price
    return max(a.floor, min(lo, a.price_24h_ago * 1.2))

How often the price moves

Matching the lowest raises the price when cheaper listings sell and cuts it when a new one appears, so a listing can bounce up and down. Moves get a budget that loosens toward doors, and raises have to wait.

Time to doorsMovesWait before a raise
More than 48 h2 per 2 h30 min
12 to 48 h1 per 30 min, bursts up to 330 min
3 to 12 h1 per 15 min, bursts up to 515 min
Last 3 h1 per 10 min, bursts up to 65 min

A raise is at most 10% a move. The wait runs from when the cheaper competition disappears. If a cheaper listing appears during it, there's no raise, which is what stops the bouncing. A buyer who arrives during the wait pays the current price.

Cuts spend the budget. Raises also need the wait

def move_ok(a, new, now):
    rate, burst, wait = budget(hours_to_doors(now))
    if not a.moves.allows(rate, burst):
        return False
    if new <= a.price:
        return True
    return (new <= a.price * 1.10
            and a.pool.cheaper_gone_for(now) >= wait)

"You earn at least" to a list price. Fee is 5% here, the seller rate in the last 7 h, rounded up per ticket

def list_floor(N, ctx, ask):
    if ask.allow_partial:
        qs = range(1, ask.qty_left + 1)
    else:
        qs = [ask.qty_left]
    def net(p):
        return min((p * q - 100 * seller_fee(ctx, p, q)) // q
                   for q in qs)
    p = ceil_dollar((N + ctx.shipping) / (1 - seller_pct))
    while net(p - 100) >= N:
        p -= 100
    while net(p) < N:
        p += 100
    return p

# earn $80 -> list $85, fee ceil(4.25) = $5
# list $98 -> earn $93, fee ceil(4.90) = $5
# 2 sold at $98 -> $186
WhereTodayWith DJ goals
evaluator.py:30Holds with fewer than 2 other sellers0 others hold, 1 or more use the position rule
evaluator.py:53Clamps between cheapest and dearestBypassed, so it can lead
evaluator.py:80Skips moves under 2%$1 step
evaluator.py:103No raise without salesRaises to the next competitor once the cheaper one has stayed gone for the wait
evaluator.py:206Compares same-quantity listings onlyPer-ticket price, any listing with 1 or more open
evaluator.py:26510% a move, 20% a daySell-by: the move budget above, raises at most 10% a move, cuts bounded by the floor. Hold: 20% a day
service.py:261Re-evaluates every 5 to 60 minBackstop. Primary is the signal queue
price_apply_service.py:101Rejects no minimum, rejects crossing a bidAllows no minimum, returns CROSSES_BID and sells to it
types.py:6, migration3 goals, min_net_cents NOT NULLAdds DJ_SELL_BY, DJ_HOLD, nullable minimum, dj_plan_uqid
  • Selling to an offer. Within $1 of DJ's price, or crossing it. Lock the bid NOWAIT, re-check status, price, quantity and a capturable intent, then call calc_sell(use_bid_uqid=B) by name. Without the name it fills the cheapest qualifying bid (ms:4259). This ships after the core pricer.
  • Last call is when carting closes, doors_close_time, unless the plan's own deadline is earlier. The heads-up goes out 30 min before.
  • Ties. calc_buy breaks ties by created_at (ms:1570). Add ask.queue_at and sort by price then queue_at, there and on the event page.
  • Scope today. The pricer only runs for house inventory: Drew's Hub, groups of up to 20, a DICE adapter. Consumer owners and other providers get added here.

Recommending limits

recommend_limits is engine code. The agent calls it and puts the answer into words. It never picks a number itself.

What should my max be?

It's $94 now. Here's how often each price showed up before doors.

Four Tet · $94 lowest now

Watch under

$80 · most nights$70 · half the nights$58 · 1 in 3 nights
Buy now
Buyer maxSeller minimum
ModelHow low the price went, relative to the lowest at the time, on past events, by hours to doors, plan length and book depth. Calibrated by plan length so each option holds up, refit monthlyWhere each price would sit in line, and what it earns there
OptionsThree prices with two-word hints (most nights, half the nights, 1 in 3 nights), plus buy now. Sometimes nothing cheaper ever appears, so buy now is always one of themThe price that puts them first in line, and what they'd earn at it
DJ demandThe suggested discount shrinks as DJ's share of waiting demand grows. The odds count DJ plans already waiting at or above a price, since those get listings firstAny lift above the seller's own price is still open (below)
GuardNever above the lowest all in. Past that, the answer is "Buy now at $94"Never under what they paid on CrowdVolt without saying so
AnchorsTheir alert, their offer, what they last paid for this artistTheir list price, what they paid. Wallet purchase prices are unknown

Options as facts

The odds stay inside. Each option is a fact with a frame: how often shows like this got that low before doors. DJ never says a price will drop and never calls one a good deal. Value works the same way. "Bought at $76, $18 under the lowest when you asked" is true. "Saved $18" isn't, because the price might have dropped anyway.

Why this is also the most money for us

Revenue is per trade. On the $73 Four Tet ticket that's a $3 buyer fee and a $3 to $4 seller fee, about $7. A few dollars on price moves fees by cents, and rounding often makes it zero. A trade that doesn't happen loses all $7. So the recommender aims for trades that happen, which is what the user wants too.

What creates trades

Nudging bidders into the spread. DJ buyers meeting DJ sellers. Limits that clear instead of wishful ones. A recommender that counts DJ demand, so buyers following it together don't stall the market. Selling inventory before carting closes, since an unsold ticket earns nothing. No upward bias and no fake urgency.

Market information: what DJ tells people

People ask "is this a good price", "where should I list", "will it drop". Answers should help a trade happen without teaching everyone to wait out the market, handing competitors CrowdVolt's data, or exposing anyone's limits. The tools enforce it. None returns per-sale rows or anyone's limits, so no card can show them.

  • Share what helps a trade happen. Tell a seller exactly where to be first in line: "$98 puts you first. $110 is 11th of 11." Tell a buyer their own plan's odds, as facts. No event-wide forecasts like "prices usually fall into doors", because advice everyone follows stops trades.
  • Never reveal private limits. No DJ maxes, minimums, or the shape of waiting DJ demand. A seller can see "About 10 buyers are watching", a rounded count, never at what prices.
  • Round, range and lag. "About 20 listed", "Sold today: 30+". Exact counts and sale-by-sale history stay internal.
  • Personal, never a dataset. Stats come as answers to one person's plan in the thread, never through an open market-data API. The market tools share the 20 messages per 5 min budget, so scraping the book through DJ is slow and useless.
  • Where the lowest has been, instead of a verdict. Each recent day's low to high, like Weather's 10-day rows, plus the plan's odds as facts, and DJ catches the dip. Verdicts like Low or Typical didn't predict whether a better price came later.
  • Measure, and pull what hurts. Each new kind of answer ships behind a flag with a holdout. If it lowers sell-through, pushes purchases into the last hour, or raises lowball maxes, it's turned off.
QuestionDJ answers withNever with
"Is $95 a good price?"The lowest now, where the lowest has been each recent day, and a plan's odds as facts tied to a Watch chipA Low, Typical or High verdict, a sale range, "it'll probably drop"
"Where should I list?"The price for first in line, and what you'd earn at itOther sellers' minimums or DJ's pool strategy
"Will it drop?"Their plan's odds at 2 or 3 maxes, as factsEvent-wide forecasts
"How many want this?""Lots of buyers are watching", with a rounded countTheir maxes, or how many are DJ plans

The agent: a tool loop that can't move money

GPT-6 Luna on the Responses API, built on the ops_copilot loop. It can look things up, answer questions, find events and handle several asks in one turn. The safety line isn't "no tools". It's that no tool can approve, charge, list or raise a limit.

flowchart LR
  tap["Chip or picker tap"] -->|"no model"| h["Handler
instant"] text["Typed text"] --> look["Event lookup
market attached"] look --> m["GPT-6 Luna
cached prefix + thread"] m <-->|"parallel"| read[("Read tools
market, plans, quotes,
limits, discover")] m -->|"draft, edit"| draft["Draft tools
cards, no money"] m -->|"pause, lower"| safe["Safe actions
receipt in the thread"] m -->|"needs approval"| ask["Ask the user
sheet, picker, confirm"] m --> g{"Every number
from a tool?"} g -->|"no, retry once"| m g -->|"yes"| out["Stream the reply
designed card first"] h --> out ask -.->|"tick"| eng["Engine
moves money"] classDef nw fill:__NEWSOFT__,stroke:__NEW__,color:__INK__ classDef bad fill:__BADSOFT__,stroke:__BAD__,color:__INK__ classDef ex fill:__SURF2__,stroke:__LINE__,color:__INK__ classDef good fill:__GOODSOFT__,stroke:__GOOD__,color:__INK__ classDef money fill:__WARNSOFT__,stroke:__WARN__,color:__INK__ class tap,text ex class h,look,draft,safe,g,out nw class m bad class read ex class ask good class eng money
The only red box is the model, and none of its arrows reach money. That arrow starts at the user's tap. Up to 16 rounds with a 45-second lookup budget and bounded wrap-up, with text streaming as it's written. Replies ride inside the final tool call, so when every call in a response ends the turn and none errors, there's no extra round.
KindToolsRule
readsearch_events, discover, get_market, get_my_plans, get_my_listings, get_my_orders, get_my_alerts, get_wallet_tickets, quote, recommend_limits, helpScoped to the user on the server. Run in parallel. Never return text other users wrote, per-sale rows or anyone's limits. Short aliases instead of real ids. Wallet only after opt-in
draftdraft_plan, edit_draft, create_price_alertNo money. Several a turn. Each renders a card. A sell draft must name the caller's own live listing
showshow_template, show_cardA designed card first. Composing from the kit only when none fits. The server validates every spec
safe actionpause_plan, lower_limitOnly ever reduces power. Leaves a receipt
ask the userrequest_approval, show_picker, confirmRenders a component. The user's tap does the work. Stop and resume go through confirm
doesn't existapprove, raise a limit, buy, list, sell to an offer, change the cardOnly the approval sheet does these

Several asks at once

"Get me 2 for Four Tet under 80 and sell my Jamie xx by Friday" makes two drafts. Questions around them ("what if it doesn't drop?", "what's the fee?") get answers from tools while the drafts wait. DJ drafts whatever is ready and asks about the rest in the same turn. The buy and the sell get a sheet each, back to back (above).

Fast and cheap

A simple ask is one model call. Prompt caching: about 5k tokens of system prompt, tool schemas and glossary go first, the same for everyone. User state comes from tools, never the prefix. prompt_cache_key per user, store: false. First token under 1 s at p95, a typical turn under 3 s. It streams over the authed response, never the socket.

Gates before it ships

The model never writes digits. It writes references like {m1.lowest_all_in} that the server fills from tool results, and the server does the arithmetic. Draft limits must trace to the user's words, a chip or recommend_limits. Deploy gates over 500+ cases: the right event first try at least 95% of the time, the right tool and arguments at least 97%, median rounds 2 or fewer, no ungrounded numbers, every injection case handled. If it would need a money tool, it asks for approval instead, every time. When the model is down, chips, pickers and sheets still work.

Cards: designed first, composed as the fallback

Answer small, reveal on tap. The message gives the answer with enough detail to make the decision. The card holds only what you act on: one row with the price and a short note, and one or two buttons. History and options open inside the card on tap. Every common question has a designed card; the agent composes from the same kit only when none fits.

TierWhatWho decides the layout
fixedThe plan card, approval sheet, edit sheets, receipts, the bar, Live Activities, home screen widgetsNobody. They render from the plan row because they're the money record. The agent can only cause them, by drafting
designedPrice check, watch options, compare, fee, place in line, demand, why it moved, events, helpA designer. The agent picks one with show_template and feeds it references
composedThe long tail of questionsThe agent arranges kit blocks in a small spec with show_card
neverModel-written markup or free layoutNot allowed
  • Numbers are references. A series block binds to a series a tool returned, so the agent chooses what to show but can't supply data. The server writes the notes, pill hints and button labels.
  • Two buttons at most, from a whitelist. Open checkout, draft a plan from a recommend_limits option, open the edit sheet, open an event. None of them moves money.
  • Chips are prompts. Tapping one posts its text as the person's message. Anything that goes somewhere is a button or a tappable row for a typed entity, and the app routes it. No free-form links.
  • Checked before sending. Known blocks only, five at most, references that resolve with the right type. A failing card gets one regenerate with the errors, then the designed card for its intent. The message itself is guided by the prompt, never gated for style; only its numbers are checked.
  • Old app versions. The client sends its kit version. A block it doesn't know falls back to the text answer and the nearest older block.

Suggestions

A suggestion is DJ starting the conversation: a plan the user didn't ask for, built from something they did on purpose. "Four Tet dropped to $78. It's in your Likes. Want me to get 2 under $80?". Reasons come only from Likes, alerts, orders and offers, never views or searches, and never with a date.

MessageExamplesPushCapped
asked forBought, sold, needs you, price alert hit, last callAlwaysNo
fills nowAn expired offer would fill right now. An alert lost the race and a new listing is at the alert priceYesYes
other ideasA liked event moved. An artist is rebookedNo, thread plus a dot on the DJ barYes
TriggerSignalRunsWeight
Alert lost the raceThe alert's listing sold to someone else within 60 min and the user didn't buy. Needs price_alert.trigger_ask_uqidStream, on buy_approved1.0
Expired offer would fillAn expired bid, then a listing at or under it within 7 daysStream, through the matcher0.9
Liked event movedIn Likes, lowest moved 10% or more in 24 h, between 48 h out and doorsEvery 30 min0.6
Artist rebookedA performer from a past order announces a date in the user's cityOn publish0.5
  • The cap adapts. It starts at 1 a day and 3 a week. Two accepted suggestions add one a week, up to 5. Two ignored in a row pause them for 14 days. Three "Not now" on an artist mutes it.
  • Why cap at all. Every unasked message spends trust. A user who mutes DJ also loses the messages they asked for. And CrowdVolt's other marketing shares the budget, through MarketingFrequencyService.
  • Score is weight × fillable × 2^(−age_h / 12). The suggested price is always the user's own number (their alert or offer) or the current lowest.
  • No double sends. DJ registers as an OfferProvider, so a suggestion or plan holds back the generic offer-rescue and new-supply sends for 72 h.
  • Wallets only with the opt-in asked once, after the wallet-link copy and privacy policy say so.

Discovery: what should I do tonight

DJ can also find events. "Something melodic tonight under $60", "what's good this weekend", "I liked Four Tet, what else". It rides on the recs stack py-mkt already has. Buying and selling mostly move existing demand around, so discovery is where new demand comes from, in nights people wouldn't have found otherwise.

The tool

discover(when, vibe, scenes, budget_each, qty, near). when is tonight, this weekend or a date range, vibe is the person's own words, and near defaults to their metro. It returns 3 to 5 events, each with the lowest all in, a rounded count listed, and at most 2 reasons built from things the person did on purpose: Likes, past orders, alerts, artists they bought.

Ranking

Built directly on events. Hard filters first: the metro, the window, not cancelled, a live listing. Then a score: taste match from a fresh user vector against the event embedding, demand, vibe match, and a bonus when the lowest all in fits the budget. That extends the "More for you" blend, the personalization that has already proven itself. Then variety, so the picks aren't five nights of one headliner.

The conversation

At most one clarifying question, and only when "when" is missing. Then picks, each with a next step: watch under a max, buy now, or like. The model never invents lineups, set times or facts. It only phrases what discover and the event data return. When a plan's event is sold out or out of budget, DJ offers alternatives that same night.

  • Reasons only from explicit actions. Never from views or searches, and nothing guessed about the person.
  • Wallet history stays out of discovery's reasons and ranking until resold events are stripped from it and product and legal sign off on consent.
  • Measured from day one. Every pick is logged with stable item ids through tap and purchase, and judged against the "More for you" blend and plain popularity. The offline test asks discover as a real past buyer, at a time before their purchase, and checks whether the event they bought lands in the top 5.

Data

All tables live in tktx.*, with prefixed uqid keys, money in *_cents, and _history tables. Append-only tables reject UPDATE and DELETE with a trigger.

erDiagram
  dj_plan ||--o{ dj_plan_version : "versions"
  dj_plan ||--o{ dj_consent : "approved by"
  dj_plan ||--o{ dj_execution : "attempts"
  dj_plan ||--o{ dj_buy_scope : "match rows"
  dj_plan ||--o{ dj_event : "audit log"
  dj_plan ||--o{ dj_thread_message : "card, receipts"
  dj_plan |o--o| pricing_policy : "sell plans"
  dj_plan ||--o{ dj_live_activity : "deadlines"
  dj_suggestion |o--o| dj_plan : "became"
  dj_plan {
    text uqid PK
    text kind "auto_buy, price_alert, sell_deadline, sell_hold"
    text state
    int max_all_in_cents
    int min_net_cents
    timestamptz deadline_at
    text pinned_pm
    int version
    int revision
    timestamptz queue_at
  }
  dj_consent {
    text uqid PK
    text terms_sha256
    text rendered_text_sha256
    int cap_cents
    text challenge UK
    text client_request_id UK
    timestamptz revoked_at
  }
  dj_execution {
    text uqid PK
    text idempotency_key UK "dj:plan:ask:attempt"
    text kind "buy, sell_to_bid"
    text state
    text payment_intent
    timestamptz hold_expires_at
  }
  dj_buy_scope {
    text event
    text ticket_type
    int cap_normal_cents
    int cap_surge_cents
    timestamptz queue_at
  }
One plan, many immutable children. A consent never changes; revoking sets one timestamp. An execution's key is also Stripe's idempotency key, so a retry can't charge twice.
TableHoldsGuarantee
dj_planSide, kind, state, event, types, limits, deadline, pinned card and delivery platform, spent and earnedOne live buy plan per user, event and type. One live sell plan per listing
dj_plan_versionTerms and their hash, pending or currentOne pending version per plan
dj_consentText version, rendered text and hash, cap or minimum, card, SetupIntent, IP, deviceImmutable. Box must be ticked. Unique request id
dj_executionEach attempt: ask or bid, cart, intent, failure codesUnique key. One in flight per plan
dj_eventEverything that happened, with the actor: user, engine, ops, system, llmAppend-only
dj_thread_messageTyped rows: text, plan card, receipt, picker, bank check, suggestion. text_key and frozen paramsOne plan card per plan. Only resolved or redacted can change
dj_poolOne price per event and connected ticket types. DJ listings reference itStored once, so a move doesn't rewrite every listing
dj_live_activityUser, plan, kind, push token, start, end and why it endedOne DJ activity at a time. Starting one ends any other
dj_settings, dj_suggestion, dj_control, dj_llm_turnBar mode, pause, opt-ins. Suggestions and mutes. Kill switch rows. Model callsThe global control row ships off

API and realtime

Every lookup is scoped WHERE uqid = ? AND user_uqid = ?. A mismatch is a 404. Behind the backend flag dj-agent.

RouteDoes
POST /api/dj/messagesText, a pick or a reply to a plan, with client_message_id. Streams the reply over the authed response, and cards arrive as their tools finish. Picks and chips answer without the model. 20 per 5 min
GET /api/dj/thread, /sincePaged thread, catch-up after a reconnect
GET /api/dj/plans, /barThe DJ page (running, drafts, past) and the bar's pages: needs you, then news, then soonest
POST /plans/id/editChanges against base_revision. Says whether it needs approval. 409 on conflict
POST /plans/id/approval, /approveChallenge, then verified approve (above). The plans on one sheet share one challenge
POST /plans/id/pause, /resume, /stopEach posts a receipt. Resuming an ops pause is a 409
POST /pause_all, /resume_all, /settingsTurning on wallet ideas writes a consent
POST /executions/id/auth, /auth_completeBank check resume
POST /plans/id/sell_to_offerSelling under the minimum with the seller present, its own consent kind

Socket ids only

dj_plan_updated with id and revision, dj_message with a sequence number. Frames never carry data, so the app refetches over authed REST. Price ticks coalesce to one per plan every 3 s.

Push

dj-bought, dj-sold, dj-last-call are transactional. dj-needs-auth expires with the hold. dj-needs-payment, dj-needs-you, dj-price-hit, dj-found. dj-suggestion needs opt-in. When DJ made the sale, the generic bought and sold pushes are suppressed.

Deep links and app

cv://dj/plan/id, cv://dj/auth/execution. Live Activities need crowdvolt:// because cv:// is only parsed in JS. The plan cache is shared by the card, bar and page. Typed messages use the support chat outbox. Approve and more-power edits are never optimistic.

Live Activities: only for deadlines

A plan can run for days, and a Live Activity is capped at 8 h and meant for something with a clear end. So the Dynamic Island shows DJ in four moments, one activity at a time, most urgent first.

MomentStartsShows
1 Bank checkneeds_authThe cart-hold countdown, 5 min, or 3 near doors
2 Seller last call60 min before carting closesThe place in line and what happens at last call
3 Buyer last hours6 h before doors, only if the lowest is within 20% of the max. With no real chance, it would just be noiseThe lowest against the max
4 ResultBought or soldA final update with an alert, then a short dismissal
  • Nothing spends from the island. Buttons are crowdvolt:// deep links into the edit sheet or checkout. No App Intents touch money.
  • Native side. An ActivityKit widget target via expo-widgets. Countdowns use a system timer, so they tick without pushes.
  • Server side. Expo push can't send Live Activity pushes, so py-mkt sends straight to APNs. It keeps each activity's push token and a push-to-start token, because moments 2 and 3 open while the app is closed. Older iOS gets the normal push instead.
  • Only real changes update it. The lowest crosses a band, first place changes, the best offer changes, or there's a result. Low priority unless there's a countdown the user must act on.
  • One switch in DJ settings, on by default for people with push on. Android gets an ongoing notification for bank checks only.

Guardrails

A kill switch, per-person and per-event spending limits, price-impact pauses and circuit breakers guard every automated buy and sale. The exact thresholds stay in the repository.

Fix first

Backend issues found while mapping DJ are tracked in the repository.

Build order

One team, about 10 weeks to beta and 12 through it. Each phase has an exit test, and nothing charges a card until shadow mode passes.

wk 02468101214
0 Foundations
fixes, tables
1 Thread and agent
evals gate
2 Buy
shadow, then staff
3 Sell
shadow replay
4 Beta, 5%
kill criteria clear
5 Suggestions
then polish
PhaseScopeDone when
0 FoundationsThe blocking fixes, dj_* tables, dj_control off, flagsMigrations live and dark, fixes tested
1 Thread and agentRoutes, thread model, the agent loop with read, draft and safe tools, the event lookup, aliases, designed cards and the kit, streaming, evals, plan card, pickers. No approvalEval gates met, staff chat, ask and draft
2 BuyApprove with SetupIntent, one sheet per side, the signal coalesced per listing, matcher with smart waiting, parallel executors, reconciler, bank check resume, pushes, socket, bar. Shadow, then staff onlyp95 under 5 s in shadow, zero duplicate charges, staff purchases reconcile
3 SellPricer goals, pooling on one stored price, match early and lead late with the demand anchor, the move budget, earn to list price, last call at doors_close_time, listing sheet entry, DICE prefill. Selling to offers once the core pricer works. Shadow replay against real seller editsBeats manual reprices on sell-through without lowering price realized
4 Beta5% of users with alerts or listings, breakers on, dashboardsTwo weeks without hitting a kill criterion
5 SuggestionsTriggers, settings, wallet opt-in, Live Activities for deadlines, Android fallbackAccept rate above a no-suggestion holdout

Still open

  1. The visible offer under the max. Off by default. Where it sits: recent fills, or a percentage under the max.
  2. DJ buyer with DJ seller. Crossing them at a price inside both limits needs consent wording on both sides and a legal read.
  3. Spend caps above, to confirm with risk.
  4. Executor placement. If the worker hop blows the 950 ms headroom, run the executor inside the matcher.
  5. Suggestion cap. The starting numbers are guesses. Tune them against a holdout.

Settled since the first version: selling to an offer within $1 of DJ's price (Sep 28), no hold-off on new listings until DJ holds about a quarter of an event's waiting demand (Sep 28), one sheet per side (Sep 29), a visible DJ offer charged off-session when a seller accepts it, with no hold (Sep 29), pricing above the seller's own price only as a lift shown on the approval, never after it (Sep 29), and what happens at doors: bids are cancelled when doors open, so the seller chooses between keeping the listing up until last call (the default) or taking the best offer just before doors (Sep 29).