Appearance
Felt Liveness Contract
Decided: 2026-07-19 · Plan: felt-liveness-contract (implements ui-stability ADR D1 + D2) · Status: shipped
The decision
No poker hand may wedge silently, and no frame the server broadcasts may contradict the felt's temporal contract without an operator-visible signal. Both properties are enforced structurally — by code + tests that can be re-checked, not by vigilance.
The driver was the EK71HB staging freeze: a heads-up hand 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; the watchdog and the turn notifier both early-returned on the null actor; and the table served handInProgress: true, currentActor: null to every client forever. The audit traced this to an unowned band — a seat with 0 < stack ≤ small blind belonged to no layer.
What was built (five mechanisms, each a source-level fix)
- Deal-time runout — a hand that lands unactionable runs the board out immediately at the deal (
PokerEngine.runOutIfUnactionable→ the standard hand-end boundary). - Sub-blind band ownership — a cash seat felted below the big blind is sat out at hand-end with a rebuy window and excluded from the deal quorum, so it is never dealt into the wedge.
- The turn-liveness watchdog covers the no-actor state — it recovers via runout or freezes the table loudly through the single state-machine writer.
- The frame contract (D1) —
@numero/types/frame-invariants.tsis the single source of truth for the felt's temporal truths (FLC-A2/A10/A12/A5), enforced in both the wire schema's.superRefine(fails any contradictory frame in CI) and the game-server broadcast assert pass (poker.frame.contract_violatedERROR span). handNumber is restored frompoker.handson restart. - The limbo doors — persist-fail is bounded-retry-then-loud-freeze, the deal-contract read is guarded against unhandled rejection, a blind-collection timeout broadcasts a cancellation + re-deals, and all four timer callbacks are wrapped (
poker.timer.callback_failed).
A harness sequence validator checks the cross-frame truths on every scenario for free, and the observer SSE staleness state distinguishes a dead socket from a quiet table.
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, 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.
Key tradeoffs accepted
- Conservation is NOT a frame contract. A frame-to-frame money-delta can't distinguish a leak from a legitimate buy-in (which raises the table total between two frames sharing a handNumber). Conservation stays server-side, where deposits + rake are known (the DB chip-conservation check + the custody invariant, which freeze on divergence). The harness validator's sound subset is within-hand + within-street, the grand total never decreases.
- 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.
- One residual is loud, not recovered — a thrown auto-deal timer is surfaced (
poker.timer.callback_failed+ alert) but not yet auto-re-kicked; that recovery is a documented follow-up. Silent → loud was the win.
Falsification hardening
The plan's adversarial pass found three real defects the happy-path tests missed, each in an interaction the author didn't test:
- A frame-contract false positive — a sub-blind windowed seat could
sit_intoseatedwithout clearing its window → FLC-A12 fired in production. Fixed:handleSitInrefuses a below-floor cash sit-in. - A re-introduced silent wedge — the timer guard's own catch block could throw. Fixed: inner-guard the telemetry (a catch that emits telemetry must itself be exception-safe).
- The staleness test's fiction — the gateway sent keepalives as an SSE
:commentthe browser never dispatches; the test only passed on a fake emitting a named event. Fixed: namedheartbeatevent.
See the full ADR in the archive: .indusk/planning/archive/felt-liveness-contract/ and the accepted ui-stability ADR (numero/.indusk/planning/ui-stability/adr.md). Operations: the felt-wedge-recovery runbook + alert-pack rules 11/12.