Appearance
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:
- WS messages with
statusfields are state-machine drivers, not telemetry. Every server-emitted_progressmessage carrying astatus(deposit_progress,withdraw_progress) MUST be consumed in the dispatcher's switch as a real state driver — setState, gate affordances, route onfailed. Never as a fire-and-forget log line. - No optimistic UI for fund-affecting flows. Affordances render only when the action is currently valid; state transitions render only on
confirmedfrom the relevant intent state machine. Sit-down, top-up, withdraw, settlement all gate their UI surface on the durable intent reachingconfirmed, not on the HTTP response of the submission. - 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).
- Coded errors route via
ErrorRouter.errormessages with acodefield are routed to defined UI responses, not toasted generically. New error codes added to the wire schema require a routing entry inErrorRouterin 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.ts—depositReservationslice (lines 142-143, 380-422);withdrawIntentslice (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.ts—ErrorRoutercomponent + 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.sitInas defense-in-depth
Server-side broadcasters:
apps/admin-server/src/lib/deposit-progress-notifier.ts— service-to-service notifierapps/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 bypm.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'suseGameDispatcherhook 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_progressslice driving UI state AND the/api/withdrawHTTP response settingwithdrawStatus="complete"inusePokerSocket.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_changeevents instead of a dedicatedseat.reservationspan. The PM emits state-change events at every transition; a separateseat.reservationspan 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-uiTier 4 (A1 + A4) +DepositFlow.test.tsxstate-branch coverage. Tier 4 ASSERTS where manual smoke just OBSERVES — higher fidelity for the same coverage area.
Companion Reads
- State-Machine Driver Pattern Guide — the convention with code examples, the sit-down worked example, and the pre-flight + coded-error rules.
- Bulletproof State — the on-chain invariant this UI work conforms to.