Files
archy/.planning/phases/02-ui-performance/02-09-PLAN.md
T

235 lines
30 KiB
Markdown

---
phase: 02-ui-performance
plan: 09
type: execute
wave: 6
depends_on: ["02-08"]
files_modified:
- neode-ui/e2e/perf/keepalive-remount-probe.spec.ts
- neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
- 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
- .planning/phases/02-ui-performance/02-FINDINGS.md
autonomous: false
gap_closure: true
requirements: [PERF-02]
user_setup:
- service: archi-dev-box (the node's own UI login)
why: "The remount probe drives a real authenticated browser session against the deployed build on archi-dev-box — D-11's named on-device verification target. The password is not derivable from this environment (same gate 02-01 and 02-08 hit)."
env_vars:
- name: ARCHY_PASSWORD
source: "Supplied out-of-band by the coordinator at runtime, exactly as in 02-08. Passed only as an environment variable to the probe command — never written to a file, never committed, never echoed into a log or a planning artifact."
must_haves:
truths:
- "A tab round-trip away from and back to /dashboard/server on archi-dev-box leaves the Server component instance alive: the corrected visible-ancestor remount probe reports the same instance for /dashboard/server as it does for Home, Apps, Marketplace, Cloud, Web5 and Fleet"
- "The reason /dashboard/server behaved differently from every other registered KEEP_ALIVE_PATHS tab is named in 02-FINDINGS.md with the discriminating evidence that proves it — a measured cause, not a guess (D-10's measure-before-fix discipline applies to this gap the same way it applied to the phase)"
- "keepAliveLifecycle.test.ts fails if /dashboard/server stops surviving a round-trip through the real DashboardRouterView — the regression is pinned, not just fixed once"
- "The remount evidence is re-runnable by anyone: the corrected probe 02-08 ran ad hoc is a committed spec file, not a method description in a summary"
- "Nothing visual changed anywhere in the dashboard: page margins, the slide/depth route transitions and every animation render exactly as they did before this plan (D-01's caching must stay invisible)"
artifacts:
- "neode-ui/e2e/perf/keepalive-remount-probe.spec.ts — committed, re-runnable corrected remount probe covering every KEEP_ALIVE_PATHS tab"
- "neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts — regression test pinning Server's round-trip survival through the real router + DashboardRouterView"
- ".planning/phases/02-ui-performance/02-FINDINGS.md — a `## Server KeepAlive Root Cause (gap closure)` section naming the cause, the evidence that discriminated it, and the fix"
key_links:
- "DashboardRouterView.vue `:include=keepAliveIncludes` -> dashboardViewWrappers.ts `keepAliveIncludeNames()` -> keepAliveRoutes.ts `KEEP_ALIVE_PATHS` — the registration chain that is supposed to cover /dashboard/server and demonstrably did not deliver for it"
- "The committed probe -> the build actually served from /opt/archipelago/web-ui on archi-dev-box — the probe must exercise the deployed bundle, not a local dev server"
---
<objective>
Close verification gap 1: `/dashboard/server` has been registered in `KEEP_ALIVE_PATHS` since 02-04, yet 02-08's corrected, twice-reproduced remount probe shows it fully remounting on every tab revisit — the only registered tab that does. 02-FINDINGS.md explicitly deferred the root cause ("Task 2's scope is measurement, not per-view debugging"). This plan names that cause and closes it.
Purpose: PERF-02's substance ("main-tab switches render immediately from cached state") is delivered for every main tab except this one. D-01 says main tabs get BOTH `<KeepAlive>` instance caching AND `useCachedResource` data caching; Server got the data half (RPC count 8 -> 2, measured) and not the instance half. D-10's discipline — name the measured cause before the fix — applies to this gap exactly as it applied to the phase.
Output: a committed re-runnable remount probe, a named root cause in 02-FINDINGS.md, a targeted fix, a regression test in `keepAliveLifecycle.test.ts`, a `--frontend-only` deploy to archi-dev-box only (D-15 — no fleet, no OTA), and a human confirmation on real hardware (D-11).
Coverage: this plan covers verification gap 1 (Server.vue remount) and contributes to PERF-02. Verification gap 2 (the six timing-regression surfaces) is covered by 02-10-PLAN.md.
Absolute constraint, inherited from CLAUDE.md and D-01: **no visual or animation change of any kind.** The KeepAlive machinery in `DashboardRouterView.vue` / `dashboardViewWrappers.ts` already broke the visual contract once (02-02's Task 3 checkpoint: broken page margins + missing slide transitions, caused by moving `view-wrapper` off the transitioning element). Those files carry long comments explaining exactly which invariants must hold. Read them before touching them, and keep `keepAliveTabs.test.ts` untouched and green — it is the structural pin for that contract.
</objective>
<source_audit>
## Multi-Source Coverage Audit (gap-closure scope, covers 02-09 + 02-10)
| Source | Item | Covered by | Status |
|---|---|---|---|
| VERIFICATION gap 1 | Server.vue registered in KEEP_ALIVE_PATHS but genuinely remounts on every revisit; root cause never investigated | 02-09 Tasks 1-3 | COVERED |
| VERIFICATION gap 1 | "or an explicit accepted override" alternative | 02-09 Task 3 checkpoint (human decides only if the fix branch is exhausted) | COVERED |
| VERIFICATION gap 2 | Six surfaces (Discover, Server, Web5, AppDetails, OpenWrtGateway, wallet/send-flow) unimproved or regressed vs baseline | 02-10 Tasks 1-2 | COVERED |
| VERIFICATION gap 2 | Unresolved environmental-noise theory (different time of day, 85% disk) flagged but never tested | 02-10 Task 1 (re-run under recorded current conditions) + Task 2 (three-way comparison) | COVERED |
| VERIFICATION human_verification 1 | "Does Server show a visible spinner/blank frame on real hardware?" | 02-09 Task 3 checkpoint | COVERED |
| VERIFICATION human_verification 2 | "Real regression or environmental noise?" | 02-10 Tasks 1-2 | COVERED |
| REQ PERF-02 | Main-tab switches render immediately from cached state | 02-09 (Server instance caching), 02-10 (Discover/Server/Web5 timing verdict) | COVERED |
| REQ PERF-03 | Secondary screens open without blocking reload, instant on repeat | 02-10 (AppDetails/OpenWrtGateway/wallet-send timing verdict) | COVERED |
| CONTEXT D-01 | Main tabs use BOTH KeepAlive and useCachedResource | 02-09 (delivers the KeepAlive half for Server) | COVERED |
| CONTEXT D-10 | Measured cause named before the fix lands | 02-09 Task 1 gates Task 2; 02-10 Task 1 gates Task 2 | COVERED |
| CONTEXT D-11 | On-device verification on archi-dev-box; pass bar = no visible spinner/blank on revisit | 02-09 Tasks 2-3; 02-10 Task 1 | COVERED |
| CONTEXT D-15 | Dev pair only, no OTA/fleet | 02-09 Task 2 deploy step; 02-10 Task 1 | COVERED |
No MISSING rows. Explicitly out of scope for these two plans (already recorded as open follow-ups in 02-FINDINGS.md `## Outstanding` and owned by no gap in 02-VERIFICATION.md): Fleet's 330->1054ms split-signal regression (not in the verifier's named six), CloudFolder's `cloudStore.navigate()` TTL gap, PeerFiles.vue's unconditional refetch, `/dashboard/settings` remaining withheld from the cache, `ContainerAppDetails.vue` dead-code deletion, the `HealthNotifications.vue` toast intercepting clicks, and the archy-x250-dev deploy. Do not pick any of these up here.
</source_audit>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@CLAUDE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-VERIFICATION.md
Read these source files before changing anything — they carry the invariants this plan must not break:
@neode-ui/src/views/dashboard/DashboardRouterView.vue
@neode-ui/src/views/dashboard/dashboardViewWrappers.ts
@neode-ui/src/views/dashboard/keepAliveRoutes.ts
@neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
Do NOT read `02-PERF-BASELINE.json` or `02-PERF-AFTER.json` into context (50-60KB each). If a field is needed, extract it with `node -e` or `jq`.
Read only these parts of the phase's prior artifacts (they are large):
- `.planning/phases/02-ui-performance/02-FINDINGS.md` `## Results` and `## Outstanding` sections (the Server row and the first Outstanding bullet are the gap under repair; the paragraph above the Results table documents the corrected probe method this plan turns into a committed spec).
- `.planning/phases/02-ui-performance/02-08-SUMMARY.md` `key-decisions` — the deploy command and the probe-correction decision.
</context>
<tasks>
<task type="auto">
<name>Task 1: Name the measured cause of Server's KeepAlive miss, and commit the probe that proves it</name>
<files>neode-ui/e2e/perf/keepalive-remount-probe.spec.ts, .planning/phases/02-ui-performance/02-FINDINGS.md</files>
<precondition>`ARCHY_PASSWORD` is exported in the environment and `curl -sS -o /dev/null -w '%{http_code}' http://archi-dev-box/` returns a 2xx/3xx — the probe drives a real authenticated UI session on the deployed build (D-11). If either is unmet, stop and report it rather than substituting a local dev server or a mock backend; a local run cannot answer this question.</precondition>
<read_first>
`neode-ui/src/views/dashboard/dashboardViewWrappers.ts` (the whole file — the `KeepWrap:` naming scheme and the memoized `wrapperFor` cache are the mechanism under suspicion), `neode-ui/src/views/dashboard/DashboardRouterView.vue` (the `:include` wiring and the four numbered structural invariants in its script comment), `neode-ui/src/views/dashboard/keepAliveRoutes.ts` (`KEEP_ALIVE_PATHS` derivation and `KEEP_ALIVE_MAX = 6`), `neode-ui/e2e/perf/measure.ts` `stampRoot`/`readRootProbe`/`NEUTRAL_SELECTOR` (the original probe and the away-hop it uses), `neode-ui/e2e/perf/surfaces.ts` (the `server`, `web5` and `fleet` rows all share `rootSelector: '.view-container'` and the `contentSelector` shape `.view-container [data-controller-container]`), and `neode-ui/src/views/Server.vue` lines 930-1000 (`onActivated`/`onDeactivated`/`onUnmounted`/`onMounted` and the seven `useCachedResource` groups' arming).
</read_first>
<action>
Produce a named, evidence-backed cause for why `/dashboard/server` does not survive a tab round-trip while every other registered path does, and leave behind a probe anyone can re-run. Per D-10, no fix may land until this task has named the cause — a fix without a named cause is forbidden in this phase.
Work the diagnosis in this order, cheapest discriminator first, and stop as soon as one reproduces:
Step A — jsdom reproduction (no device, no password). Extend the existing pattern at the bottom of `keepAliveLifecycle.test.ts` (the `createRouter` + `mount(DashboardRouterView)` + per-path mount-count test that proves LRU eviction), but with the REAL `Server.vue` component at the REAL `/dashboard/server` path, a real away-hop to `/dashboard/settings` (the same neutral tab `measure.ts`'s `NEUTRAL_SELECTOR` uses), and a return hop. Count `onMounted` invocations. Reuse the existing `vi.mock('@/api/rpc-client')` and `vi.mock('@/stores/app')` module-scope mocks and the `QuickActionsCard`/`TorServicesCard`/`ServerModals`/`FipsNetworkCard` stubs already in that file. If the mount count reaches 2 on the return hop, the cause is structural and reproducible locally — capture which of the suspects below explains it and skip to writing it up. This scratch reproduction becomes Task 2's regression test, so write it where it will live.
Step B — on-device instrumented probe (only if Step A does not reproduce). Create `neode-ui/e2e/perf/keepalive-remount-probe.spec.ts`: a standalone Playwright spec that logs into `ARCHY_BASE_URL` (default `http://archi-dev-box`) with `process.env.ARCHY_PASSWORD`, then for EVERY path in `KEEP_ALIVE_PATHS` (import it directly from `@/views/dashboard/keepAliveRoutes` or re-declare the list with a comment saying why, if the e2e tsconfig cannot resolve the alias) performs visit -> away to `/dashboard/settings` -> return, and reports per path whether the instance survived. Do NOT edit `measure.ts`, `surfaces.ts` or `surface-perf.spec.ts` — the 02-01 harness stays frozen so 02-10 can re-run it unmodified. Implement the corrected method 02-08 documented (`02-FINDINGS.md`, the paragraph above the Results table): stamp and read back the `.view-container` ancestor of the surface's own VISIBLE `contentSelector` match, using `getBoundingClientRect`/`offsetParent` to exclude KeepAlive's inactive cached instances, never `document.querySelector`'s first DOM match. Add three instruments the ad-hoc 02-08 probe did not have, because they are what discriminate the suspects: (1) alongside the element stamp, record a monotonic per-mount counter written by the page itself so element identity and instance identity can disagree visibly in the output; (2) subscribe to `page.on('pageerror')` and `page.on('console')` for the whole round-trip and print anything captured; (3) after each hop, log `document.querySelectorAll('.view-container').length` and the observed `location.pathname` so cache population and the actual route path are visible in the transcript.
Ranked suspects, with the static evidence already gathered — test them in this order and record which the data kills:
1. The probe measures DOM-element identity, not component-instance identity. Server's root is a single stable `<div class="pb-6">` (`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.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; 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.</automated>
<automated>grep -q "## Server KeepAlive Root Cause (gap closure)" .planning/phases/02-ui-performance/02-FINDINGS.md &amp;&amp; git log --oneline -1 -- .planning/phases/02-ui-performance/02-FINDINGS.md</automated>
</verify>
<done>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).</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Land the targeted fix, pin it with a regression test, and deploy to archi-dev-box</name>
<files>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)</files>
<reversibility rating="reversible">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).</reversibility>
<behavior>
- 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).
</behavior>
<action>
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`).
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npm test 2>&amp;1 | tail -20 &amp;&amp; npm run type-check &amp;&amp; npm run build</automated>
<automated>cd neode-ui &amp;&amp; npx vitest run src/views/dashboard/__tests__/keepAliveLifecycle.test.ts src/views/dashboard/__tests__/keepAliveTabs.test.ts --reporter=verbose</automated>
<automated>cd neode-ui &amp;&amp; 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)</automated>
</verify>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Confirm on archi-dev-box that Server revisits are instant and nothing visual moved</name>
<what-built>
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.
</what-built>
<how-to-verify>
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.
</how-to-verify>
<resume-signal>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.</resume-signal>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
- 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>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-09-SUMMARY.md` when done. Record the named cause, which suspects the evidence eliminated, the exact fix, the pre-fix failing-test observation, the post-deploy probe reading, archy-x250-dev's reachability, and the checkpoint outcome verbatim.
</output>
</content>
</invoke>