The Marathon module runs one-wine-at-a-time storefront events (Marathons and Daily Offers) against Shopify. It is a pointer model: Trellis keeps exactly one product elevated per store — ACTIVE, published to the required channels, tagged, and (for manual-collection surfaces) in the offer collection — and transitions move that pointer. All state is durable DB state; no process "owns" a running event, so web instances and the worker can restart mid-event and pick up where the rows say.
/api/marathon, registered with require_admin_or_permission("marathon.edit") for mutations, require_auth for reads. Planner CRUD, verification, live controls, tag audit, live payload.elevate/retire/swap, gid resolution, tag ops, collection membership/position, inventory snapshots, live catalog fetch. Store loader with the per-event overlay.trellis-product-updated in-place swap).marathon_error_log table (web + worker both install it); rendered on the Module Admin page. Never raises, 60s per-message throttle, 30-day prune.orders/create includeFields management (wide at rest, minimal during real Marathons).min(interval, next_marathon_deadline + 0.5s), default interval 45s, so scheduled fires land on the clock, sub-second when needed./salesfloor + /api/salesfloor — read-only whole-company display, deliberately outside the marathon.edit gate, 8s server cache over a 6s page poll)./orders/marathon-issues (hold collisions, address mismatches, parked orders; Hold Selected + Update All Addresses bulk writers with dual-system readback and an actions ledger). Fulfillment surface, not part of this module's router — documented in the User Guide's "After the sale".Two independent execution contexts matter: the web service (routers, manual controls, webhook routes, Live Board payloads) and the worker (tick lane, pre-roll, auto-start). Manual controls work even when the worker lane is disabled or stalled — they share the engine functions, not the loop.
All tables in public. Marathon DDL predating 2026-08-11 was applied out-of-band; everything since lives in supabase/migrations/ (dev pushed manually, prod auto-applies on merge to main).
draft → scheduled → live → completed (or canceled). scheduled/live/completed/canceled are _LOCKED_STATUSES: replace-set plan saves are refused.'marathon' | 'daily_offer' (CHECK). Chosen at creation; drives the store overlay, the webhook filter flip, pre-roll eligibility, bailout refusal, and sweep behavior.'both' | 'web' | 'app'. Maps to a named channel subset via stores.marathon_lane_channels; elevation publishes the lane's channels and strips the other lane's for isolation. web and app events may run simultaneously with different wines; the gates refuse SKU overlap across concurrent lanes (order attribution is SKU-only — overlap would double-count).rev compare-and-set token and per-entry stage_errors / retiring grace state. See Standby pre-load.test_mode kwarg through the primitives), inventory simulated via sim_*. Tick driven by the open Live Board, not the worker. Conductor and pre-roll skip test events entirely._commit_pointer_move guards on status='live' AND expected current_product_id; a lost race rolls back the incoming elevation (_rollback_elevation).expected_updated_at → 409 on mismatch). Engine-side stamps (last_tick_at, preroll) deliberately do NOT touch it.staged_at, product_id, sku, gid, preexisting_tags, confirmed_at, late, promoted_at, promote_note, swept_at, sweep, stage_errors.max(150s, 3×MARATHON_SWAP_INTERVAL)).starts_at null = queued cut-in.enter_default_block), loops with rules suspended, keeps its queue across gaps; boundary advance requeues the cut-off wine at the FRONT.loop_swap_minutes adds a uniform lap timer — hand off every N minutes via the elevated_minutes machinery (reason loop_timed); stock-out still swaps early. Enabling loop mid-block sweeps retired-with-stock wines back into the queue.pending → live → done (or dropped). Next-up = first PENDING in sort_order with inventory; requeue = sort_order max+1 (bottom) or min−1 (front).manual | on_extinguished | threshold; threshold types units_remaining | sell_through_pct | velocity | elevated_minutes. Velocity's threshold_value is $/min (converted from bottles/min × unit_price at evaluation). elevated_minutes is the timed rule: swap after N minutes on stage, measured from elevated_at against the Trellis clock (override-aware), shifted by the transition lead; the board renders it as a "Swap in" countdown. Stock-out floors both.{item_id:{location_id:{available,updated_at}}} arming the inventory cross-check. See Inventory model.marathon_record_sale(p_product_id, p_qty).{t,u} of the units counter: seeded u=0 at elevation, 10s burst coalescing, pruned to window+anchor, 240 cap. Maintained by pure update_velocity_samples in tick and webhook checks.products/update webhook sync pulls variant price/gids into products.products (2026-08-25), so a price edit on Shopify fixes a priceless row — revenue is recomputed on read, retroactively._sim_start_for); verification and insertion attribution.tags text[] (≤20, ≤50 chars, case-insensitive dedupe), shopify_product_id stamp.elevate | retire | swap_in | swap_out | bailout + reasons; ordered by seq identity (ties can't flip); swap writes BOTH rows in narrative order. Actor from created_by (null = engine → "auto").X-Shopify-Webhook-Id: batch upsert with returned-rows-are-fresh semantics + same-batch first-copy-wins guard. Fails OPEN; only written while a marathon is live.
Activation gates (check_activation_gates, shared by Activate Now,
Schedule, and the tick's auto-start): store has an elevation target (collection gid OR tag,
post-overlay — Marathons additionally require the collection, since bailout needs a shelf),
the event's lane resolves to ≥1 channel via marathon_lane_channels, no active
Daily Ops offer for the store, no conflicting live event on the store (lane-aware; test
events only exclude other test events), no SKU overlap with a concurrent lane event,
non-empty titled+timed blocks (default block exempt from timing) whose sort order matches
the timeline, threshold rules complete. Verification is advisory (2026-08-19) —
unverified products warn on the Launch checklist but no longer gate. Scheduling validates
at start time (for_start) so a back-to-back daily offer arms while
today's still runs. Real events fire early by
MARATHON_TRANSITION_LEAD_SECONDS (default 2) so Shopify writes land
ON the boundary; test events have zero lead. Auto-starts refused past their start retry
through MARATHON_HANDOFF_GRACE_SECONDS before a Slack page
(_alert_auto_start_blocked, one per distinct gate error).
Activation sequence (try_activate): gates → status flip to live →
orders/create filter → minimal (real non-daily-offer only, via _drives_order_filter)
→ conductor apply_event_mode → elevate(first wine) (consuming pre-roll
provenance when present) → post-activation ensure_offer_position pass (promote +
sweep). Every side effect is best-effort; the pointer commit is not.
Completion paths — all three call run_completion_restores
(webhook filter restore + conductor resting restore, each store-scoped and self-gating):
operator stop_event (guarded retire→complete retry loop ×3 then FORCED — stop
always wins), the tick's end-of-plan completion in advance_block, and bailout
(which restores the filter but deliberately NOT the conductor — a bailed-out sale keeps
selling in marathon mode).
Planner concurrency: replace-set saves regenerate row ids, so the client
applies verification verdicts by SKU, never row id; saves carry
expected_updated_at and 409 on staleness; autosave uses per-section generation
counters so overlapping flushes can't drop edits.
elevate(db, store, row, gid, …): set ACTIVE → publish to
required_channels (store's marathon_channels, falling back to
publish_channels) → collectionAddProducts when a collection gid is
configured → tag pre-read for provenance (skipped when preexisting_tags is passed)
→ tagsAdd of marathon_tags(store) → promote_to_top →
inventory snapshot (est seed + inv_levels ledger) → row update (state=live,
counters reset, unit_price snapshot) → log → Pusher notify. Each step appends to an
errors list rather than aborting: partial failure is visible, DB state reflects intent.
retire(…): remove from collection → strip managed tags minus
preexisting_tags → set DRAFT → unpublish channels → row update → log. Draft is
the resting state — retire pulls the wine off every storefront surface instantly (render
checks and smart-collection rules both drop drafts).
Tag lifecycles — three tiers, all store-configured:
the managed tag itself (marathon_tag, on at elevate / off at retire),
managed extras (stores.marathon_managed_tags, e.g.
freeship:1 — ride the same on/off lifecycle, marking the sale perk), and
persistent tags (stores.marathon_persistent_tags, e.g.
marshipping — added at elevation and never stripped: a permanent
"this wine ran a marathon" marker). Daily Offers blank the managed-extra and persistent
sets — those are Marathon-only vocabulary.
swap(outgoing, incoming): no-gap order — elevate incoming first,
then retire outgoing; one combined Pusher event after both sides; both log rows written
in narrative order. Same-SKU handoff (last wine of block N = first of N+1)
passes skip_shopify=True to the retire so the just-elevated product isn't drafted.
Gid resolution (resolve_gid): row's stamped
shopify_product_id first; else 'shopify'-picker stores resolve from
their own live catalog (shopify_sku_ids, exceptions propagate — a transient fetch
failure must not read as "product missing"); else the reco cache at the store's Linnworks
locations. Shopify ids are shop-scoped: reco rows under a shared location carry the owning
store's ids, which is why the picker-source split exists.
Position (promote_to_top / collection_has_product):
the offer collection is the explicit pointer or, tag-only stores, the smart collection
whose rule is TAG EQUALS the managed tag. On MANUAL-sorted collections
collectionReorderProducts moves the wine to position 0 (accepted on smart
collections too — verified); non-manual sorts report "not settable" and settle. "Not a
member yet" is unsettled → the tick retries (membership lags tagsAdd ~30–75s).
ensure_offer_position memoizes settlement per plan-row in
_POSITION_SETTLED (in-memory; restart just re-verifies once).
search_literal() wraps tag/handle values
— an unquoted value containing a colon (tag:status:dailyoffer) silently matches
nothing, which once turned the sweep into a no-op for an entire rehearsal.
One mechanism makes the same engine drive two surfaces:
store_for_event(store, event) overlays
marathon_daily_offer_collection_gid → marathon_collection_gid and
marathon_daily_offer_tag → marathon_tag for event_type='daily_offer'.
It is applied inside load_store(db, store_id, event), so every consumer that
loads a store beside an event — engine, elevation, pusher, tag audit, conductor — retargets
with no internal branching. The tag-ops endpoints scope by ?event_id= (planner)
or the store's live event (board); the tag-audit cache is keyed (store, tag).
Daily Offers are tag-only: marathon_daily_offer_collection_gid is
NULL and marathon_daily_offer_tag='status:dailyoffer'. Membership in the
homepage collection (SMART: tag EQUALS status:dailyoffer AND inventory > 0,
sortOrder MANUAL) comes from the tag + product status — draft and zero-inventory
products are excluded by the rule, which is why staging must set ACTIVE and publish,
not just tag. The web theme picks the first in-stock product in the section's collection
carrying offer_tag; the mobile app reads collectionByHandle("homepage")
and takes the first available product regardless of tags — position 1 is
load-bearing for the app.
Lanes: every Daily Offer carries lane ∈ both | web | app,
mapped to named channel subsets in stores.marathon_lane_channels. Elevation
publishes the lane's channels and actively strips the other lane's channels for isolation,
so a web and an app event can run simultaneously with different
wines. The gates refuse an empty lane mapping and any SKU shared across concurrent lane
events (order attribution is SKU-only), and the tag sweep excludes every live
lane's elevated wine, not just this event's.
Daily Offers do not flip the orders/create field filter
(_drives_order_filter excludes them: normal order volume, and map pings must keep
flowing), are refused by bailout (it rebuilds the marathon shelf), and get
the post-activation sweep (below). A live Daily Offer neither triggers nor
blocks the Marathon filter restore. And a Daily Offer that runs dry with no successor
holds instead of completing: the slot keeps the sold-out wine, a Slack
alert fires once per wine (_alert_daily_offer_dry, stamped on
preroll.dry_alert_for), and the board banners it — ending early would leave
the storefront's offer slot empty.
Smart-collection materialization (tagsAdd → membership) runs ~30–75s, occasionally
minutes — activation-time staging cannot flip on time. run_daily_offer_preroll
runs in the tick's scheduled-events loop; next_marathon_deadline emits exact
fire times for the stage and the promote, which the deadline-aware worker hits on the clock.
State machine in marathon_events.preroll; every step best-effort with retries.
Provenance handoff: the pre-roll's own tagsAdd would poison elevate's
pre-read (it would classify our tag as pre-existing and retire would never strip it), so
the pre-roll records preexisting_tags before tagging and
try_activate passes it into elevate(preexisting_tags=…), matched by
preroll.product_id == first_product.id.
The sweep (storefront_conductor.sweep_daily_offer, invoked from
_maybe_daily_offer_sweep inside ensure_offer_position): candidates =
tag search (quoted) ∪ direct collection-member read (immune to search-index staleness),
minus exclude_gid (the elevated wine); each confirmed by direct product read
before tagsRemove (confirm failure aborts — no blind writes); runs once per event
(preroll.swept_at), only while the conductor level ≠ off. Sweeping after
position settles is deliberate: sweeping first empties the collection while the lag keeps
the new wine invisible. Manual Activate Now = no pre-roll; the same promote/sweep cycle
completes on the tick, so the flip lands when position settles (~1–2 min).
marathon_standby.py closes the "No Offer" gap between swaps: the engine's
predicted successor(s) are pre-staged — ACTIVE, published, tagged, added
to the collection below the live wine, never promoted — so the theme's render-time
fallback ("first in-stock tagged product") shows the next wine the instant the current one
drafts, instead of an empty slot while Shopify materializes the real swap. Core invariant:
the staged set is exactly the engine's successor prediction — both call
the same pure selectors, which is why every queue mutation (reorder, insert, spawn, loop,
hold) calls refresh_standby_async. Collection order is re-enforced each pass.
Depth defaults to 20 staged successors (raised from 1) with
inventory-based escalation (MARATHON_STANDBY_COUNT / _MAX_COUNT / _MIN_UNITS /
_BOUNDARY_LEAD_SECONDS); gated by MARATHON_STANDBY_ENABLED. State lives
in marathon_events.standby jsonb behind a rev compare-and-set —
there is no cross-process lock, the rev-CAS plus retire grace carry the
concurrency. Un-staging is two-phase: dropped entries move to
retiring and the Shopify revert executes only after 60s
(DROP_GRACE_SECONDS) if still dropped, so a drop racing an in-flight swap
waits the swap out. The tag audit counts staged wines as owned, not strays; a
staged wine being reachable by direct URL/search is an accepted trade.
run_marathon_tick: for each live event → _tick_event; for each
scheduled event → pre-roll step, then _auto_start_if_due. Per-event
try/except — one event's failure never takes down the pass. Gated by
MARATHON_SWAP_ENABLED (tick lane AND webhook-driven swaps share the gate;
manual controls do not).
_tick_event in order:
last_tick_at stamped first, fault-isolated — proof of life for the stall banner even on no-op passes.elevation_target_missing: real event whose overlaid store has neither collection nor tag → log error, skip transitions, board banner (config can change under a live event).ensure_offer_position: promote until settled, then the one-shot sweep.evaluate_swap (pure): stock-out floor for every policy; thresholds on units/sell-through; velocity = trailing-window average $/min with warm-up hold; elevated_minutes = fixed stage time from elevated_at (also the loop lap timer's machinery, reason loop_timed). Automated swaps are single-flight across processes via a DB swap lease (MARATHON_SWAP_LEASE_SECONDS). Exhausted block → enter_default_block else early advance; passed-over dry pending rows are struck done and sink below the queue (cosmetic — selectors read state, not position).ends_at with no next block → guarded completion (held final block exempt; a dry Daily Offer holds instead — see Daily Offer mechanics). Reconcile pass: _reconcile_est_inventory (down-only Shopify read), plus a full Shopify inventory sync for live marathons on MARATHON_INV_SYNC_INTERVAL_SECONDS, with upward ledger corrections only after MARATHON_INV_UP_QUIET_SECONDS of order silence.
Deadlines (next_marathon_deadline): earliest of scheduled starts
(−lead), live event ends (−lead), live block boundaries (−lead), pre-roll stage
(−MARATHON_PREROLL_SECONDS), pre-roll promote (−MARATHON_PROMOTE_LEAD_SECONDS).
The worker sleeps to the earliest future fire; the pickwave fast lane is on its own 60s
cadence so sub-second marathon wakes don't re-run it.
Transitions are engine functions, shared by tick and endpoints:
advance_block (target-block override for cut-ins; end-of-plan completion),
swap_to_next_in_block, enter_default_block, stop_event.
All re-check event status/pointer before acting (_recheck_live) and commit via CAS.
Dynamic blocks (POST /events/{id}/blocks/spawn) validate wholly before creating,
force on_extinguished, and are positioned so the shared advance logic needs zero
engine changes; live queue reorder is a two-phase renumber allowing only pending rows to move.
During a burst, Shopify's totalInventory lags checkouts too much to drive swaps.
Real events run on est_inventory: seeded from the elevation snapshot, decremented
per orders/create by marathon_record_sale, cross-checked by
inventory_levels/update via marathon_apply_inventory_level (row-locked, rejects
unseeded items and stale per-entry timestamps), and reconciled by the tick's Shopify read.
Every writer is down-only: upward drift is ignored on purpose — Shopify
lagging high post-burst must never resurrect sold bottles; swap-early is the safe direction.
Precedence when the board or insert menus need a number: est remainder → sim counter (test)
→ bulk Shopify read (get_live_inventory_bulk). Shopify is the lone
inventory truth during a live event — Linnworks is never read; Linnworks quantities
appear only on the planning picker (display) and reco is only a gid map.
Both routes are HMAC-verified (per-store <PREFIX>_WEBHOOK_SECRET = the
GENCON API secret for app-owned subscriptions), fast-ACK, and do zero DB work
inline — the request thread only parses and enqueues. Subscriptions are API-owned,
created by the local scripts/ensure_marathon_webhooks.py (dry-run first; wrong
base URL creates duplicates).
MARATHON_ORDER_FLUSH_INTERVAL (0.5s): per domain resolve targets → ONE batch receipts upsert (dedup, fails open) → ONE summed marathon_record_sale → ONE debounced swap check. Lookup failure requeues the batch once; post-receipt failures never requeue (would dedup away and under-count). Also feeds order_pings (state-count buffer, one Realtime POST/2s) and order_webhook_sync (id-only buffer whose flusher drops batches while any real event is live).["id","line_items","shipping_address"]. _drives_order_filter (real ∧ not daily_offer) → minimal ["id","line_items"] at activation; restore_orders_filter_if_idle (store-scoped, called from stop, end-of-plan, bailout) restores when no filter-driving event remains live on the store.maybe_run_inventory_check: 0.25s per-event gap shared by the order flush and the inventory path. Tick = safety net + time-based transitions.
Pusher (marathon_pusher): after each real transition, fires
trellis-product-updated (consumed by the in-repo theme snippet
trellis-live-swap.liquid: Section Rendering API in-place swap, no reload, cart
scrub, self-heal on load/reconnect) and, until MARATHON_PUSHER_LEGACY_ENABLED=false,
the legacy collection-updated reload hint (+echo). Creds come from the Collection
Monitor's metaobjects per store (automatic prod/test isolation); gated by
MARATHON_PUSHER_ENABLED AND the metaobject isActive kill switch; suppressed on
test/skip_shopify/same-SKU handoffs; best-effort always. Tag-collection resolution is cached
per (store, tag).
Conductor (storefront_conductor): per-store level
off | metaobject | full. Metaobject: storefront_app_configs.special_mode_enabled
plus the app's special_mode_collection_handle via metaobjectUpdate —
the switch read by both the theme snippet and the mobile app. Full adds theme conduction:
templates/index.json (product__main.collection + offer_tag —
the section renders "first in-stock product tagged offer_tag" within the collection,
so the tag must be conducted with the collection) and templates/cart.json
(cart__main.homepage_collection) on the MAIN theme — sections located by type
(refuse on 0/many), collection settings are handles, the OS 2.0 comment header is
preserved byte-for-byte, every write read-back verified, prior settings snapshotted once
into stores.theme_snapshot (one unrestored snapshot kept — back-to-back events
can't clobber the true resting state) and restored by restore_resting at
completion, with a roll-in collection purge. Marathon → special ON +
sections at the marathon collection handle; Daily Offer → special OFF + sections at
daily_offer_theme_handle (default 'homepage') — the resting state, enforced
rather than changed; test events skipped; all steps
best-effort with per-step results in the activation response.
Bailout (marathon_bailout): marathon-only (409 for daily offers).
No UI entry point since 2026-09-02 — the module has run a real Marathon end to end,
so the rescue is API-only (POST /events/{id}/bailout, status at
/bailout/status), kept on the books as a last resort; the Live Board still
shows the passive status banner if one runs.
Completes the event first with NO retire (pointer cleared, live wine stays up, tick stands
down), then a background thread tags/activates/publishes the entire lineup (all blocks +
unplaced pool, deduped by SKU) onto the manual marathon collection and
reorders it to plan order — ops then runs the sale with the Collection Monitor's "Move
Product to Top". (The LBW collection was cut over from smart to manual 2026-08-18; the
smart-membership poll survives only as fallback for a store still on a smart collection.)
Completed events may bail out too. No Pusher event is fired — a transition event would
scrub live carts. Progress is in-memory (single-instance; a restart loses progress but
every step is idempotent → re-click re-runs). Restores the webhook filter but not the
conductor — a bailed-out sale keeps selling in marathon mode.
stores.marathon_picker_source: 'reco' (default) builds candidates
from marathon.products_verify via the marathon_picker_products RPC —
every ACTIVE/DRAFT listing on the store's Shopify (webhook + hourly-sweep fresh), one jsonb
document per call, with Linnworks totals + the per-warehouse split folded in from
inventory_reco and the verify facts (data_ok / is_verified /
photo / has_inventory, stored generated columns) riding each row so the
picker and pool paint green / amber / red as they load (2026-09-02).
'shopify' (the testing sandbox) fetches the store's own
catalog live (shopify_catalog_products: 250/page GraphQL, non-archived SKU'd
products, first-wins dedupe, 240s cache) and uses it for SKU→id resolution too
(_resolve_shopify_ids — a fetch failure 502s the save rather than persisting
id-less rows). Wine attributes come from products.skus (inlined in the RPC for reco stores,
marathon_product_attrs for the sandbox).
Verification resolves catalog rows by SKU preferring the store-matching row and falling
back to any store's row.
last_tick_at vs max(150s, 3×interval); separate immediate banner when MARATHON_SWAP_ENABLED is off while live. Manual controls (web service) keep working either way.units_remaining rule already satisfied when a lap begins (encore or late insert with ≤ N bottles) is evaluated as stock-out for that lap — otherwise it fires on the first check and two such wines swap back and forth forever. (b) The upward est reconcile never raises above Shopify's total: the per-location ledger is never pruned, so a disconnected location's last reading would otherwise hold est above zero and a dry wine would never be seen as dry. (c) Block-end gap fill honours a queued cut-in (it takes the opening, as on exhaustion) and leaves alone a block the operator entered after its window had already ended (entry time from the elevation log). (d) Deleting an event is a status-CAS'd disarm plus a delete guarded on status ≠ live: an activation that lands mid-delete wins and the delete is refused._alert_auto_start_blocked (scheduled event past start refused by the gates — one page per distinct gate error, after MARATHON_HANDOFF_GRACE_SECONDS) and _alert_daily_offer_dry (sold-out offer holding with no successor — once per wine). Channel: TRELLIS_ALERTS_SLACK_CHANNEL, default #trellis_alerts.available, reserved (held by checkouts in progress) and committed (on placed, unfulfilled orders) per location. The elevation snapshot and the tick reconcile now read all three in the same call; block_products.reserved_qty / committed_qty / states_at hold the sums, and the Live Board's current-wine card shows "In checkout". Display only — never feeds est_inventory or a swap decision, and never appears on the storefront. Carts hold nothing in Shopify; reserved is the real "spoken for but not yet sold" number (cart webhooks were deliberately NOT adopted as an inventory state).GET /public/marathon/live?shop=<permanent domain> (services/marathon_status.py, no JWT) — Trellis's authoritative pointer for the storefront snippet's self-heal: {shop, live, live_product_id, event_type, version, as_of} and NOTHING else (the next wine and inventory never appear on the site or in any public payload — standing rule). Per-shop 2 s in-process cache, Cache-Control: public, s-maxage + CDN-Cache-Control + ETag/304 so a Cloudflare cache rule on /public/marathon/live* serves every shopper tab from the edge; unknown shops 404 (cached 30 s). The theme side lives in the theme repo's OfferWatchdog (seed-last-bottle-main, PR #199 base + the Trellis port): it consults this document first, confirms with the section's own snapshot, chases a lagging render, binds Trellis's realtime event on the existing Pusher connection, and self-heals the cart. The standalone trellis-live-swap snippet is retired (a second Pusher connection per tab, a products.json fallback the CDN rule blocks, a hardcoded section key).services/marathon_presence.py + marathon.presence_intents — every real elevate/retire opens an intent row BEFORE its first Shopify write, stamps each step (status · publish · lane_unpublish · tags · collection · promote · snapshot · row / collection_remove · tags_remove · status_draft · unpublish · row) as it lands, and closes only when all succeeded. repair_open_intents (worker tick + engine-off reconcile loop) handles anything open after MARATHON_PRESENCE_REPAIR_AFTER_SECONDS (20): finish the missing steps for the wine that owns the stage (repaired), revert an elevation whose pointer never committed (reverted), leave a product alone when another row owns it live or staged (superseded), and after MARATHON_PRESENCE_MAX_ATTEMPTS (5) give up loudly (abandoned, errors kept). Replaces the in-memory retry thread as the durable path; test events, same-product handoffs and pre-roll/standby staging carry no ledger. Module Admin lists open intents and recent repairs.services/marathon_settings.py + marathon.settings — the engine gates (automatic swaps, standby, pusher, legacy hints, reconciliation, inbox mode) resolve as Module Admin override → env var → default, cached 5 s per process, stale-on-failure (a Supabase blip can never turn a switch off). Flips land in the web service and the worker within seconds, no deploy; every change appends to marathon.settings_log with the actor. Overrides are read only in deployed processes (RENDER_SERVICE_NAME) or with MARATHON_SETTINGS_DB=on — local runs and tests keep env semantics. Watchtower and Module Admin report the effective value.services/shopify_budget.py — every run_gql response's extensions.cost.throttleStatus updates a per-shop bucket estimate (available + restore since). run_gql already waits out THROTTLED responses; the tracker lets NON-essential readers stand down while the bucket is low (SHOPIFY_BUDGET_LOW_POINTS 300 or _LOW_FRACTION 15% of max): the Live Board tag audit keeps its last answer, the planner catalog crawl serves its stale cache. Transitions always proceed. with_session now re-mints a token ONLY on an auth rejection (401/403/invalid token) — a throttle or 5xx used to rotate the shared credential and write it to Render.services/marathon_inbox.py — Redis Streams on a dedicated noeviction Valkey instance (MARATHON_INBOX_REDIS_URL, blueprint trellis-marathon-inbox). The orders/create and inventory_levels/update routes do one sub-ms XADD and ACK; consumer groups (marathon-engine, order-db-sync) read in batches, apply through the SAME batch functions the memory flushers used, and ack AFTER the apply — a process dying mid-batch leaves entries pending, XAUTOCLAIM hands them to the next consumer, receipts dedup drops the replays. Poison entries (≥ MARATHON_INBOX_MAX_DELIVERIES) move to marathon:inbox:dead. Retention MARATHON_INBOX_RETENTION_SECONDS (2h). Mode MARATHON_INBOX_MODE: off (memory path) · shadow (publish + memory path; consumers only tally) · primary (consumers apply; memory path only as fallback when a publish fails). In primary the db-sync consumer keeps public.orders current DURING live events (slower cadence, never sheds) — the memory-era gate left the mirror to the 15-min cron. Consumers start in main.lifespan. Retires the sale-day merge freeze.services/marathon_reconcile.py — scheduled by run_completion_restores for every real completed event (after MARATHON_RECONCILE_DELAY_SECONDS, default 180, so late webhooks settle). Pages every Shopify order in the event window and compares, per wine and per event: Shopify (authority) vs the webhook counter (units_sold_lifetime, bucketed into each row's elevated→retired window), vs the Trellis order mirror (marathon_reconcile_trellis_orders RPC over public.orders), with the inventory ledger (starting_inventory − Σ inv_levels) as context. Files marathon.reconciliations, posts ✅/⚠️ to the alerts channel, lists on Module Admin with a Re-run. Read-only against Shopify; an exception files an error row. Zero tolerance by default (MARATHON_RECONCILE_TOLERANCE). Completion stamps preroll.reconcile_due_at; if the in-process timer dies with a deploy, the tick's sweep_overdue starts the run once the stamp is MARATHON_RECONCILE_SWEEP_GRACE_SECONDS (600) past due.marathon_error_log (web + worker). The admin page (/marathon/admin, admin-only) renders the 24h log plus component health tiles — engine lane, pre-roll timings, alerts wiring, pusher gates, standby knobs — reporting the engine accessors' clamped values, not raw env, and per-event heartbeat/signal ages.scripts/unstrand_product.py removes a stranded purchasable (collection + tags) if a race ever leaves one; the CAS work makes this a museum piece.status:dailyoffer.freeship:1) and permanent elevation markers never stripped (marshipping). Both Marathon-only — blanked for Daily Offers.reco | shopify — see Picker sources.off | metaobject | full; the sections' pre-event snapshot; the daily/resting collection handle (null = 'homepage').marathon_renew_swap_lease, token-matched) for the whole write phase, so a throttled swap can never outlive its lease and invite a competing decider; a crashed holder still frees it at expiry.off | shadow | primary, default off) and its dedicated Redis URL (blueprint-wired). Knobs: _BATCH 500, _BLOCK_MS 500, _CLAIM_IDLE_MS 30000, _MAX_DELIVERIES 5, _RETENTION_SECONDS 7200, _MAXLEN 500000, _PUBLISH_TIMEOUT 0.5s, _DBSYNC_INTERVAL 5s / _DBSYNC_LIVE_INTERVAL 30s / _DBSYNC_MAX_IDS 200.MARATHON_RECONCILE_BULK_MIN_ORDERS (2500 — daily-offer windows at/above this many receipted orders, plus every marathon, read Shopify via one bulkOperationRunQuery instead of paging, so a marathon-sized pull never competes with the engine for the per-second query budget), _BULK_POLL_SECONDS (5), _BULK_MAX_WAIT_SECONDS (1800).shopify_auth.ENV_KEY_MAP and webhooks._SHOP_PREFIXES — env vars alone don't register a store.
Router mutations: admin OR marathon.edit (deliberate per-user grant). Reads:
any authenticated user. Nav + templates client-gate on window._canEditMarathon.
The Live Board is watchable by permission holders without edit acting.
/api/marathon/admin/* adds require_admin on top.
/api/salesfloor is registered separately with plain auth — the Sales Floor
display is deliberately whole-company, as is the Marathon Issues page under
/api/fulfillment.
Your account now requires a second factor. It takes a minute, and you only do it once on this account.
If you lose your phone, one of these gets you back in. Each works once. This is the only time they are shown — we store only a hash, so nobody can look them up for you later.
Enter the six-digit code from your authenticator app.
Enter one of the codes you saved when you set this up. It will clear your current authenticator so you can set up a new one — the code is then spent.