Skip to main content

Safety model

Every ESPN write changes a real league that other managers share, and most writes can't be undone. The system layers several independent checks so no single bug, replay or LLM answer can make an unintended change. Use this page to see which check stops what, and what to change to go live.

The short version
  • Every bot except lineup defaults to dry run.
  • Trades need a human approval bound to the exact terms, plus a separate enable flag per direction.
  • An unclear outcome stops for a person. It's never retried blindly.
  • Success means a fresh ESPN read shows the change, not that a request returned 200.

What does each bot do by default?​

DRY_RUN goes live only on 0, false or no. TRADE_DRY_RUN overrides it for the trade builder only, parsed the same way. Production sets no DRY_RUN, so each bot's default below applies, and sets TRADE_DRY_RUN=false so armed trade submissions can go live.

BotDefaultAlso required for a live write
LineupLiveNothing beyond the trigger's own checks
Waiver sniperDry rundry_run: false in the message, which the UI's confirm dialog sends, or DRY_RUN=false
Draft clickerDry runDRY_RUN=false. A practice message defaults to live in the practice room.
Trade builderDry runTRADE_DRY_RUN=false (or DRY_RUN=false when TRADE_DRY_RUN is unset), TRADE_SUBMIT_ENABLED, a matching offer_key approval, verified selectors, and verified counter linkage for counters
Trade responseDry runTRADE_RESPONSE_ENABLED, a matching decision_key approval, a verified action and a verified pending-trade shape
Trade negotiation, trade scanNever sendsThey only stage offers for approval.

With a gate closed, trade bots still fill or build the request and record exactly what they would have sent. You can review it before opening the gate.

Where does a human approve?​

ActionYou approveBound toChecked by
Outgoing offerPOST /v1/trade/offers/{offer_key}/approveoffer_key: teams, players, note and related offerWorker recomputes it from the offer
Accept or declinePOST /v1/trade/incoming/{id}/decisionreview_token (the offer you read) and decision_key (the action you chose)Core under the offer lock, then the worker against a fresh ESPN read
CounterPOST /v1/trade/incoming/{id}/counter, then approval of the staged offerreview_token, then offer_keyAs above
Retry a stuck responsePOST …/decision/retryThe blocking claim, plus verified_not_answered for an unknown outcomeCore and worker
Draft pickImplicit: auto-publish on, not pausedPick number and playerListener before publishing

approved_by is an audit label, not an authenticated identity. The sign-in through oauth2-proxy is the identity boundary.

Which flags gate live behavior?​

FlagWhereDefaultControls
DRY_RUNWorker environmentPer bot, aboveEvery worker ESPN write
TRADE_DRY_RUNWorker environmentFalls back to DRY_RUNTrade builder submissions only. Production sets "false" on the worker ScaledJob.
TRADE_SUBMIT_ENABLEDWorker environmentOffSending outgoing offers
TRADE_RESPONSE_ENABLEDWorker environmentOffAccepting or declining incoming offers
AUTO_PUBLISH_IR_MOVESfantasy-league-config ConfigMap"false"Unattended IR move + add chains from the waiver evaluator and rumor actions
Verified-path constantsWorker code (espn_api.js)Empty or falseWrite paths not yet confirmed against ESPN's real requests: VERIFIED_TRADE_RESPONSE_ACTIONS, PENDING_TRADE_SHAPE_VERIFIED and COUNTER_LINKAGE_VERIFIED
Draft auto-publishdraft_config.auto_publish_enabled, toggled from the UIOn, with a circuit breakerWhether the listener publishes picks without a click. A per-pick pause flag lives in Redis.

The code-level flags are constants, so opening one takes a code change and a new worker image. Turning on AUTO_PUBLISH_IR_MOVES follows the Unattended IR publication runbook.

Which errors are retried?​

ClassExamplesResult
PermanentErrorBad payload, stale or unknown id, lineup locked, ESPN 4xx, approval mismatchDiscarded. Broker-declared queues dead-letter it to <queue>.dlq.
SessionExpiredErrorESPN rejects the cookiesRequeued after 30 s. Trade responses treat it as permanent.
Transient ErrorNetwork failure, ESPN 5xx or 429, failed verificationRequeued after 5 s
Claim held elsewhereAnother pod owns the tracking_idRequeued after 1 s
Ambiguous writeA redelivery finds external_write_started, or 5 attempts have runExecution failed with manual_verification_required. A person checks ESPN first.

Core callers retry connection errors, timeouts and 5xx responses up to six times with the same idempotency key. They never retry a 4xx.

Why re-read ESPN after every write?​

ESPN's API is undocumented. A 200 doesn't prove the change landed, and a timeout doesn't prove it failed.

ReasonExample
Responses don't prove stateA roster POST can succeed while a lock or eligibility rule leaves a player in place.
Changes can be delayedA waiver claim queues until waivers process, so it's recorded as awaiting_processing.
Redeliveries must be safeEach bot reads first and sends only what's missing, so a replayed message is a no-op.
Unknown outcomes must not double-sendAfter a timeout the trade builder and response bot look for the result in ESPN's pending trades and history. They never click again on a guess.

What stops a duplicate?​

LayerWhereKey
Producer idempotencyCore, unique index on activity_actions(league_id, kind, idempotency_key)Per-queue key with a release TTL
Trade claimsCore, under a Postgres advisory lockoffer_key, or the incoming publication_key
Action claimWorker, activity.claim with an advisory lock per tracking_idtracking_id
Trade execution claimsWorker, execution_log under an advisory lockoffer_key, decision_key
Read before writeEach botCurrent ESPN state