src/switcher/state/WindowEventReducerSpaceSpecs.md
Pins the SPLIT between the two reactions to a Space switch: spaceTransitionStarted (the leading edge of
the 1329/1401 burst) and spaceChangeSettled (its trailing edge, 250ms later). Specs + Tests without a
same-named kernel, like WindowEventReducerPhantom: the subject is which effects each branch emits, not a
pure function of its own.
Driven through WindowEventReducer.reduce directly. The replay harness cannot judge this — it records both
branches' requests into the same pendingRequests bucket and swallows .refreshUi entirely as display-side,
so a scenario replay sees no difference between the two.
v11.3.1 reacted to a Space switch on the LEADING edge of NSWorkspace.activeSpaceDidChange. The WindowServer
migration replaced that with a trailing-only 250ms debounce, so the active Space stayed stale for ~273ms after
every switch (measured live) and a Cmd+Tab inside that window was filtered, sorted and DISCOVERED against the
Space the user had just left. That is not merely a display filter: Window.init defaults a new window to
[Spaces.currentSpaceId] and the Space-join branch gates promotion on visibleSpaces, so a stale active
Space poisons the model exactly while the arriving Space's windows are being reported.
The reaction is therefore split by cost, and the split is what these tests hold in place:
The trap the first test guards. The leading edge must NOT repaint. App.refreshOpenUiAfterExternalEvent
is throttled at 200ms leading-edge, so a repaint fired the instant the Space flips SPENDS that edge, and the
update that actually matters — the arriving Space's focus 808, which lands 14–67ms later and re-orders the
tiles — then waits out the tail. Measured live with the switcher open across a transition: it pushed the MRU
correction from 19ms to 220ms after the summon. It looks free and it is not.
.spaceTransitionStarted emits exactly
[.refreshSpacesTopology]: no repaint (the 200ms-throttle trap above), and none of the settled branch's
expensive work..spaceChangeSettled still emits the
per-window Space sync, the WindowServer state re-query for every tracked window, the shortcut re-check and
the repaint. Collapsing the two branches into one would either run this storm-time work early or lose it.syncSpacesState captures the tracked wid list on main, does its Space enumeration plus per-window backfill
off-main, and applies the result when it lands. A window discovered in that gap is in the model but was never
part of the question, so the pass has nothing to say about it unless its Space enumeration happened to list it.
Treating that silence as an answer turned it into a verdict — "CGS places this window nowhere", the strong
phantom signal — and hid a window whose own discovery had just read its Space correctly, until a later pass
happened to cover it. The input now carries the wids it queried, and only those are wiped by its silence.
queried keeps its Space and stays
shown.queried that the map does not place is
wiped and turns phantom: that is CGS answering "no Space", the evidence that retires dead group members and
feeds the dead-window sweep.CGSCopySpacesForWindows answers a non-NULL empty array for a wid CGS has no record of at all (measured
on macOS 26: wid 0, 1, 999999 and UINT32_MAX all answer []). So "this window is on no Space", "there is no
such window" and a read that found nothing arrive as one value, and the strong phantom signal hid the window
on all three, permanently, since nothing re-derives membership afterwards (#5954). syncSpacesState now
corroborates the wids it could not place against the WindowServer, which omits a wid it does not know and
reports a non-zero spaceTypeMask for one it places, and passes the contradictions to the reducer.