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.