docs(02): create gap closure plans

Two plans closing the gaps 02-VERIFICATION.md found (6/8 must-have truths
verified):

- 02-09 (wave 6): Server.vue is registered in KEEP_ALIVE_PATHS since 02-04 but
  02-08's corrected, twice-reproduced probe shows it fully remounting on every
  revisit. Names the measured cause first (D-10), commits the ad-hoc probe as a
  re-runnable spec, lands a targeted fix pinned by a regression test in
  keepAliveLifecycle.test.ts, deploys frontend-only to archi-dev-box (D-15),
  and closes with the D-11 human pass bar.
- 02-10 (wave 7): the six surfaces measured unimproved/regressed vs baseline had
  a flagged-but-untested environmental-noise confound. Re-runs the frozen 02-01
  harness under recorded conditions, compares three-way using per-run sample
  spread rather than bare medians, and gives every named surface a verdict —
  cleared with data, fixed, or an explicit accepted deviation for the verifier.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-31 00:20:58 -04:00
parent b448232fb5
commit 6904e78960
3 changed files with 421 additions and 1 deletions

View File

@ -82,7 +82,7 @@ Plans:
3. Secondary screens open without a blocking full reload; repeat visits are instant
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
**Plans**: 8/8 plans executed
**Plans**: 10 plans (8/8 executed; 02-09 and 02-10 added as gap closure after verification found 2 of 8 must-have truths failed)
Plans:
**Wave 1**
@ -108,6 +108,14 @@ Plans:
- [x] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03)
**Wave 6** *(gap closure — blocked on Wave 5 completion)*
- [ ] 02-09-PLAN.md — Server.vue KeepAlive remount: name the cause, fix it, pin it (PERF-02)
**Wave 7** *(gap closure — blocked on Wave 6 completion)*
- [ ] 02-10-PLAN.md — Timing-regression verdict: three-way re-measure, clear or name each surface (PERF-02/03)
**UI hint**: yes
### Phase 3: Multinode Verification Pass

View File

@ -0,0 +1,234 @@
---
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>

View File

@ -0,0 +1,178 @@
---
phase: 02-ui-performance
plan: 10
type: execute
wave: 7
depends_on: ["02-09"]
files_modified:
- .planning/phases/02-ui-performance/02-PERF-REMEASURE.json
- .planning/phases/02-ui-performance/02-FINDINGS.md
autonomous: true
gap_closure: true
requirements: [PERF-02, PERF-03]
user_setup:
- service: archi-dev-box (the node's own UI login)
why: "The perf harness drives a real authenticated browser session against the deployed build on archi-dev-box — D-11's named on-device verification target, and the same target both prior measurement runs used. The password is not derivable from this environment."
env_vars:
- name: ARCHY_PASSWORD
source: "Supplied out-of-band by the coordinator at runtime, exactly as in 02-01 and 02-08. Passed only as an environment variable on the harness command line — never written to a file, never committed, never echoed into a log or a planning artifact."
must_haves:
truths:
- "Each of the six surfaces 02-VERIFICATION.md named (Discover, Server, Web5, AppDetails, OpenWrtGateway, wallet/send-flow) carries a written verdict backed by a three-artifact comparison — either cleared as environmental noise with the dispersion data that clears it, or confirmed as a real regression with its cause named"
- "The environmental-noise theory 02-FINDINGS.md flagged but never tested is settled with data, not left as a caveat: the run conditions (clock time, disk usage, system load, whether the box was building) are recorded next to the numbers for the new run and stated for the two prior runs"
- "No surface is left with an unexplained regression: each confirmed regression is either fixed (only when its cause traces to a phase-2 change and the fix is small) or carries an explicit accepted deviation with a rationale written for the verifier to act on"
- "The three measurement artifacts stay directly comparable because the 02-01 harness is byte-for-byte unmodified across this re-run (D-10's instrument stays frozen)"
artifacts:
- ".planning/phases/02-ui-performance/02-PERF-REMEASURE.json — third measurement of all 15 surfaces against archi-dev-box under recorded current conditions"
- ".planning/phases/02-ui-performance/02-FINDINGS.md — a `## Re-measurement (gap closure)` section with the three-way table, per-surface verdicts, and the accepted-deviation list"
key_links:
- "02-PERF-REMEASURE.json -> 02-PERF-BASELINE.json / 02-PERF-AFTER.json — joined by surface `id`, compared on `revisitMs`, `revisitRpcCount` and per-run `samples` spread"
- "Each verdict in 02-FINDINGS.md -> the specific artifact field that supports it — the citation discipline 02-01 established for this doc"
---
<objective>
Close verification gap 2: six surfaces measured unimproved or regressed on revisit time in 02-PERF-AFTER.json — Discover (1083 -> 1257ms), Server (738 -> 849ms), Web5 (566 -> 709ms), AppDetails (1204 -> 1510ms), OpenWrtGateway (663.5 -> 1148ms) and wallet/send-flow (2607 -> 2556ms, essentially unchanged) — contradicting 02-08's own must-have. 02-FINDINGS.md flagged an environmental-noise theory for exactly these rows (baseline taken 10:30 local, the after-run at 01:27 the next day on the same multi-service node that was at 85% disk and doubles as the build server) but explicitly did not resolve it. Conditions have since changed materially: roughly 118G was freed on this box, so the disk-pressure component of that theory is no longer present in the same form.
Purpose: PERF-02 and PERF-03 are both marked partial in 02-VERIFICATION.md solely because these rows have no verdict. A measurement with an untested confound is not evidence either way — this plan produces the third data point that turns "regressed or noisy, unresolved" into a per-surface answer. D-10's discipline is unchanged: name the cause from measurement before touching code.
Output: a third harness run (02-PERF-REMEASURE.json) taken with the instrument unmodified and the run conditions recorded, a three-way comparison that reports dispersion rather than bare medians, and a written verdict per named surface — cleared, fixed, or explicitly accepted with rationale.
Coverage: this plan covers verification gap 2 and both human-verification item 2 and the timing half of PERF-02/PERF-03. Gap 1 (Server's remount) is covered by 02-09-PLAN.md; the full multi-source coverage audit for this gap-closure set lives in 02-09-PLAN.md's `<source_audit>` section.
Constraints: the 02-01 harness (`neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts`) is frozen — changing it would break comparability with both committed artifacts and invalidate the whole exercise. Deploy only to archi-dev-box, frontend-only, and only if a fix actually lands (D-15 — no fleet, no OTA). No new features. No visual or animation changes.
</objective>
<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 only these parts of the large phase artifacts:
- `.planning/phases/02-ui-performance/02-FINDINGS.md``## Method` (the exact run command, sample semantics and field meanings), the `## Results` table, and `## Outstanding` (the timing-variance caveat is the last-but-one bullet). Skip the `## Addendum`, which is about a different, already-closed issue.
- `.planning/phases/02-ui-performance/02-09-SUMMARY.md` — what changed in the build this run measures.
- `neode-ui/e2e/perf/surfaces.ts` — surface ids, `navSteps` and the transit-confound notes for `openwrt-gateway`, `marketplace` and `marketplace-app-details`.
**Do not read `02-PERF-BASELINE.json`, `02-PERF-AFTER.json` or `02-PERF-REMEASURE.json` into context** — they are 50-60KB each and reading all three would consume most of this plan's budget for no benefit. Extract the fields you need with `node -e` or `jq` and work from the extracted table.
</context>
<tasks>
<task type="auto">
<name>Task 1: Re-run the frozen harness against archi-dev-box under recorded conditions</name>
<files>.planning/phases/02-ui-performance/02-PERF-REMEASURE.json</files>
<precondition>`ARCHY_PASSWORD` is exported in the environment, `curl -sS -o /dev/null -w '%{http_code}' http://archi-dev-box/` returns a 2xx/3xx, and the bundle served from `/opt/archipelago/web-ui` is the one 02-09 deployed. If the password is unset, stop and report it — do not substitute a local dev server, the mock backend or the `:8100` preview, none of which can answer a question about real-node timing.</precondition>
<read_first>
`.planning/phases/02-ui-performance/02-FINDINGS.md` `## Method` — the exact command, the `runs`/median/`samples` semantics, and the `firstVisitMs`/`revisitMs`/`revisitRpcCount`/`maxConcurrentRpc`/`remounted` field definitions this run must reproduce identically. `neode-ui/e2e/perf/surface-perf.spec.ts` and `measure.ts` — read to confirm the env-var contract (`ARCHY_BASE_URL`, `ARCHY_PERF_OUT`, `ARCHY_PERF_RUNS`), not to change them.
</read_first>
<action>
Produce the third measurement artifact with the instrument untouched.
Before running, confirm the harness is unmodified since the after-run: `git diff --stat 3ee20430 -- neode-ui/e2e/perf/` must be empty. If it is not, stop — a modified instrument makes the three artifacts incomparable and this entire plan pointless. (`keepalive-remount-probe.spec.ts`, added by 02-09, is a separate spec and does not count as a modification of the three harness files; confirm the diff is limited to that new file if anything shows.)
Record the run conditions BEFORE and AFTER the run, into the SUMMARY and into Task 2's findings section — these are the variables the noise theory turns on, and 02-08 recorded none of them: wall-clock local time at start and end, `df -h /` (disk usage — roughly 118G was freed since the after-run, so this number should differ materially from the 85% recorded then), `uptime` load averages, and whether any build, test run or container churn was active on the box during the window. The point is that this run's conditions are stated rather than inferred later.
Prefer a start time close to the baseline's 10:30 local window if the schedule allows — that is the controlled comparison 02-VERIFICATION.md's human-verification item 2 asks for. If the run must happen at a different hour, do not skip it and do not pretend the hour matches: record the actual time and compensate for the uncontrolled variable by sampling more, per the next paragraph.
Run with `ARCHY_PERF_RUNS=5` rather than the default 3. This changes no harness code — it is the harness's own env knob — and the reported `revisitMs`/`firstVisitMs` stay medians, so they remain directly comparable to both prior artifacts. The extra samples exist so Task 2 can compute a real spread per surface instead of comparing three bare medians, which is the only way to separate a genuine regression from run-to-run variance.
Command shape (password supplied from the environment, never inline in a committed file or a transcript): `cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box ARCHY_PERF_RUNS=5 ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-REMEASURE.json npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line`, with `ARCHY_PASSWORD` already exported.
Expect 15 rows. Mesh and Chat may again come back `unmeasured` — record whichever reason applies rather than presenting them as anything else, exactly as both prior runs did. Note one changed condition worth checking: the after-run's Chat block was the disk-usage toast from `HealthNotifications.vue` intercepting the close button, which was a symptom of the 85% disk; with the space freed, that block may simply be gone. If Chat measures this time, record it as a bonus data point and say plainly that it has no baseline-comparable counterpart.
If a surface errors or the run aborts, re-run once and keep both transcripts; do not hand-edit the artifact. Commit and push the artifact plus the recorded conditions (`git add` by path, Co-Authored-By trailer, `git push gitea-ai main`).
</action>
<verify>
<automated>test -f .planning/phases/02-ui-performance/02-PERF-REMEASURE.json &amp;&amp; node -e "const r=require('./.planning/phases/02-ui-performance/02-PERF-REMEASURE.json'); const rows=r.surfaces||r.results||r; console.log('rows:', Array.isArray(rows)?rows.length:Object.keys(rows).length); console.log('baseUrl:', r.baseUrl, 'runs:', r.runs, 'commit:', r.commit)"</automated>
<automated>git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts (must print nothing — the instrument is frozen)</automated>
</verify>
<done>`02-PERF-REMEASURE.json` exists with 15 rows, a `baseUrl` of `http://archi-dev-box` and `runs: 5`; the three harness files are provably unmodified since the after-run commit; the run's clock time, disk usage, load and concurrent-activity state are recorded; the artifact is committed and pushed.</done>
</task>
<task type="auto">
<name>Task 2: Three-way comparison, a verdict per named surface, and the resulting action</name>
<files>.planning/phases/02-ui-performance/02-FINDINGS.md</files>
<action>
Turn three artifacts into one answer per surface.
Build the comparison table with a script, not by reading JSON into context. Extract, for every surface id present in all three artifacts, from each of `02-PERF-BASELINE.json`, `02-PERF-AFTER.json` and `02-PERF-REMEASURE.json`: `firstVisitMs`, `revisitMs`, `revisitRpcCount`, `remounted`, and — this is the part 02-08 never used — the per-run values inside `samples`, reduced to min/median/max. The baseline and after artifacts already contain their own `samples` arrays (02-FINDINGS.md `## Method`: "`samples` holds every individual run so no data is discarded"), so dispersion is computable for all three runs from what is already committed, with no re-run of history required. A one-off `node -e` script that prints a markdown table is the right tool; keep it in the transcript rather than committing a script file.
Then decide each of the six surfaces 02-VERIFICATION.md named — Discover, Server, Web5, AppDetails, OpenWrtGateway, wallet/send-flow — against this rule, and state which branch the data put it in:
- **Cleared as environmental noise** when the baseline, after and re-measure sample ranges overlap materially, or when the re-measure returns to or below the baseline median. Cite the actual ranges. "Overlapping spread" is a claim the numbers must show, not an assertion.
- **Confirmed regression** when the re-measure reproduces the after-run's elevated revisit time outside the baseline's sample spread. Then name the cause: identify the responsible mechanism the way 02-08 root-caused the Cloud connection-pool starvation — direct instrumentation on the device, `git log` bisection against the pre-phase-2 baseline commit `a75b6709`, and the surface's own RPC/remount fields — not by inspecting code and guessing. State plainly whether the cause is a phase-2 change or pre-existing.
Apply the confounds already documented rather than re-deriving them: OpenWrtGateway's and Discover's RPC counts bleed through from the tab their `navSteps` transit (Server and Apps respectively), so treat their RPC columns as unreliable and reason from `revisitMs` and `remounted`; wallet/send-flow is a modal that remounts by design via `BaseModal`'s `v-if`, was never in any plan's `files_modified`, and its anomaly (revisit slower than first visit, zero RPC either way) is pure client-side cost — if it is confirmed as a real, still-present cost, name the mechanism.
Act on each verdict, in bounds:
- Cleared -> no code change. Write the verdict and the data.
- Confirmed AND caused by a phase-2 change AND fixable small -> fix it. "Small" means: confined to one file, covered by a vitest assertion that fails without it, and followed by `npm test` + `npm run type-check` + `npm run build` green, an `ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only` deploy (D-15, archi-dev-box only), and a targeted re-run of the harness for the affected surface(s) proving the number moved. No visual or animation change is permitted as part of any such fix.
- Confirmed but caused by something pre-existing, or fixable only by a change that fails any of those bounds -> record an explicit accepted deviation. Write it for the verifier to act on: which surface, the measured numbers across all three runs, the named cause, why it is out of this phase's bounds, and where it belongs (a specific requirement such as UIFIX-06, or a named later phase). An accepted deviation with a named cause and a destination is a resolution; a shrug is not.
Write all of it into a `## Re-measurement (gap closure)` section in `02-FINDINGS.md`: the run header and recorded conditions from Task 1, the three-way table with dispersion, a verdict line per named surface, and a short `### Accepted deviations` subsection listing anything not fixed. Keep the doc's citation discipline — every verdict points at the field that supports it. Keep the doc's candor: if the re-measure shows the after-run was mostly environmental and the architecture is fine, say so and show it; if it shows real regressions the phase shipped, say that just as plainly.
Finally, reconcile the record: if the verdicts change what PERF-02/PERF-03 can honestly claim, update the status note for those rows in `.planning/REQUIREMENTS.md`'s coverage table to point at this section, so the next verification pass reads the resolved state rather than the old partial one. Commit and push (Co-Authored-By trailer, `git push gitea-ai main`).
</action>
<verify>
<automated>grep -q "## Re-measurement (gap closure)" .planning/phases/02-ui-performance/02-FINDINGS.md &amp;&amp; for s in Discover Server Web5 AppDetails OpenWrtGateway "send flow"; do grep -q "$s" .planning/phases/02-ui-performance/02-FINDINGS.md &amp;&amp; echo "present: $s"; done</automated>
<automated>cd neode-ui &amp;&amp; npm test 2>&amp;1 | tail -10 &amp;&amp; npm run type-check (required whether or not a fix landed — the suite must stay green)</automated>
</verify>
<done>`02-FINDINGS.md` has a `## Re-measurement (gap closure)` section containing the recorded run conditions, a three-way table with per-run spread, and one verdict per named surface — each either cleared with the overlapping-range data that clears it, fixed with the change and its proof, or listed under `### Accepted deviations` with a named cause and a destination. The vitest suite and type-check are green. Any fix that landed was deployed to archi-dev-box only and re-measured. Work is committed and pushed.</done>
</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 harness |
| harness -> committed measurement artifact | Observed traffic metadata is written into a repository file |
| build artifact -> `/opt/archipelago/web-ui` on a real node | Only if a confirmed-regression fix lands |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-10-01 | Information Disclosure | harness login (`ARCHY_PASSWORD`) | high | mitigate | Password read only from the environment at runtime; never inlined into a command that gets pasted into a committed file, never written to `02-PERF-REMEASURE.json`, the findings doc or the SUMMARY. The same handling 02-01 and 02-08 used. |
| T-02-10-02 | Information Disclosure | `02-PERF-REMEASURE.json` contents | medium | mitigate | The harness records RPC method names and timings only, never request or response bodies — its original threat mitigation, preserved because the harness is unmodified. Spot-check the artifact for anything resembling a token, credential or personal path before committing. |
| T-02-10-03 | Tampering | frozen harness files | medium | mitigate | Task 1 gates on `git diff --stat` over the three harness files being empty; a modified instrument silently invalidates every comparison in this plan, so it is checked rather than assumed. |
| T-02-10-04 | Tampering | `scripts/deploy-to-target.sh --frontend-only` (conditional) | medium | mitigate | Only triggered by a confirmed, phase-2-caused, small fix; frontend-only, archi-dev-box only, no OTA and no fleet/catalog path (D-15); served bundle grepped post-deploy per CLAUDE.md's silent-no-op-build gotcha. |
| T-02-10-05 | Repudiation | measurement provenance | low | mitigate | The artifact header carries `baseUrl`, `commit` and `runs`, and Task 1 additionally records clock time, disk usage and load — so a later reader can tell which build and which machine state produced each number instead of trusting a verdict's summary of them. |
| T-02-10-SC | Tampering | npm/pip/cargo installs | high | accept | No package-manager install is planned — re-running an existing harness and comparing committed JSON needs no new dependency. If one becomes necessary, halt and route through the Package Legitimacy Gate before installing anything. |
</threat_model>
<verification>
- `02-PERF-REMEASURE.json` exists, 15 rows, `baseUrl: http://archi-dev-box`, `runs: 5`, produced by a provably unmodified harness.
- Every one of the six surfaces named in 02-VERIFICATION.md gap 2 has a verdict in `02-FINDINGS.md` backed by cited artifact fields including per-run spread.
- The environmental-noise theory is either supported or refuted by stated data; the run conditions for the new run are recorded and the prior two runs' known conditions are restated for comparison.
- Anything not fixed appears under `### Accepted deviations` with a named cause and a destination requirement or phase.
- Full vitest suite and type-check green; any fix deployed to archi-dev-box only (D-15).
<human-check>
If a fix landed as part of Task 2, the D-11 pass bar applies to the affected surface: on archi-dev-box, revisit that surface after having visited it once in the session and confirm content paints immediately with no spinner or blank frame, and that nothing about its layout, margins or transition animation changed.
</human-check>
</verification>
<success_criteria>
- Verification gap 2 is closed: no surface is left in the unexplained "regressed, cause unknown, confound untested" state that blocked the phase's clean pass.
- The noise-versus-regression question is answered with dispersion data from three runs, not asserted from three medians.
- Real regressions traceable to phase 2 and cheap to fix are fixed and re-measured; everything else is an explicit, actionable accepted deviation.
- The instrument stayed frozen, so all three artifacts remain a comparable series for any future run.
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-10-SUMMARY.md` when done. Record the run conditions verbatim, the three-way table, each surface's verdict and the branch it fell into, any fix with its before/after numbers, the full accepted-deviation list, and whether Mesh/Chat measured this time.
</output>
</content>
</invoke>