← All stories

TRUNK · efx-station-shell

EFx Station Shell

Trunk Live event station displays + control + WebSocket runtime Roots: ~7 EFx branches (poll, visualizer, fast-pass, queueing, memory bank, raffle, concierge)

The realtime event surface — station displays, organizer control panels, attendee SMS-and-app interactions. Built on Durable Objects + WebSocket + the F80/F88-proven 5k-concurrent substrate. Branches rooted here describe live behavior: socket reconnects, late-join state replay, n-concurrent load, organizer pause-mid-poll, cross-station isolation.

Scope

The EFx station shell covers: organizer enabling EFx for an event (passcode), bringing a station online (visualizer display, SMS gateway, NFC reader, lead-capture iPad), the WebSocket lifecycle for the station, and the contract between organizer admin actions (start poll, pause poll, close poll) and live-station behavior (results visibility, attendee messaging). Branches rooted here include EF-073 (poll), EF-074 (visualizer), EF-079 (fast pass), EF-080 (reservation queueing), EF-082 (memory bank), EF-083 (raffle), EF-084 (concierge). EF-077 (access control) and EF-078 (lead retrieval) have native NFC components that root in native-app-shell instead.

What's NOT in scope: organizer-side EFx configuration UI (that's the event-setup trunk's territory), attendee SMS opt-in (notification trunk), or post-event SMS reporting (reporting trunk).

Preconditions

  • The tenant has EFx enabled (account-level capability).
  • The event has at least one EFx module configured AND a station passcode set.
  • The station device (browser tab on a TV, or native iPad app) is on the venue network with reliable internet.
  • For audience-interactive modules: at least one attendee has registered and provided a phone number AND opted into SMS, OR holds a valid registered-guest QR.

Lifecycle

  1. Station enters the event with passcode.

    Visit the event's station URL (visualizer.example.com/event-id, or open the iPad station app). Enter station passcode. Successful auth opens a WebSocket to the event's station namespace (Durable Object).

  2. Station joins → server replays current state.

    On WebSocket connect, server sends a state snapshot ("current poll: paused; results visible: false; checked-in count: 312"). Station renders that state immediately. From that point, server pushes incremental updates.

  3. Organizer triggers an action.

    From the admin shell, organizer clicks "Start poll question 3". Server broadcasts a state delta to all subscribed stations. All stations transition to "poll active, options A/B/C visible".

  4. Attendee participates.

    Attendee receives an SMS with a short URL (or opens the event app's poll UI). Submits their vote. Server records (idempotent by attendee ID + question ID; resubmits update the existing vote not append). Server broadcasts an aggregate-results delta to subscribed stations.

  5. Organizer pauses or closes.

    Pause freezes the tally; new votes are rejected with "voting closed". Close commits the tally + makes results visible (per the configured "show results when" rule). Both actions broadcast to all stations.

  6. Station leaves cleanly.

    Closing the station tab or signing out the iPad app closes the WebSocket. Server decrements the connected-station count (used by organizer admin to verify station presence).

Failure modes

Socket reconnect with stale state

Trigger: WiFi blip; station reconnects 30s later; the poll has been paused in the meantime; station shows stale "active" state.

On reconnect, server replays the current snapshot, NOT just the delta queue. Station's UI reconciles to the snapshot. Harness: open station, kill socket, organizer pauses poll, station reconnects, station UI shows paused within next render frame.

Out-of-order delta arrives

Trigger: deltas have monotonic IDs but TCP reordering or congestion delivers them out of sequence.

Each delta carries a sequence number. Station tracks the last applied seq; deltas with seq ≤ lastApplied are dropped (idempotent re-delivery). Deltas with seq > lastApplied + 1 trigger a snapshot-resync to fill the gap. Harness: inject seq 5 then seq 3 then seq 7, station applies 5 then re-syncs after 7 (skipping 3, fetching snapshot to cover).

Organizer closes poll → results leak before "show results" gate

Trigger: poll is configured "show results after close"; on close, results render on station; but a station that JUST joined sees them in the snapshot before closing was supposed to be visible.

The snapshot includes a "results-visible" boolean. While false, the snapshot includes vote counts but NO results display. Stations only render results when results-visible flips true. Harness: snapshot includes votes but resultsVisible=false, station doesn't render result chart; flip true, chart renders.

5k concurrent attendees swamp the Durable Object

Trigger: large event, 5000 attendees vote within a 10s window; backpressure on the DO causes some votes to drop.

F80/F88 substrate proves 5k concurrent is sustained — votes go through coordinator that batches writes. Harness equivalent: synthetic 5k vote burst, server-side row count = 5000 (not less), p99 vote-to-acknowledge ≤ 1500ms.

Cross-station isolation violation

Trigger: poll at station A's event has 3 options; a vote intended for station B's event lands in A's tally.

WebSocket namespace is event-id scoped; cross-event messages are rejected at the Durable Object boundary. Per-station IDs don't bleed across events. Harness: subscribe station to event-A, attempt to inject vote with eventId=event-B, server rejects + audit log captures the attempt.

Late-joiner sees only incremental future events

Trigger: station joins at vote 4 of 10; sees only votes 5-10; tally reads "6 votes" instead of "10".

Late-joiners receive a snapshot of CURRENT state (full tally) followed by future deltas. Snapshot is cheap to recompute; server doesn't replay every individual delta to a late joiner. Harness: 10 votes happen, then station joins, station tally reads "10" without observing the individual 1-9.

Idempotent vote: attendee resubmits, count grows

Trigger: attendee taps vote button twice quickly; both submissions succeed; vote count = 2 from one person.

Server keys votes by (attendeeId, questionId). A second vote replaces the first (or is rejected if module is configured "no change after vote"). Tally count stays accurate. Harness: same attendee submits A then B, tally shows attendee voted for B exactly once total.

Station passcode rotated mid-event

Trigger: organizer rotates passcode (e.g., suspected compromise); already-connected stations stay live (good) but a station that reconnects with old passcode succeeds (bad).

Passcode rotation invalidates the old passcode immediately. Live WebSocket sessions remain (they hold their session token). New connections with the old passcode are rejected. Harness: connect with passcode X, rotate to passcode Y, connect another station with X, second connection fails.

Stable test attributes

data-testWherePurpose
efx-station-passcode-formPasscode entry screenInitial gate
efx-station-passcode-inputPasscode fieldInput
efx-station-passcode-submitSubmit buttonAuth attempt
efx-station-passcode-errorAuth error messageVisible on failed passcode
efx-station-shellAuthenticated shellCarries module-id attr
efx-station-socket-statusConnection indicatordata-state=connecting|live|reconnecting|disconnected
efx-station-snapshot-loadedSnapshot-ready indicatorTrue after first snapshot received
efx-station-resync-bannerResync indicatorVisible during snapshot resync
efx-station-results-regionResults display regionVisible only when resultsVisible=true
efx-station-active-state"Active" / "Paused" / "Closed" indicatorReflects current module state
efx-station-attendee-countConnected attendee countVisible to organizer view
efx-station-leave-buttonSign out / leave stationCloses WebSocket cleanly

Agent test plan

WebSocket-aware harness mode required. Reuses F80/F88 instrumentation (load harness, sequence injection, latency measurement). Branches rooted here can be partially run with the existing Playwright harness if the runner is extended with a WebSocket adapter.

Probe list
- passcode-correct-opens-shell: enter correct passcode, efx-station-shell visible
- passcode-wrong-shows-error: wrong passcode, efx-station-passcode-error visible
- snapshot-applies-on-connect: connect, efx-station-snapshot-loaded=true within 2s
- delta-applies-incrementally: organizer triggers state change, station reflects within 500ms
- socket-reconnect-resilience: kill socket, reconnect within 5s, state matches server
- out-of-order-delta-tolerance: inject seq 5 then 3 then 7, station ends at correct state
- resync-on-gap: inject seq 5 then 7 (skip 6), efx-station-resync-banner visible briefly, ends correct
- attendee-late-join-shows-prior-state: 10 votes happen, station joins, tally=10 not partial
- organizer-pause-mid-poll: pause action broadcasts, station active-state=paused, votes rejected server-side
- results-visible-only-after-close: resultsVisible=false, station doesn't render results region
- results-visible-after-flip: resultsVisible=true broadcast, station renders results region
- idempotent-vote: same attendee votes A then B, tally shows B exactly once
- 5k-concurrent-load-stable: synthetic 5k vote burst, all 5k server-side rows, p99 ≤ 1500ms
- cross-station-isolation: subscribe to event-A, inject vote with eventId=B, server rejects
- station-leave-cleanup: leave station, server connected-count decrements
- passcode-rotation-locks-out-new: rotate passcode, new connection with old fails
- passcode-rotation-preserves-live: rotate passcode, existing socket session remains

Runner notes

The Playwright harness can drive the station entry flow (passcode form, shell render). The WebSocket lifecycle and load probes need a harness extension:

  • WebSocket adapter for action kinds ws-disconnect, ws-inject-delta, ws-await-state.
  • Load harness for n-concurrent-load-stable — already exists in F80/F88 form; reusable for story-driven runs.
  • Sequence injection for out-of-order tolerance probes.

Until the WebSocket adapter is wired, branch stories rooted here run their non-WebSocket portions via Playwright + their WebSocket portions via the F80 load harness, with results stitched.