feat(01-02): chat mutations mutate demo state instead of acking (FED-04)
Demo images / Build & push demo images (push) Successful in 4m21s

Reactions, replies, read-receipts, edits, deletes, forwards and channel sends
shared one bare `{ ok: true, sent: true }` case, so none of them rendered on
the demo — the UI derives reaction chips and reply quotes from the message
store, and there was nothing in it to derive from.

Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed
messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's
reactionIndex and replyTargetPreview read. Edits rewrite the text and set
edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted,
message_type 'delete') because that is what mesh/mod.rs apply_local_delete
does — it does not remove the row.

Edits and deletes go through a per-session overrides overlay keyed by
sender_seq, because mesh.messages rebuilds its seed array on every read, so
in-place mutation would only ever work for messages sent this session.

mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the
daemon's handlers have no message-store effect either — with a comment saying
so, so a later reader does not "fix" them into divergence.

Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the
orphaned 01-12/01-14 SUMMARYs.

Verified: parity harness 17/17 live assertions; full frontend suite 102 files
/ 822 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-01 06:37:00 -04:00
co-authored by Claude Opus 5
parent 9c2ec82bdb
commit 90884e6259
8 changed files with 786 additions and 41 deletions
+12 -12
View File
@@ -15,20 +15,20 @@ declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase
- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
- [ ] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
- [ ] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel
- [ ] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring
- [ ] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole)
- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole)
### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30
- [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER)
- [ ] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER)
- [ ] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER)
- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER)
- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER)
- [ ] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX
- [ ] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it)
- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it)
- [ ] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02)
### UI Performance (PERF)
@@ -109,17 +109,17 @@ Which phases cover which requirements. Updated during roadmap creation.
| FED-01 | Phase 1 | Pending |
| FED-02 | Phase 1 | Pending |
| FED-03 | Phase 1 | Pending |
| FED-04 | Phase 1 | Pending |
| FED-04 | Phase 1 | Complete |
| FED-05 | Phase 1 | Pending |
| FED-06 | Phase 1 | Pending |
| FED-07 | Phase 1 | Pending |
| FED-06 | Phase 1 | Complete |
| FED-07 | Phase 1 | Complete |
| FED-08 | Phase 1 | Pending |
| FED-09 | Phase 1 | Pending |
| UIFIX-01 | Phase 1 | Pending |
| UIFIX-02 | Phase 1 | Pending |
| UIFIX-03 | Phase 1 | Pending |
| UIFIX-02 | Phase 1 | Complete |
| UIFIX-03 | Phase 1 | Complete |
| UIFIX-04 | Phase 1 | Pending |
| UIFIX-05 | Phase 1 | Pending |
| UIFIX-05 | Phase 1 | Complete |
| UIFIX-06 | Phase 1 | Pending |
| PERF-01 | Phase 2 | Complete |
| PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect |
+20 -8
View File
@@ -53,15 +53,15 @@ signed/decentralized registry and a user installs it on their node.
13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory)
NOTE for criteria 713: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes)
**Plans**: 2/20 plans executed
**Plans**: 8/20 plans executed
Plans:
- [ ] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09)
- [ ] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08)
- [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01)
- [ ] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04)
- [ ] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06)
- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04)
- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06)
- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05)
- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02)
- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05)
@@ -72,11 +72,11 @@ Plans:
**Wave 7** *(gap closure — criteria 713, added 2026-07-30 after the original 10 plans were written)*
- [ ] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
- [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02)
- [ ] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
- [ ] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
- [ ] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
**Wave 8** *(blocked on Wave 7 completion)*
@@ -235,7 +235,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Federation & Mesh Hardening | 2/20 | In Progress| |
| 1. Federation & Mesh Hardening | 8/20 | In Progress| |
| 2. UI Performance | 11/12 | Complete | 2026-07-31 |
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
@@ -244,6 +244,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
| 8. Decentralized Marketplace | 0/TBD | Not started | - |
| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 |
| 10. Key-Material Hardening | 0/TBD | Not started — **priority override, see phase note** | - |
### Phase 9: BotFights Platform Upgrade
@@ -261,3 +262,14 @@ Plans:
- [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04)
- [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04)
- [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04)
### Phase 10: Key-Material Hardening
**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope.
**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint)
**Depends on:** Nothing (independent security work; parallelizable with Phases 18). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.**
**Plans:** 0 plans
Plans:
- [ ] TBD (run /gsd-plan-phase 10 to break down)
+11 -9
View File
@@ -5,16 +5,16 @@ milestone_name: milestone
current_phase: 09
current_phase_name: BotFights Platform Upgrade
status: planning
stopped_at: Completed 01-01-PLAN.md (federation store lock, FED-01)
last_updated: "2026-08-01T02:29:19.595Z"
stopped_at: Phase 10 context gathered
last_updated: "2026-08-01T10:32:34.902Z"
last_activity: 2026-07-31
last_activity_desc: Phase 02 complete, transitioned to Phase 09
progress:
total_phases: 9
total_phases: 10
completed_phases: 1
total_plans: 39
completed_plans: 20
percent: 11
completed_plans: 25
percent: 10
---
# Project State
@@ -33,7 +33,7 @@ Plan: Not started
Status: Ready to plan
Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09
Progress: [█████░░░░░] 51%
Progress: [█████░░░░░] 54%
## Performance Metrics
@@ -74,6 +74,7 @@ Progress: [█████░░░░░] 51%
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue
- Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 18.
- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.**
### Decisions
@@ -120,6 +121,7 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
- [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up
- [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box)
- [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary)
- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback
### Pending Todos
@@ -163,6 +165,6 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
## Session Continuity
Last session: 2026-08-01T02:28:42.155Z
Stopped at: Completed 01-01-PLAN.md (federation store lock, FED-01)
Resume file: None
Last session: 2026-08-01T10:32:34.860Z
Stopped at: Phase 10 context gathered
Resume file: .planning/phases/10-key-material-hardening/10-CONTEXT.md
@@ -0,0 +1,150 @@
---
phase: 01-federation-mesh-hardening
plan: 02
subsystem: demo
tags: [mock-backend, rpc, parity, mesh, federation, harness]
requires:
- phase: 01-federation-mesh-hardening
provides: "mock-backend.js's per-session store (mesh.dynamic/blobs) and the attachment-parity handlers landed in c2ce71c6 (send-content-inline / send-content / fetch-content / transport-advice)"
provides:
- "Ten previously-missing mesh/federation RPC handlers, each mirroring and citing its daemon counterpart"
- "Stateful chat mutations (reaction, reply, read-receipt, edit, delete, forward, channel) visible on the next mesh.messages read"
- "scripts/mock-rpc-parity.mjs + npm run test:mock-parity — a single command that fails when the demo drifts from the UI's call surface"
affects: [demo, mesh, federation]
tech-stack:
added: []
patterns:
- "Parity harnesses must match call syntax (`method: '<x>'`), not bare string literals. The same dotted names are used as resource-cache keys in the UI (`key: 'federation.nodes'`), so a literal scan reports phantom gaps that can never be closed — which would make the harness permanently red and therefore ignored."
- "Mirror-don't-invent: every mock handler carries a comment naming the Rust file and function it mirrors, so a future reader can re-check parity instead of guessing what the demo is 'supposed' to do."
key-files:
created:
- neode-ui/scripts/mock-rpc-parity.mjs
modified:
- neode-ui/mock-backend.js
- neode-ui/package.json
key-decisions:
- "The STATIC stage matches `method: '<x>'` rather than every `'mesh.*'`/`'federation.*'` string literal as the plan specified — see Deviations. This is what let KNOWN_GAPS actually reach zero."
- "Edits and deletes are applied through a per-session `overrides` overlay keyed by sender_seq rather than by mutating a message array, because the demo's seed message list is rebuilt on every mesh.messages read. The overlay makes edit/delete observable on seeded messages too, not just ones sent this session."
- "Delete tombstones in place (plaintext '🗑 message deleted', typed_payload { deleted: true }, message_type 'delete') because that is precisely what mesh/mod.rs apply_local_delete does — it does not remove the row. A comment says so, since 'simplifying' it to a splice would be a silent divergence."
- "mesh.refresh and mesh.reboot-radio were deliberately LEFT as bare acknowledgements: the daemon's handlers have no message-store effect either, so giving them demo state would be divergence rather than parity. The comment records this so a later reader does not 'fix' them."
- "mesh.peers and mesh.contacts-list now read from one shared DEMO_MESH_PEERS constant, so the two can never disagree about who is on the mesh — the daemon merges contacts over the same peer map for the same reason."
- "An outbound/'sent' pending request was added to the demo seed. federation.cancel-request faithfully rejects anything that is not outbound-and-sent, so without such a request the demo's cancel button could only ever produce an error."
requirements-completed: [FED-04]
coverage:
- id: D1
description: "Every mesh.*/federation.* RPC the UI calls has a mock handler — the demo never answers a UI call with 'Method not found'"
requirement: "FED-04"
verification:
- kind: integration
ref: "neode-ui/scripts/mock-rpc-parity.mjs STATIC stage — 57 UI-called methods, 0 unhandled"
status: pass
human_judgment: false
- id: D2
description: "Renaming a mesh peer on the demo persists across a contacts-list read"
requirement: "FED-04"
verification:
- kind: integration
ref: "…LIVE stage — contacts-save then contacts-list round-trips the alias"
status: pass
human_judgment: false
- id: D3
description: "Reaction, reply, edit, delete and forward mutate demo state and are visible on the next mesh.messages read"
requirement: "FED-04"
verification:
- kind: integration
ref: "…LIVE stage — six mutation assertions, each re-reading mesh.messages and checking the UI-expected shape"
status: pass
human_judgment: false
- id: D4
description: "The parity check fails when a UI-called method has no mock handler"
requirement: "FED-04"
verification:
- kind: integration
ref: "Fail-first proof: disabling the mesh.clear-all case → exit 1, 'no mock handler for mesh.clear-all'; restored → exit 0 twice consecutively"
status: pass
human_judgment: false
- id: D5
description: "The harness leaves no stray listener behind"
requirement: "FED-04"
verification:
- kind: integration
ref: "Two consecutive runs both exit 0 (child killed in a finally block; ephemeral port via MOCK_BACKEND_PORT)"
status: pass
human_judgment: false
duration: 75min
completed: 2026-08-01
status: complete
---
# Phase 1 Plan 2: Demo/Real Mesh RPC Parity (FED-04) Summary
**Closed the ten mesh/federation methods the demo answered with "Method not found", made the six ack-only chat mutations actually mutate demo state, and replaced "squint at the browser console" with a single command that fails when the demo drifts.**
## Performance
- **Duration:** ~75 min
- **Completed:** 2026-08-01
- **Tasks:** 3/3
- **Files modified:** 3 (mock backend, new harness, package.json)
## Accomplishments
- Ten new handlers, each citing the Rust it mirrors: `mesh.contacts-list`/`-save` (typed_messages.rs), `mesh.clear-all` (status.rs), `mesh.schedule-message`/`list-scheduled`/`cancel-scheduled` (assistant.rs + scheduler.rs), `mesh.assistant-status`/`-configure` (assistant.rs), `federation.cancel-request` and `federation.notify-did-change` (federation/handlers.rs).
- The chat mutations are no longer bare acknowledgements. Reactions, replies and read-receipts push typed messages carrying the `{ sender_pubkey, sender_seq }` target key the UI's `reactionIndex`/`replyTargetPreview` read; edits rewrite the text and set `edited_at`; deletes tombstone in place; forwards copy to the destination peer; channel sends are channel-addressed.
- `mesh.peers` and `mesh.contacts-list` share one `DEMO_MESH_PEERS` list, and the peer with no `pubkey_hex` is omitted from contacts exactly as the daemon's `if let Some(pk)` guard omits it.
- `scripts/mock-rpc-parity.mjs` runs a static cross-reference then boots the mock on an ephemeral port and drives 17 live assertions. No `|| fallback` escapes anywhere in it — a failed spawn or fetch fails the run rather than producing a green run that measured nothing.
## Task Commits
1. **Tasks 1 + 2: contacts round-trip, harness, and the ten missing methods**`b8979f36` (feat)
2. **Task 3: make the message-mutation stubs mutate demo state** — committed with this SUMMARY
## Deviations from Plan
### The STATIC scan matches call syntax, not every string literal — and the plan's gap list had two false positives
**Found during:** Task 1, building the static cross-reference
**Issue:** The plan specifies scanning `src/**` for "every `'mesh.<verb>'` / `'federation.<verb>'` string literal". That over-reports badly: `Mesh.vue` and `Federation.vue` use the same dotted names as **resource-cache keys** (`key: 'mesh.self-did'`, `key: 'mesh.transport-status'`, `key: 'federation.nodes'`, `key: 'federation.dwn-status'`), and `stores/sync.ts` invalidates by the same strings. None are RPC methods. A literal scan reports 18 gaps where 10 exist.
Two of those phantoms — **`federation.nodes` and `federation.dwn-status`** — are named in the plan's own Task 2 gap list. They are not RPC methods and the daemon's dispatcher has no such routes; `Federation.vue` uses them purely as cache keys. Implementing them would have added demo-only endpoints the real node does not serve, which the plan's own transparency prohibition forbids.
**Resolution:** The scan matches `method:\s*['"]…['"]`, i.e. an actual call site. The gap list becomes exactly the 10 real methods, `KNOWN_GAPS` is empty, and the harness is genuinely failable — proven by disabling a case and watching it exit 1.
**Files modified:** `neode-ui/scripts/mock-rpc-parity.mjs`
### Added an outbound pending request to the demo seed
**Found during:** Task 2, implementing `federation.cancel-request`
**Issue:** The daemon only permits cancelling an **outbound** request in **sent** state. The demo seed contained a single inbound/pending request, so a faithful handler could only ever return an error and the cancel path was unexercisable.
**Resolution:** Added `preq-demo-out-1` (outbound, state `sent`) to `pendingPeerRequests`. The handler stays faithful — it still rejects non-outbound and non-sent requests with the daemon's own message.
**Files modified:** `neode-ui/mock-backend.js`
### Edits/deletes use an overlay rather than in-place mutation
**Found during:** Task 3
**Issue:** `mesh.messages` rebuilds its seeded message array on every read, so mutating "the message" in place would be lost immediately for any seeded message and only work for messages sent in this session.
**Resolution:** A per-session `overrides` map keyed by `sender_seq`, applied over the merged list on read. This matches the daemon's matching rule (own-Sent message by `sender_seq`) and makes edit/delete observable for seeded messages too.
**Files modified:** `neode-ui/mock-backend.js`
## Known Stubs
`mesh.assistant-status` reports `ollama_detected: false`, `claude_available: false` and an empty model list. That is the honest answer for a browser demo with no local model — the UI's "not detected" path is what a visitor should see, and fabricating a model list would be exactly the demo-only divergence this plan's prohibition forbids.
## Threat Flags
None. This plan touches only the demo/mock backend and a dev-time harness; no production code path, endpoint or trust boundary is involved. The `T-01-SC` package-install threat does not apply — no dependencies were added (the harness uses only `node:` builtins and global `fetch`).
## Self-Check: PASSED
- CONFIRMED: `node --check mock-backend.js` exits 0
- CONFIRMED: `node scripts/mock-rpc-parity.mjs` exits 0 — 17 live assertions, 0 unhandled methods
- CONFIRMED: fail-first proof (disable a case → exit 1 naming it; restore → exit 0 twice in a row)
- CONFIRMED: each of the ten methods appears exactly once as a `case`
- CONFIRMED: `KNOWN_GAPS` is present and empty; `MOCK_BACKEND_PORT` honoured; `typed_messages.rs` cited twice
- CONFIRMED: `npm run test:mock-parity` registered in package.json
</content>
@@ -0,0 +1,172 @@
---
phase: 01-federation-mesh-hardening
plan: 12
subsystem: ui
tags: [vue, tailwind, flexbox, css-grid, web5, scroll]
requires:
- phase: 01-federation-mesh-hardening
provides: "Web5.vue's existing xl:grid-cols-2 row layout and Web5ConnectedNodes.vue's tabbed card (quick task 260729-je5 made the list fill the card's height; this plan adds the missing other half)"
provides:
- "A bounded, sibling-matched scroll contract on Web5ConnectedNodes.vue's three tab panes (trusted/observers/requests), pinned by a structural vitest suite"
- "Settled verdict on the second 'connected nodes' surface flagged by the originating todo: AccountInfoSection.vue's hits are changelog prose describing the feature, not a second scrolling list — no fix needed there"
affects: [web5, federation-mesh-hardening]
tech-stack:
added: []
patterns:
- "Sibling-matched equal-height row + inner-scroll: give the growing child `xl:flex-1 xl:basis-0` (zero flex-basis so it contributes no intrinsic height) instead of `flex-auto`/no-basis, so a CSS Grid row's default `align-items: stretch` sizes the card by its sibling, and the pane's own overflow-y-auto scrolls inside the leftover height. Card root needs `min-h-0` for the flex column to be allowed to shrink below content height, plus a row-breakpoint `xl:min-h-[Nrem]` floor for the case where the sibling itself is short."
key-files:
created:
- neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
modified:
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
key-decisions:
- "Row-breakpoint height floor set to xl:min-h-[40rem] (not the planner's initial 20rem guess) — confirmed via Dorian's live-browser check: with node discovery disabled, Web5NodeVisibility (the row sibling) renders short, the floor takes over, and 20rem looked stunted; he asked for double, i.e. 40rem."
- "Settings 'connected nodes' surface (AccountInfoSection.vue) is unrelated to this defect — its 10 'connected' hits are all changelog/what's-new prose describing the Connected Nodes feature by name, not a second scrolling list component. No code change made there."
- "Live-browser verification for wide (>=1280px xl breakpoint, sibling-height match + forced-overflow scroll) and narrow (<1280px, stacked layout unchanged) viewports was performed by Dorian directly on his own already-running dev session, not by the executor — the executor does not touch :8100/:5173/:5175/:5959/:3141 (a hard constraint clarified mid-execution to mean 'never kill/restart/disturb', not 'never read from')."
requirements-completed: [UIFIX-02]
coverage:
- id: D1
description: "The connected-nodes card's height at the xl (row) breakpoint tracks its Web5NodeVisibility sibling via CSS Grid stretch + a zero-basis flex child, instead of growing to fit every row"
requirement: "UIFIX-02"
verification:
- kind: unit
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract"
status: pass
- kind: manual_procedural
ref: "Dorian's direct visual check on his running :8100 session, wide viewport — reported 'we are good' for height match, scroll, and stacked layout"
status: pass
human_judgment: false
- id: D2
description: "The inner list scrolls within the matched height rather than growing scroll-free, for all three tabs (trusted/observers/requests)"
requirement: "UIFIX-02"
verification:
- kind: unit
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract"
status: pass
- kind: manual_procedural
ref: "Dorian's direct visual check — scroll behaviour confirmed correct"
status: pass
human_judgment: false
- id: D3
description: "A short sibling (discovery disabled) still leaves a usable list height via the xl:min-h-[40rem] floor, rather than collapsing to header+tabs"
requirement: "UIFIX-02"
verification:
- kind: unit
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives the card root a min-h-0 flex column with a row-breakpoint height floor"
status: pass
- kind: manual_procedural
ref: "Dorian's direct feedback ('too short when discovery is disabled, should be twice as tall') drove the 20rem -> 40rem correction, applied and re-verified"
status: pass
human_judgment: false
- id: D4
description: "The stacked (below-xl) single-column layout is byte-identical to before: same capped max-h-72 height, same scroll"
requirement: "UIFIX-02"
verification:
- kind: unit
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts (max-h-72 asserted on all three panes; git diff confirms Web5.vue untouched and no script-block change)"
status: pass
- kind: manual_procedural
ref: "Dorian's direct visual check, narrow viewport — stacked layout confirmed unchanged"
status: pass
human_judgment: false
- id: D5
description: "The zero-node/empty-list edge case still renders the existing empty/loading row and the pane stays in the tree (does not collapse)"
requirement: "UIFIX-02"
verification:
- kind: unit
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#still renders the empty-state row for each pane when the node list is empty"
status: pass
human_judgment: false
- id: D6
description: "The second 'connected nodes' surface flagged by the originating todo (settings AccountInfoSection.vue) is investigated and given an explicit verdict rather than silently skipped"
verification:
- kind: other
ref: "grep -n -i connected neode-ui/src/views/settings/AccountInfoSection.vue — all 10 hits are changelog prose, not a scrolling list"
status: pass
human_judgment: false
duration: 105min
completed: 2026-07-31
status: complete
---
# Phase 1 Plan 12: Connected-Nodes Row-Matched Scroll (UIFIX-02) Summary
**Gave the connected-nodes card's three tab panes a zero-basis flex-grow contract so the card's height at the row breakpoint comes from its `Web5NodeVisibility` sibling via Grid's default stretch, with the list scrolling inside that height instead of growing to fit every row — floor tuned to 40rem per Dorian's live-browser feedback.**
## Performance
- **Duration:** ~105 min (across two work sessions, separated by a live-browser verification checkpoint)
- **Completed:** 2026-07-31
- **Tasks:** 2/2 (Task 2's dev-preview portion completed by Dorian directly, not the executor — see Deviations)
- **Files modified:** 2 (1 component, 1 new test file)
## Accomplishments
- All three tab panes (trusted/observers/requests) in `Web5ConnectedNodes.vue` now carry `min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none` — below the `xl` breakpoint nothing changed (same cap, same scroll); at `xl` the pane contributes zero intrinsic height, so the grid row is sized by the sibling alone and the pane scrolls inside the leftover height.
- Card root gained `min-h-0 xl:min-h-[40rem]` so a short sibling (e.g. discovery disabled, `Web5NodeVisibility` renders small) still leaves a full, usable list area instead of collapsing to the header+tabs strip.
- New structural test `Web5ConnectedNodesScroll.test.ts` pins this contract (3 tests) so a future cleanup cannot regress it a third time (the todo notes this was already a regression of an earlier fix).
- Settled the ambiguity the originating todo explicitly flagged: the "connected nodes" hits in `settings/AccountInfoSection.vue` are changelog prose describing the feature by name, not a second scrolling list — confirmed by direct grep of all 10 hits, no code change needed there.
- Live-browser verification (sibling-height match at wide viewport, forced-overflow internal scroll, unchanged stacked layout at narrow viewport) was performed by Dorian on his own running `:8100` dev session rather than by the executor spinning up a competing instance.
## Task Commits
Each task was committed atomically, across two rounds (the second correcting the height floor per live feedback):
1. **Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height** - `ceafbcb5` (fix) — added the scroll contract classes + wrote the pinning test (initial floor: `xl:min-h-[20rem]`, the planner's flagged judgement call)
2. **Task 2 correction: raise the height floor to 40rem** - `b5628d96` (fix) — Dorian verified height-match/scroll/stacked-layout were all correct on his own running session but reported the floor was too short with discovery disabled ("should be twice as tall"); floor changed `20rem``40rem`, test's expected value updated to match
Both commits pushed to `gitea-ai main`. No separate plan-metadata commit was needed beyond this SUMMARY's own final commit (below).
_Note: this plan's Task 1 was `type="tracer" tdd="true"` — test file written first (RED verified analytically via `git diff` showing the exact classes the test's `toContain`/`not.toContain` assertions depend on), then the fix applied and the test confirmed green (GREEN)._
## Files Created/Modified
- `neode-ui/src/views/web5/Web5ConnectedNodes.vue` - card root: `min-h-0 xl:min-h-[40rem]`; all three tab panes: `xl:flex-1 xl:basis-0 xl:max-h-none` replacing `flex-auto`/no-basis, keeping `min-h-0 overflow-y-auto max-h-72` unchanged. No script-block, header, tab-strip, row-markup, or `Web5.vue` changes (verified via `git diff --stat` showing zero change to `Web5.vue`, and zero `<script` diff lines).
- `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` - new: 3 tests pinning (1) the bounded scroll contract classes on all three panes, (2) the card root's min-h-0/flex-col/`xl:min-h-[40rem]` floor, (3) the empty-state row still rendering for each pane.
## Decisions Made
- Height floor: `xl:min-h-[40rem]` (final, confirmed value) — see key-decisions above for the full reasoning trail (planner's 20rem guess → Dorian's live feedback → 40rem).
- Settings `AccountInfoSection.vue` "connected nodes" surface: confirmed unrelated (changelog prose), left untouched.
- Live-browser verification delegated to Dorian's own already-running dev session rather than the executor starting a competing instance — see Deviations.
## Deviations from Plan
### Auto-fixed Issues
None beyond the planned scope — the height/overflow class changes and test file are exactly what the plan specified (with the floor value corrected per live feedback, which the plan itself flagged as an open judgement call to be settled at this exact step).
### Process deviation: live-browser verification performed by the user, not the executor
**Found during:** Task 2, precondition step ("local dev preview can be started... on :8100")
**Issue:** The session's hard constraints (as initially worded) listed `:8100`/`:5173`/`:5175`/`:5959`/`:3141` as ports the executor must never touch. All of `:8100`, `:5959`, `:5173`, `:5175` were confirmed (via `ss -ltnp`) to already be live processes belonging to running `vite`/mock-backend sessions in this same working tree, so starting a new instance risked colliding with a live session. The executor halted and returned a `checkpoint:human-verify` rather than starting anything or reading from those ports.
**Resolution:** Dorian checked his own already-running `:8100` session directly (a live `vite` dev server on this exact `neode-ui` checkout, which had already hot-reloaded the template change via HMR) and reported: height match, internal scroll, and stacked layout all correct ("we are good"), with the floor value needing to double. The coordinator also clarified mid-execution that the constraint's intent was "never kill/restart/disturb," not "never read from" — read-only observation of an already-running dev server is fine going forward.
**Files modified:** none as a result of this deviation itself (informational); the floor-value correction it produced is `neode-ui/src/views/web5/Web5ConnectedNodes.vue` (commit `b5628d96`).
**Commit:** N/A (process note, not a code fix)
### Auth gates
None encountered.
## Known Stubs
None — no hardcoded empty values, placeholder text, or unwired data introduced by this plan.
## Threat Flags
None — this plan is a template-class-only change plus a new test file; no new endpoints, auth paths, or trust-boundary surface introduced.
## Self-Check: PASSED
- FOUND: neode-ui/src/views/web5/Web5ConnectedNodes.vue (modified, confirmed via git diff)
- FOUND: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts (created)
- FOUND commit ceafbcb5 (git log --oneline --all | grep ceafbcb5)
- FOUND commit b5628d96 (git log --oneline --all | grep b5628d96)
- CONFIRMED: `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` byte-identical (git diff --stat shows no change) and green in every `npx vitest run` pass during this plan.
@@ -0,0 +1,188 @@
---
phase: 01-federation-mesh-hardening
plan: 14
subsystem: ui
tags: [vue, composable, lightbox, loading-state, vitest]
requires:
- phase: 02-ui-performance
provides: "PERF-02 no-spinner-on-cached-revisit guarantee, useCachedResource/KeepAlive infrastructure, and the split-signal-cost root-cause findings for the eight named slow-open surfaces"
provides:
- "usePaidItemViewer() composable — fetch/decode/route/loading/error state for a purchased item, replacing the inline window.open() path"
- "A second MediaLightbox instance in Cloud.vue, fed synthetic FileBrowserItems for purchased images/video"
- "Row-level 'Opening…' loading affordance and non-interactive-while-loading guard on Paid Files rows"
- "Evidence-backed per-surface verdict for every surface phase 2's findings named as a slow-open regression"
affects: [cloud, media-viewer]
tech-stack:
added: []
patterns:
- "Fetch-before-show: the composable fetches+decodes the purchased blob BEFORE opening the lightbox, so the lightbox's own fetchBlobUrl/streamUrl props resolve instantly from an internal map — the row's own 'opening' spinner covers the real (possibly-slow) network wait, not the lightbox's own (now-instant) internal loading flash."
- "One key, one owner: opening/inFlight/urlByPath are all keyed by the same paidItemKey(onion+content_id) formula the row's v-for :key already uses, so the row's spinner condition and the composable's dedup guard can never drift apart."
key-files:
created:
- neode-ui/src/composables/usePaidItemViewer.ts
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
modified:
- neode-ui/src/views/Cloud.vue
key-decisions:
- "Documents (and any other mime with no in-app viewer) keep the browser-tab fallback unchanged — this is a scope boundary from the plan, not an omission."
- "All eight surfaces phase 2 flagged as slow-opening (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) are CACHED-REVISIT render-cost regressions, not missing-first-load-affordance gaps — every one already shows a loading affordance on its genuinely slow path (first load or the specific in-flight async op), so no loader was added anywhere in Task 2. Adding one to any of them would put a spinner on a cached revisit, a direct PERF-02 regression."
- "URL ownership: the lightbox revokes every URL it is handed, on unmount, exactly as it already does for My Files/Peer Files. The composable never schedules a competing revoke for a URL routed to the lightbox — only the browser-tab fallback path keeps its own revoke timer, unchanged from before."
patterns-established:
- "Row-level loading + non-interactive guard for a fetch-then-route action, reusing PeerFiles.vue's existing 3x3 spinner + 'Opening...' treatment rather than inventing new visual language."
requirements-completed: [UIFIX-04, UIFIX-06]
coverage:
- id: D1
description: "A purchased picture or video opens in the app's own MediaLightbox instead of a browser tab"
requirement: UIFIX-04
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an image mime to the lightbox, not window.open"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes a video mime to the lightbox, not window.open"
status: pass
human_judgment: false
- id: D2
description: "Audio still routes to the global bottom-bar player, never the lightbox; documents keep today's browser-tab fallback"
requirement: UIFIX-04
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an audio mime to the audio player, never the lightbox"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#falls back to the browser tab for a mime with no in-app viewer"
status: pass
human_judgment: false
- id: D3
description: "The Paid Files row shows a visible loading affordance for the whole duration of a purchased-item fetch, and clears it (including on failure); a second click on the same item in flight issues one RPC"
requirement: UIFIX-06
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#sets opening for the whole duration of the fetch and clears it on success"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#issues exactly one RPC when open() is called twice in quick succession for the same item"
status: pass
human_judgment: false
- id: D4
description: "Every surface phase 2's findings named as slow-opening has a recorded, evidence-backed verdict; no loader was added to a cached-revisit path"
requirement: UIFIX-06
verification:
- kind: other
ref: "Per-surface verdict table below, cross-referenced against .planning/phases/02-ui-performance/02-FINDINGS.md's Per-surface verdict / Accepted deviations sections and direct grep of each surface's own loading-state code"
status: pass
human_judgment: false
duration: 30min
completed: 2026-07-31
status: complete
---
# Phase 01 Plan 14: Paid Files lightbox + loading-state audit Summary
**Purchased pictures/videos now open in the app's own MediaLightbox with a visible "Opening…" row spinner and real error surfacing, replacing `window.open()` + a swallowed catch; the eight other surfaces phase 2 flagged as slow are confirmed cached-revisit render-cost regressions, not missing loaders, so none of them were touched.**
## Performance
- **Duration:** ~30 min
- **Started:** 2026-07-31T22:00Z (approx, from first read)
- **Completed:** 2026-07-31T22:30Z
- **Tasks:** 2 (Task 1 tracer + Task 2 audit)
- **Files modified:** 3
## Accomplishments
- New `usePaidItemViewer()` composable: moves `Cloud.vue`'s inline `content.owned-get` fetch/decode chain into a reusable, unit-tested unit with per-mime routing (image/video → lightbox, audio → bottom-bar player, everything else → today's browser-tab fallback), a single-flight `open()` guard keyed on `onion+content_id`, and a `resolveBlobUrl` the lightbox calls to read the already-fetched URL.
- `Cloud.vue`'s Paid Files row: `window.open()` is gone entirely (`grep -c 'window.open'` = 0); the row now shows a spinner + "Opening…" label (reusing `PeerFiles.vue`'s existing treatment verbatim) for the fetch's duration and becomes non-interactive so a second click can't queue a second fetch.
- A second `MediaLightbox` instance added below the existing My Files one, fed the composable's `lightboxItems`/`lightboxIndex` and `resolveBlobUrl` for both `fetch-blob-url` and `stream-url`.
- The composable's `catch` sets a real, user-facing `error` message that Cloud.vue surfaces through its existing `loadError`/`alert-error` block — no more silent failure.
- Task 2 audit: every one of the eight surfaces phase 2's findings named (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) confirmed as a cached-revisit client-side render-cost regression with an existing first-load/first-fetch affordance already in place — no new loader added anywhere.
## Task Commits
1. **Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible** - `bc9a210c` (fix)
- Follow-up type-check fix (vue-tsc caught two possibly-undefined array reads `npx vitest run` alone doesn't flag): `4a8925f0` (fix)
2. **Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing** - no code changes; audit-only, documented below (the pinning test required by this task's acceptance criteria was already written as part of Task 1's test file, see "opening is not derived from a background-refresh flag" test)
**Plan metadata:** (this commit)
## Files Created/Modified
- `neode-ui/src/composables/usePaidItemViewer.ts` - fetch/decode/route/loading/error composable for purchased items
- `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts` - 9 tests: per-mime routing (image/video/audio/no-viewer), extension-carrying synthetic item, loading-state, the not-derived-from-refresh pin, error surfacing, double-click dedup
- `neode-ui/src/views/Cloud.vue` - `viewPaidItem()` now delegates to the composable; row loading/error UI; second `MediaLightbox` instance
## Per-Surface Slow-Open Verdict (Task 2)
Audited against `.planning/phases/02-ui-performance/02-FINDINGS.md`'s "Per-surface verdict" and "Accepted deviations" sections (the authoritative measured evidence) plus a direct grep of each surface's own loading-state code, per the plan's own instruction to re-verify rather than assume a grep hit covers the first uncached open.
| Surface | File | Existing affordance (first/genuinely-slow open) | Genuinely slow first open? | Action taken |
|---|---|---|---|---|
| Discover | `neode-ui/src/views/Discover.vue` | `catalogResource.entry.loadState === 'loading'` drives a loading message (`loadingCommunity`, line 303) | No — 02-FINDINGS: 0 RPC confound-free reading, regression is Apps-tab-KeepAlive-transit render/reactivation cost on **revisit**, not the first uncached open | None — first-load already covered; a revisit regression is out of scope for a loader (would violate PERF-02) |
| Server | `neode-ui/src/views/Server.vue` | Multiple `animate-spin` loading indicators per-section (e.g. line 91, 250) plus keyed `useCachedResource` per load group (02-06) | No — 02-FINDINGS/02-09: component instance confirmed to genuinely survive the round-trip (`vm.$.uid` proof); regression is 7 cached resources' reactivation cost on **revisit**, RPC count *improved* (8→2) | None — same reasoning; revisit-only cost |
| Web5 | `neode-ui/src/views/web5/Web5.vue` | `loadingDidDoc` text indicator (line 44) plus each sub-card's own `useCachedResource` | No — 02-FINDINGS: strongest zero-overlap evidence of pure client-side reactivation cost on a confirmed-surviving instance, 0 RPC throughout, all three measurement runs | None — same reasoning; revisit-only cost |
| Fleet | `neode-ui/src/views/Fleet.vue` | `animate-spin` loading state (lines 64, 93) | No — 02-FINDINGS: instance survives (confirmed), 0 RPC either time, the most severe of the split-signal regressions (330ms→2631ms) but purely a revisit/reactivation cost | None — same reasoning; revisit-only cost |
| AppDetails | `neode-ui/src/views/AppDetails.vue` | `credentialsLoading` computed from `credentialsResource.loadState.value === 'loading'`, passed to its credentials section (line 44/206) | No — 02-FINDINGS: RPC count *improved* (2→1) via keyed `useCachedResource` (02-03); the keyed-lookup/re-render cost now exceeds what the eliminated fetch saved, on revisit | None — same reasoning; revisit-only cost |
| OpenWrtGateway | `neode-ui/src/views/server/OpenWrtGateway.vue` | Explicit "Loading skeleton" block (line 522) with a documented first-load-only condition (line 102-104 comment) | No — 02-FINDINGS: same split-signal class as AppDetails; RPC evidence confounded by Server-tab transit but `revisitMs` alone shows the regression is on the cached path, and the skeleton is already gated to first-load-only | None — same reasoning; revisit-only cost, and the surface deliberately avoids showing a skeleton on revisit already (matches PERF-02) |
| MarketplaceAppDetails | `neode-ui/src/views/MarketplaceAppDetails.vue` | `v-if="loading"` full-card skeleton with `animate-spin` (line 7-8) | No — 02-FINDINGS: RPC evidence confounded by Home-tab transit; only `package.versions` is a trustworthy call and it improved; regression (if any residual) reads as revisit render cost, and the raw `remounted: false` reading is flagged as likely the same selector-ambiguity artifact 02-09 proved, not new instance caching | None — first-load skeleton already present and correctly gated |
| Wallet / send flow (`SendBitcoinModal.vue`) | `neode-ui/src/components/SendBitcoinModal.vue` | `feeEstimateLoading` ref renders `'…'` in place of the fee figure while the fee-estimate call is in flight (lines 388-419) | No — 02-FINDINGS: **cleared as environmental noise** in the re-measurement (median dropped below both prior runs); the separate, still-open anomaly (revisit consistently slower than first-visit, 0 RPC) is because `BaseModal`'s `v-if` always fully remounts the modal — not a missing loader, and out of this plan's `files_modified` (`SendBitcoinModal.vue` isn't in this plan's scope; carried forward in 02-FINDINGS' Outstanding section) | None — the modal's own async op (fee estimate) already has a first-load affordance; the remount-cost anomaly is a pre-existing, separately-tracked issue this plan doesn't own |
**No affordance was added to any cached-revisit path in this plan.** PERF-02's no-spinner-on-revisit guarantee is preserved everywhere — the only genuinely slow, uncached, first-open gap that existed (`Cloud.vue`'s `viewPaidItem()` / `content.owned-get`, 60s timeout, zero indicator) is the one fixed in Task 1; every other named surface's slow-open regression is client-side render/reactivation cost on an already-cached revisit, which a loader cannot fix and must not paper over.
## Decisions Made
- Documents (no in-app viewer) deliberately keep the browser-tab fallback — a scope boundary named explicitly in the plan, not a gap.
- No new loader was added anywhere in Task 2 — see the per-surface verdict table above; every named surface's regression is revisit-only render cost, confirmed by phase 2's own measured evidence (`02-FINDINGS.md`), not a missing first-load affordance.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Fixed two TS strict-null-check errors caught by `npm run build`'s `vue-tsc -b` pass**
- **Found during:** Task 1, post-commit `npm run build` verification
- **Issue:** `viewer.lightboxItems.value[0].name` — TS2532 "Object is possibly 'undefined'" on two array-index test assertions. `npx vitest run` alone doesn't type-check test files under this project's config, so this only surfaced during the build's own `vue-tsc -b` step.
- **Fix:** Optional-chained both reads (`viewer.lightboxItems.value[0]?.name`).
- **Files modified:** `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts`
- **Verification:** `npm run build` exits 0 after the fix; `npx vitest run` for the file still 9/9 green.
- **Committed in:** `4a8925f0`
---
**Total deviations:** 1 auto-fixed (blocking, build type-check)
**Impact on plan:** Test-only, no behavior change. No scope creep.
## Issues Encountered
None beyond the above.
## Verification
- `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` — 9/9 passed.
- `cd neode-ui && npx vitest run` — 99 test files / 812 tests, all green (`keepAliveTabs.test.ts` confirmed byte-for-byte unmodified via `git status --short` and still passing within that run).
- `cd neode-ui && npx vue-tsc --noEmit` — clean.
- `cd neode-ui && npm run build` — succeeds; `grep -rq 'Opening…' ../web/dist/neode-ui/assets/Cloud-*.js` confirms the new string reached the bundle.
- `grep -c 'window.open' neode-ui/src/views/Cloud.vue` = 0; `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` = 4 (import + 3 usages incl. the doc comment referencing it); `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` = 3.
- `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` = 0 — no cached-resource key touched.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- UIFIX-04 and UIFIX-06 are both closed for their named gap (Paid Files). No blockers for future phase-1 plans.
- Carried-forward, unrelated-to-this-plan items already tracked in `02-FINDINGS.md`'s Outstanding section (Discover/Server/Web5/Fleet/AppDetails/OpenWrtGateway split-signal render-cost regressions, Wallet-send's remount anomaly) remain open there — this plan's audit reconfirmed them but did not fix them, per the plan's own explicit instruction that a cached-revisit render-cost regression is not a UIFIX-06 loader gap.
---
*Phase: 01-federation-mesh-hardening*
*Completed: 2026-07-31*
+170 -11
View File
@@ -136,6 +136,63 @@ const DEMO_MESH_PEERS = [
// reported it (the seed values are offsets in ms).
const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 }
const DEMO_PEER_NAMES = Object.fromEntries(
DEMO_MESH_PEERS.map((p) => [p.contact_id, p.advert_name]),
)
// This node's own key on the demo. Reactions/replies the visitor sends are
// direction:'sent', which is what the UI keys '__self__' off — but the field
// still has to be present and stable for the message key to work.
const DEMO_SELF_PUBKEY = 'demo02abababababababababababababababababababababababababababab'
// Append a message to this session's mesh store, matching the field set of
// mesh/types.rs MeshMessage (id, direction, peer_contact_id, peer_name,
// plaintext, timestamp, delivered, encrypted, transport, message_type,
// typed_payload, sender_pubkey, sender_seq).
function pushMeshMessage({
contact_id = null,
plaintext = '',
message_type = 'text',
typed_payload = null,
transport = 'meshcore',
channel = null,
}) {
const meshStore = currentStore().mesh
const id = 100 + meshStore.dynamic.length
const msg = {
id,
direction: 'sent',
peer_contact_id: contact_id,
peer_name: contact_id === null ? (channel === null ? null : `channel-${channel}`) : DEMO_PEER_NAMES[contact_id] || `peer-${contact_id}`,
plaintext,
timestamp: new Date().toISOString(),
delivered: true,
// mountain-node (3) has no pubkey, so traffic to it is unencrypted — the
// same asymmetry the rest of the demo already models.
encrypted: contact_id !== 3,
transport,
sender_pubkey: DEMO_SELF_PUBKEY,
sender_seq: id,
message_type,
typed_payload,
}
if (channel !== null) msg.channel = channel
meshStore.dynamic.push(msg)
return msg
}
// Apply this session's edit/delete overrides over a message list. Keyed by
// sender_seq, mirroring mesh/mod.rs apply_local_edit / apply_local_delete,
// which match an own-Sent message by sender_seq. Kept as an overlay rather
// than mutating the seed array, because the seed is rebuilt on every read.
function applyMeshOverrides(messages) {
const overrides = currentStore().mesh.overrides
if (!overrides || Object.keys(overrides).length === 0) return messages
return messages.map((m) => {
const o = m.direction === 'sent' && m.sender_seq != null ? overrides[m.sender_seq] : null
return o ? { ...m, ...o } : m
})
}
// Boot mode: simulate server startup delay
let BOOT_START_TIME = Date.now()
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
@@ -3265,7 +3322,7 @@ app.post('/rpc/v1', (req, res) => {
]
// Messages sent this session (texts + attachments) ride after the
// static seed so refresh-after-send shows them, same as a real node.
const withDynamic = [...allMessages, ...currentStore().mesh.dynamic]
const withDynamic = applyMeshOverrides([...allMessages, ...currentStore().mesh.dynamic])
return res.json({
result: {
messages: withDynamic.slice(0, limit),
@@ -4844,19 +4901,121 @@ app.post('/rpc/v1', (req, res) => {
})
}
// Chat actions the demo only needs to acknowledge.
//
// ── Chat mutations ──────────────────────────────────────────────────
// Reactions, replies, receipts and forwards are new typed messages
// carrying a target key (typed_messages.rs send_typed_wire); edits and
// deletes mutate the target in place (mesh/mod.rs apply_local_edit /
// apply_local_delete). The UI derives reaction chips and reply quotes
// from exactly these shapes (Mesh.vue reactionIndex /
// replyTargetPreview), so a bare ack renders as nothing at all.
case 'mesh.send-reaction': {
const emoji = params?.emoji ?? ''
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: emoji,
message_type: 'reaction',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
// An empty emoji clears this reactor's reaction, per the UI's
// reactionIndex (freshest per reactor wins; '' deletes).
emoji,
},
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-reply': {
const text = params?.text ?? ''
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: text,
message_type: 'reply',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
text,
},
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-read-receipt': {
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: '',
message_type: 'read_receipt',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
},
})
return res.json({ result: { ok: true, sent: true } })
}
// apply_local_edit: replaces plaintext and merges { edited_at, text }
// into the existing typed_payload of the own-Sent message with that seq.
case 'mesh.edit-message': {
const seq = params?.target_seq
const newText = params?.new_text ?? ''
if (seq == null) {
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
}
const overrides = currentStore().mesh.overrides
const prior = overrides[seq] || {}
overrides[seq] = {
...prior,
plaintext: newText,
typed_payload: {
...(prior.typed_payload || {}),
edited_at: Math.floor(Date.now() / 1000),
text: newText,
},
}
return res.json({ result: { ok: true, sent: true } })
}
// apply_local_delete: tombstone in place — the daemon does NOT remove
// the row, it rewrites plaintext, sets typed_payload { deleted: true }
// and message_type 'delete'. Mirrored exactly; do not "simplify" this
// into a splice, the UI renders the tombstone bubble.
case 'mesh.delete-message': {
const seq = params?.target_seq
if (seq == null) {
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
}
currentStore().mesh.overrides[seq] = {
plaintext: '🗑 message deleted',
typed_payload: { deleted: true },
message_type: 'delete',
}
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.forward-message': {
const sourceId = params?.source_message_id
const all = applyMeshOverrides(currentStore().mesh.dynamic)
const source = all.find((m) => m.id === sourceId)
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: source ? source.plaintext : '(forwarded)',
message_type: source?.message_type === 'content_ref' ? 'content_ref' : 'text',
typed_payload: source?.typed_payload ?? null,
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-channel': {
pushMeshMessage({
contact_id: null,
channel: params?.channel ?? 0,
plaintext: params?.message ?? '',
message_type: 'text',
})
return res.json({ result: { ok: true, sent: true } })
}
// mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON
// PURPOSE: the daemon's handlers have no message-store effect either
// (refresh re-polls the radio, reboot-radio power-cycles it), so giving
// them demo-side state would be divergence, not parity. Do not "fix".
case 'mesh.send-reaction':
case 'mesh.send-reply':
case 'mesh.send-read-receipt':
case 'mesh.edit-message':
case 'mesh.delete-message':
case 'mesh.forward-message':
case 'mesh.send-channel':
case 'mesh.refresh':
case 'mesh.reboot-radio': {
return res.json({ result: { ok: true, sent: true } })
@@ -5877,7 +6036,7 @@ function makeSessionStore() {
// contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the
// daemon's state.contacts map. scheduled: queued messages awaiting their
// fire_at, mirroring svc.scheduler's list.
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 },
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1, overrides: {} },
sockets: new Set(),
lastSeen: Date.now(),
}
+63 -1
View File
@@ -214,7 +214,69 @@ try {
else fail('cancelled request still listed as pending')
}
// 6. federation.notify-did-change reports per-peer results.
// 6. Chat mutations are observable on the next mesh.messages read — the
// whole point of FED-04's "not a bare ok acknowledgement" requirement.
const sent = await rpc('mesh.send-content-inline', {
contact_id: 1,
mime: 'text/plain',
filename: 'mutate.txt',
bytes_b64: Buffer.from('mutate me').toString('base64'),
})
const targetSeq = sent.message_id
await rpc('mesh.send-reaction', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
emoji: '🔥',
})
let msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reaction = msgs.find(
(m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq,
)
if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key')
else fail('reaction did not appear in mesh.messages with the UI-expected shape')
await rpc('mesh.send-reply', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
text: 'replying to that',
})
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq)
if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target')
else fail('reply did not appear with a target key')
await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at)
pass('mesh.edit-message rewrote the text and set the edited marker')
else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`)
await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
// The daemon tombstones in place rather than removing the row.
if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true)
pass('mesh.delete-message tombstoned in place, as apply_local_delete does')
else fail(`delete representation wrong: ${JSON.stringify(deleted)}`)
const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count
await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent')
if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer')
else fail('forward did not create a message for the destination peer')
await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast'))
pass('mesh.send-channel pushed a channel-addressed message')
else fail('channel message not visible in mesh.messages')
// 7. federation.notify-did-change reports per-peer results.
const notified = await rpc('federation.notify-did-change', {
old_did: 'did:key:zOld',
new_did: 'did:key:zNew',