Skip to content

2026-07-28 — Table ownership lease: exactly-one-owner, enforced

"Exactly one game-server hosts a table" is now a runtime invariant, not an assumption. A per-table ownership lease on the turnstore (lease:table:{env}:{id}, TTL 15s) is acquired — in loadOrCreate and at boot-revive, before the room is constructed. A process that finds the lease held elsewhere refuses to host (owned_elsewhere) instead of split-braining onto the same hand's money columns; the deploy-overlap double-advance/double-payout class (incident 3F6UUI, ~90k chips) becomes unrepresentable.

A monotonic fencing token (fence:table:{env}:{id}, persisted to tables.owner_fencing_token) makes a stale owner's boundary write refuse loudly (StaleOwnerBoundaryWrite) — the fund-safety backstop even in a brief overlap. A renew heartbeat (~5s) keeps the lease alive; a lost lease freezes the table (reason: lease_lost) and evicts it (the winner rehydrates on its own boot — no live handoff), surfacing a lease.lost ERROR span behind the new 11-lease-lost Dash0 alert.

Scope is N=1. Enforcement is active by default (going to staging), with a lease_enforcement game_settings row (set false) as the per-environment break-glass; at one instance per environment the lease is pure safety and changes nothing observable. The enforcement-ON soak proved that — and found one real regression (a restarted instance couldn't reclaim its own tables until the TTL expired), fixed by a stable per-host instance id (no pid), same-owner idempotent re-acquire, and a graceful lease release on SIGTERM. The split-brain guard (a different owner is still refused) is intact.

Follow-ups: game-server-multi-instance (a router to the lease owner) and ws-gateway-fanout turn N=1 refuse-to-host into N>1 correct-routing.

See the decision record and the lease-loss runbook.