Foodops
All docs

Counter · Updated 2026-08-24 · v774c49b

Cross-Device Session Sync

Audio narration for this article is generated with the weekly docs refresh.

Plain-English summary

A register session (the open shift on one register) is a server-side resource shared across devices, not locked to the device that opened it. When one cashier opens Register 1 on the front tablet, every other device in the organisation sees the session appear in its Open Sessions list in real time; another cashier can tap Join on that session card from a laptop or second tablet and work the same session. When anyone closes the register, every other device sitting in that session is locked out by a blocking "Register Closed" dialog — and a device that was offline when the close happened finds out on reconnect, because the app re-asks the server whether its session is still open. SignalR (a WebSocket transport) carries the open/close broadcasts; the draft cart itself stays local to each device (it is submitted orders and session state that sync, not keystrokes).

When you'd use this

  • A cashier needs to step away mid-shift; another cashier joins the same register session from a different device without closing/reopening
  • Front-of-house tablet dies mid-service — manager joins the same session from a back-office machine and continues
  • Multi-cashier coverage on a single high-volume register (both devices attached to the same session; orders submitted by one appear for the other)
  • Network blip drops a device — on reconnect the device revalidates session state instead of trusting stale local cache

Walkthrough

Verified against foodops.counter.app@4cbcfd7 + hubits-api-sales@5d917e5 (both prod). UI labels below are from component source.

Opening a register from a fresh device

  1. Cashier opens the Counter page and opens a register with the opening float (existing flow — see Cash Handling).
  2. The server creates the session and — best-effort, never rolling back the open — broadcasts SessionOpened to the whole organisation's SignalR group. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:823-848]
  3. On every other device the client dispatches a session-list-refresh-needed event and the Counter page's Open Sessions grid refreshes — the new session card appears with outlet, register, opened time and the opener's name, without a manual reload. [code: foodops.counter.app/src/services/signalr/signalRService.ts:286-288; src/pages/CounterPage.tsx:76-86,260-280]

Joining an already-open session from a second device

  1. Second cashier loads the Counter page on another device. The already-open session shows as a card in Open Sessions with two buttons: Join and Details. [code: CounterPage.tsx:281-299]
  2. Tap JoinPOST /sessions/join. The server checks the session exists, belongs to the org, and is open — then records the joiner as the session's locked-by user and returns a redirect into that session's POS (/sessions/{id}/pos). Notably, the "already locked by another user" rejection is commented out in current code (still commented at L533-544): joining is open to any org user, and the lock fields are bookkeeping (last joiner wins), not an exclusivity gate. [code: SessionsController.cs:520-562]
  3. Both devices are now working the same session. Orders submitted on either device propagate to the other in near real time over the order hubs (OrderAdded/OrderUpdated events) — see Kitchen Display System for the SignalR architecture.
  4. What does not sync: the in-progress draft cart. Cart contents live in the device's local state until the order is submitted; no cart-level SignalR event exists in current code as of 2026-06-11.

Connection-drop recovery (reconnect revalidation)

  1. A device's SignalR connection drops (network blip, server restart). The client auto-reconnects using a full-jitter backoff policy — each retry delay is randomized between 0 and an exponentially growing cap (1s, 2s, 4s, ... capped at 30s), replacing the previous fixed 0s/2s/5s/10s/30s ladder (Issue 2.9). [code: signalRService.ts:194; reconnectJitter.ts]
  2. On reconnect the client rejoins its hub group and dispatches signalr-reconnected. [code: signalRService.ts:204-217]
  3. Because SignalR does not replay missed events, the lockout guard then revalidates: it asks the server whether the device's current session is still open, and triggers the lockout if it was closed during the outage. [code: src/components/session/SessionLockoutGuard.tsx:53-79]
  4. As a backstop, SessionDetector re-validates the session on tab focus, throttled to once per 5 minutes (real-time close handling is the SignalR path; the focus check is a secondary net). [code: src/components/session/SessionDetector.tsx:24-50]

Closing the register from any device

  1. Any device attached to the session can initiate close (subject to existing close permissions).
  2. On submit the close lands the session in PendingClose — every cashier-driven close awaits supervisor approval, regardless of variance (see Session Lifecycle). [code: SessionsController.cs:1862-1872]
  3. The server broadcasts SessionClosed to the organisation group, carrying status (PendingClose or Closed), hasDifferences, the register id, who closed it and when. Broadcast is best-effort: a SignalR failure never rolls back the persisted close. [code: SessionsController.cs:1888-1900]
  4. Other devices sitting in that session are locked out: SessionLockoutGuard renders a non-dismissible "Register Closed" modal that forces the user back to /counter. The message distinguishes the variance case: "This register has been closed (with variances pending review) by another device. You can no longer ring up sales on this session." versus "This register has been closed by another device. You can no longer ring up sales on this session." [code: SessionLockoutGuard.tsx:111-114,133]
  5. The device that initiated the close is not shown the modal — selfCloseTracker marks "I just closed session X" (10-second TTL) right before the close API call, because the broadcast arrives milliseconds ahead of the HTTP response. [code: src/services/session/selfCloseTracker.ts]

Key concepts

  • Session-as-shared-resource — since 2026-05-01 (73a5a08) a session is a server-side resource any authorised org user can join; SignalR keeps device session-lists and lockout state in sync.
  • Explicit Join, soft lock — attaching is an explicit Join action, not silent. The server records LockedByUserId/LockedTime on join but does not reject competing joins (the rejection branch is commented out at 5d917e5) — multiple devices can hold the same session. [code: SessionsController.cs:533-547]
  • Open-race guard — two devices opening the same register simultaneously: the server rejects the second with "Register already has an open session" (a compiled-query check on every open). The loser joins the winner's session instead. There is no timed "lockout window" in current code — the earlier stub's claim of one is corrected to this existence check. [code: SessionsController.cs:712-719]
  • Close lockout — the SessionClosed broadcast plus SessionLockoutGuard's blocking modal is what prevents ringing sales on a closed session; the lockout fires at close-submit (PendingClose), not only at final supervisor approval. [code: SessionsController.cs:1872; SessionLockoutGuard.tsx]
  • Self-close tracker — in-memory map of recently self-closed session ids (10s TTL) that suppresses the lockout modal on the originating device. [code: selfCloseTracker.ts:18]
  • Reconnect revalidation — on every SignalR reconnect the client re-asks the server "is my current session still open?" instead of trusting local state, because missed broadcasts are not replayed. [code: SessionLockoutGuard.tsx:53-79]
  • Organisation-group scoping — session broadcasts go to the organisation's hub group (HubGroups.Organization(...)), not per-outlet groups: every device in the org receives them and filters locally by its current session id. (Resolves the earlier open question — scoping is per-organisation.) [code: SessionsController.cs:1888-1889 (.Group(HubGroups.Organization(...)).SendAsync("SessionClosed", ...))]
  • SignalR transport — same WebSocket channel that powers the Kitchen Display and order updates; automatic reconnect with backoff, long-polling fallback. See Kitchen Display System.
  • Force-update on session entry (NEW 2026-06, ADR 0009) — opening a register, joining a session, or switching sessions is now a third "safe-state" trigger for applying a pending PWA update. If a new app version is waiting and it is safe (online, no unsynced loss-bearing orders/payments — the same countUnsyncedLossBearingItems guard as leave/close), the app does a nuke-and-reload (clears caches, unregisters the service worker) behind a brief "Updating to the latest version…" overlay, landing the user back in the session on fresh code. It never reloads over unsynced work, and a session entry with no pending update navigates instantly. This means a Join/Open can occasionally trigger a short reload — by design, not a fault. [code: foodops.counter.app/docs/adr/0009-force-update-on-session-entry.md; forceUpdateIfNewVersionAvailable UI helper]

Common questions

Q: Can two cashiers be on the same register at the same time? A: Yes — both devices Join the same session; submitted orders flow to both via the order hubs. The current code does not reject a second joiner. [code: SessionsController.cs:533-547]

Q: How do I take over a register from another device? A: Counter page → Open Sessions → the session card (outlet, register, opened time, opener's name) → Join. [code: CounterPage.tsx:281-290]

Q: Will my draft cart sync to the second device? A: No — the in-progress cart is device-local until the order is submitted. Submitted orders sync in near real time. (No cart-sync event found in current code as of 2026-06-11.)

Q: What happens if my connection drops mid-shift? A: The client reconnects automatically (0s/2s/5s/10s/30s backoff). On reconnect it rejoins its hub group and revalidates the session — if the register was closed while you were offline, the Register Closed dialog appears then. Offline-queued orders sync via the separate offline-orders flow. [code: signalRService.ts:193-217; SessionLockoutGuard.tsx:53-79]

Q: What if two devices try to open the same register at the same instant? A: One open succeeds; the other gets "Register already has an open session" and can Join the winner's session. [code: SessionsController.cs:712-719]

Q: What if two devices try to close the same session at the same moment? A: The first close lands the session in PendingClose and broadcasts SessionClosed; the second device is locked out by the Register Closed modal rather than submitting a competing close. [code: SessionsController.cs:1872-1900; SessionLockoutGuard.tsx:113-114]

Q: Another device closed the register but it shows "variances pending review" — is it actually closed? A: The close was submitted but awaits supervisor approval (the two-step close: PendingClose → approve → Closed / adjust / cancel back to Open). Devices are locked out of ringing sales from the PendingClose moment. See Session Lifecycle. [code: SessionsController.cs:1862-1872]

Q: I closed the register myself — why didn't I see the "closed by another device" dialog? A: By design: the self-close tracker suppresses the modal on the originating device for 10 seconds around your own close. If you ever see the dialog for your own close, that's a regression. [code: selfCloseTracker.ts]

Q: Do I need to do anything to enable cross-device sync? A: No — SignalR connects when the counter app loads and joins your organisation's group automatically. If the connection can't establish, the device degrades to manual-refresh behaviour until reconnect; the status indicator reflects connection state. [code: signalRService.ts; src/components/layout/StatusIndicator.tsx]

Q: Does this work across FoodOps and Shops on the same register? A: Unverified and treat as not supported — the sync lives in foodops.counter.app; the Shops register surface (haafai.app.pos) is a separate codebase, and no shared session hub group between them was found.

Q: Is there an audit log of who joined a session and when? A: Joins are server-logged ("User X joined session Y") and the session records the most recent joiner (LockedByUserId); a full join history surface was not found in current code as of 2026-06-11. [code: SessionsController.cs:549]

Q: Does the session lock me out after a long disconnection? A: No duration-based lockout exists — the only lockout trigger is the session having been closed. After any disconnection the reconnect revalidation decides: still open → carry on; closed → Register Closed dialog.

Edge cases and known issues

  • Simultaneous open race — symptom: both cashiers tap Open at the same instant; one gets "Register already has an open session". Expected: the loser joins the existing session. [code: SessionsController.cs:712-719]
  • Broadcast loss on flaky networks — SignalR doesn't replay missed events; a device offline during a close learns of it via reconnect revalidation (or the 5-minute focus backstop), so there can be a window where a stale tab still displays an open session. Server-side checks stop actual sales landing on the closed session. [code: SessionLockoutGuard.tsx:53-79; SessionDetector.tsx:24-50]
  • Self-close double-prompt — historical race where the closer saw their own lockout dialog; fixed by selfCloseTracker (broadcast-beats-HTTP-response). Recurrence = regression. [code: selfCloseTracker.ts]
  • Backgrounded tabs — browsers throttle backgrounded tabs' connections; the focus-triggered validation in SessionDetector (throttled to 5 min) plus reconnect revalidation re-syncs on return. [code: SessionDetector.tsx:24-50]
  • Best-effort broadcasts — a SignalR failure during open/close logs and continues: the session row is authoritative; devices then rely on revalidation paths. [code: SessionsController.cs:846-848,1900-1904]
  • Lock fields are not locksLockedByUserId reflects the most recent joiner only; do not read it as "who owns the session". The commented-out rejection suggests exclusive locking was tried and deliberately disabled. [code: SessionsController.cs:533-541]

Related

  • Sales Sessions and Register Management — the underlying session lifecycle, now amended for the cross-device contract
  • Session Lifecycle — the two-step PendingClose → Closed approval flow
  • Session Handover — formally passing a session to another cashier
  • Kitchen Display System — the other major SignalR consumer; shares transport architecture
  • Register Sessions (Shops) — sibling concept on the Shops side; cross-app sync is unverified/not supported
  • Drift report context: _meta/audit-2026-05-02/drift-report-initial.md (commit 73a5a08 flagged as top behavior-affecting change for sales-sessions.md)

What changed since 2026-05-03

Re-checked declared globs against haafai/foodops.counter.app@staging on 2026-05-04. All four glob targets still exist (src/services/signalr/**, src/services/session/**, src/components/session/**, src/store/slices/sessionSlice.ts).

Commit log on the declared globs since the entry was authored (2026-05-03):

  • 1337cbe (2026-05-02) — "feat(signalr): subscribe counter to ordering-api /orderHub for OrderAdded/OrderUpdated/OrderDeleted". Touches src/services/signalr/** (adds new orderingHubService.ts) and src/hooks/useSignalR.ts. Not session-sync-affecting — the change adds an additional SignalR subscription for real-time order updates from the ordering API, parallel to the existing session-sync hub. No modifications to lockout, reconnect-revalidation, selfCloseTracker, or any session-state handling described in this entry.

  • 2026-08-20 — hubits-api-sales 98c202d..c7e4894 touches SessionsController.cs (declared glob) but not this entry's documented SYNC contract: SessionOpened/SessionClosed broadcasts, Join, PendingClose, and the SessionLockoutGuard/selfCloseTracker/reconnect-revalidation code paths are all untouched by this diff. Two out-of-scope-but-notable additions: (1) GetSessionCloseSummary and GetSessionCloseSummaryPdf now populate a new SessionCloseSummaryDto.DiscardLog (backed by new SessionDiscardLogDto/SessionDiscardRowDto — order count/qty/total plus a per-order row: reference, voided date, order type, reason, requested-by, reviewed-by, amount, status) and a new SalesRegisterId field, folding the Void Bin/discard log (previously only a DiscardLogTab UI surface per the 2026-08-17 verification note) into the printed/PDF close summary as well. (2) CloseRegister's pre-close unreviewed-void-bin gate comment changes from 'approved/rejected' to 'approved/rejected/acknowledged' — a third review outcome now satisfies the gate. Both are close-summary/close-validation UX, not cross-device broadcast/lockout — worth a note on session-lifecycle.md or a dedicated Void Bin entry, not here. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-17 — foodops.counter.app fe749d8..f30dbeb. Most hunks in this range overlap commits already assessed in the 2026-08-10/2026-08-11 verification-history entries (OrderCancelled ordering-hub handler, SessionDetailsModal/SessionOrdersTab onRefresh wiring, real hasCreatePermission via getOrderButtonConfig, VoidBinUpdated live-sync handler, sessionSlice enforceUniqueBlazeNumber persist/restore) — not re-logged here. None of it touches this entry's documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched). One genuinely new addition lands inside the declared src/components/session/** glob: two brand-new components, DiscardLogTab.tsx and DiscardOrderLogModal.tsx, implement the actual UI for the 'Void Bin'/discard log previously only present as a live-sync plumbing note (2026-08-10: VoidBinUpdated hydrates IndexedDB voidBinEntries with no consuming UI identified at the time). DiscardLogTab renders a session-scoped table (order id, date/time, order type, reason, requested-by, reviewed-by, total, status pill Approved/Rejected/Approval Pending) driven by useLiveQuery over the same voidBinEntries store VoidBinUpdated hydrates, and DiscardOrderLogModal shows a per-order timeline merging server operation logs with Discard Requested/Approved/Rejected events. This is new user-facing behavior (a full audit-style log tab for voided/discarded orders) but it is close/order-detail UX, not cross-device session open/close/join sync — worth a note on session-lifecycle.md or a dedicated Void Bin entry, not this one. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-13 — hubits-api-sales 144add7..04ad21d (report-export/typed-export-pipeline commits; the intersecting SessionsController/SessionDto/SessionDetailsDto hunks are incidental). This range is a superset of the already-assessed 2026-08-03 (PosDefaultViewMode/SoftEnforcePaymentReference on SessionDto), 2026-08-08 (OpenedTime ISO-8601 round-trip format, ExpectedAmount seeded to opening float), and 2026-08-11 (EnforceUniqueBlazeNumber stamped on OpenSessions/SessionDto) hunks — same commits reappearing off the same 144add7 base, not reassessed here. One genuinely new, out-of-scope-but-notable addition lands inside the declared Hubits.Sales.Api/Dto/Counter/Sessions/** glob: SessionInvoiceDto (SessionDetailsDto.cs) gains a CustomerName field alongside the existing Id/TransactionId/Reference/InvoiceDate/Amount/Balance, presumably so the session-details Orders/Payments/Invoices surface can show who an invoice belongs to without a second lookup. None of this touches the documented cross-device SYNC contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are all untouched by this diff — no edit required to this entry's claims. Worth a note on session-lifecycle.md or a Payments/Invoices-tab entry once a corresponding frontend hunk confirms consumption (none was included in this diff). (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-11 — foodops.counter.app fe749d8..cc07f19 (feat(salesmade) till grouping, quote-to-invoice price-list fix, order-cancellation SignalR handling, dine-in Take Out/Delivery button hide, and the real hasCreatePermission fix already logged 2026-08-10 all land in this range). None of this touches the documented cross-device SYNC contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are all untouched by this diff. Two out-of-scope-but-notable additions land inside declared globs: (1) orderingHubService.ts (src/services/signalr/**) adds an OrderCancelled handler dispatching 'ordering-order-cancelled' (salesOrderId/orderNumber/cancelRemark/cancelledAt, falling back to orderId when salesOrderId is absent) — same ordering-hub layering already assessed as not session-sync-affecting on 2026-05-02 (OrderAdded/OrderUpdated). Per the new code comment, this exists because a customer cancelling from the ordering app only ever reaches this hub (ordering-api broadcasts to its own org group; nothing forwards to Sales.Api's hub) — worth a note on a Kitchen-Display/order-sync entry, not here. (2) sessionSlice.ts's detectCurrentSession thunk (declared glob) extends the existing offline-hard-refresh snapshot/restore mechanism to also persist/restore enforceUniqueBlazeNumber (the Transfer-Receipt-reuse-warning flag) — the same pattern already used for isQuoteModeEnabled/posDefaultViewMode/softEnforcePaymentReference, not a change to SessionOpened/SessionClosed, Join, lockout, self-close, or reconnect revalidation. The remaining hunks in this diff (SessionDetailsModal/SessionOrdersTab onRefresh wiring, real hasCreatePermission via getOrderButtonConfig, VoidBinUpdated) were already assessed in the 2026-08-10 verification entry — same assessment stands, no re-logging needed. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-11 — hubits-api-sales 144add7..7273baf (report-export refactor commits; overlaps the already-logged 2026-08-03 PosDefaultViewMode/SoftEnforcePaymentReference and 2026-08-08 OpenedTime ISO-8601 / ExpectedAmount-seeding hunks, which are not reassessed here). One genuinely new addition lands on GetMySessionsAndRegisters and SessionDto: a third org-scoped field, EnforceUniqueBlazeNumber (from the 'Enforce Unique Blaze Number' Outlets General setting), is now stamped on every OpenSessions row — previously only returned on the session-DETAILS payload for the Issues tab and close gate, now also surfaced at the list/payment level so the counter can warn when a Transfer Receipt's reference (Blaze number) was already used this session. This is out of scope for this entry's documented cross-device SYNC contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are all untouched by this diff — but lands inside the declared Hubits.Sales.Api/Dto/Counter/Sessions/** and SessionsController.cs globs, so flagging here per the established pattern. Worth a note on session-lifecycle.md or a Payments-tab entry once the frontend consumption is confirmed (no counter.app hunk was included in this diff). No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-10 — foodops.counter.app fe749d8..5de51d4 (no hubits-api-sales hunks in this diff). Three out-of-scope-but-notable additions land inside this entry's declared globs; none touch the documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched by this diff). (1) signalRService.ts (src/services/signalr/) adds a VoidBinUpdated handler that drops foreign-session events then re-hydrates the current session's void-bin entries into IndexedDB via hydrateSessionVoidBinEntries — the live-sync counterpart to the 'void bin' close-time gate already noted 2026-08-04 (SessionDetailsModal.tsx isVoidBinGate). Same layering as prior settings/status broadcasts (KotStatusChanged, BillSplitCreated) that share this entry's 'SignalR transport' hub connection but are unrelated to session open/close/join — belongs on session-lifecycle.md or a dedicated Void Bin entry, not here. (2) orderingHubService.ts (src/services/signalr/) adds an OrderCancelled handler dispatching 'ordering-order-cancelled', extending the OrderAdded/OrderUpdated ordering-hub pattern already assessed as not session-sync-affecting on 2026-05-02 (1337cbe) — same assessment applies. (3) Also inside the declared src/components/session/** glob: SessionDetailsModal.tsx and SessionOrdersTab.tsx wire a new onRefresh callback so closing/updating an order from the session's Orders tab refetches the parent session snapshot, and SessionOrdersTab now reads a real hasCreatePermission via a new sessionApiService.getOrderButtonConfig call instead of a hardcoded false — both are order-details UX/permission fixes inside the session detail view, not SignalR session-sync, lockout, or join changes. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-08 — hubits-api-sales 144add7..cdb3b32 (report-export refactor commits; the intersecting SessionsController/SessionDto hunks are incidental). None of this touches the documented cross-device SYNC contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are all untouched by this diff. Two genuinely new, out-of-scope-but-notable additions land on GetMySessionsAndRegisters/OpenRegister: (1) OpenedTime is now emitted as an explicit UTC ISO-8601 round-trip string (DateTime.SpecifyKind(..., Utc).ToString("O")) instead of a zone-less yyyy-MM-dd HH:mm:ss, fixing a bug where the client's new Date(...) read the naive string as local time and displayed a just-opened register's time hours off; this entry doesn't pin an OpenedTime format so no edit is needed here, but it's worth noting on whichever entry documents the Open Sessions card fields. (2) OpenRegister now seeds each new balance's ExpectedAmount to the opening float (ExpectedAmount = openingAmount / = request.OpeningCashAmount) instead of leaving it at 0, plus a new private helper recomputes ExpectedAmount on close paths ahead of the debounced Hangfire recompute — cash-reconciliation math, not session sync; belongs on session-lifecycle.md or cash-handling.md. Also re-confirms (already logged 2026-08-03) the PosDefaultViewMode/SoftEnforcePaymentReference additive fields on SessionDto — same commit, no new assessment needed. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-04 — foodops.counter.app a00083c..37ad39c (extends the 2026-08-03 pass, which covered a00083c..2e88679's SessionPaymentsTab admin-link and sessionSlice snapshot-field additions — both already assessed as out of scope). Two further out-of-scope-but-notable additions land inside this entry's declared globs; neither touches the documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched by this diff). (1) SessionDetailsModal.tsx (src/components/session/) adds a new close-time gate: isVoidBinGate detects a 'void bin' message in the close-register response and — instead of the existing financial-posting FailedPostingsDialog path — closes both open dialogs and shows a toast with a 'Review' action that stamps sessionStorage 'foodops_open_discard' and navigates to /sessions/{id}/dinein. New SessionDetailsModal.forceClose.test.tsx confirms this fires even for blind-count users, i.e. it is NOT bypassed by the blind force-close path. This mirrors the financial-posting gate pattern already noted 2026-07-08 as out of scope; it's close-validation UX for the new 'void bin' feature (per this commit range's other subjects, 'feat: void bin offline plumbing'), not cross-device broadcast/lockout — belongs on session-lifecycle.md or a dedicated Void Bin entry, not here. (2) signalRService.ts (src/services/signalr/) adds a KotStatusChanged handler dispatching a 'kot-status-changed-broadcast' window event for register-level auto-print-KOT toggles pushed from RestaurantSettingsController — same layering as the existing settings-class broadcasts (e.g. outlet-online-orders-toggled), sharing the hub connection this entry's 'SignalR transport' key concept describes but unrelated to session open/close/join. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-03 — hubits-api-sales 144add7..df4179d (report-export refactor commits; the intersecting hunk is incidental) adds two new org-scoped fields to SessionDto, stamped onto every entry in GetMySessionsAndRegisters's OpenSessions list: PosDefaultViewMode ('tile'|'compact', from the Outlets General 'Default to Tile View' setting, default 'compact') and SoftEnforcePaymentReference (from the Outlets General 'Soft Enforce Payment Reference Number' setting, default false — when true the counter requires a reference for Transfer payments). Both are read once per request via new PosViewSettings.ReadDefaultViewMode/PaymentReferenceSettings.IsSoftEnforcedAsync helpers and are explicitly additive (comment: 'foodops clients ignore it'). This is out of scope for this entry's documented cross-device SYNC contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are all untouched by this diff — it's new session-payload data for seeding POS layout/payment-reference defaults, not sync behavior. No frontend hunk was included in this diff, so whether foodops.counter.app currently reads these two new fields is unverified. Worth a note on a Counter POS-settings entry (or session-lifecycle.md) once the frontend consumption is confirmed — not here. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-03 — foodops.counter.app a00083c..2e88679: two changes land on this entry's declared globs; neither touches the documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched by this diff). (1) SessionPaymentsTab.tsx (src/components/session/**) makes each payment's Transaction Ref a clickable link out to admin.foodops.io's payment detail page (/sales/payments/{transactionId}) when a transactionId is present, falling back to plain text otherwise (new adminAppUrl import, new SessionPaymentsTab.adminLink.test.tsx). This is new user-facing behavior on the session Payments tab, but it's an admin cross-link, not a cross-device sync/broadcast change — worth a note on session-lifecycle.md or a Payments-tab-specific entry, not here. (2) sessionSlice.ts's detectCurrentSession thunk (a declared glob) extends the existing offline-hard-refresh snapshot/restore mechanism (previously only isQuoteModeEnabled) to also persist and restore posDefaultViewMode and softEnforcePaymentReference, so the org's default POS layout and the transfer-payment-reference-enforcement flag survive an offline hard-refresh. This is a same-pattern extension of session-snapshot hydration, not a change to SessionOpened/SessionClosed broadcasts, Join, lockout, self-close suppression, or reconnect revalidation — out of scope for this entry, but flagged per the established pattern since it lands inside the declared src/store/slices/sessionSlice.ts glob. No edit required to this entry's cross-device sync claims. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-01 — hubits-api-sales bb69312..0d94f42: two backend additions land, neither contradicts this entry's documented SessionOpened/SessionClosed/Join/lockout/self-close/reconnect-revalidation contract (SessionsController's existing broadcast methods are untouched — only new methods appended after them). (1) New GET /sessions/{sessionId}/close-summary and /close-summary/pdf endpoints (SessionsController.cs), backed by a new SessionCloseSummaryDto reusing DaySummaryReportService so the figures reconcile with the Day Summary report, plus HTML->PDF rendering. This is the server side of the 'Session Summary' print/PDF capability already flagged as new user-facing behavior in the 2026-07-31 verification note (counter.app's getCloseSummary/getCloseSummaryPdf) — confirms it shipped end-to-end. Still belongs on session-lifecycle.md or a dedicated entry, not here, since it's close-flow reporting, not cross-device session sync. (2) New OrderEventEnvelope/OrderEventPublisher classes formalize order-hub broadcasts with routing fields (sessionId, restaurantTableId) and a scope flag (status vs content) so terminals can drop foreign-session events or patch in place instead of refetching — this is the backend counterpart to the frontend isForeignSessionEvent/OrderEventEnvelope gate already assessed as out-of-scope on 2026-07-31. The envelope's SessionId-based routing is consistent with that prior assessment: two devices sharing a Joined session still match on sessionId and continue to see each other's order events, so this entry's 'Orders submitted on either device propagate to the other... over the order hubs' claim remains accurate. Neither change touches SessionOpened/SessionClosed, Join, SessionLockoutGuard, selfCloseTracker, or reconnect revalidation. No edit required to this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-31 — foodops.counter.app 5468530..447007a: two out-of-scope-but-notable changes land on this entry's declared globs; neither touches the documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched by this diff). (1) sessionApiService.ts gains getCloseSummary/getCachedCloseSummary/getCloseSummaryPdf — a new offline-cached 'Session Summary' print + server-rendered PDF capability for the register-close flow, mirroring the existing getSessionDetails offline-first pattern. This is new user-facing behavior worth documenting, but on session-lifecycle.md or a dedicated entry, not here — it's close-summary reporting, not SignalR session sync. (2) signalRService.ts adds an OrderEventEnvelope sessionId/scope gate (isForeignSessionEvent, currentSessionId, statusPatchFromEnvelope) that drops ORDER events (OrderUpdated/OrderPaid/OrderClosed/OrderCancelled) belonging to a session other than the terminal's current one, replacing a refetch-on-every-event pattern that was saturating the DB at rush (per new orderEventSessionGate.test.ts). This is a performance fix to the order hub, not the session-open/close/join broadcasts this entry documents — the gate fails open when sessionId is absent/unknown, and two devices sharing a Joined session still see each other's order events normally since their sessionId matches, so this entry's 'Orders submitted on either device propagate to the other... over the order hubs' claim is unaffected. Also bundled: a fix so onOrderCancelled no longer strips the connection-level IDB-mirror handler when a page subscribes (an off()-with-no-args regression), and MenuItemsUpdated no longer toasts — both isolated to non-session order/menu handling with no session-lifecycle impact. No edit required to this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — foodops.counter.app c4cf5dc..5468530 ships 'blind cash counting': users without the ViewExpectedAmount permission no longer see Expected/Difference figures anywhere in session UI (CashReconciliationModal, CloseRegisterModal, SessionBalancesTab/OverviewTab/ReconciliationTab/HandoverTab, SessionOrdersTab's aggregate totals) — gated by new canViewExpectedCash/canSeeExpectedForSession hooks — and the close button/copy changes from 'Close Register'/'Closing...' to 'Submit Count'/'Submitting...'. This is out of scope for this entry's documented cross-device SYNC contract (SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, reconnect revalidation are all untouched by this diff) but lands inside the declared src/components/session/** glob, so flagging here per the established pattern. Worth a maintainer's attention: for blind-counting users with a variance, SessionDetailsModal now calls approveBlindClose()sessionApiService.approveClose(sid) directly and immediately after closeRegister returns (skipping the interactive recount modal, since recount would leak the hidden target), landing on a new read-only 'Variance Summary' (VarianceSummaryModal). Whether this same-device approveClose call is equivalent to the supervisor sign-off this entry's own Walkthrough describes ('every cashier-driven close awaits supervisor approval, regardless of variance', citing SessionsController.cs:1862-1872) or is a separate cashier-side commit step that still leaves the session gated for a later human approval elsewhere could not be determined from this frontend-only diff (no hubits-api-sales hunks). This should be checked against the API and documented on session-lifecycle.md (or a new 'Blind Cash Counting' entry) — not here, since this entry's scope is the SignalR session-sync contract, not the close/approval state machine or reconciliation UI. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-19 — foodops.counter.app b43110b..50c4771: NEEDS_EDIT on the documented reconnect backoff. signalRService.ts (and orderingHubService.ts, printHubService.ts) swapped the fixed [0, 2000, 5000, 10000, 30000] reconnect ladder for a new jitteredReconnectPolicy()/fullJitterDelay() (new file reconnectJitter.ts, Issue 2.9) — a full-jitter delay randomized between 0 and an exponentially-scaled cap (1s,2s,4s,... capped at 30s), intended to decorrelate fleet-wide reconnect storms. This directly contradicts the entry's cited '0s/2s/5s/10s/30s' schedule in the Walkthrough and the 'connection drops mid-shift' Q&A (both edited above); the 2026-06-29 verification-history line citing the same fixed schedule is left as-is since it's a dated historical snapshot, not a live claim. Everything else in this diff is out of scope for this entry's cross-device sync contract: (1) SessionDetector.tsx gained a large temp-session-id → real-id resolution path (open-register auto-join fix) — this addresses a same-device race between offline sync and a missed one-shot event, not the SignalR session broadcast/lockout/Join contract this entry documents, and does not touch the focus-throttled revalidation code this entry cites at SessionDetector.tsx:24-50; (2) SessionDetailsModal.tsx now also clears the /sessions/history cache key on close/cancel-close — a Sessions-history-list cache-freshness fix, arguably belongs on session-lifecycle.md; (3) signalRService.ts gained new BillSplitCreated/BillSplitCancelled/SplitPartPaid broadcast handlers sharing the same hub connection this entry's 'SignalR transport' key concept describes, but they're unrelated to session open/close/join — a bill-split feature doc's territory; (4) printHubService.ts also adopted the jitter policy and added an onreconnected window-event dispatch, entirely outside this entry's session-hub scope. No other session-open/close/join/lockout/self-close behavior changed. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-09 — Two unrelated changes landed on the declared session globs; neither touches this entry's documented cross-device sync contract. (1) foodops.counter.app PR #235 broadens SessionDetector's one-time self-heal re-detect (the 'already holding this session' branch) from only backfilling a missing outletId to also backfilling isQuoteModeEnabled when undefined, and adds an explicit guard skipping temp_* session ids (a temp id has no server record to enrich from, and each detect flips session.loading, which was remounting SessionDetector and looping /sessions/my). This is a POS-catalog/Quote-button data-hydration fix on page refresh, not a SignalR/broadcast/lockout change — it is a different code path from the focus-throttled revalidation this entry cites at SessionDetector.tsx:24-50 (Edge cases > Backgrounded tabs; Walkthrough > Connection-drop recovery step 4), which this diff does not touch. (2) foodops.counter.app@e4e7aaa adds FailedPostingsDialog/salesPostingService and a sessionApiService.closeRegister structured-400 handler for dead-lettered financial postings blocking close — already assessed as out-of-scope for this entry in the 2026-07-08 verification-history note (close-validation UX, not cross-device broadcast/lockout); no new assessment needed. Recommend a note on session-lifecycle.md or a POS-catalog-hydration entry if a maintainer wants the SessionDetector self-heal behavior documented somewhere — not here. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-08 — NEW close-time gate found in [email protected] (out of scope for this entry's sync contract, but touches the declared src/components/session/** / src/services/session/** globs so flagging here). Close-register attempts can now be blocked by dead-lettered financial postings (sales whose async invoice/payment/journal/stock posting failed every retry): sessionApiService.closeRegister now catches the server's structured HTTP 400 {success:false, message} gate response and returns it as a normal {success:false} result instead of throwing; SessionDetailsModal detects the financial-posting-gate message pattern (/financial posting|still posting/i) and opens a new FailedPostingsDialog (new component, backed by new salesPostingService hitting GET /v1/sales/posting/dead-letter and POST /v1/sales/posting/{id}/retry) that lists the stuck sales for that session and lets the cashier retry one or all, closing the register again once cleared. This does NOT change anything in this entry's documented cross-device sync contract — SessionOpened/SessionClosed broadcasts, Join, SessionLockoutGuard, selfCloseTracker, and reconnect revalidation are untouched by this diff — so no edit to the existing walkthrough/Q&A is required. Worth a note or new Key-concept bullet only if a future maintainer wants this close-failure UX documented (arguably belongs on session-lifecycle.md or a dedicated 'Failed Financial Postings' entry rather than here, since it's about close validation, not cross-device broadcast/lockout behavior). (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-06 — hubits-api-sales@e31810a adds operation-log audit coverage to the session lifecycle: OpenRegister now calls newSession.TouchCreate("Register opened."), and CloseRegister/CancelClose/AdjustRegister each call session.Touch(...) recording close/cancel/adjust events with actor, timestamp, and optional comments. A new GET /sessions/{sessionId}/history endpoint returns this audit timeline (Opened/Updated entries with actor, remarks, and parsed field-level diffs from OperationLog.ExtraDetails), mirroring the catalog/finance History tabs. This is new, worth documenting as a 'Session Audit History' capability — but note it does NOT touch the Join action: no Touch call was added to the join endpoint in this diff, so the entry's existing claim that 'a full join history surface was not found in current code' remains accurate as written (the new history covers open/close/cancel/adjust, not who-joined-when). Recommend a new Key-concepts bullet and/or Q&A once a maintainer confirms whether join events are separately surfaced elsewhere, and whether this history endpoint has a corresponding UI surface (a 'History' tab) worth screenshotting. (auto-applied by the truth pipeline; adversarially gated)

Warning: Re-checked 2026-05-04 · codebase only — entry then stub / low confidence Verified_by: codebase · Last verified: 2026-05-04 · Verified against haafai/foodops.counter.app@73a5a08 Entry authored from source-control changelog on 2026-05-03; re-checked on 2026-05-04. All declared source globs still exist; one new commit on the signalr glob (1337cbe — orderingHub subscription) was triaged as not behavior-affecting for cross-device session sync. UI-level claims were marked TODO pending a Playwright walk.

  • 2026-06-03 — codebase: CHANGED (close/lockout). Re-verified against hubits-api-sales@5274891 and foodops.counter.app@e002374. The cross-device close path now: (a) lands the session in the new PendingClose status; (b) broadcasts SessionClosed carrying status + hasDifferences; (c) SessionLockoutGuard shows a "Register Closed" dialog with distinct copy for the variances-pending-review case. These lockout-message strings are code-confirmed. Updated the "Closing the register from any device" walkthrough and two Q&As. The open/attach/reconnect-revalidation behaviour otherwise unchanged at that pass.

  • 2026-06-11 — codebase: promoted stub → draft against foodops.counter.app@4cbcfd7 + hubits-api-sales@5d917e5. Full re-verification of the sync contract with corrected line pins (PendingClose :1818, SessionClosed broadcast :1825-1846, SessionOpened :820-845, lockout copy SessionLockoutGuard.tsx:111-114). CORRECTIONS vs the 2026-05-03 stub: (1) attach is an explicit Join button on the Counter page's Open Sessions cards, backed by POST /sessions/join whose exclusive-lock rejection is commented out — soft last-joiner-wins lock, no takeover modal; (2) there is no timed "lockout window" — the open race is settled by a "Register already has an open session" existence check; (3) the draft cart does not sync across devices (no cart-level SignalR event) — submitted orders sync via the order hubs; (4) broadcast scoping confirmed per-organisation (HubGroups.Organization); (5) selfCloseTracker TTL confirmed 10s; reconnect backoff 0/2/5/10/30s; SessionDetector focus backstop throttled to 5 min. Confidence raised to medium (all behaviour code-confirmed; live screenshots still pending).

  • 2026-06-29 — LAUNCH RE-VERIFY (new-stack only). Re-pinned to foodops.counter.app@32669a9, hubits-api-sales@19190b9. Verdict ACCURATE — re-confirmed every behaviour at current HEAD: SignalR session events still only SessionOpened/SessionClosed (signalRService.ts:286,290 — no join broadcast); reconnect backoff [0,2000,5000,10000,30000] (signalRService.ts:193); selfCloseTracker TTL_MS = 10_000; SessionLockoutGuard "Register Closed" + variances-pending-review / plain copy verbatim (L113-114,133); SessionDetector focus revalidation throttled to 5 min (L39); open-race "Register already has an open session" (SessionsController.cs:712-719); close→PendingClose @1872; SessionOpened broadcast @823-848, SessionClosed broadcast @1888-1900 (per-org HubGroups.Organization). NEW_INFO: ADR 0009 adds session entry (open/join/switch) as a force-update trigger — a safe-state-gated nuke-and-reload to fresh code, never over unsynced work (added a Key-concept bullet). Repinned the drifted SessionsController line cites; counter.app cites verified still in range.

Additional verification events are appended here by the weekly KB sync and by manual re-verification passes. See _meta/failure-log.md for the global failure index.

Go beyond POS.
Choose the total solution.

Focus on creating the best experience for your guests while we handle the rest.