` (`Server.vue:2`) that receives `view-container flex-none` by attribute fallthrough from `DashboardRouterView.vue:13`, so a surviving instance should keep the same element — but Server, Web5 and Fleet all share the generic `contentSelector` `.view-container [data-controller-container]`, so a mid-transition moment where two cached views are simultaneously laid out can still let the visible-ancestor filter pick a different view's root. Instrument (1) settles this outright.
2. A Server-specific runtime error during activation or deactivation tearing down the cached subtree — Server carries seven `useCachedResource` groups plus `armVpnPoll`/`disarmVpnPoll` (`Server.vue:944-949`) and a once-per-session seed in `onMounted` (`Server.vue:963`). A device-only failure would explain why Step A passes while the device does not. Instrument (2) settles this.
3. `KEEP_ALIVE_MAX = 6` LRU eviction with 10 registered paths. The arithmetic argues against it for an immediate revisit — Vue re-adds the just-activated key as newest and prunes `keys.values().next().value` — but the probe's session visits every registered tab, so confirm rather than assume: instrument (3) plus a run with the cap temporarily raised (do not commit a raised cap; `KEEP_ALIVE_MAX` is a measured value per 02-08's on-device heap reading).
4. `route.path` differing between visits, which would make `:key="route.path"` and `wrapperFor(route.path)` resolve to a different cache entry each time. `DashboardSidebar.vue:148` links `/dashboard/server` and `Dashboard.vue:230` pushes `{ path: '/dashboard/server', query: {} }` for the mobile swipe target, so this should hold — instrument (3) confirms it from the device instead of from the source.
5. `include`-name matching. Vue's `matches()` splits each array entry on `,` (`p.split(',').includes(name)`); the wrapper names contain `:` and `/` but no comma, so this should hold identically for all ten paths — nearly free to assert in Step A, and if it fails it fails for every tab, not just Server.
Then write a `## Server KeepAlive Root Cause (gap closure)` section into `02-FINDINGS.md` recording: the named cause in one sentence, the specific observation that proves it, which suspects the data killed and how, and the intended fix. Keep the phase's established candor — if the evidence proves the earlier reading was a probe artifact and Server's instance was surviving all along, say exactly that and show the instance-identity data that proves it. Absence of a reproduction is not proof of survival; only a positive instance-identity reading across the round-trip on the deployed build counts.
Commit and push this task on its own (`git add` by path, Co-Authored-By trailer, `git push gitea-ai main`) before starting Task 2 — the findings-before-fix ordering must be visible in `git log`, exactly as 02-01's gate was.
cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box npx playwright test e2e/perf/keepalive-remount-probe.spec.ts --project=chromium --reporter=line (requires ARCHY_PASSWORD in env; must exit 0 and print a per-path survival line for all 10 KEEP_ALIVE_PATHS entries, including /dashboard/server). If Step A reproduced the fault in jsdom and Step B was therefore not needed for diagnosis, the probe spec is still created and this command still runs — it is Task 2's proof-of-fix instrument and 02-VERIFICATION's re-runnable evidence.
grep -q "## Server KeepAlive Root Cause (gap closure)" .planning/phases/02-ui-performance/02-FINDINGS.md && git log --oneline -1 -- .planning/phases/02-ui-performance/02-FINDINGS.md
02-FINDINGS.md names one measured cause for `/dashboard/server`'s remount, with the observation that proves it and the suspects the data eliminated; `keepalive-remount-probe.spec.ts` exists, runs green against archi-dev-box, and reports instance survival per registered path; the findings commit is pushed and precedes any source change in `git log` (D-10).
Task 2: Land the targeted fix, pin it with a regression test, and deploy to archi-dev-box
neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts, plus exactly the file Task 1's named cause identifies (one of neode-ui/src/views/dashboard/dashboardViewWrappers.ts, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/views/Server.vue)
The fix is confined to the dashboard KeepAlive wiring or to Server.vue; a single `git revert` restores the current behavior, and the deploy is a `--frontend-only` push to one dev node with no schema, no migration and no fleet/OTA exposure (D-15).
- Test 1 (the gap): routing the real `DashboardRouterView` to `/dashboard/server`, away to `/dashboard/settings`, then back to `/dashboard/server` mounts the real `Server.vue` exactly once — the second arrival reactivates rather than remounts. This test must fail against the pre-fix code (run it before the fix and record that it did).
- Test 2 (no collateral damage): the same round-trip through at least two other registered paths keeps their mount counts at 1, so the fix did not trade Server's survival for another tab's.
- Test 3 (registration is really the include list): every name in `keepAliveIncludeNames()` is matched by Vue's own `include` semantics for the path it was derived from, and `/dashboard/server`'s wrapper name is among them.
- Test 4 (the bound stays bound): the existing LRU-eviction test still passes unchanged — `KEEP_ALIVE_MAX` still evicts, so the fix did not buy instance survival by disabling the memory cap (D-03).
Write the tests first, watch Test 1 fail against current code, then implement the smallest change that makes it pass.
Scope the fix to the cause Task 1 named. Do not "harden" adjacent code, do not widen `KEEP_ALIVE_PATHS`, do not raise `KEEP_ALIVE_MAX` (02-08 measured it at 6 from an on-device heap reading), and do not register `/dashboard/settings` (deliberately withheld pending an audit its child sections have not had). If the named cause turns out to sit in `Server.vue` itself, keep the change to the lifecycle/reactivation wiring — the seven `useCachedResource` groups and their TTLs are 02-06's measured tuning and stay as they are.
The visual contract is non-negotiable. `dashboardViewWrappers.ts` and `DashboardRouterView.vue` both carry comments explaining the exact invariants: the transitioning keyed element must be `div.view-wrapper`, it must be the direct child of `.perspective-container`, the per-route padded/full-bleed shapes live inside it, and KeepAlive must never be keyed, `v-if`-toggled or nested under a per-route element. `keepAliveTabs.test.ts` pins those structurally — leave that file untouched and green. If the fix appears to require changing rendered DOM shape, stop and raise it at Task 3's checkpoint instead of shipping a layout change.
Then verify locally and ship it to one node:
- `npm test` (full vitest suite), `npm run type-check`, and `npm run build` must all be green. The build gotcha from CLAUDE.md applies: after `npm run build`, grep `web/dist/neode-ui` for a string introduced by this change to confirm the build was not a silent no-op.
- Deploy frontend-only to archi-dev-box and nowhere else, using the same path 02-08 used: `ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only`. No fleet, no OTA, no signed-catalog work (D-15). Confirm the served bundle at `/opt/archipelago/web-ui` — not just the local `web/dist` copy — contains the change.
- Re-run `keepalive-remount-probe.spec.ts` against the redeployed build and confirm `/dashboard/server` now reports the same instance-survival result as Home/Apps/Marketplace/Cloud/Web5/Fleet.
- `archy-x250-dev` may still be offline. Check it once, and if it is unreachable record that honestly in the SUMMARY (as 02-08 did) rather than blocking; archi-dev-box is D-11's named target and single-node measurement is acceptable here.
If Task 1's evidence positively proved that Server's instance was surviving all along and the earlier reading was a probe artifact, then there is no source change to make: land Tests 1-4 (they will pass immediately, which is itself the pin), skip the build/deploy step, and record in the SUMMARY exactly which instance-identity observation justified the no-change branch. This branch is only available on positive proof of survival on the deployed build — never on a failure to reproduce.
Append the outcome to the `## Server KeepAlive Root Cause (gap closure)` section: what changed, the pre-fix failing-test observation, and the post-deploy probe reading. Commit and push (Co-Authored-By trailer, `git push gitea-ai main`).
cd neode-ui && npm test 2>&1 | tail -20 && npm run type-check && npm run build
cd neode-ui && npx vitest run src/views/dashboard/__tests__/keepAliveLifecycle.test.ts src/views/dashboard/__tests__/keepAliveTabs.test.ts --reporter=verbose
cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box npx playwright test e2e/perf/keepalive-remount-probe.spec.ts --project=chromium --reporter=line (ARCHY_PASSWORD in env; /dashboard/server must report instance survival)
The round-trip test for `/dashboard/server` failed before the change and passes after it; the whole vitest suite, `type-check` and `build` are green; `keepAliveTabs.test.ts` is unmodified and passing; the deployed bundle on archi-dev-box contains the change and the committed probe reports `/dashboard/server` surviving a round-trip like every other registered tab; the work is committed and pushed.
Task 3: Confirm on archi-dev-box that Server revisits are instant and nothing visual moved
Server's KeepAlive instance-caching gap is closed: Task 1 named the measured cause and committed a re-runnable remount probe; Task 2 landed the targeted fix, pinned it with a regression test, and deployed frontend-only to archi-dev-box. This checkpoint is the D-11 pass bar for this one tab, and it also closes the two human-verification items 02-VERIFICATION.md raised for gap 1.
On archi-dev-box (the deployed UI, not the :8100 dev preview), in a fresh browser session:
1. Log in and click through to the Network tab (`/dashboard/server`). Let it finish painting.
2. Switch away to Settings, then back to Network. Do this four or five times, watching the Network tab specifically each time it returns.
- Expected: content is there the instant the tab returns. No spinner, no blank frame, no visible re-layout. This is D-11's literal pass bar.
3. Before switching away, scroll the Network page down and note where you are, or open one of its expandable cards. Switch away and back.
- Expected: your scroll position and the open card are still as you left them — that is what instance survival buys, and it is the observable difference from the previous behavior.
4. Visual invisibility check (this is the regression this machinery caused once before): on Network and on three or four other tabs, confirm page margins/padding look normal (content not pinned to the window edges) and that the slide/depth transition between tabs still animates as it always has.
- Expected: indistinguishable from before this plan. Any change here is a failure even if the caching works.
5. Spot-check that Server's data is still fresh, not frozen: leave Network for a minute, come back, and confirm the network/VPN figures update rather than showing stale values forever.
Type "approved", or describe exactly what you saw (which tab, which step, spinner vs blank vs wrong margins). If step 2 still shows a spinner or blank frame, say so — that means the fix did not land the user-visible outcome even if the probe went green. If you would rather accept the current behavior than take further changes to this machinery, say "accept as-is" and the deviation will be recorded with your rationale for the verifier.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator workstation -> archi-dev-box UI login | The real node password crosses this boundary at runtime to drive the probe |
| build artifact -> `/opt/archipelago/web-ui` on a real node | A frontend bundle is written to a running node's served directory |
| browser client -> node `/rpc/v1` | Existing boundary; unchanged by this plan (no RPC surface is added or modified) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-09-01 | Information Disclosure | `keepalive-remount-probe.spec.ts` login step | high | mitigate | Password read only from `process.env.ARCHY_PASSWORD`; never a default value, never a literal, never written to a file or a planning artifact, never printed by the probe's own logging. The probe's console/pageerror capture prints page-origin messages only — review the transcript before pasting it anywhere. |
| T-02-09-02 | Information Disclosure | probe transcript / committed artifacts | medium | mitigate | The probe records paths, instance-survival booleans and error text only — no RPC bodies, no tokens, no cookies. Same rule the 02-01 harness already follows. |
| T-02-09-03 | Tampering | `scripts/deploy-to-target.sh --frontend-only` to archi-dev-box | medium | mitigate | Deploy is frontend-only, to the single named dev node, with no OTA and no fleet/catalog path (D-15). The served bundle is grepped post-deploy to confirm it is the intended build and not a stale or partial copy. |
| T-02-09-04 | Denial of Service | `KEEP_ALIVE_MAX` / instance cache growth | medium | mitigate | The fix may not buy instance survival by raising or removing the cap; behavior Test 4 keeps the existing LRU-eviction assertion green, so bounded memory on low-power fleet nodes (D-03) is preserved. |
| T-02-09-05 | Denial of Service | a cached Server instance keeping timers/polls running while off-screen | low | mitigate | `keepAliveLifecycle.test.ts`'s existing assertion that Server's `vpnPollInterval` does not fire while deactivated stays green; any change to Server's lifecycle wiring must keep it so. |
| T-02-09-SC | Tampering | npm/pip/cargo installs | high | accept | No package-manager install is planned in this plan — no new dependency is required to diagnose or fix a KeepAlive registration issue. If one becomes necessary, halt and route through the Package Legitimacy Gate before installing anything. |
- The committed probe run against the deployed archi-dev-box build reports instance survival for `/dashboard/server` alongside Home, Apps, Marketplace, Cloud, Web5 and Fleet.
- `02-FINDINGS.md` contains a named cause with the discriminating evidence, committed before the source change (visible in `git log` ordering).
- Full vitest suite, `npm run type-check` and `npm run build` are green; `keepAliveTabs.test.ts` is byte-for-byte unmodified.
- The human checkpoint returned "approved" (or an explicitly recorded "accept as-is" with rationale).
- Only archi-dev-box received a build; no fleet, no OTA (D-15). archy-x250-dev's reachability is recorded honestly either way.
- Verification gap 1 is closed: `/dashboard/server` either survives a tab round-trip on real hardware, or is proven by positive instance-identity evidence to have been surviving all along — with the cause named either way.
- A regression test in `keepAliveLifecycle.test.ts` fails if Server stops surviving a round-trip.
- The remount evidence is re-runnable from a committed spec instead of an ad-hoc session.
- Nothing visual changed: margins, transitions and animations are indistinguishable from before, confirmed by both the untouched structural tests and the human pass.