Skip to content

Bulletproof State Machine (UI Companion)

Date: 2026-05-08 (originally landed 2026-05-05; closed via reconciliation) Status: Implemented Companion to: Bulletproof State (on-chain invariant). This page is the UI-side complement.

The Decision

The UI is a conformant consumer of the server's existing intent state machines (deposit_intents, withdrawal_intents). It MUST NOT commit the user to a state that the server's state machine hasn't confirmed. Optimistic UI is forbidden for fund-affecting flows; the intent IS the source of truth.

This is enforced via four structural rules captured in CLAUDE.md ## Conventions:

  1. WS messages with status fields are state-machine drivers, not telemetry. Every server-emitted _progress message carrying a status (deposit_progress, withdraw_progress) MUST be consumed in the dispatcher's switch as a real state driver — setState, gate affordances, route on failed. Never as a fire-and-forget log line.
  2. No optimistic UI for fund-affecting flows. Affordances render only when the action is currently valid; state transitions render only on confirmed from the relevant intent state machine. Sit-down, top-up, withdraw, settlement all gate their UI surface on the durable intent reaching confirmed, not on the HTTP response of the submission.
  3. Pre-flight = affordance gate. Chain mismatch, insufficient balance, missing precondition (not seated, not your turn, no blind requested) → the button isn't there. Not disabled. Not silently no-op. Absent. When the gate fails, an actionable alternative renders in its place (Switch Network, Get Faucet Tokens, Sign In, Top Up).
  4. Coded errors route via ErrorRouter. error messages with a code field are routed to defined UI responses, not toasted generically. New error codes added to the wire schema require a routing entry in ErrorRouter in the same PR.

Why This Decision

The original bulletproof-state ADR established the on-chain invariant — deposits, settlements, withdraws are reconcilable from event history. But the UI surfaces could still commit the user to a state the server hadn't confirmed: render "you're seated" on /api/deposit 200 (before the on-chain confirm), or render "cash out complete" on the HTTP response of /api/withdraw (before the intent state machine confirmed it). When admin-server confirmed differently — failure, partial credit, settlement skew — the UI was already past the confirmation moment and couldn't recover cleanly.

The UI fix is to subscribe to the intent state machine via _progress WS messages and drive UI state changes off those broadcasts. The HTTP response of the action endpoint is a "kicked off the flow" signal, not a "complete" signal. The complete signal comes from the durable intent's confirmed status reaching the UI via WS broadcast.

The Three UI Span Families

useGameDispatcher is the single point of ui.action.* instrumentation. Three span families fire from one hook:

What Shipped

UI-side intent consumers:

  • apps/poker/hooks/usePokerSocket.tsdepositReservation slice (lines 142-143, 380-422); withdrawIntent slice (line 144, 278-329)
  • apps/poker/src/poker/components/DepositFlow.tsx — wallet-aware boundary surface; 11 discrete states (connect_wallet → signin_needed → wrong_chain → low_balance → idle → signing → submitting → pending → submitted → confirmed | failed)
  • apps/poker/src/framework/lib/error-router.tsErrorRouter component + 5 routes (hand_persist_failed, wallet_not_linked, table_not_found, top_up_shape_changed, sit_down_shape_changed); unregistered codes log warning + telemetry
  • Affordance gates across Dashboard (Sit In / Stand Up / Post Blind / action buttons)
  • Dispatcher-level rejection in usePokerSocket.sitIn as defense-in-depth

Server-side broadcasters:

  • apps/admin-server/src/lib/deposit-progress-notifier.ts — service-to-service notifier
  • apps/admin-server/src/lib/withdraw-progress-notifier.ts — symmetric for withdraw (closed the gap discovered in falsification: UI consumed the messages but no server broadcaster existed)
  • apps/game-server/src/routes/{deposit,withdraw}-progress.ts — WS forwarders that map service-level intent statuses (pending | submitted | confirmed | failed) to the UI's WS-status enum (settling | submitted | complete | failed)

Trajectory + scope shifts:

  • Seat-reservation primitive (pm.reserveSeat / TTL) deliberately not restored — replaced by pm.seatUnfunded + credit-chips reconciler retry. The "stuck reservation" risk that motivated the TTL was structurally prevented at the affordance gate.
  • UI-side OTel spans (ui.action.<type>, ui.error.routed, ui.dispatch.rejected) absorbed into bulletproof-pregame-ui's useGameDispatcher hook plan — same dispatcher contract, no duplication.

Tradeoffs Accepted

  • Dual-driver shape on success path. With the notifier shipped, the happy path has both the withdraw_progress slice driving UI state AND the /api/withdraw HTTP response setting withdrawStatus="complete" in usePokerSocket.ts:372. The redundant overwrite is no-op on the success path. The HTTP fallback is defense-in-depth for the case where the notifier (best-effort by design) drops a message. Honest re-read: this is NOT optimistic UI — both signals reach the same end state via different paths.
  • participant.state_change events instead of a dedicated seat.reservation span. The PM emits state-change events at every transition; a separate seat.reservation span would be redundant trace data with worse cohesion. Re-using existing surfaces.
  • Manual smoke tests compressed into Tier 4 e2e + unit branch coverage. The plan's T18–T21 + T28 manual smoke trajectory rows were satisfied by bulletproof-pregame-ui Tier 4 (A1 + A4) + DepositFlow.test.tsx state-branch coverage. Tier 4 ASSERTS where manual smoke just OBSERVES — higher fidelity for the same coverage area.

Companion Reads