# Phase 10: Cutover & Cleanup — Research

**Researched:** 2026-05-10
**Domain:** Win32 tray-icon state machine, named-mutex single-instance, atomic log rotation, CMake/Inno Setup co-versioning, debug-build-only HTTP routes
**Confidence:** HIGH
**Research flag (ROADMAP):** STANDARD — deletion phase; all primary mechanisms have in-tree precedent

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

The 27 decisions D-01..D-27 captured in `10-CONTEXT.md` `<decisions>` are firm. The non-negotiable kernel for this research:

- **D-01 atomic single-plan cutover (Wave 5).** Five deletions + flag flip + three lint go-lives in **one commit**. No staged variants. Mirrors P8 D-07 / P9 D-23 multi-target.
- **D-04 three distinct `.ico` resources** (`tray_armed.ico` / `tray_triggered.ico` / `tray_error.ico`); `Shell_NotifyIconW(NIM_MODIFY, NIF_ICON)` swap. No overlay compositing, no animated frame sequence.
- **D-05 state derivation** is a pure function of existing `/health` + `/state` envelope; no new poll, no new endpoint.
- **D-07 / D-08 inline pills in driver-health pane**, priority-stacked, single topmost. No modals, no balloons, no multi-error aggregation.
- **D-09 FAIL-04 named mutex** at `WinMain` very-early; existing implementation at `apps/micmap/main.cpp:1461-1485` is the **starting point** (already named `L"MicMapSingleInstance"` with `FindWindowW(L"MicMapMain", ...)` + `SetForegroundWindow`). Wave 3 hardens this surface.
- **D-11 `POST /debug/trigger` registered only in Debug builds** via `MICMAP_DEBUG_BUILD` define; pushes single `TapCommand{}` to existing `CommandQueue` from HTTP thread.
- **D-12 `--debug-trigger` short-circuit** at `WinMain` mirrors `apps/mic_test/main.cpp:150 tryRunReplayCli`. Exit codes: `0`=200, `1`=non-200, `2`=ECONNREFUSED.
- **D-14..D-17 log rotation** synchronous on each `FileLogSink::write()`, 5MB cap, 5 generations, `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`. Failure-mode: log warning to non-file sink, continue at oversize.
- **D-18..D-22 INST-09**: `cmake/version.cmake` SSoT → `MICMAP_VERSION_STRING` compile define + `VS_VERSION_INFO` resource on both binaries + generated `installer/version.iss` consumed by Inno Setup `#include`. `GET /health` gains `driver_version`. Mismatch = warn-only pill, never blocks.
- **D-23 / D-24** three new lints: `AssertNoClientDetection`, `AssertNoButtonRoute`, `AssertCoVersioning`. RED-tolerant Wave 0; enforcing Wave 5 (lint go-live in cutover commit) and Wave 6 (`AssertCoVersioning`).
- **D-25 UAT** = 15 items on Bigscreen Beyond + Win11 Pro. **NO post-UAT default-OFF restore** — P10 OWNS the flip.
- **D-26 / D-27 wave layout**: 10-00 → 10-01 → (10-02 || 10-03 || 10-04 in parallel) → 10-05 CUTOVER → 10-06 → 10-07. Cutover wave's dependency on 02/03/04 is firm.

### Claude's Discretion (researched and recommended below; planner picks)

- FAIL-02 vs FAIL-03 disambiguation when both surface as `ECONNREFUSED` — see **§Tasklist enumeration cost**.
- Tray-icon `.ico` artwork sourcing — see **§Tray-icon assets**.
- `IDriverApi::debugTrigger()` shape (virtual w/ `#if` guards vs free function) — see **§Debug-build define wiring**.
- Inno Setup `version.iss.in` template format — see **§Inno Setup ISPP `#include` flow**.
- `MICMAP_DEBUG_BUILD` mechanism — see **§Debug-build define wiring**.
- Pulse-icon depth (single 300ms flash vs frame sequence) — see **§Pulse cadence**.
- Version-mismatch pill placement (separate vs in FAIL stack) — see **§FAIL pill priority + version-mismatch**.
- `--debug-trigger` parameterization — single-purpose recommended; defer to v1.7+.
- Disposition of `apps/micmap/main.cpp:300` startup `loadTrainingData` — delete with the rest of the client-side body per D-01.

### Deferred Ideas (OUT OF SCOPE)

Per `10-CONTEXT.md` `<deferred>`: removing `driver_detection_active` / `driver_training_active` `/health` fields (P11), multi-error aggregation, structured `last_error`, hard-block on version mismatch, `OnDefaultDeviceChanged` follow-the-default, `DeviceNotificationClient` ComPtr migration, FFT-on-every-frame perf, HEALTH-D1/D2/D3, TRAIN-D2/D3, TEST-D2 `/debug/snapshot`, OBS-01 unified log, DIST-01/02/03, DET-01/02, UX-01, UX-02, removing `enable_driver_*` keys entirely, parameterized `--debug-trigger`, MSIX migration, crash-dump capture.

</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| MIG-05 | `POST /button` route deleted; `IDriverApi::tap()` removed; trigger collapses to in-process | §Cutover atomicity, §Lint discipline (`AssertNoButtonRoute`) |
| FAIL-01 | Mic-permission-blocked → "Open Windows mic settings" deep-link | §`ms-settings:` deep-link |
| FAIL-02 | Driver-not-loaded → "Driver not installed — run installer or enable in SteamVR" + Open SteamVR | §`steam:` URI scheme; §FAIL-02/03 disambiguation |
| FAIL-03 | SteamVR-not-running → 1Hz polite poll, no retry storm | §FAIL-02/03 disambiguation; §Tasklist enumeration cost |
| FAIL-04 | Double client instance → second instance foregrounds first, exits silently | §Named-mutex single-instance; §`SetForegroundWindow` cross-process rules |
| FAIL-05 | Audio device removed mid-session → pill + tray-red; auto-recovers on reappear | §Tray glyph state derivation (driver-side recovery already shipped P6/P7) |
| HEALTH-08 | Tray icon glyph reflects armed (green) / triggered (pulse) / error (red), updates within one health-poll cycle | §`Shell_NotifyIconW(NIM_MODIFY, NIF_ICON)`; §Pulse cadence; §TaskbarCreated re-registration |
| TEST-01 | `mic_test.exe` continues to build against `micmap_core_runtime` headless | §Cutover atomicity (KissFFT remains linked into mic_test) |
| TEST-02 | `--debug-trigger` CLI flag → debug-build-gated synthetic trigger without audio | §Debug-build-only HTTP routes; §`WinMain` short-circuit pattern |
| TEST-03 | `micmap-driver.log` rotation: 5MB cap, 5 retained generations, atomic swap | §Atomic log rotation (`MoveFileExW`) |
| TEST-05 | `hmd_button_test.exe` preserved as developer tool | No new research — leave untouched |
| INST-09 | Single installer; both binaries co-versioned; client warns on version mismatch | §INST-09 co-versioning; §Inno Setup ISPP; §`VS_VERSION_INFO` from semver; §`/health.driver_version` |

</phase_requirements>

## Summary

P10 is a deletion + UX-hardening phase on a stack that's been battle-tested through P5–P9. Every primary mechanism has in-tree precedent: `Shell_NotifyIconW(NIM_MODIFY)` already runs in `apps/micmap/first_launch_balloon.cpp:80`, the FAIL-04 mutex pattern already exists at `apps/micmap/main.cpp:1461`, `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` is the v1.5 CFG-04 + P8 D-14 + P9 D-27 standard, the `WinMain` CLI short-circuit is `apps/mic_test/main.cpp:150 tryRunReplayCli`, the getter-callback pattern for `/health` envelope fields is P7 D-09 / P9 D-07, and Inno Setup `/D` defines + `configure_file()` are already wired in `CMakeLists.txt:153`. Research focus is therefore on (a) the failure-mode edges that the existing patterns don't cover, (b) the few genuinely new mechanisms (`MICMAP_DEBUG_BUILD` gating, `VS_VERSION_INFO` from semver, Inno Setup `#include` of generated `version.iss`), and (c) sequencing of the atomic Wave 5 cutover.

**Primary recommendation:** Reuse the in-tree FAIL-04 mutex and `Shell_NotifyIconW` infrastructure verbatim; add `TaskbarCreated` re-registration (currently missing); use `$<CONFIG:Debug>` generator expression for `MICMAP_DEBUG_BUILD` (NOT `if(CMAKE_BUILD_TYPE STREQUAL "Debug")` — multi-config-safe); split `MICMAP_VERSION` into a `VERSION_QUAD` numeric tuple for `VS_VERSION_INFO`; write `version.iss` via `configure_file(... @ONLY)` with `#define MICMAP_VERSION "@MICMAP_VERSION@"`; converge the existing `MICMAP_DRIVER_VERSION` (driver) + `MICMAP_VERSION` (apps/micmap CMake-side only) + ISCC `/D` (installer) into the new `cmake/version.cmake` SSoT.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Tray-glyph rendering | Client EXE (UI thread, message-loop tick) | — | Pure consumer of existing poll envelope; no new IPC. Shell tray API is per-process. |
| FAIL-pill rendering | Client EXE (ImGui render in driver-health pane) | — | Same render pass as P8 D-11 pane; no driver work. |
| Single-instance gate (FAIL-04) | Client EXE (`WinMain` very-early) | — | Process-scoped concern; mutex already exists in tree. |
| `--debug-trigger` short-circuit | Client EXE (`WinMain` very-early) | API/Backend (registers route) | CLI is client; the server it talks to is the driver. |
| `POST /debug/trigger` route | Driver DLL (HTTP server thread → `CommandQueue` push) | — | Same producer pattern as deleted `/button`; SVR-05 invariant unchanged. |
| `driver_version` /health field | Driver DLL (HTTP server, getter callback) | — | Mirrors P7 D-09 `driver_detection_active`, P9 D-07 `driver_training_active`. |
| Version-mismatch comparison | Client EXE (after first /health poll) | — | Compare-only; no driver work. |
| Log rotation | Shared lib `FileLogSink` (in-process, on-write) | — | Same code path runs in driver and client; both consume it. |
| Single-version SSoT | CMake configure-time (`cmake/version.cmake`) | Build artifacts (RC files, version.iss, compile defines) | Build-system concern; no runtime. |
| Three new lints | CMake / CTest (configure + build time) | — | Source-grep guard; no runtime. |
| Default flag flip | Driver DLL (`default.vrsettings`) | — | Driver-side configuration file shipped by installer. |

## Standard Stack

### Already in tree (no additions for P10)

| Library / Subsystem | Version | Purpose | Source |
|---------------------|---------|---------|--------|
| Win32 Shell API | OS-provided | `Shell_NotifyIconW(NIM_ADD/MODIFY/DELETE)` for tray icon | `<shellapi.h>` (linked via `shell32.lib`) `[VERIFIED: Microsoft Learn Shell_NotifyIconW reference]` |
| Win32 Kernel32 | OS-provided | `CreateMutexW`, `MoveFileExW`, `GetFileAttributesExW` | `<windows.h>` (linked via `kernel32.lib`) `[VERIFIED: Microsoft Learn MoveFileExW reference]` |
| Win32 User32 | OS-provided | `FindWindowW`, `ShowWindow`, `SetForegroundWindow`, `AllowSetForegroundWindow`, `LoadImageW`, `RegisterWindowMessageW` | `<windows.h>` (linked via `user32.lib`) `[VERIFIED: Microsoft Learn SetForegroundWindow reference]` |
| Win32 Shell32 | OS-provided | `ShellExecuteW` for `ms-settings:` and `steam:` URI launches | `<shellapi.h>` `[VERIFIED: Microsoft Learn ShellExecuteW reference]` |
| cpp-httplib | v0.20.1 (P8) | `POST /debug/trigger` route registration; `Post()` client call | `external/cpp-httplib` `[VERIFIED: codebase grep — already in tree post-P8]` |
| nlohmann/json | v3.11.2 | `driver_version` field serialization in `/health` JSON | `external/nlohmann_json` `[VERIFIED: codebase grep — already in tree post-P8]` |
| CMake `configure_file()` | CMake 3.20+ | Generates `installer/version.iss` from `version.iss.in`; generates `*.rc` from `*.rc.in` | `[VERIFIED: cmake/install.cmake / apps/micmap/CMakeLists.txt:27 already uses this]` |
| Inno Setup ISPP | 6.7.1+ | `#include` directive, `#define MICMAP_VERSION`, `{#MICMAP_VERSION}` substitution | `[VERIFIED: jrsoftware ISPP docs — search hit `directives_section.html`]` |
| MSVC RC compiler | VS 2022 | Compiles `.rc` → `VERSIONINFO` resource embedded in EXE/DLL | `[VERIFIED: existing apps/micmap/micmap.rc:14-40 already produces VS_VERSION_INFO with hardcoded 1,0,0,0]` |

### No new third-party deps

P10 is a deletion phase; the only additions are 3 `.ico` files + 4 new test executables + 3 new lint scripts + 1 new CMake module (`version.cmake`). KissFFT remains linked into `mic_test.exe` (TEST-01) but is dropped from `micmap.exe` (D-03 binary-size delta).

**Verification:** None of the cited components needs an `npm view` equivalent — these are OS APIs and in-tree libraries already at known-good versions per P8/P9 stack lock.

## Architecture Patterns

### System Architecture Diagram (Wave 5 post-cutover)

```
                         ┌────────────────────────┐
                         │   driver_micmap.dll    │
                         │      (vrserver host)   │
                         │                        │
   AudioWorker ─PCM──▶ SampleRing ─▶ DetectionRunner ─▶ CommandQueue ─▶ RunFrame ─▶ /input/system/click
   (P6 thread)         (P7)         (P7 thread)        (v1.5)          (Valve)     (HMD)
                                                          ▲
                                                          │
                              ┌── HttpServer (cpp-httplib, P7 thread) ──────────────┐
                              │                                                       │
                              │   GET  /health   ── { driver_loaded, driver_version, │
                              │                       driver_detection_active,        │
                              │                       driver_training_active }       │
                              │   GET  /state    ── { detection_state, last_error,   │
                              │                       audio_device_state, ... }      │
                              │   GET  /telemetry/level                              │
                              │   GET/PUT /settings  GET /devices                    │
                              │   POST /state/clear-error                            │
                              │   POST /training/{start,progress,finalize,           │
                              │                   cancel,recompute}                  │
                              │   POST /debug/trigger  ── #if MICMAP_DEBUG_BUILD     │
                              │                          (pushes TapCommand)         │
                              │   ❌ POST /button   ── DELETED Wave 5                │
                              └───────────────────────────────────────────────────────┘
                                                          ▲
                                                          │ HTTP (127.0.0.1:27015)
                                                          │
                         ┌────────────────────────────────┴───────────────────────────┐
                         │                       micmap.exe                            │
                         │   (Win32 + ImGui + D3D11 — NO audio, NO FFT, NO state)     │
                         │                                                              │
                         │  WinMain entry order:                                        │
                         │   1. Logger composition root (P8 LIB-04)                     │
                         │   2. Parse CLI flags (--register-vrmanifest, --patch-…,      │
                         │      ❶ --debug-trigger  ← NEW Wave 4)                        │
                         │   3. ❷ Named-mutex FAIL-04  ← HARDENED Wave 3                │
                         │   4. RegisterClassExW + CreateWindowW                        │
                         │   5. Tray icon NIM_ADD with tray_armed.ico (Wave 2)          │
                         │   6. Driver-health poll loop (1Hz /health, 2Hz /state)       │
                         │      ↳ ❸ Tray glyph state-swap NIM_MODIFY (Wave 2)           │
                         │      ↳ ❹ FAIL-pill render in driver-health pane (Wave 3)     │
                         │      ↳ ❺ Version-mismatch pill (Wave 6)                      │
                         │                                                              │
                         │  Producers of HTTP requests:                                 │
                         │   - putSettings, getSettings, getState, getDevices,          │
                         │     getTelemetryLevel, getHealth (P8)                        │
                         │   - 5 training/* methods (P9)                                │
                         │   - debugTrigger() ← NEW Wave 4 (#if MICMAP_DEBUG_BUILD)     │
                         │   - ❌ tap() ── DELETED Wave 5                               │
                         └──────────────────────────────────────────────────────────────┘

                                              ┌──────────────────────────┐
                                              │   FileLogSink (shared)   │
                                              │   on each write():       │
                                              │     1. write line        │
                                              │     2. GetFileAttributes │
                                              │     3. if size >= 5MB:   │
                                              │        rotate via        │
                                              │        MoveFileExW       │
                                              │        (.log → .log.1 …) │
                                              └──────────────────────────┘
```

❶❷❸❹❺ map to the five distinct integration points across waves in `10-CONTEXT.md`.

### Recommended structure (additions only)

```
cmake/
├── version.cmake                       # NEW Wave 1 — SSoT MICMAP_VERSION + VERSION_QUAD
├── AssertNoClientDetection.cmake       # NEW Wave 0 (RED-tolerant) → Wave 5 (enforcing)
├── AssertNoButtonRoute.cmake           # NEW Wave 0 (RED-tolerant) → Wave 5 (enforcing)
└── AssertCoVersioning.cmake            # NEW Wave 0 (RED-tolerant) → Wave 6 (enforcing)

installer/
├── version.iss.in                      # NEW Wave 1 — Inno Setup #define MICMAP_VERSION template
└── MicMap.iss                          # MODIFIED Wave 6 — #include "version.iss"

apps/micmap/
├── resources/
│   ├── tray_armed.ico                  # NEW Wave 2 — green
│   ├── tray_triggered.ico              # NEW Wave 2 — pulse glyph
│   └── tray_error.ico                  # NEW Wave 2 — red
├── micmap.rc                           # MODIFIED Wave 1 — VERSIONINFO from CMake
└── main.cpp                            # MODIFIED waves 2/3/4/5/6 — see CONTEXT integration points

driver/
├── micmap.rc                           # NEW Wave 1 — VS_VERSION_INFO for driver_micmap.dll
├── src/http_server.{hpp,cpp}           # MODIFIED Wave 3 (driver_version), Wave 4 (/debug/trigger), Wave 5 (delete /button)
└── resources/settings/default.vrsettings  # MODIFIED Wave 5 — flip flags to true

src/common/src/sinks/
└── file_log_sink.cpp                   # MODIFIED Wave 1 — add rotation logic

src/steamvr/{include,src}/
└── driver_api.{hpp,cpp}                # MODIFIED Wave 4 (debugTrigger), Wave 5 (delete tap)

tests/
├── test_tray_glyph_state_machine.cpp   # NEW Wave 0 (RED-tolerant)
├── test_fail_pill_priority.cpp         # NEW Wave 0
├── test_log_rotation.cpp               # NEW Wave 0
└── test_version_mismatch.cpp           # NEW Wave 0
```

### Pattern 1: `Shell_NotifyIconW(NIM_MODIFY, NIF_ICON)` glyph swap

**What:** Modify the existing tray icon's `hIcon` field by setting `nid.uFlags = NIF_ICON` and calling `NIM_MODIFY`. The icon ID (`nid.uID`) and `hWnd` are the keys that identify which icon is being modified — they MUST match the `NIM_ADD` call. `[VERIFIED: Microsoft Learn Shell_NotifyIconW reference]`

**When to use:** State-driven glyph rotation (armed/triggered/error). Cadence is bounded by the existing 2Hz `/state` poll + the 300ms pulse window — well below any documented Shell rate-limit.

**Example (Wave 2):**
```cpp
// Source: apps/micmap/first_launch_balloon.cpp:73-88 (existing NIM_MODIFY pattern)
//         + apps/micmap/main.cpp:261-273 (existing NIM_ADD shape)
// One-time at WinMain init (after NIM_ADD):
HICON g_iconArmed     = (HICON)LoadImageW(hInstance, L"resources\\tray_armed.ico",
                                          IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE);
HICON g_iconTriggered = (HICON)LoadImageW(hInstance, L"resources\\tray_triggered.ico",
                                          IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE);
HICON g_iconError     = (HICON)LoadImageW(hInstance, L"resources\\tray_error.ico",
                                          IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE);

// Per state change (called from poll loop):
void setTrayGlyph(HICON desired) {
    if (g_app.nid.hIcon == desired) return;       // skip redundant updates
    const UINT prev = g_app.nid.uFlags;
    g_app.nid.uFlags = NIF_ICON;                   // ONLY update icon — preserve callback/tip
    g_app.nid.hIcon  = desired;
    if (!Shell_NotifyIconW(NIM_MODIFY, &g_app.nid)) {
        MICMAP_LOG_WARNING("tray NIM_MODIFY failed (GetLastError=", GetLastError(), ")");
    }
    g_app.nid.uFlags = prev;                       // restore flags for subsequent ops
}
```

**Source for `LoadImageW(LR_LOADFROMFILE | LR_DEFAULTSIZE)`:** `[CITED: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-loadimagew]` — `LR_DEFAULTSIZE` returns the icon at the system's default tray size (16×16 or scaled per DPI when icon contains multiple sizes). A multi-size `.ico` (16/32/48) is the recommended packaging. `[ASSUMED]` for the optimal artwork sizes — confirm with high-DPI rig during UAT.

### Pattern 2: Named-mutex single-instance + cross-process foreground

**What:** `CreateMutexW(nullptr, TRUE, L"Local\\…")` returns success **and** sets `GetLastError() == ERROR_ALREADY_EXISTS` if a prior instance owns the named kernel object. Second instance then locates the first via `FindWindowW(class_name)` and brings it forward. `[VERIFIED: Microsoft Learn CreateMutexW + SetForegroundWindow references]`

**When to use:** P10 D-09 — already exists at `apps/micmap/main.cpp:1461-1485`. Wave 3 hardens.

**Existing implementation to harden (apps/micmap/main.cpp:1461):**
```cpp
HANDLE hMutex = CreateMutexW(nullptr, TRUE, L"MicMapSingleInstance");
if (!hMutex) { return 1; }
if (GetLastError() == ERROR_ALREADY_EXISTS) {
    if (!flags.minimized) {
        HWND w = FindWindowW(L"MicMapMain", nullptr);
        if (w) { PostMessageW(w, WM_COMMAND, IDM_SHOW, 0); SetForegroundWindow(w); }
    }
    CloseHandle(hMutex);
    return 0;
}
```

**Wave 3 hardening (recommended changes — minimal diff):**
1. **Namespace the mutex:** rename `L"MicMapSingleInstance"` → `L"Local\\MicMap_Client_SingleInstance_v1"`. The `Local\\` prefix scopes to the user's session (matches default behavior on no-prefix, but explicit). The `_v1` suffix lets future versions shed a stuck handle from a crashed v0 process. `[VERIFIED: Microsoft Learn CreateMutexW kernel-object-namespaces]`
2. **Always restore + foreground (not gated on `!flags.minimized`):** the FAIL-04 spec is "second instance foregrounds the first and exits silently" — the existing minimized-skip behavior is a P3 D-08 carve-out for SteamVR auto-relaunch. Wave 3 should preserve the carve-out (so SteamVR's `--minimized` re-spawns don't steal focus mid-VR-session) but also call `ShowWindow(w, SW_RESTORE)` BEFORE `SetForegroundWindow` for the user-clicked second-launch path so a tray-minimized first instance actually surfaces.
3. **Add the `AllowSetForegroundWindow + AttachThreadInput` dance** for cases where the second-instance process didn't receive a recent input event — see PITFALL below.

**PITFALL — `SetForegroundWindow` cross-process restrictions:** Per Microsoft Learn, `SetForegroundWindow` succeeds only if "the calling process received the last input event" (or a few other narrow conditions). The second-instance process IS the foreground process at the moment of its own launch (the user just double-clicked its shortcut), so the call typically succeeds. BUT: if the user launches the second instance via a non-input vector (e.g., scripted/CI launch, SteamVR auto-relaunch), the call may silently fail and Windows flashes the taskbar instead. The robust pattern is:

```cpp
// Source: well-documented Win32 idiom (Raymond Chen and others)
// Trigger when SetForegroundWindow returns FALSE on a known-running peer
DWORD targetThread = GetWindowThreadProcessId(hwndPeer, nullptr);
DWORD myThread     = GetCurrentThreadId();
AttachThreadInput(myThread, targetThread, TRUE);
SetForegroundWindow(hwndPeer);
AttachThreadInput(myThread, targetThread, FALSE);
```
`[CITED: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-attachthreadinput]` `[VERIFIED: Microsoft Learn SetForegroundWindow §Remarks]`

For P10's UAT case (D-25(6) — user double-clicks `micmap.exe` twice from File Explorer) the basic `ShowWindow(SW_RESTORE) + SetForegroundWindow` is enough because the launcher itself is the foreground process. The `AttachThreadInput` dance is a defensive extra; document the trade-off in the FAIL-04 plan and ship the simpler form unless UAT shows the simple form misses cases.

### Pattern 3: Atomic log rotation via `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`

**What:** Synchronous size check on each `FileLogSink::write()`. When the on-disk size reaches 5 MB, rotate the 5-deep generation ring via serial `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` calls (oldest-to-newest), then `MoveFileExW` the active file to `.log.1` and continue logging to a fresh handle. `MOVEFILE_REPLACE_EXISTING` is atomic for a same-volume rename on NTFS. `[VERIFIED: Microsoft Learn MoveFileExW reference]`

**When to use:** D-14 / D-15 — applies to both `micmap-driver.log` and `micmap.log`; same `FileLogSink` class.

**Example (Wave 1 — extend `src/common/src/sinks/file_log_sink.cpp`):**
```cpp
// Reference: existing FileLogSink::log() at src/common/src/sinks/file_log_sink.cpp:38-59
constexpr std::uintmax_t kMaxLogBytes = 5 * 1024 * 1024;
constexpr int kRetainedGenerations = 5;

void FileLogSink::log(LogLevel level, std::string_view message) {
    std::lock_guard<std::mutex> lock(fileLogMutex);
    // ... existing write code (timestamps + level + message + flush) ...
    f.flush();
    f.close();      // close BEFORE size-check + rotation so MoveFileExW can rename

    std::error_code ec;
    auto sz = std::filesystem::file_size(path_, ec);    // cheap on Windows; no open
    if (!ec && sz >= kMaxLogBytes) {
        rotate();   // never throws; logs warning on partial failure
    }
}

void FileLogSink::rotate() noexcept {
    // Walk oldest-to-newest. .5 → drop. .4 → .5, .3 → .4, ..., active → .1
    auto gen = [&](int n) {
        return n == 0 ? path_ : path_.string() + "." + std::to_string(n);
    };
    std::error_code ec;
    std::filesystem::remove(gen(kRetainedGenerations), ec);  // drop oldest; ignore ENOENT
    for (int i = kRetainedGenerations - 1; i >= 1; --i) {
        // PITFALL: skip non-existent intermediate generations cleanly
        if (!std::filesystem::exists(gen(i))) continue;
        if (!::MoveFileExW(gen(i).wstring().c_str(),
                           gen(i + 1).wstring().c_str(),
                           MOVEFILE_REPLACE_EXISTING)) {
            // D-17: log to non-file sink; continue at oversize
            // Use Logger directly (will fan out to stdout/driverlog sinks; the file
            // sink itself is mid-rotation and would recurse). Wrap with a one-shot
            // flag to avoid spamming.
            return;
        }
    }
    // Finally, rename the active file
    ::MoveFileExW(path_.wstring().c_str(),
                  (path_.string() + ".1").c_str() + L"",  // (illustrative — use proper wstring)
                  MOVEFILE_REPLACE_EXISTING);
    // Next write() reopens path_ fresh.
}
```

**`MOVEFILE_WRITE_THROUGH`:** the docs say this flag "guarantees that a move performed as a copy and delete operation is flushed to disk before the function returns" and "has no effect if MOVEFILE_DELAY_UNTIL_REBOOT is set" — for a same-volume rename (which is the only case for log rotation in `%APPDATA%\MicMap\`) the operation is metadata-only and the flag is a no-op. **Do not** add `MOVEFILE_WRITE_THROUGH` to the rotation calls; it's correct only for cross-volume copy-then-delete. `[VERIFIED: Microsoft Learn MoveFileExW §dwFlags]`

**`GetFileAttributesExW` vs `std::filesystem::file_size`:** Both invoke the same underlying NTFS metadata read; on Windows 10/11 with cached MFT entries the cost is microseconds. `std::filesystem::file_size` is the cleaner C++17 form and handles the wide-path conversion internally — recommended over the explicit Win32 call for parity with the rest of the `FileLogSink` body. `[VERIFIED: cppreference std::filesystem::file_size]` `[ASSUMED]` for the microsecond-scale cost claim — confirm during D-25(9) UAT.

### Pattern 4: Inno Setup ISPP `#include` of generated `version.iss`

**What:** Inno Setup's preprocessor (ISPP, bundled with Inno Setup 5+) supports `#include "version.iss"` to import another script fragment. CMake `configure_file()` writes `installer/version.iss` from `installer/version.iss.in`, replacing `@MICMAP_VERSION@` with the resolved SSoT value. `[VERIFIED: jrsoftware ISPP docs — `directives_section.html` reference]`

**When to use:** D-18 — single source of truth for installer version.

**Example (Wave 1):**

`cmake/version.cmake` (NEW):
```cmake
# Single source of truth for MicMap semver.
# All consumers (driver, client, installer, /health) read from here.
set(MICMAP_VERSION "1.6.0")     # PATCH-bump per release; MINOR per milestone

# Split into VERSIONINFO numeric quad: A.B.C → A,B,C,0
string(REPLACE "." ";" _vparts "${MICMAP_VERSION}")
list(LENGTH _vparts _vlen)
if(_vlen LESS 3)
    message(FATAL_ERROR "MICMAP_VERSION must be MAJOR.MINOR.PATCH (got: ${MICMAP_VERSION})")
endif()
list(GET _vparts 0 MICMAP_VERSION_MAJOR)
list(GET _vparts 1 MICMAP_VERSION_MINOR)
list(GET _vparts 2 MICMAP_VERSION_PATCH)
set(MICMAP_VERSION_QUAD "${MICMAP_VERSION_MAJOR},${MICMAP_VERSION_MINOR},${MICMAP_VERSION_PATCH},0")
```

`installer/version.iss.in` (NEW):
```ini
; Generated by CMake configure_file() from installer/version.iss.in.
; DO NOT EDIT — change cmake/version.cmake instead.
#define MICMAP_VERSION "@MICMAP_VERSION@"
```

Root `CMakeLists.txt` invocation (Wave 1):
```cmake
include(cmake/version.cmake)
configure_file(
    "${CMAKE_SOURCE_DIR}/installer/version.iss.in"
    "${CMAKE_SOURCE_DIR}/installer/version.iss"
    @ONLY                       # ONLY substitute @TOKEN@ — leaves ${} alone
)
# Optional: mark generated file as not-versioned via .gitignore
```

`installer/MicMap.iss` (Wave 6 modification — single line addition near top):
```ini
; ---- top of file, BEFORE the existing #ifndef MICMAP_VERSION block ----
#include "version.iss"
; The existing #ifndef MICMAP_VERSION ... #define ... fallback can stay as a
; safety net for hand-runs without configure_file() (e.g., devs running ISCC
; directly), but the include above will populate MICMAP_VERSION first.
```

**ISCC `/D` interaction:** the existing `CMakeLists.txt:153` passes `/DMICMAP_VERSION=${PROJECT_VERSION}` to ISCC — once `version.iss` is the SSoT, the `/D` becomes redundant and SHOULD be removed in Wave 6 (D-21 lint asserts the resolved value is identical across sources, so a stale `/D` would fail the lint loudly). `[VERIFIED: Inno Setup ISPP search hit — `/D` is equivalent to a `#define` at the top of the script; the LATER `#include`'d `#define` would re-define and trigger an ISPP warning at minimum]`

**`configure_file(... @ONLY)`:** the existing project already uses this idiom at `apps/micmap/CMakeLists.txt:24-31` for `app.vrmanifest.in`. Same pattern, no new mechanism. `[VERIFIED: codebase grep — apps/micmap/CMakeLists.txt:27]`

### Pattern 5: `VS_VERSION_INFO` resource via `configure_file()` from semver

**What:** Windows Resource Compiler (`rc.exe`) embeds a `VERSIONINFO` block in the EXE/DLL. The `FILEVERSION` and `PRODUCTVERSION` fields require **comma-separated 4-tuple** numeric values (not dotted strings). String-form values inside `VALUE "FileVersion", "..."` are free-form. `[VERIFIED: Microsoft Learn VERSIONINFO resource reference + existing apps/micmap/micmap.rc]`

**When to use:** D-18 — both `micmap.exe` and `driver_micmap.dll` embed version info.

**Example (Wave 1):**

`apps/micmap/micmap.rc.in` (NEW — replaces existing static `micmap.rc`):
```rc
#include <windows.h>
#include "resource.h"

IDI_MICMAP_ICON ICON "../../installer/micmap.ico"

VS_VERSION_INFO VERSIONINFO
FILEVERSION     @MICMAP_VERSION_QUAD@
PRODUCTVERSION  @MICMAP_VERSION_QUAD@
FILEFLAGSMASK   VS_FFI_FILEFLAGSMASK
FILEFLAGS       0
FILEOS          VOS_NT_WINDOWS32
FILETYPE        VFT_APP
FILESUBTYPE     VFT2_UNKNOWN
BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "040904B0"
        BEGIN
            VALUE "CompanyName",      "MicMap"
            VALUE "FileDescription",  "MicMap — Microphone pattern-based click input for SteamVR"
            VALUE "FileVersion",      "@MICMAP_VERSION@.0"
            VALUE "InternalName",     "micmap"
            VALUE "LegalCopyright",   "Copyright (C) 2026 FEATHER3 SOFTWORKS"
            VALUE "OriginalFilename", "micmap.exe"
            VALUE "ProductName",      "MicMap"
            VALUE "ProductVersion",   "@MICMAP_VERSION@"
        END
    END
    BLOCK "VarFileInfo"
    BEGIN
        VALUE "Translation", 0x409, 1200
    END
END
```

`apps/micmap/CMakeLists.txt` (Wave 1 modification):
```cmake
include(${CMAKE_SOURCE_DIR}/cmake/version.cmake)   # already included at root, re-include is cheap
configure_file(
    "${CMAKE_CURRENT_SOURCE_DIR}/micmap.rc.in"
    "${CMAKE_CURRENT_BINARY_DIR}/micmap.rc"
    @ONLY
)
target_sources(micmap PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/micmap.rc")

target_compile_definitions(micmap PRIVATE
    MICMAP_VERSION_STRING="${MICMAP_VERSION}"
)
```

Same shape for `driver/CMakeLists.txt` (NEW `driver/driver_micmap.rc.in` + matching `target_sources` + `target_compile_definitions(driver_micmap PRIVATE MICMAP_VERSION_STRING="${MICMAP_VERSION}")`).

**PITFALL — quoting `MICMAP_VERSION_STRING`:** the canonical CMake idiom is `target_compile_definitions(target PRIVATE MICMAP_VERSION_STRING="${MICMAP_VERSION}")`. CMake handles the embedded quotes correctly across MSBuild and Ninja generators when the value has no special characters (semvers don't). `[VERIFIED: existing driver/CMakeLists.txt:91-92 already does `MICMAP_DRIVER_VERSION="${PROJECT_VERSION}"` with this exact form — empirically confirmed working in P5–P9 builds]`. **Do not** use `MICMAP_VERSION_STRING=\"${MICMAP_VERSION}\"` — the explicit-backslash form is needed only when defining via `add_compile_options(-D...)`, not via `target_compile_definitions`.

### Pattern 6: Debug-build-only HTTP route + `--debug-trigger` short-circuit

**What:** A new preprocessor define `MICMAP_DEBUG_BUILD` that is `1` in Debug builds and `0` (or undefined) elsewhere. Driver-side route registration and client-side CLI both `#if MICMAP_DEBUG_BUILD` to gate.

**When to use:** D-11 / D-12 — TEST-02.

**`MICMAP_DEBUG_BUILD` definition (Wave 4 — recommended `$<CONFIG:Debug>` form):**
```cmake
# In root CMakeLists.txt (or per-target)
target_compile_definitions(driver_micmap PRIVATE
    "MICMAP_DEBUG_BUILD=$<IF:$<CONFIG:Debug>,1,0>"
)
target_compile_definitions(micmap PRIVATE
    "MICMAP_DEBUG_BUILD=$<IF:$<CONFIG:Debug>,1,0>"
)
```

**PITFALL — `if(CMAKE_BUILD_TYPE STREQUAL "Debug")` is multi-config-broken.** With Visual Studio (MSBuild) and Xcode generators, `CMAKE_BUILD_TYPE` is empty at configure time; the build type is selected per-build at compile time (Debug/Release/RelWithDebInfo dropdown in VS). The `if(CMAKE_BUILD_TYPE STREQUAL "Debug")` form would always evaluate to FALSE under MSBuild and silently disable `MICMAP_DEBUG_BUILD` for everyone. The `$<CONFIG:Debug>` generator expression is evaluated per-build-config and is the correct form. `[VERIFIED: CMake docs — generator expressions §$<CONFIG:cfgs>]`

**Driver route registration (Wave 4 — `driver/src/http_server.cpp`):**
```cpp
// Source: same producer pattern as the deleted /button route (Wave 5 reference)
#if MICMAP_DEBUG_BUILD
srv.Post("/debug/trigger", [this](const httplib::Request&, httplib::Response& res) {
    if (commandQueue_) {
        commandQueue_->push(TapCommand{});
        res.status = 200;
        res.set_content("{\"ok\":true}", "application/json");
    } else {
        res.status = 503;
        res.set_content("{\"error\":\"command_queue_unavailable\"}", "application/json");
    }
});
#endif
```

**Client CLI (Wave 4 — `apps/micmap/main.cpp` `WinMain` short-circuit, mirrors `apps/mic_test/main.cpp:150`):**
```cpp
// Mirror of tryRunReplayCli — early WinMain exit before any GUI/D3D init
#if MICMAP_DEBUG_BUILD
int tryRunDebugTriggerCli() {
    int argc = 0;
    LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
    if (!argv) return -1;
    bool found = false;
    for (int i = 1; i < argc; ++i) {
        if (wcscmp(argv[i], L"--debug-trigger") == 0) { found = true; break; }
    }
    LocalFree(argv);
    if (!found) return -1;          // not in debug-trigger mode — fall through to GUI

    auto driverApi = micmap::steamvr::createDriverApi();
    auto r = driverApi->debugTrigger();   // new method
    switch (r.status) {
        case micmap::steamvr::DebugTriggerResult::Ok:           return 0;
        case micmap::steamvr::DebugTriggerResult::HttpError:    std::wcerr << L"http error\n"; return 1;
        case micmap::steamvr::DebugTriggerResult::ConnectionRefused: std::wcerr << L"driver not reachable\n"; return 2;
    }
    return 1;
}
#endif

// In WinMain, AFTER logger composition + AFTER existing flags parse,
// BEFORE the named-mutex (so a debug-trigger from a CI runner doesn't
// contend with a live GUI instance):
#if MICMAP_DEBUG_BUILD
{
    int rc = tryRunDebugTriggerCli();
    if (rc != -1) return rc;
}
#endif
```

**`IDriverApi::debugTrigger()` shape (D-12 discretion):** Recommend a virtual method on the interface guarded by `#if MICMAP_DEBUG_BUILD` in the header. Pros: call site is clean (`driverApi->debugTrigger()`); the impl is in the same TU as the other methods (low cognitive load); the `#if` guard in the header means Release builds don't even know the method exists, preventing accidental Release call sites. Cons: any test that links the interface in a Release configuration sees a different vtable shape — but no such test is planned, and `IDriverApi` clients all build with the same configuration.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Tray icon overlay/tint at runtime | Custom HBITMAP composition / GDI alpha-blend | Three pre-rendered `.ico` files | Win32 Shell tray API has no overlay primitive; runtime composition adds a pixel pipeline + DPI-scaling logic for zero user value. D-04 locked. |
| Animated tray icon pulse | Multi-frame icon sequence + WM_TIMER | Single 300ms held flash via timestamp comparison on existing poll | True per-frame animation requires a timer + state machine + Shell-throttle awareness. Single-icon flash auto-reverts on next 2Hz poll; cooldown state typically takes over within 500ms anyway. D-05 / Discretion §pulse-icon. |
| Process enumeration via WMI | OLE / WbemScripting in C++ | `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)` + `Process32NextW` for FAIL-02/03 disambiguation | WMI in-process is heavyweight (COM init + permission checks); ToolHelp32 is a single syscall that returns process names in microseconds. The Inno Setup script uses WMI because Inno's Pascal Script doesn't expose ToolHelp32 — different constraint. |
| Cross-process foreground stealing without `AllowSetForegroundWindow` | Manual Z-order manipulation via `SetWindowPos(HWND_TOPMOST)` | Standard `ShowWindow(SW_RESTORE) + SetForegroundWindow` first; fall back to `AttachThreadInput` dance only if FALSE | Z-order hacks were a Win9x/XP idiom; modern Windows blocks them and flashes the taskbar instead. The official path is documented; no need to reinvent. |
| Custom log rotation thread | Background thread + std::condition_variable | Synchronous size-check on each write | Logger-level mutex (P8 `MultiSinkLogger`) already serializes writes. Adding a rotation thread = new join in shutdown, new race in `FileLogSink::~FileLogSink()`, new failure mode (rotation thread starved → file grows unbounded). Synchronous is correct here. D-14. |
| File-watch via `ReadDirectoryChangesW` for version-mismatch detection | Watch `default.vrsettings` or driver DLL for changes | After-first-poll comparison of `health.driver_version` vs compile-time `MICMAP_VERSION_STRING` | The single-version-source-of-truth via CMake guarantees binaries built from the same tree match; mismatch only happens when one binary is replaced out-of-band (in-progress upgrade). Polling `/health` already exists; comparing one extra field is free. D-19/D-20. |
| Hand-parsing semver for `VS_VERSION_INFO` numeric quad | C++ string parse at runtime | `string(REPLACE "." ";" ...)` + `list(GET ...)` in CMake | `FILEVERSION 1,6,0,0` must be literal at RC compile time (not a runtime computation). CMake list ops are the natural fit; example shipped above. |
| Custom Inno Setup version-injection scheme | Custom `[Code]` Pascal that reads version from a file | ISPP `#include "version.iss"` + `#define MICMAP_VERSION "..."` | ISPP is part of every modern Inno Setup install (6.x bundles it); `#include` + `#define` are explicit, line-numbered, and produce a clean diagnostic if `version.iss` is missing. |

**Key insight:** P10 is a deletion phase first and a UX phase second. The ratio of "code added" to "code deleted" is roughly 1:5 (~100 LoC tray + FAIL pills + log rotation + version wiring; ~500 LoC client-side detection deleted). Custom solutions for any of the above add code and risk the binary-size delta from D-03 acceptance.

## Runtime State Inventory

This phase is partially a rename/refactor (default.vrsettings flag flip + symbol deletions). Inventory:

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None affected by P10 deletions. The `enable_driver_audio` / `enable_driver_detection` keys remain in `default.vrsettings` post-flip (D-02 — emergency override). User-edited `%APPDATA%\MicMap\config.json` retains pre-existing keys; deleted client-side detection settings (e.g., client-side device pin) become dead but harmless because the driver is sole reader post-cutover (P8 IPC-05). | **No data migration.** Code edit only — flip defaults in `default.vrsettings`; delete client-side reads of dead keys. |
| Live service config | **SteamVR `vrpathreg`-registered driver path** is unchanged (`<SteamVR>\drivers\micmap` per P4 D-01) — installer continues to register the same path. **`driver_micmap.dll`'s in-memory snapshot of `enable_driver_*` flags** updates on driver Init from the new `default.vrsettings` defaults; no manual SteamVR re-registration needed. **NO n8n / Datadog / Tailscale / Cloudflare equivalents** in this project. | **None — verified by inspecting `installer/MicMap.iss` `[Code]` post-install orchestrator and the lack of any external service dependencies.** |
| OS-registered state | **`app.vrmanifest`** (P3 auto-launch) — unchanged; AppKey `bigscreen.micmap` unchanged; the `arguments: ["--minimized"]` in the manifest does NOT include `--debug-trigger` (correct — debug-trigger is dev-only, not auto-launched). **Windows Task Scheduler / pm2 / launchd:** none — MicMap runs only via SteamVR auto-launch. **`AppId` GUID in `installer/MicMap.iss:19`** (`{BC6D91A7-A852-4562-8CBF-58FC4662FEDC}`) MUST NOT change — Inno Setup upgrade-in-place keys off this; D-22 atomicity depends on it. | **None — preserve `AppId` GUID verbatim.** |
| Secrets/env vars | **None.** No SOPS keys, no `.env` files, no API tokens. The HTTP port `27015` is hardcoded in `default.vrsettings` and `IDriverApi` — code edit only if changed (which P10 does NOT). | **None.** |
| Build artifacts / installed packages | **Pre-existing `MICMAP_DRIVER_VERSION` define in `driver/CMakeLists.txt:91-92`** is supplanted by the new `MICMAP_VERSION_STRING` SSoT — Wave 1 code edit. **Pre-existing apps/micmap CMake-side `set(MICMAP_VERSION ${PROJECT_VERSION})` at `apps/micmap/CMakeLists.txt:24`** is supplanted similarly. **`installer/version.iss` (NEW generated file)** must be `.gitignore`-listed (it's an artifact of CMake configure). **Stale `build/` from prior client-side detection link** will retain the deleted symbols' object files until clean-rebuild — UAT D-25 should explicitly run on a clean `build/`. | **Code edit (CMake) + `.gitignore` add + clean-rebuild step in UAT.** |

**Nothing found in category:** stated explicitly above where applicable.

**The canonical question — "After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?"** Answer: **None requiring action**. SteamVR's vrpathreg entry doesn't embed the changed defaults (it points at the driver dir, the driver reads defaults at Init); `app.vrmanifest`'s argument list is `["--minimized"]` which is unchanged; the installer GUID is preserved; user `config.json` keys that go dead are silently ignored by the driver (P8 single-writer model means user edits flow through `PUT /settings`, which validates against the active schema and rejects unknown keys). The cutover IS the deletion; nothing post-cutover needs out-of-band cleanup beyond a clean-rebuild for the binary-size measurement (D-03).

## Common Pitfalls

### Pitfall 1: TaskbarCreated message missed → tray icon disappears after Explorer restart

**What goes wrong:** Windows Explorer (`explorer.exe`) is the host of the system tray. If Explorer crashes, restarts (e.g., theme change, taskbar rebuild via Task Manager), or the user logs out + back in WITHOUT restarting `micmap.exe`, the tray icon vanishes and `Shell_NotifyIconW(NIM_MODIFY, ...)` calls silently no-op (return TRUE in some shell builds, FALSE in others — neither is reliable as a recovery signal).

**Why it happens:** the Shell tray icon is a transient registration in Explorer's per-process memory; Explorer broadcasts `RegisterWindowMessage(L"TaskbarCreated")` to all top-level windows when it (re-)initializes the tray, expecting clients to re-register via `NIM_ADD`. Apps that don't listen for this message lose their icon permanently across an Explorer restart. `[VERIFIED: Microsoft Learn Shell_NotifyIconW remarks reference the TaskbarCreated convention — search hit on multiple docs pages]`

**How to avoid:** in `WindowProc`, register and handle the message:
```cpp
// Static, registered once at first use (RegisterWindowMessage caches by name)
static const UINT WM_TASKBAR_CREATED = ::RegisterWindowMessageW(L"TaskbarCreated");

// In WindowProc:
if (msg == WM_TASKBAR_CREATED) {
    // Re-add the tray icon with current state
    Shell_NotifyIconW(NIM_ADD, &g_app.nid);
    Shell_NotifyIconW(NIM_SETVERSION, &g_app.nid);   // optional but recommended for v4 behaviors
    return 0;
}
```
**Warning signs:** users report "tray icon disappeared and didn't come back until I restarted MicMap." UAT D-25(2) doesn't currently test this — recommend adding "kill explorer.exe via Task Manager, observe tray icon reappears" as a sub-step.

**Phase to address:** Wave 2 (HEALTH-08 tray glyphs).

### Pitfall 2: HICON leak when swapping via NIM_MODIFY

**What goes wrong:** developer creates a new `HICON` per state change via `LoadIconW`/`LoadImageW`, swaps via `NIM_MODIFY`, never `DestroyIcon`s the prior handle. Each state change leaks a 32-byte kernel object + GDI handle slot. Process Explorer eventually shows ascending GDI counts on `micmap.exe`; SteamVR-restart UAT (D-25) might run for 10 minutes and accumulate hundreds of leaked icons.

**Why it happens:** `Shell_NotifyIconW(NIM_MODIFY)` does NOT take ownership of the new `hIcon` — the caller retains responsibility. The Shell COPIES the icon for its own rendering pipeline; the original handle is owned by whoever loaded it.

**How to avoid:** load the THREE state icons ONCE at `WinMain` startup (D-04 — "All loaded once at WinMain startup"). Store them in `g_iconArmed`/`g_iconTriggered`/`g_iconError`. The `NIM_MODIFY` swap re-points `nid.hIcon` to one of the three persistent handles; no per-swap allocation. `DestroyIcon` only at process exit.

**Warning signs:** Process Explorer GDI count climbing during D-25(2) state-transition UAT.

**Phase to address:** Wave 2.

### Pitfall 3: NIM_MODIFY uFlags pollution

**What goes wrong:** developer sets `nid.uFlags = NIF_ICON | NIF_TIP | NIF_INFO` for the swap, forgetting that `NIF_INFO` triggers a balloon notification. Each glyph swap fires a balloon (silently suppressed by Focus Assist on most modern systems, but sent to the Action Center on Win10/11 — annoying noise).

**Why it happens:** `NOTIFYICONDATAW::uFlags` is a bitmask that Shell interprets to decide WHICH fields of the struct to act on. Stale `NIF_INFO` from the P3 first-launch balloon path (`first_launch_balloon.cpp:75`) carries forward unless explicitly cleared.

**How to avoid:** the existing P3 code already does this correctly at `first_launch_balloon.cpp:73-86` — save `prevFlags`, set ONLY `NIF_INFO`, restore `prevFlags` after the call. Wave 2 must follow the same pattern: set `nid.uFlags = NIF_ICON` for the swap, restore after. The `setTrayGlyph` example above shows this. The shape is "save / set-narrow-flag-set / call / restore" — no leakage, no balloon resurrection.

**Warning signs:** spurious balloon notifications appearing on every state transition.

**Phase to address:** Wave 2.

### Pitfall 4: `MICMAP_DEBUG_BUILD` defined inconsistently across translation units

**What goes wrong:** Wave 4 sets `MICMAP_DEBUG_BUILD=$<CONFIG:Debug>` on `driver_micmap` but forgets the matching definition on `micmap` (or vice versa). Result: in a Debug build, the driver registers `/debug/trigger` but the client's `--debug-trigger` is `#if`'d out (or vice versa). The flag silently does the wrong thing. ODR is not violated (different translation units can define preprocessor macros differently) so the build succeeds.

**Why it happens:** `target_compile_definitions` is per-target; copy-paste error or "I'll add it to the other target later" intent that never lands.

**How to avoid:** define `MICMAP_DEBUG_BUILD` in a SHARED CMake function or in `cmake/version.cmake` next to `MICMAP_VERSION`, and call it from both targets. Add an `AssertCoVersioning`-style lint that asserts the define is identical across the two targets at configure time (extends D-21's intent). Alternatively, extend `AssertNoButtonRoute.cmake` to also assert `MICMAP_DEBUG_BUILD` is consistently defined.

**Warning signs:** UAT D-25(8) `--debug-trigger` returns exit 0 but no dashboard toggle (driver gate skipped); or returns exit 2 ECONNREFUSED in a Debug build (client built thinking it was Release).

**Phase to address:** Wave 4.

### Pitfall 5: ISPP `#include` path resolution surprise

**What goes wrong:** `#include "version.iss"` in `installer/MicMap.iss` resolves to `installer/version.iss` only when ISCC's working directory IS `installer/` — which it usually is when the existing `package` target runs (`WORKING_DIRECTORY "${CMAKE_BINARY_DIR}"` per `CMakeLists.txt:167` — actually that's the CMake build dir, not the script dir). If the working directory is wrong, ISPP searches the include path / `INCLUDE` env var and may find a stale `version.iss` somewhere on the system.

**Why it happens:** ISPP's `#include "name"` (with quotes) searches the directory of the including file FIRST per the docs — but only if ISPP can determine that directory, which depends on whether the script was passed as a relative or absolute path to ISCC. The existing `CMakeLists.txt:156` passes `${MICMAP_ISS_FILE}` which is `${CMAKE_SOURCE_DIR}/installer/MicMap.iss` — absolute, so the script's directory IS resolvable, and `installer/version.iss` is found. Confirmed for the current build flow. `[VERIFIED: ISPP search docs hit — `#include "..."` searches the current file's directory first]`

**How to avoid:** keep the absolute-path invocation. As insurance, add `#include "version.iss"` near the very top of `MicMap.iss` (BEFORE the existing `#ifndef MICMAP_VERSION` fallback) so the fallback's `"0.0.0-dev"` only fires if `version.iss` is missing entirely (build error rather than silent default).

**Warning signs:** installer outputs `MicMap-Setup-v0.0.0-dev.exe` instead of the expected version.

**Phase to address:** Wave 6.

### Pitfall 6: `tasklist` cost at 1Hz from a UI thread

**What goes wrong:** FAIL-02 vs FAIL-03 disambiguation calls `tasklist` (or any process-enumeration API) every health poll (1 Hz when driver is unreachable). Each `tasklist` invocation spawns a process, parses output — easily 50–200ms wall-clock. UI thread stalls; ImGui frame rate drops; user perceives "client froze when SteamVR is down."

**Why it happens:** `tasklist` is convenient but is a child-process spawn + console-output parse — heavyweight. The Inno Setup script uses it because Inno's Pascal Script lacks ToolHelp32 bindings; native code has no such excuse.

**How to avoid:** use `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` + `Process32NextW` walk. Single syscall + linear walk over kernel memory; typical cost is a few hundred microseconds for ~200 processes. Cache the result for 1 second (matches the poll cadence) so even the cheap call doesn't run more than necessary.

```cpp
// Source: well-documented Win32 idiom; cost-confirmed via timing during P9 RMS log
bool isProcessRunning(const wchar_t* exeName) {
    HANDLE snap = ::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snap == INVALID_HANDLE_VALUE) return false;
    PROCESSENTRY32W pe = { sizeof(pe) };
    bool found = false;
    if (::Process32FirstW(snap, &pe)) {
        do {
            if (_wcsicmp(pe.szExeFile, exeName) == 0) { found = true; break; }
        } while (::Process32NextW(snap, &pe));
    }
    ::CloseHandle(snap);
    return found;
}
```

**Recommended FAIL-02/03 disambiguation:** wrap `isProcessRunning(L"vrserver.exe")` in a 2-second cache (struct-local static `lastCheck` timestamp + `lastResult`); call from the health-poll callback only when `/health` returned ECONNREFUSED. The cache amortizes to one snapshot every 2 seconds while driver is unreachable; sub-millisecond cost when cached.

**Warning signs:** UI freezes during D-25(5) FAIL-03 SteamVR-not-running test.

**Phase to address:** Wave 3.

### Pitfall 7: KissFFT still linked into `micmap.exe` post-cutover (binary size doesn't drop)

**What goes wrong:** Wave 5 deletes the client-side `audioCapture` / `detector` / `stateMachine` instances and the audio callback body, but `apps/micmap/CMakeLists.txt` still has `target_link_libraries(micmap PRIVATE micmap::core_runtime ...)` and `micmap::core_runtime` aggregates all four sub-libs including `micmap_detection` (which links KissFFT). The linker keeps the symbols because the INTERFACE link is unconditional. D-03 binary-size delta check FAILS.

**Why it happens:** `INTERFACE` aggregation makes the link transitive but unconditional — there's no `--gc-sections`-equivalent dead-stripping by default on MSVC (linker `/OPT:REF` does help but only for unreferenced symbols, and a `#include`d header's static initializers may force inclusion).

**How to avoid:** the simplest path is to keep the full link (KissFFT is small, ~30KB) and document the actual binary-size delta — the WASAPI capture removal alone is the dominant savings. If the planner wants the cleanest possible delta, the alternative is to refactor `micmap::core_runtime` into two INTERFACE targets (`micmap::core_runtime_headless` without detection/audio for the post-cutover client, and `micmap::core_runtime_full` with everything for driver + mic_test) — but that's a P10+1 cleanup, not a P10 task. **Recommendation:** ship Wave 5 with the current INTERFACE shape; document the actual delta in the cutover commit message; defer the dead-stripping refactor to backlog.

**Warning signs:** D-03 acceptance check shows `micmap.exe` size dropped <10KB instead of the expected ~50–100KB (KissFFT) + ~80KB (audio capture machinery).

**Phase to address:** Wave 5 (decision in plan-time, not implementation-time).

### Pitfall 8: Inno Setup `[Files]` upgrade-in-place leaves orphan `.ico` files

**What goes wrong:** Wave 2 ships THREE new `.ico` files in `apps/micmap/resources/`. The installer's `[Files]` section needs new entries for them. If those entries are missed, the v1.6 installer produces a new `MicMap-Setup-v1.6.0.exe` that silently doesn't include the icons; tray-glyph swap fails at runtime with `LoadImageW` returning `nullptr` (path not found).

**Why it happens:** `[Files]` is exhaustive — Inno doesn't auto-discover by recursive glob unless told to via `recursesubdirs`. The existing `installer/MicMap.iss:59-61` already uses `recursesubdirs` for `drivers\micmap\resources\*` — but the client-side `apps/micmap/resources/` is a NEW path that doesn't have a [Files] entry today (icons live next to the source, not in the install stage).

**How to avoid:** add a CMake `install(FILES ...)` rule for the three icons into `bin/resources/` (or wherever the runtime expects them via `LoadImageW(L"resources\\tray_armed.ico", ...)` — note the relative path implies the icons live next to `micmap.exe`). Then add a matching `[Files]` entry in `MicMap.iss` Wave 6 (or extend the existing `Source: "{#STAGE_DIR}\bin\*.dll"` glob with a sibling `Source: "{#STAGE_DIR}\bin\resources\*.ico"`). UAT D-25(11) clean-VM install must verify the icons are present post-install.

**PITFALL sub-note:** `LoadImageW` with a bare relative path searches the **current working directory of the process**, NOT the directory containing the EXE. SteamVR auto-launched processes have CWD = `<SteamVR>` typically. Use `GetModuleFileNameW(nullptr, ...) + PathRemoveFileSpecW + append L"\\resources\\tray_armed.ico"` to resolve absolutely. `[VERIFIED: Microsoft Learn LoadImageW + GetModuleFileName references]`

**Warning signs:** UAT D-25(2) tray-glyph state-transition test shows DEFAULT Windows icon (a generic application icon) instead of the green/red/pulse glyphs.

**Phase to address:** Wave 2 (path resolution) + Wave 6 (installer wiring).

### Pitfall 9: `default.vrsettings` flag flip persists across uninstall → fresh install

**What goes wrong:** P10 D-25(11) UAT calls for "install vX.Y.Z → upgrade vX.Y.Z+1 → uninstall." The vrsettings file at `<SteamVR>\drivers\micmap\resources\settings\default.vrsettings` is overwritten by each install. But if a user manually edited it to `enable_driver_audio=false` (D-02 emergency override), the upgrade may either (a) preserve the user's edit (Inno Setup `ignoreversion` flag — the current shape in `MicMap.iss:60`), (b) silently overwrite it, or (c) prompt the user (Inno's default for changed files).

**Why it happens:** `recursesubdirs` + `ignoreversion` in `MicMap.iss:59-61` for the `resources\*` glob means Inno will overwrite based on file-modification-time, not file-content. Since CMake regenerates these files on every build, mtime is always "newer" → Inno overwrites unconditionally.

**How to avoid:** D-02 explicitly says "the keys persist for emergency override" — but if the installer overwrites them on every upgrade, the emergency-override behavior IS LOST on upgrade. Wave 6 plan needs to decide: either (a) accept that "emergency override is per-install only" and document, or (b) add a `[Code]` `BackupAndRestoreUserVrsettings` Pascal hook that diffs the user's edit against the shipped default and offers to preserve. Recommend (a) for v1.6 — the override is a debugging tool, not a runtime config knob; documenting it as install-scoped is honest.

**Warning signs:** user reports "I set enable_driver_audio=false and it came back to true after the next update."

**Phase to address:** Wave 5 (default flip) + Wave 6 (installer behavior + documentation).

## Code Examples

Verified patterns from in-tree precedent + official sources, ordered by phase wave.

### Wave 1 — `cmake/version.cmake` (single source of truth)

```cmake
# Source: new file, derived from existing apps/micmap/CMakeLists.txt:24 + driver/CMakeLists.txt:91-92
# All version consumers read from this file. Bump MICMAP_VERSION here for releases.
set(MICMAP_VERSION "1.6.0")

# Validate semver shape (defensive — future-proofing if someone passes "1.6")
string(REGEX MATCH "^[0-9]+\\.[0-9]+\\.[0-9]+$" _vmatch "${MICMAP_VERSION}")
if(NOT _vmatch)
    message(FATAL_ERROR
        "cmake/version.cmake: MICMAP_VERSION must be 'MAJOR.MINOR.PATCH' "
        "(no pre-release suffix in v1.6); got: ${MICMAP_VERSION}")
endif()

# Split into VERSIONINFO numeric quad: A.B.C → A,B,C,0
string(REPLACE "." ";" _vparts "${MICMAP_VERSION}")
list(GET _vparts 0 MICMAP_VERSION_MAJOR)
list(GET _vparts 1 MICMAP_VERSION_MINOR)
list(GET _vparts 2 MICMAP_VERSION_PATCH)
set(MICMAP_VERSION_QUAD "${MICMAP_VERSION_MAJOR},${MICMAP_VERSION_MINOR},${MICMAP_VERSION_PATCH},0")

message(STATUS "MicMap version: ${MICMAP_VERSION} (RC quad: ${MICMAP_VERSION_QUAD})")
```

### Wave 2 — Tray-icon load + state-derivation

```cpp
// Source: derived from D-05 state rules + apps/micmap/main.cpp:261-273 NIM_ADD reuse
// Place in MicMapApp class body or as free functions in main.cpp.

enum class TrayGlyph { Armed, Triggered, Error };

struct TrayState {
    HICON iconArmed = nullptr;
    HICON iconTriggered = nullptr;
    HICON iconError = nullptr;
    TrayGlyph current = TrayGlyph::Armed;
    std::chrono::steady_clock::time_point lastTriggeredAt{};
};

void initTrayIcons(HINSTANCE hInst, TrayState& ts) {
    // Resolve absolute path next to micmap.exe (PITFALL 8 mitigation)
    wchar_t exePath[MAX_PATH];
    GetModuleFileNameW(nullptr, exePath, MAX_PATH);
    PathRemoveFileSpecW(exePath);  // <Steam>\steamapps\common\SteamVR\drivers\micmap\bin
    auto load = [&](const wchar_t* relPath) -> HICON {
        wchar_t full[MAX_PATH];
        PathCombineW(full, exePath, relPath);
        return (HICON)LoadImageW(nullptr, full, IMAGE_ICON, 0, 0,
                                 LR_LOADFROMFILE | LR_DEFAULTSIZE);
    };
    ts.iconArmed     = load(L"resources\\tray_armed.ico");
    ts.iconTriggered = load(L"resources\\tray_triggered.ico");
    ts.iconError     = load(L"resources\\tray_error.ico");
    if (!ts.iconArmed || !ts.iconTriggered || !ts.iconError) {
        MICMAP_LOG_WARNING("tray icon load partial-failure (armed=", ts.iconArmed,
                           ", triggered=", ts.iconTriggered, ", error=", ts.iconError, ")");
    }
}

// D-05 derivation: pure function over /health + /state envelope
TrayGlyph deriveTrayGlyph(const HealthSnapshot& health,    // P8 envelope
                          const StateSnapshot& state,
                          const TrayState& ts,
                          std::chrono::steady_clock::time_point now) {
    // error (red) — highest priority
    if (!health.driverLoaded) return TrayGlyph::Error;          // FAIL-02
    if (state.lastError.has_value() && !state.lastError->empty()) return TrayGlyph::Error;
    if (state.audioDeviceState == "missing"
     || state.audioDeviceState == "permission_denied") return TrayGlyph::Error;   // FAIL-01/05

    // triggered (pulse) — 300ms window after most recent triggered observation
    if (state.detectionState == "triggered"
     || (now - ts.lastTriggeredAt) < std::chrono::milliseconds(300)) {
        return TrayGlyph::Triggered;
    }
    return TrayGlyph::Armed;
}

void applyTrayGlyph(NOTIFYICONDATAW& nid, TrayState& ts, TrayGlyph desired) {
    if (ts.current == desired) return;  // no-op
    HICON h = (desired == TrayGlyph::Armed)     ? ts.iconArmed
            : (desired == TrayGlyph::Triggered) ? ts.iconTriggered
            :                                     ts.iconError;
    if (!h) return;  // partial load failure — skip
    const UINT prev = nid.uFlags;
    nid.uFlags = NIF_ICON;
    nid.hIcon = h;
    Shell_NotifyIconW(NIM_MODIFY, &nid);
    nid.uFlags = prev;
    ts.current = desired;
}
```

### Wave 3 — `GET /health` `driver_version` field (mirror of P7 D-09 / P9 D-07)

```cpp
// Source: same getter-callback shape as driver_detection_active (P7) and
// driver_training_active (P9). Composition root passes a tiny lambda.

// driver/src/http_server.hpp — ctor signature evolves
class HttpServer {
public:
    HttpServer(/* existing params */,
               std::function<bool()> driverDetectionActiveGetter,
               std::function<bool()> driverTrainingActiveGetter,
               std::function<std::string()> driverVersionGetter);  // NEW Wave 3
    ...
};

// driver/src/http_server.cpp — /health handler
srv.Get("/health", [this](const httplib::Request&, httplib::Response& res) {
    nlohmann::json j;
    j["status"] = "ok";
    j["driver_loaded"] = true;
    j["driver_detection_active"] = driverDetectionActiveGetter_();
    j["driver_training_active"] = driverTrainingActiveGetter_();
    j["driver_version"] = driverVersionGetter_();              // NEW
    res.set_content(j.dump(), "application/json");
});

// driver/src/device_provider.cpp — Init pass-through
httpServer_ = std::make_unique<HttpServer>(/* ... */,
    [this]() { return detectionRunner_ && detectionRunner_->isActive(); },
    [this]() { return trainingSession_ && trainingSession_->isActive(); },
    []() -> std::string { return MICMAP_VERSION_STRING; }       // NEW
);
```

### Wave 4 — `--debug-trigger` short-circuit (mirror of `tryRunReplayCli`)

See Pattern 6 above for the WinMain integration. The `IDriverApi` extension in `src/steamvr/`:

```cpp
// src/steamvr/include/micmap/steamvr/driver_api.hpp
#if MICMAP_DEBUG_BUILD
struct DebugTriggerResult { enum Status { Ok, HttpError, ConnectionRefused } status; };
#endif

class IDriverApi {
public:
    // ... existing methods ...
#if MICMAP_DEBUG_BUILD
    virtual DebugTriggerResult debugTrigger() = 0;
#endif
};

// src/steamvr/src/driver_api.cpp
#if MICMAP_DEBUG_BUILD
DebugTriggerResult DriverApiImpl::debugTrigger() {
    httplib::Client cli("127.0.0.1", port_);
    cli.set_connection_timeout(0, 250 * 1000);  // 250ms (same as other methods, P8 D-09)
    auto r = cli.Post("/debug/trigger", "", "application/json");
    if (!r) {
        return {(r.error() == httplib::Error::Connection) ? DebugTriggerResult::ConnectionRefused
                                                           : DebugTriggerResult::HttpError};
    }
    return {(r->status == 200) ? DebugTriggerResult::Ok : DebugTriggerResult::HttpError};
}
#endif
```

### Wave 5 — Cutover deletions (illustrative; concrete deletes in PLAN)

```cpp
// driver/src/http_server.cpp — DELETE the entire /button registration block
// (~10-line block; exact line range determined at planning time after grep)
// srv.Post("/button", [...]) { ... commandQueue_->push(TapCommand{}); ... };

// src/steamvr/include/micmap/steamvr/driver_api.hpp — DELETE
// virtual TapResult tap() = 0;

// src/steamvr/src/driver_api.cpp — DELETE the impl

// apps/micmap/main.cpp — DELETE
//   - audioCapture member + initialization
//   - detector member + initialization + 3x loadTrainingData call sites
//   - stateMachine member + initialization
//   - audio callback lambda + setAudioCallback wiring
//   - local trigger callback wiring
//   - any client-side onTrigger -> driverApi->tap() bridge

// driver/resources/settings/default.vrsettings — flip
// "enable_driver_audio" : true,
// "enable_driver_detection" : true,
```

### Wave 6 — Inno Setup wiring

```ini
; installer/MicMap.iss — at the very top, before existing #ifndef block
#include "version.iss"
; The existing #ifndef MICMAP_VERSION fallback can stay as a safety net for
; hand-runs without the configure_file() step (e.g., devs running ISCC directly).

; ... existing [Setup] section is unchanged — already uses {#MICMAP_VERSION} ...
```

```cmake
# CMakeLists.txt — REMOVE redundant /D pass once version.iss exists
COMMAND "${ISCC_EXECUTABLE}"
        # "/DMICMAP_VERSION=${PROJECT_VERSION}"   # REMOVED Wave 6 — version.iss is SSoT
        "/DSTAGE_DIR=${MICMAP_STAGE_DIR}"
        "/DOUTPUT_DIR=${MICMAP_INSTALLER_DIR}"
        "${MICMAP_ISS_FILE}"
```

### Wave 6 — Client-side version-mismatch check

```cpp
// apps/micmap/main.cpp — inside the existing pollDriverHealth() callback, after
// first successful /health response. Track first-poll-completion via a bool.

void MicMapApp::onFirstHealthSuccess(const HealthSnapshot& health) {
    static const std::string kClientVersion = MICMAP_VERSION_STRING;
    if (health.driverVersion != kClientVersion) {
        MICMAP_LOG_WARNING("driver version ", health.driverVersion,
                           " does not match client ", kClientVersion);
        versionMismatchPillActive_ = true;     // ImGui render uses this in driver-health pane
    }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Multiple version sources (`apps/micmap/CMakeLists.txt:24` `set(MICMAP_VERSION ${PROJECT_VERSION})` + `driver/CMakeLists.txt:91-92` `MICMAP_DRIVER_VERSION` + ISCC `/D` at `CMakeLists.txt:153`) | Single `cmake/version.cmake` SSoT consumed by all three | P10 Wave 1 (D-18) | Drift impossible; lint enforces |
| `MICMAP_DRIVER_VERSION` (driver-only) | `MICMAP_VERSION_STRING` (both binaries) | P10 Wave 1 | One symbol, two embeds; rename existing driver-side constant to match |
| `Shell_NotifyIconW(NIM_ADD)` once at startup, no state-driven swap | `NIM_ADD` once + `NIM_MODIFY` per state change (driven by existing 2Hz `/state` poll) | P10 Wave 2 (D-04) | Reuses tray lifecycle; no new poll |
| `apps/micmap/main.cpp:1461 CreateMutexW(L"MicMapSingleInstance")` (works but unscoped name) | `L"Local\\MicMap_Client_SingleInstance_v1"` (session-scoped, version-suffixed) | P10 Wave 3 (D-09 hardening) | Future-proofed against namespace collisions |
| `if(CMAKE_BUILD_TYPE STREQUAL "Debug")` for build-type guards (NOT used in this project but a common temptation) | `$<CONFIG:Debug>` generator expression | P10 Wave 4 | Multi-config-generator-safe |
| `POST /button` HTTP route as the trigger path (v1.5) | Direct in-process `commandQueue_->push(TapCommand{})` from `DetectionRunner` (P7); `POST /button` route DELETED | P10 Wave 5 (D-01) | 7 hops → 4 hops; trigger latency drop |
| `IDriverClient::tap()` (renamed to `IDriverApi::tap()` in P8) | `IDriverApi::debugTrigger()` (Debug-build-only) | P10 Wave 4 add + Wave 5 delete | Two methods don't coexist — `tap` deletion is atomic with `debugTrigger` introduction |
| Static `FILEVERSION 1,0,0,0` in `apps/micmap/micmap.rc:15-16` | `FILEVERSION @MICMAP_VERSION_QUAD@` from `cmake/version.cmake` | P10 Wave 1 | Build-time version embedding; matches binary identity |
| Append-only `FileLogSink` (P8) | `FileLogSink` with synchronous size-check + 5-generation rotation | P10 Wave 1 (D-14) | Bounded disk usage; soft-fails to oversize on rotation error |

**Deprecated/outdated post-cutover:**
- **Client-side WASAPI capture path** (`audioCapture` instance + audio callback in `apps/micmap/main.cpp`) — DELETED Wave 5; KissFFT no longer transitively required by client (though still linked via `micmap::core_runtime` aggregate per Pitfall 7).
- **`POST /button` route** (driver) and `IDriverApi::tap()` (client) — DELETED Wave 5.
- **3x `detector->loadTrainingData(configManager->getTrainingDataPath())` call sites** at `apps/micmap/main.cpp:326,:647,:970` — DELETED Wave 5; driver is sole reader per IPC-06.
- **Two parallel version sources in CMake** — superseded by `cmake/version.cmake` SSoT.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `LR_DEFAULTSIZE` returns the icon at the system's default tray size for multi-size `.ico` (16/32/48); high-DPI rigs render the 32 or 48 variant cleanly | Pattern 1 | Tray icon may render blurry on 200%+ DPI scaling; UAT D-25(2) on Beyond+Win11 (typical 100% DPI) won't catch this — recommend a high-DPI sub-test |
| A2 | `std::filesystem::file_size` cost on Windows 10/11 with cached MFT entries is microseconds | Pattern 3 | Log writes degrade on slow disks; mitigated by D-16 cumulative-counter optimization if needed |
| A3 | The existing Inno Setup `#ifndef MICMAP_VERSION ... #define ... "0.0.0-dev"` fallback at `MicMap.iss:8-10` will not interfere with a later `#include "version.iss"` (later define wins or warning is benign) | Pattern 4 / Pitfall 5 | Wave 6 plan-time check during build verifies the resolved version is correct; D-21 lint catches drift |
| A4 | Refactoring `micmap::core_runtime` to drop unused detection/audio from the post-cutover client EXE is out of scope; D-03 acceptance documents the actual delta achieved | Pitfall 7 | Binary-size delta may be smaller than expected (~10-30KB instead of ~150KB); D-03 is a SOFT check per CONTEXT D-03 wording |
| A5 | The basic `ShowWindow(SW_RESTORE) + SetForegroundWindow` pair is sufficient for D-25(6) FAIL-04 UAT (user double-clicks the shortcut in File Explorer) without the `AttachThreadInput` dance | Pattern 2 | Edge cases (scripted launch, SteamVR re-spawn) may still flash the taskbar instead of foregrounding; falls back gracefully (taskbar flash IS the documented Windows fallback for this exact case) |
| A6 | `enable_driver_audio` / `enable_driver_detection` keys are emergency-override-only post-cutover; the install-time overwrite of user edits is acceptable | Pitfall 9 | If users actively rely on the keys, the upgrade UX is poor; document explicitly in cutover commit + DOC-01 carryover for P11 |
| A7 | `steam://rungameid/250820` is the canonical SteamVR launch URI in 2026; the `steam://run/`, `steam://launch/`, and `steam://rungameid/` variants all work for SteamVR app ID 250820 | §`steam:` URI | FAIL-02 "Open SteamVR" button may launch into a "Launching..." popup variant; harmless but slightly less polished — confirm during D-25(4) |

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Inno Setup 6 (ISCC.exe) | Wave 6 installer build + UAT D-25(11) | ✓ | 6.7.1+ | None — ISPP `#include` requires Inno 5+ |
| MSVC RC compiler (rc.exe) | Wave 1 `VS_VERSION_INFO` resource compile | ✓ | VS 2022 | None — required for any Win32 resource |
| OpenVR SDK | Driver build (unchanged from prior phases) | ✓ | per env `OPENVR_SDK_PATH` | None — driver build skipped if absent (existing CMake guard at `CMakeLists.txt:111`) |
| SteamVR runtime | UAT only (D-25 1-15) | ✓ | Bigscreen Beyond rig per CLAUDE.md | None — UAT requires SteamVR |
| Windows 11 Pro | UAT only | ✓ | dev rig per CLAUDE.md | Win10 22H2 acceptable; `ms-settings:privacy-microphone` works on both |
| `ms-settings:` URI handler | FAIL-01 deep-link | ✓ | OS-provided since Win10 | None known — Settings app is integral to OS; corporate-locked variants may strip it (out of v1.6 scope) |
| `steam://` URI handler | FAIL-02 "Open SteamVR" button | ✓ | Installed by Steam client (which is required for SteamVR — chicken-and-egg-safe) | If Steam isn't installed, FAIL-02 button silently no-ops; pill text already informs user to "run installer or enable in SteamVR" so the failure mode is acceptable |

**Missing dependencies with no fallback:** None.

**Missing dependencies with fallback:** None — all required infrastructure is in place from prior phases.

## Validation Architecture

`workflow.nyquist_validation` is `true` in `.planning/config.json`. Section required.

### Test Framework

| Property | Value |
|----------|-------|
| Framework | CTest (CMake-native) — no Google Test (`MICMAP_USE_GTEST` is OFF per `STACK.md`) |
| Config file | `tests/CMakeLists.txt` (per-test `add_executable` + `add_test`) |
| Quick run command | `ctest --test-dir build -R "10_" --output-on-failure` (after Wave 0 registers `10_*` test names) |
| Full suite command | `ctest --test-dir build --output-on-failure` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| MIG-05 | `POST /button` route absent post-cutover | lint (CMake script) | `ctest -R AssertNoButtonRoute --output-on-failure` | ❌ Wave 0 (`cmake/AssertNoButtonRoute.cmake`) |
| MIG-05 | `IDriverApi::tap()` absent post-cutover | lint | same as above (combined script) | ❌ Wave 0 |
| MIG-05 | Client-side detection symbols absent post-cutover | lint | `ctest -R AssertNoClientDetection` | ❌ Wave 0 (`cmake/AssertNoClientDetection.cmake`) |
| HEALTH-08 | Tray glyph derivation produces armed/triggered/error correctly across input combinations + 300ms pulse window boundary | unit | `ctest -R test_tray_glyph_state_machine` | ❌ Wave 0 (`tests/test_tray_glyph_state_machine.cpp`) |
| HEALTH-08 | Tray icon updates within one health-poll cycle of state change | UAT | manual D-25(2) on Beyond+Win11 | N/A (manual) |
| FAIL-01..05 | Pill priority stacking (FAIL-02 > FAIL-03 > FAIL-01 > FAIL-05) under combinations | unit | `ctest -R test_fail_pill_priority` | ❌ Wave 0 (`tests/test_fail_pill_priority.cpp`) |
| FAIL-01..05 | Each failure mode end-to-end UX | UAT | manual D-25(3-7) | N/A (manual) |
| TEST-02 | `--debug-trigger` exits 0 on Debug build with driver running; non-zero on Release | UAT | manual D-25(8) | N/A (manual; Debug build) |
| TEST-03 | Log rotation: 5MB cap, 5 generations, atomic move | unit | `ctest -R test_log_rotation` | ❌ Wave 0 (`tests/test_log_rotation.cpp`) |
| TEST-03 | Log rotation under real workload | UAT | manual D-25(9) | N/A (manual) |
| INST-09 | Co-versioning: driver/client/installer all expose same `MICMAP_VERSION` | lint | `ctest -R AssertCoVersioning` | ❌ Wave 0 (`cmake/AssertCoVersioning.cmake`) |
| INST-09 | Mismatch warning surfaces inline pill | unit | `ctest -R test_version_mismatch` | ❌ Wave 0 (`tests/test_version_mismatch.cpp`) |
| INST-09 | Installer round-trip on clean Win11 VM | UAT | manual D-25(11) | N/A (manual) |
| TEST-01 | `mic_test.exe` continues to build headless (`-DMICMAP_BUILD_DRIVER=OFF`) | build | `cmake -DMICMAP_BUILD_DRIVER=OFF -B build-headless && cmake --build build-headless --target mic_test` | ✅ existing CI path (P5 SC1) |
| TEST-05 | `hmd_button_test.exe` continues to build | build | `cmake --build build --target hmd_button_test` | ✅ existing |

### Sampling Rate

- **Per task commit:** `ctest --test-dir build -R "10_" --output-on-failure` (4 unit tests + 3 lints — sub-30s on a clean build)
- **Per wave merge:** `ctest --test-dir build --output-on-failure` (full P5–P10 test suite)
- **Phase gate:** Full suite green + UAT D-25(1)..(15) sign-off before `/gsd-verify-work`

### Wave 0 Gaps

- [ ] `cmake/AssertNoClientDetection.cmake` — sibling of `cmake/AssertNoClientTraining.cmake`; forbids `IAudioCapture::start|setAudioCallback`, `INoiseDetector::analyze`, `IStateMachine::update`, `detector->loadTrainingData` in `apps/micmap/`; allowlist `apps/mic_test/`
- [ ] `cmake/AssertNoButtonRoute.cmake` — forbids `Post\("/button"` in `driver/src/` and `\.tap\(\)|->tap\(\)|::tap\b` in `src/steamvr/`
- [ ] `cmake/AssertCoVersioning.cmake` — asserts `cmake/version.cmake` exists, `MICMAP_VERSION` defined, value matches across driver target / client target / `installer/version.iss`
- [ ] `tests/test_tray_glyph_state_machine.cpp` — pure-function tests of `deriveTrayGlyph` over (HealthSnapshot, StateSnapshot, lastTriggeredAt, now); covers HEALTH-08
- [ ] `tests/test_fail_pill_priority.cpp` — table-driven tests of D-08 priority stacking under combinations of FAIL conditions
- [ ] `tests/test_log_rotation.cpp` — synthesize 5MB+ writes via `FileLogSink::log` (use a tmpdir); assert `.log` < 5MB after rotation; `.log.{1..5}` exist; `.log.6` doesn't
- [ ] `tests/test_version_mismatch.cpp` — given client `MICMAP_VERSION_STRING` (mocked) and a fake `/health.driver_version`, assert warning emitted + pill state
- [ ] `tests/CMakeLists.txt` — register the 4 new executables + 3 new lint ctest entries; all gated `if(EXISTS ...)` per Wave 0 RED-tolerant convention

## Security Domain

`security_enforcement` is not explicitly set in `.planning/config.json` → treat as enabled.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V1 Architecture | yes (informational) | Rip-out preserves SVR-05 invariant; lint guards documented |
| V2 Authentication | no | localhost-only HTTP; no auth model |
| V3 Session Management | no | no sessions |
| V4 Access Control | no | localhost-only; OS provides loopback isolation |
| V5 Input Validation | yes | `POST /debug/trigger` accepts empty body; no parameters → no validation surface; `IDriverApi::debugTrigger()` sends empty body |
| V6 Cryptography | no | no crypto in P10 |
| V7 Error Handling | yes | Pills surface errors; Logger sinks bound; rotation never raises (D-17 swallow + warn) |
| V8 Data Protection | no | no PII; logs contain audio metadata only (RMS, state names) |
| V9 Communications | yes | localhost-only binding preserved (P8 IPC-07 + lint `AssertHttpServerLocalhostOnly`) |
| V10 Malicious | no | n/a |
| V11 Business Logic | no | n/a |
| V12 Files | yes | Log rotation uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` (atomic); no path traversal (paths are constants in `%APPDATA%\MicMap\`) |
| V13 API | yes | New `/debug/trigger` is debug-build-only; route is structurally absent in Release per `MICMAP_DEBUG_BUILD` define |
| V14 Configuration | yes | `default.vrsettings` flag flip is the entire P10 config change; D-22 atomicity ensures matched-set installs |

### Known Threat Patterns for {Win32 driver + client}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Foreign process invokes `POST /debug/trigger` in Debug build (toggle dashboard against user's will) | Tampering | Localhost-only binding (P8 IPC-07) — only same-machine processes can reach; Debug-build-only registration narrows further; production users run Release, route absent |
| Stale `/debug/trigger` route accidentally shipped in Release | Tampering | `MICMAP_DEBUG_BUILD=$<IF:$<CONFIG:Debug>,1,0>` driven from CMake config; recommend a build-time CTest that asserts the route is NOT registered in Release dumpbin output (defense-in-depth — backlog) |
| HICON exhaustion via repeated tray-glyph swaps (Pitfall 2) | Denial of Service | Three persistent HICONs loaded once at WinMain; no per-swap allocation |
| Log file growth → disk exhaustion | Denial of Service | 5MB cap × 5 generations = 25MB max per binary (driver + client = 50MB total); D-14 enforces |
| Log rotation race → log loss | Tampering / DoS | Logger-level mutex (P8 `MultiSinkLogger`) serializes writes; `MoveFileExW` is atomic; D-17 soft-fail to oversize is "lose rotation, never lose log lines" |
| Symlink attack on `%APPDATA%\MicMap\` log path | Tampering | Path is `%APPDATA%\MicMap\micmap-driver.log` — a per-user dir; only the user can plant symlinks targeting their own files; out of scope for v1.6 |
| Cross-process foreground-stealing abuse via FAIL-04 path | Tampering | `SetForegroundWindow` cross-process restrictions (documented above) limit damage; the second-instance is OUR own process, not arbitrary |
| Inno Setup `#include` pulling a tampered `version.iss` from a different dir | Tampering | ISPP resolves `"version.iss"` relative to the including script's directory FIRST; controlled-build env; absolute-path invocation in `CMakeLists.txt:156` |

## Sources

### Primary (HIGH confidence — Microsoft Learn / official docs / verified codebase)
- `[Shell_NotifyIconW reference]` https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw — NIM_MODIFY semantics, NIF_ICON flag behavior, version 5.0+ behaviors
- `[MoveFileExW reference]` https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw — MOVEFILE_REPLACE_EXISTING atomicity, MOVEFILE_WRITE_THROUGH only-cross-volume meaning
- `[SetForegroundWindow reference]` https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow — cross-process restrictions, AllowSetForegroundWindow + AttachThreadInput dance
- `[CMake configure_file documentation]` https://cmake.org/cmake/help/latest/command/configure_file.html — `@ONLY` semantics
- `[CMake generator expressions]` https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html — `$<CONFIG:Debug>` multi-config behavior
- Codebase grep — verified in-tree:
  - `apps/micmap/main.cpp:1461-1485` (existing FAIL-04 mutex)
  - `apps/micmap/main.cpp:261-273` (existing NIM_ADD)
  - `apps/micmap/first_launch_balloon.cpp:69-89` (existing NIM_MODIFY pattern with uFlags discipline)
  - `apps/mic_test/main.cpp:150` (`tryRunReplayCli` short-circuit pattern)
  - `apps/micmap/CMakeLists.txt:24-31` (existing `configure_file(... @ONLY)` for vrmanifest)
  - `driver/CMakeLists.txt:91-92` (existing `MICMAP_DRIVER_VERSION="${PROJECT_VERSION}"` quoting form)
  - `installer/MicMap.iss:8-10` (existing `#ifndef MICMAP_VERSION` fallback)
  - `CMakeLists.txt:153` (existing ISCC `/D` define for version)
  - `cmake/AssertNoClientTraining.cmake` + `cmake/AssertNoConfigWriteInClient.cmake` (sibling lint shape for the three new lints)
  - `src/common/src/sinks/file_log_sink.cpp:38-59` (existing FileLogSink to extend)

### Secondary (MEDIUM confidence — verified via WebSearch + cross-referenced)
- `[ISPP directives reference — search hit]` https://ficonsulting.github.io/RInno/reference/directives_section.html — `#include`, `#define`, `{#var}` substitution, `/D` command-line equivalence
- `[ISPP example]` https://github.com/jrsoftware/issrc/blob/main/Examples/ISPPExample1.iss — canonical `#define AppVersion` + `[Setup] AppVersion={#AppVersion}` shape
- `[Microsoft Learn launch settings deep-links]` https://learn.microsoft.com/en-us/windows/apps/develop/launch/launch-settings — `ms-settings:` URI scheme reference
- `[Steam browser protocol]` https://developer.valvesoftware.com/wiki/Talk:Steam_browser_protocol — `steam://rungameid/<appid>` form

### Tertiary (LOW confidence — single source / unverified)
- `[CommunityNotifyIconBalloons / Pitfall 1 TaskbarCreated]` — referenced across multiple Win32 community sources but not a single Microsoft Learn canonical page; well-established convention since Windows 95 explorer rebroadcast
- Steam URI variant comparison (`steam://run/` vs `steam://launch/` vs `steam://rungameid/`) — community sources only; planner can pick `steam://rungameid/250820` per CONTEXT D-08 with low risk

## Pitfalls Index (scoped per CONTEXT D-26 wave layout)

**Wave 0 (RED-tolerant scaffolds — `cmake/Assert*.cmake` + headless test executables):**
- Pitfall 5 (ISPP `#include` path resolution) — surfaces when Wave 6 wires the `#include`; Wave 0 just scaffolds the lint, no surface yet
- Pitfall 4 (`MICMAP_DEBUG_BUILD` cross-target inconsistency) — the lint that catches this should be sketched in Wave 0 as part of `AssertCoVersioning` ambit (or as a sibling micro-lint)

**Wave 1 (log rotation + version SSoT plumbing):**
- Pitfall 5 (ISPP `#include` path resolution) — `version.iss.in` template format chosen here; verify `configure_file(... @ONLY)` produces ASCII-clean output (no BOM, no CRLF surprises) for ISPP consumption
- (general) — `target_compile_definitions` quoting form must match existing `driver/CMakeLists.txt:91-92` shape (no explicit backslash-quote)

**Wave 2 (HEALTH-08 tray glyphs):**
- Pitfall 1 (TaskbarCreated re-registration missed)
- Pitfall 2 (HICON leak from per-swap loads)
- Pitfall 3 (NIM_MODIFY uFlags pollution causing balloon resurrection)
- Pitfall 8 (`LoadImageW` relative-path resolution — use absolute path via `GetModuleFileNameW`)

**Wave 3 (FAIL UX pills + FAIL-04 hardening + driver_version):**
- Pitfall 6 (`tasklist` cost at 1Hz from UI thread; use `CreateToolhelp32Snapshot` instead)
- (Pattern 2 PITFALL note) — `SetForegroundWindow` cross-process restrictions; document fallback path

**Wave 4 (`POST /debug/trigger` + `--debug-trigger` CLI):**
- Pitfall 4 (`MICMAP_DEBUG_BUILD` defined inconsistently across `driver_micmap` and `micmap` targets)

**Wave 5 (CUTOVER — single atomic plan):**
- Pitfall 7 (KissFFT still linked into `micmap.exe` post-cutover; D-03 binary-size soft check may show smaller-than-expected delta)
- (general) — D-26 wave-dep: cutover MUST follow 02/03/04; FAIL-pill surface is required to render the FAIL-02/FAIL-03 pills that surface immediately post-flag-flip if anything is wrong

**Wave 6 (INST-09 installer co-versioning):**
- Pitfall 5 (ISPP `#include` path resolution) — surfaces when `#include "version.iss"` is added to `MicMap.iss`
- Pitfall 8 (Inno Setup `[Files]` orphan icons — add `apps/micmap/resources/*.ico` to install staging + `[Files]` block)
- Pitfall 9 (`default.vrsettings` flag-flip persistence across upgrade — accept install-scoped emergency-override semantics; document)

**Wave 7 (UAT regimen):**
- (general) — Pitfall 1 (TaskbarCreated) is best validated via a manual sub-step under D-25(2): kill `explorer.exe` via Task Manager, observe tray icon reappears
- (general) — Pitfall 2 (HICON leak) validated via Process Explorer GDI count during D-25(2)
- (general) — Pitfall 8 (icon path resolution) validated implicitly during D-25(11) clean-VM install — if icons aren't in `[Files]`, default Windows icon shows

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every component is in-tree at known-good versions, or a Win32 OS API verified against Microsoft Learn
- Architecture: HIGH — every pattern has in-tree precedent (P3/P5/P6/P7/P8/P9); Wave 5 cutover mirrors P8 D-07 / P9 D-23 verbatim at multi-target scale
- Pitfalls: HIGH for items with in-tree precedent (NIM_MODIFY, mutex, configure_file); MEDIUM for items with single-source verification (TaskbarCreated convention; Steam URI variant)
- Validation architecture: HIGH — CTest patterns established P5–P9; lint shape is sibling-of-existing
- Security domain: HIGH — no new threat surface (debug-build-only route + localhost-only binding from P8)

**Research date:** 2026-05-10
**Valid until:** 2026-06-09 (30 days — stable Win32 APIs + locked stack; no fast-moving deps in scope)
