Skip to content

Felt Liveness Contract — no poker hand wedges silently, no frame lies ​

2026-07-19

A poker table froze on staging (specimen EK71HB) and served the same dead frame — handInProgress: true, currentActor: null — to every client forever. The cause was a hand dealt where the button/small blind was all-in via the blind with zero chips behind: the engine created no betting round, so there was no actor; notifyActingPlayer and the turn watchdog both early-returned on the null actor; and only a player action would have run the board out. The hand was structurally unfinishable, and nothing said so. The audit (ui-stability) traced this to an unowned band — a seat with 0 < stack ≤ small blind belonged to no layer: bust detection is exact-zero, and the engine considers a seat actionable only by chips-behind-after-blinds.

This plan closes the freeze class — and the wider "a frame the server broadcast contradicts the felt's own rules, silently" class — from the source, with each fix backed by a triple-gated fault driver so the recovery paths stay testable forever.

What shipped ​

The freeze is closed from four directions (so a wedge in production is now abnormal):

  • Deal-time runout — a hand that lands unactionable runs the board out immediately at the deal (PokerEngine.runOutIfUnactionable → the standard hand-end boundary via the shared applyEnginePhaseResult).
  • Sub-blind band ownership — a cash seat felted below the big blind is sat out at hand-end with a rebuy window (chips untouched, cash-only) and excluded from the deal quorum, so it is never dealt into the wedge.
  • The turn-liveness watchdog now covers the no-actor state: a hand parked past the budget recovers via runout, or freezes the table loudly through the single state-machine writer — graduated recovery, never a second silent wedge (poker.watchdog.no_actor_recovery).
  • The all-in derivation was corrected so a stack=0, live-bet, in-hand seat is labeled allIn:true on every view — the mislabeled frame that made the wedge invisible.

The frame contract (D1) — @numero/types frame-invariants.ts is now the single source of truth for the felt's temporal truths (FLC-A2 blind-all-in labeling, FLC-A10 waiting-vs-in-hand, FLC-A12 rebuy state, FLC-A5 handNumber monotonic), enforced in both the wire schema's .superRefine (any harness scenario that emits a contradictory frame fails in CI) and the game-server broadcast assert pass (poker.frame.contract_violated ERROR span). A restart no longer regresses the hand counter (onReconstructed seeds it from the last committed hand).

The limbo doors — every hand-end path now has an explicit conclusion: persist-fail is bounded-retry-then-loud-freeze (never an eternal ceremony), the deal-contract read is guarded (never an unhandled rejection from the fire-and-forget finalize), a blind-collection timeout broadcasts blind_collection_cancelled and re-deals with the remaining quorum, and all four timer callbacks are wrapped (poker.timer.callback_failed, never a silent wedge).

The tripwires — a harness sequence validator checks the cross-frame truths on every scenario for free (shared with the schema refines); a spectate stream that goes silent past 30s reads as stalled and reconnects (a dead socket, distinguished from a quiet-but-heartbeating table). Alert-pack rules 11/12 and the felt-wedge-recovery runbook make a production freeze operator-visible.

The load-bearing findings ​

  • Conservation is NOT a frame contract. A buy-in or late sit-down legitimately raises the table total between two frames sharing a handNumber, so a frame-to-frame delta can't tell a leak from a buy-in. The frame contract stays scoped to felt-rendering truths; money conservation stays where deposits and rake are known — the server-side DB chip-conservation check and the custody invariant, which freeze the table on divergence. The harness validator's sound subset is within-hand + within-street, the grand total never decreases. (This was caught by the validator firing on a 9-agent table that was still funding up — 500 → 900 within "hand 1".)
  • Every-hand-concludes is realized at the source, not as a passive-stream rule. A passive frame stream can't tell a real wedge from a test that stopped observing mid-hand, so that check lives in the watchdog
    • the limbo-door fixes, not the validator.

The rule going forward ​

A new wire invariant lands in three places together — the pure predicate in @numero/types/frame-invariants.ts, the schema .superRefine (so the harness catches it), and the harness sequence-validator.ts if it is cross-frame — so an invariant can never be true in one layer and unchecked in another. See the "Felt Liveness Contract" convention in the project instructions.

Falsification hardening ​

The plan's adversarial pass (goal flipped from "does it work?" to "what input breaks it?") found three real defects the happy-path tests missed — each in an interaction the author didn't think to test:

  • A frame-contract FALSE POSITIVE. A short-stack (sub-blind) seat with an open rebuy window could sit_in (it has chips > 0) → seated while the window stayed open → FLC-A12 fired in production (a spurious ERROR span — the exact dishonesty the contract exists to kill) and the dealable floor won't deal a sub-blind seat, a soft limbo. handleSitIn now refuses a below-floor cash sit-in (sit_in_below_min): the only coherent exits from a short-stack sitting_out state are a top-up (≥ big blind, which clears the window) or window expiry.
  • A re-introduced SILENT WEDGE. runTimerCallback's catch block emitted a span + log with no inner guard — a throwing tracer there would escape the function into the setTimeout callback (unhandled), re-wedging the table. The catch's telemetry is now inner-guarded. The lesson generalizes: a catch that emits telemetry must itself be exception-safe.
  • The staleness test's fiction. The observer-gateway sent keepalives as an SSE comment (:keepalive), which the browser's EventSource never dispatches as a JS event — so the staleness hook's heartbeat listener never fired, and a genuinely quiet-but-alive stream would flip to stalled and reconnect-churn. The T14 F7 test only passed because its fake emitted a named event the gateway never sent. The gateway now emits event: heartbeat. An SSE keepalive a browser must observe has to be a named event, not a comment.