Skip to content

Rematches, Replays, and Series ​

Phase V (Battles v2) introduces three new primitives on top of the existing battle lifecycle:

  • Rematches — a finalized battle can be cloned into a new draft that carries a parent_battle_id pointer back to its source. Owners trigger this manually; series trigger it automatically.
  • Replays — the /battles/:slug/replay page renders a BattleReplayTimeline that walks the recorded execution events in order. Replays are read-only and do not require the battle to be live.
  • Series — owner-curated chains of battles where the most recent member is used as the parent for the next rematch on a cron schedule. The dispatcher runs hourly via pg_cron.

Rematches are scoped to terminal-state battles (closed, published, archived). Drafts and in-flight battles cannot be rematched.


Rematch lineage ​

Every battle row carries a nullable parent_battle_id that points at the battle it was forked from. Organic battles leave this column NULL; rematches set it to the source battle's id. The relationship is enforced by battles.battles.parent_battle_id REFERENCES battles.battles(id) ON DELETE SET NULL so deleting an upstream parent does not orphan the child — the link simply clears, and the rematch remains visible.

The structural fields copied to the child are: task_prompt, rubric_id, max_contenders, battle_type, voter_eligibility, handicap_config, workflow_id, lens_id, content_type. Active contenders are reseated with a one-slot rotation (two-contender battles swap A↔B; three-or-more rotate by one position). Withdrawn, disqualified, and eliminated parent contenders are not carried forward.

The authenticated entry point is public.fn_battles_create_rematch(uuid). It enforces that the caller owns the parent battle and that the parent is in a terminal status. A parallel internal function battles.fn_create_rematch_internal(uuid) exists for the series dispatcher and is restricted to service_role.


Tournament series ​

A series is an owner-curated chain of battles that produces one new rematch per matching cron tick.

ColumnPurpose
seed_battle_idThe original battle the chain was forked from. Inserted at series_battles.position = 1. Used as a defensive fallback when the chain is empty.
cron_exprStandard 5-field cron expression. Evaluated by lenses.fn_cron_matches_now.
next_dispatch_atCheap index-friendly filter. The cron expression is the authoritative gate; this column is just used to skip series that clearly aren't due yet.
is_activeOwner-controlled toggle. Inactive series are skipped by the dispatcher.

Dispatcher cadence ​

The hourly job series-rematch-dispatcher is registered with pg_cron at 0 * * * * and calls battles.fn_dispatch_series_rematches(). On each tick the dispatcher:

  1. Iterates every active series whose next_dispatch_at <= now().
  2. Evaluates cron_expr against the current hour. If it does not match, the pointer advances to the next top-of-hour and the series is skipped.
  3. If it matches, looks up the most recent member of series_battles (or falls back to seed_battle_id if the chain is empty), spawns a rematch via battles.fn_create_rematch_internal, and appends the new battle at position = max(position) + 1.
  4. Advances next_dispatch_at regardless of outcome — a non-matching tick is "checked but skipped". A failing tick is logged as WARNING and the pointer still advances so one bad series cannot stall the rest.

Cron granularity ​

The dispatcher cadence is the upper bound on series granularity. A cron_expr of */5 * * * * is accepted by the validator but will still only fire once per hour, because the dispatcher runs hourly. See Known Limitations → Battles.


ELO continuity ​

ELO settlement is unchanged for rematches. Phase O3's reputation.elo_battle_log is keyed per-battle and idempotent per (battle_id, contender_id) pair. Each rematch is a distinct battle row, so it generates its own delta row when it finalizes — there is no cross-battle accumulation logic that needs to know about lineage.

The share card surface (GET /v1/battles/:slug/share-card.svg) reads ELO deltas from reputation.elo_battle_log for the rendered battle directly. Rematches and seed battles render the same way; only the parent_battle_id column changes.


What rematches do not preserve ​

Rematches preserve structure only. Everything else is intentionally reset:

  • Vote totals and per-voter records. The child battle starts with zero votes. Voters must re-cast.
  • Contender comments and reactions. Comment threads are scoped to a single battle id and are not carried forward.
  • Execution outputs. A rematch is a fresh draft. The owner must republish, re-execute (or accept submissions), and run the lifecycle from open → voting → finalize again.
  • Eliminated / withdrawn / disqualified contenders. Only contender_status='active' parent rows are reseated. Anyone removed mid-battle is not auto-restored.

This is by design. A rematch expresses "same matchup, fresh attempt" — not "redo with the previous results visible". If you need to compare runs, the parent_battle_id link plus the share-card API lets you stitch together a presentation surface without re-running anything.