Three related correctness gaps in MatchLobbyStore that combine to suppress or mis-time the active-match lobby experience.
currentMatch picks newest-created match with no status priority, suppressing the active-match alert
Location: stores/MatchLobbyStore.ts:448 (web)
What: currentMatch is computed as myMatches.value.at(0), and the subscribeToMyMatches query orders results purely by created_at desc (lines 343-347) with no status weighting. The sole consumer that nags the player to go to their live game, MatchActiveAlert.vue, reads useMatchLobbyStore().currentMatch (MatchActiveAlert.vue:118-119) and only shows when that single match's status is in [WaitingForCheckIn, Veto, Live] (MatchActiveAlert.vue:104-126). When the most-recently-created match in myMatches is a non-alertable status (e.g. Scheduled, which the cutoff filter at lines 382-391 admits once it is within an hour of kickoff), currentMatch resolves to it and isAlertable is false, so the alert never fires even though an older alertable (Live / Veto / WaitingForCheckIn) match is also present deeper in the list.
Impact: A player is in a Live match M1 (created earlier) and is then scheduled into a second match M2 (Scheduled, kickoff in 30 min, so it passes the 1h cutoff and M2.created_at > M1.created_at). myMatches = [M2(Scheduled), M1(Live)] by created_at desc, so currentMatch = M2. MatchActiveAlert sees status Scheduled, isAlertable is false, and the player gets no ready/return-to-match alert for the ongoing Live match M1.
Suggested fix: Select currentMatch by status priority (prefer Live/Veto/WaitingForCheckIn over Scheduled) rather than raw created_at ordering, or have MatchActiveAlert scan myMatches for the first alertable match instead of relying on index 0.
Verifier evidence
MatchLobbyStore.ts:343-347 order_by: [ { created_at: order_by.desc } ] (no status weighting). MatchLobbyStore.ts:384-390 admits Scheduled within 1h: if (match.status !== e_match_status_enum.Scheduled) return true; return !!match.scheduled_at && new Date(match.scheduled_at).getTime() <= cutoff; with cutoff = Date.now() + 60*60*1000 (line 382). MatchLobbyStore.ts:448-450 currentMatch: computed(() => { return myMatches.value.at(0); }). MatchActiveAlert.vue:118-119 match(): any { return useMatchLobbyStore().currentMatch; }. MatchActiveAlert.vue ALERT_STATUSES = [WaitingForCheckIn, Veto, Live]; isAlertable(): boolean { return !!this.match && ALERT_STATUSES.includes(this.match.status); } and shouldShow(): boolean { if (!this.isAlertable) return false; ... }.
Scheduled-match cutoff filter is evaluated only on subscription push, so a match never enters the lobby at T-1h on its own
Location: stores/MatchLobbyStore.ts:382 (web)
What: In the subscribeToMyMatches next handler, const cutoff = Date.now() + 60 * 60 * 1000 is computed and applied to filter out Scheduled matches whose scheduled_at is more than one hour away. Because this recomputation only happens when the Hasura subscription delivers a new payload, and the payload does not change merely because wall-clock time advances, a match scheduled more than an hour out is filtered out and will not reappear in myMatches when it later falls inside the one-hour window until some unrelated field on the row changes and triggers a fresh push.
Impact: A match is scheduled for 90 minutes from now; the subscription payload arrives once and the match is filtered out. Fifty minutes later it is well within the 1h window and should appear in the lobby nav / MyUpcoming, but no new subscription event has fired, so myMatches still omits it. It only surfaces once its status changes (e.g. to WaitingForCheckIn) or another column mutates.
Suggested fix: Store the unfiltered matches and derive the visible list through a computed that also depends on a periodically-updated clock ref (e.g. a 30-60s interval), so the cutoff is re-evaluated over time rather than only on subscription pushes.
Verifier evidence
stores/MatchLobbyStore.ts:382 const cutoff = Date.now() + 60 * 60 * 1000;
stores/MatchLobbyStore.ts:383-391 filter assigning myMatches.value = (data?.matches ?? []).filter(... new Date(match.scheduled_at).getTime() <= cutoff), only place myMatches is set.
stores/MatchLobbyStore.ts:330-339 subscription where clause constrains by status: { _in: [...Scheduled...] } only (no scheduled_at bound), so server pushes far-future scheduled matches.
No setInterval/clock ref in file (grep: only Date.now at 382). subscribeToMyMatches called once: AuthStore.ts:159 useMatchLobbyStore().subscribeToMyMatches();
myMatches subscription captures the caller's role once and never resubscribes on role change
Location: stores/MatchLobbyStore.ts:321 (web)
What: subscribeToMyMatches builds its GraphQL where-clause using useAuthStore().isRoleAbove(e_player_roles_enum.match_organizer) evaluated synchronously at subscription-construction time (line 321). The subscription is started exactly once behind AuthStore's postAuthSubscriptionsStarted guard (AuthStore.ts:156-165) and is never torn down/rebuilt when the player's role changes. AuthStore's setMe only calls socket.rejoinAll() on a role change (AuthStore.ts:111-119); it does not resubscribe the Hasura subscriptions. So the organizer branch of the filter is frozen at whatever the role was at first login.
Impact: A user logs in as a plain user (isRoleAbove(match_organizer) === false), so the organizer arm of the _or omits the is_in_lineup constraint. An admin later promotes them to match_organizer during the same session; the live me subscription updates the role but the myMatches subscription keeps its original where-clause, so their organizer/lineup match visibility in the lobby nav is wrong until a full page reload.
Suggested fix: Rebuild (unsubscribe + resubscribe) the myMatches subscription when me.role changes, or express the role-dependent branch via a subscription variable rather than baking it into the query at construction time.
Verifier evidence
MatchLobbyStore.ts:321-329 conditional is_in_lineup based on isRoleAbove; caller AuthStore.ts:159 guarded by postAuthSubscriptionsStarted set once at 157; setMe only calls socket.rejoinAll (AuthStore.ts:116-118); rejoinAll re-joins websocket rooms only (Socket.ts:199-203).
Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P2/P3). One of a batch of findings from that pass.
Three related correctness gaps in MatchLobbyStore that combine to suppress or mis-time the active-match lobby experience.
currentMatch picks newest-created match with no status priority, suppressing the active-match alert
Location:
stores/MatchLobbyStore.ts:448(web)What:
currentMatchis computed asmyMatches.value.at(0), and thesubscribeToMyMatchesquery orders results purely bycreated_at desc(lines 343-347) with no status weighting. The sole consumer that nags the player to go to their live game, MatchActiveAlert.vue, readsuseMatchLobbyStore().currentMatch(MatchActiveAlert.vue:118-119) and only shows when that single match's status is in[WaitingForCheckIn, Veto, Live](MatchActiveAlert.vue:104-126). When the most-recently-created match inmyMatchesis a non-alertable status (e.g. Scheduled, which the cutoff filter at lines 382-391 admits once it is within an hour of kickoff),currentMatchresolves to it andisAlertableis false, so the alert never fires even though an older alertable (Live / Veto / WaitingForCheckIn) match is also present deeper in the list.Impact: A player is in a Live match M1 (created earlier) and is then scheduled into a second match M2 (Scheduled, kickoff in 30 min, so it passes the 1h cutoff and M2.created_at > M1.created_at).
myMatches= [M2(Scheduled), M1(Live)] by created_at desc, socurrentMatch= M2. MatchActiveAlert sees status Scheduled,isAlertableis false, and the player gets no ready/return-to-match alert for the ongoing Live match M1.Suggested fix: Select
currentMatchby status priority (prefer Live/Veto/WaitingForCheckIn over Scheduled) rather than raw created_at ordering, or have MatchActiveAlert scanmyMatchesfor the first alertable match instead of relying on index 0.Verifier evidence
MatchLobbyStore.ts:343-347
order_by: [ { created_at: order_by.desc } ](no status weighting). MatchLobbyStore.ts:384-390 admits Scheduled within 1h:if (match.status !== e_match_status_enum.Scheduled) return true; return !!match.scheduled_at && new Date(match.scheduled_at).getTime() <= cutoff;withcutoff = Date.now() + 60*60*1000(line 382). MatchLobbyStore.ts:448-450currentMatch: computed(() => { return myMatches.value.at(0); }). MatchActiveAlert.vue:118-119match(): any { return useMatchLobbyStore().currentMatch; }. MatchActiveAlert.vue ALERT_STATUSES = [WaitingForCheckIn, Veto, Live];isAlertable(): boolean { return !!this.match && ALERT_STATUSES.includes(this.match.status); }andshouldShow(): boolean { if (!this.isAlertable) return false; ... }.Scheduled-match cutoff filter is evaluated only on subscription push, so a match never enters the lobby at T-1h on its own
Location:
stores/MatchLobbyStore.ts:382(web)What: In the
subscribeToMyMatchesnexthandler,const cutoff = Date.now() + 60 * 60 * 1000is computed and applied to filter out Scheduled matches whosescheduled_atis more than one hour away. Because this recomputation only happens when the Hasura subscription delivers a new payload, and the payload does not change merely because wall-clock time advances, a match scheduled more than an hour out is filtered out and will not reappear inmyMatcheswhen it later falls inside the one-hour window until some unrelated field on the row changes and triggers a fresh push.Impact: A match is scheduled for 90 minutes from now; the subscription payload arrives once and the match is filtered out. Fifty minutes later it is well within the 1h window and should appear in the lobby nav / MyUpcoming, but no new subscription event has fired, so
myMatchesstill omits it. It only surfaces once its status changes (e.g. to WaitingForCheckIn) or another column mutates.Suggested fix: Store the unfiltered matches and derive the visible list through a computed that also depends on a periodically-updated clock ref (e.g. a 30-60s interval), so the cutoff is re-evaluated over time rather than only on subscription pushes.
Verifier evidence
stores/MatchLobbyStore.ts:382
const cutoff = Date.now() + 60 * 60 * 1000;stores/MatchLobbyStore.ts:383-391 filter assigning
myMatches.value = (data?.matches ?? []).filter(... new Date(match.scheduled_at).getTime() <= cutoff), only place myMatches is set.stores/MatchLobbyStore.ts:330-339 subscription where clause constrains by
status: { _in: [...Scheduled...] }only (no scheduled_at bound), so server pushes far-future scheduled matches.No setInterval/clock ref in file (grep: only Date.now at 382). subscribeToMyMatches called once: AuthStore.ts:159
useMatchLobbyStore().subscribeToMyMatches();myMatches subscription captures the caller's role once and never resubscribes on role change
Location:
stores/MatchLobbyStore.ts:321(web)What:
subscribeToMyMatchesbuilds its GraphQL where-clause usinguseAuthStore().isRoleAbove(e_player_roles_enum.match_organizer)evaluated synchronously at subscription-construction time (line 321). The subscription is started exactly once behind AuthStore'spostAuthSubscriptionsStartedguard (AuthStore.ts:156-165) and is never torn down/rebuilt when the player's role changes. AuthStore'ssetMeonly callssocket.rejoinAll()on a role change (AuthStore.ts:111-119); it does not resubscribe the Hasura subscriptions. So the organizer branch of the filter is frozen at whatever the role was at first login.Impact: A user logs in as a plain user (isRoleAbove(match_organizer) === false), so the organizer arm of the
_oromits theis_in_lineupconstraint. An admin later promotes them to match_organizer during the same session; the livemesubscription updates the role but the myMatches subscription keeps its original where-clause, so their organizer/lineup match visibility in the lobby nav is wrong until a full page reload.Suggested fix: Rebuild (unsubscribe + resubscribe) the myMatches subscription when
me.rolechanges, or express the role-dependent branch via a subscription variable rather than baking it into the query at construction time.Verifier evidence
MatchLobbyStore.ts:321-329 conditional is_in_lineup based on isRoleAbove; caller AuthStore.ts:159 guarded by postAuthSubscriptionsStarted set once at 157; setMe only calls socket.rejoinAll (AuthStore.ts:116-118); rejoinAll re-joins websocket rooms only (Socket.ts:199-203).
Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P2/P3). One of a batch of findings from that pass.