From ee8e60a37c6fc294deb82ad21ae52d35f41066eb Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 20:14:03 -0400 Subject: [PATCH] docs: commit phase pattern maps + discuss checkpoint for portability --- .../01-PATTERNS.md | 398 ++++++++++++++++++ .../02-DISCUSS-CHECKPOINT.json | 41 ++ .../phases/02-ui-performance/02-PATTERNS.md | 322 ++++++++++++++ 3 files changed, 761 insertions(+) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-PATTERNS.md create mode 100644 .planning/phases/02-ui-performance/02-DISCUSS-CHECKPOINT.json create mode 100644 .planning/phases/02-ui-performance/02-PATTERNS.md diff --git a/.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md b/.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md new file mode 100644 index 00000000..a3df1ac7 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md @@ -0,0 +1,398 @@ +# Phase 1: Federation & Mesh Hardening - Pattern Map + +**Mapped:** 2026-07-29 +**Files analyzed:** 11 (backend touch points) + 5 (frontend/mock touch points) +**Analogs found:** 15 / 16 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `core/archipelago/src/federation/storage.rs` (add lock) | model/store | CRUD (file-backed) | `core/archipelago/src/update.rs` `UPDATE_OP_LOCK` (`try_lock` guard on a mutating op) | role-match (same "serialize file-mutating ops" problem, different domain) | +| `core/archipelago/src/server.rs` (~L497, ~L840 loops) | service (periodic loop) | event-driven/batch | itself (Tor-refresh loop at ~L480) + `mesh/listener/session.rs:386` `PORT_OPEN_LOCK` for the coordination primitive | exact (loop shape) / role-match (lock) | +| `core/archipelago/src/federation/types.rs` (NodeStateSnapshot + FederationPeerHint additions) | model | transform (serde) | itself — `shared_location` (`lat`/`lon`) opt-in field, same struct | exact | +| `core/archipelago/src/api/rpc/federation/handlers.rs` (build_local_state call site) | controller/RPC handler | request-response | itself — `shared_location` gating block (L476-479) | exact | +| `core/archipelago/src/api/rpc/lnd/info.rs` (`handle_lnd_getinfo` extension) | controller/RPC handler | request-response | `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (adjacent LND REST handler, validation + response shaping style) | role-match | +| `core/archipelago/src/api/rpc/dispatcher.rs` (new method registration) | route/dispatcher | request-response | itself — `"lnd.getinfo"`/`"lnd.openchannel"`/`"federation.list-nodes"` match arms | exact | +| `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (new mesh capability / channel-open-request message type) | controller/RPC handler | event-driven | itself — `handle_mesh_contacts_list` (L1215) and reaction/reply handlers (L637-976) | exact | +| `neode-ui/mock-backend.js` (`mesh.contacts-list`/`contacts-save`, stateful reaction/reply/edit/delete/forward) | mock RPC handler | CRUD (in-memory per-session store) | itself — `mesh.send-content-inline` (L4355) for stateful `meshStore.dynamic` mutation; `mesh.transport-advice` (L4318) for "mirror the daemon comment" convention | exact | +| `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05) | component/modal | request-response | `neode-ui/src/components/LightningChannelsPanel.vue` (open-channel form + error handling) + `neode-ui/src/components/BaseModal.vue` (Teleport shell) + `neode-ui/src/components/federation/PeerRequestModal.vue` (request flow) | exact (composite of 3 analogs) | +| `neode-ui/src/views/federation/NodeList.vue` (picker-row pattern reused inside new modal) | component | request-response | itself | exact | +| `neode-ui/src/components/ScreensaverRing.vue` (new `badge` size variant) | component | transform (pure CSS/SVG) | itself — existing `compact`/`default` size-class pattern | exact | +| `neode-ui/src/components/SendBitcoinModal.vue` / `WalletScanModal.vue` (swap ring) | component | transform | `neode-ui/src/components/Screensaver.vue` (existing `ScreensaverRing` + centered-content layering pattern) | exact | +| `neode-ui/src/api/rpc-client.ts` (new method wrappers: own LN URI, channel-open-request) | service (API client) | request-response | itself — `mesh.contacts-list`/`contacts-save` wrappers (L804, L813) | exact | + +## Pattern Assignments + +### `core/archipelago/src/federation/storage.rs` (locking fix) + +**Analog:** `core/archipelago/src/update.rs:35` (`UPDATE_OP_LOCK`) + +**Core pattern** (lines 25-35, `update.rs`): +```rust +/// Serializes the mutating update operations (download, apply, and the +/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was +/// exactly this race: two concurrent `update.download` RPCs shared one +/// staging file, a cancel wiped staging mid-flight, a third download began +/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of +/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this +/// via `try_lock` so a concurrent caller gets an explicit "already running" +/// error instead of silently interleaving. +static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +``` +This is the closest documented precedent in the codebase for "two async +call sites race on the same on-disk resource, fix with a static +`tokio::sync::Mutex::const_new(())`" — same shape of bug as Pitfall 1 in +RESEARCH.md, already root-caused and fixed once before. Copy the doc-comment +style (explain *why*, cite the historical incident) and the `try_lock` +vs. `.lock().await` decision: prefer plain `.lock().await` (blocking wait, +not `try_lock`-reject) for the federation case, since federation writes are +infrequent and a caller silently failing "already syncing" would reintroduce +the original bug's symptom (lost writes) rather than fix it — unlike +`update.rs`'s deliberate reject-on-contention UX. + +**Secondary reference:** `core/archipelago/src/container/app_ops.rs:17-24` — a +`HashMap>>` keyed per-app-id, for when a +single global lock is too coarse. Not needed here (one `data_dir` = one +federation store = one lock is fine), but note this pattern exists if the +planner decides per-node-id granularity is warranted. + +**Also apply:** `federation::storage::save_nodes` is a direct `fs::write()`, +not atomic temp+rename. No existing atomic-write helper was found elsewhere +in `core/archipelago/src/` (grepped, none present) — this will be genuinely +new code; keep it minimal (`fs::write` to `nodes.json.tmp` then `fs::rename`). + +--- + +### `core/archipelago/src/server.rs` (two federation sync loops, ~L497 / ~L840) + +**Analog:** itself (the Tor-refresh loop pattern immediately above, ~L479) and `mesh/listener/session.rs:386`'s `PORT_OPEN_LOCK` for how a shared static lock is threaded through an async loop body. + +**Loop skeleton pattern** (both existing loops share this shape — `server.rs:497-515` and `server.rs:840-853`): +```rust +tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(20)).await; // startup settle delay + let mut interval = tokio::time::interval(Duration::from_secs(90)); + // 1800s loop additionally sets: + // interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let nodes = match crate::federation::load_nodes(&data_dir).await { + Ok(n) if !n.is_empty() => n, + _ => continue, + }; + // ... snapshot local identity, iterate `nodes`, call sync_with_peer ... + } +}); +``` +**Error handling pattern:** both loops only `debug!()` on failure (RESEARCH.md +Anti-Pattern flagged this — FED-02 requires operator-visible errors). Do not +copy this part; extend to persist `last_sync_error` on the node record +(mirrors how `record_peer_transport` already persists `last_transport`/ +`last_transport_at` in `federation/storage.rs:120-147` — same "write a +result field back to the node struct after each attempt" shape, just for the +error side instead of the success side). + +**If collapsing the two loops:** the 1800s loop's unique tail call is +`refresh_federation_mesh_peers()` (per RESEARCH.md Open Question 1) — move +that single call to the end of the 90s loop's per-pass completion rather +than deleting the loop body wholesale, preserving whatever roster-propagation +behavior it provides. + +--- + +### `core/archipelago/src/federation/types.rs` (NodeStateSnapshot Lightning fields) + +**Analog:** itself — the existing `shared_location` (`lat`/`lon`) opt-in field on the same struct (lines 124-131). + +**Exact pattern to mirror** (`federation/types.rs:124-131`): +```rust +/// This node's own location, for the Mesh Map — only present when the +/// sender has opted in via `server.set-location`'s `share` flag. Absent +/// (not just null) for nodes that haven't opted in, so older receivers +/// and the map's "no location shared" state both fall out naturally. +#[serde(default)] +pub lat: Option, +#[serde(default)] +pub lon: Option, +``` +Add `lightning_uri: Option` (or `lightning_pubkey` + `lightning_host` +split, matching `FederationPeerHint`'s `pubkey`/`onion` split style at +line 137-145) with the same `#[serde(default)]` back-compat annotation and a +doc comment explaining the opt-in gating (per CONTEXT.md's locked decision: +default ON for federation, unlike `shared_location`'s default-off — call +this out explicitly in the doc comment since it deviates from the analog). + +**Gating call site analog** (`api/rpc/federation/handlers.rs:476-479`): +```rust +let shared_location = if data.server_info.share_location { + data.server_info.lat.zip(data.server_info.lon) +} else { + None +}; +``` +Mirror this shape for the Lightning URI gate, then thread it through +`federation::build_local_state(...)` the same way `shared_location` is +threaded (`sync.rs:230,258-259` — accepted as a parameter, mapped into the +snapshot fields at construction time). If FED-05 lands the "default ON" +decision, this becomes a simpler unconditional read (no `if`), but keep the +struct-level `Option` + `#[serde(default)]` regardless so a future opt-out +setting is a pure additive change. + +--- + +### `core/archipelago/src/api/rpc/lnd/info.rs` (own-node Lightning URI RPC) + +**Analog:** `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (`channels.rs:238-336`) for response/validation style in the same file family — reuse verbatim, do not modify; new code only needs to *read* `identity_pubkey`/`uris` out of the same LND REST response `handle_lnd_getinfo` already fetches but doesn't forward. Read `info.rs`'s current struct/response shape directly before editing (not excerpted here — small, single-file change, one Read call is enough at implementation time). + +**Dispatcher registration analog** (`api/rpc/dispatcher.rs:125,128`): +```rust +"lnd.getinfo" => self.handle_lnd_getinfo().await, +... +"lnd.openchannel" => self.handle_lnd_openchannel(params).await, +``` +Any new RPC (e.g. a dedicated `lnd.own-uri` if the planner decides not to +extend `getinfo`) follows this exact one-line match-arm registration +convention — no separate route table, no middleware wiring beyond this. + +--- + +### `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (mesh capability advertisement / channel-open-request message type) + +**No direct analog exists** — RESEARCH.md confirms this is greenfield (no +capability-advertisement field on mesh peers/contacts today). Closest +structural analogs for *how to add a new field to a broadcast peer struct* +and *how to add a new mesh message type*: + +**Analog A — read/return handler shape** (`typed_messages.rs:1215-1234`, +`handle_mesh_contacts_list`): +```rust +pub(in crate::api::rpc) async fn handle_mesh_contacts_list( + &self, + _params: Option, +) -> Result { + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + let state = svc.shared_state(); + let contacts = state.contacts.read().await; + let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect(); + // ... merge/collapse logic ... +} +``` +Use this shape (`mesh_service.read().await` → `shared_state()` → +`.read().await` on the relevant map) for any new "list peers with lightning +capability" RPC. + +**Analog B — event-driven send handlers** (`typed_messages.rs:637-976`, +reaction/reply/receipt/forward family) — mirror for a new +"channel-open-request" mesh message type: same struct-per-message-type, +serialize-and-broadcast pattern already used for reactions/replies. + +**Security note (carries from RESEARCH.md V4):** any new RPC meant to be +peer-reachable (not just locally-authenticated) must be added to +`is_peer_allowed_path()` (`server.rs:1270`) explicitly — don't assume it +inherits reachability. + +--- + +### `neode-ui/mock-backend.js` (contacts-list/save + stateful reaction/reply/edit/delete/forward) + +**Analog — stateful mutation pattern** (`mock-backend.js:4355-4370`, +`mesh.send-content-inline`): +```javascript +case 'mesh.send-content-inline': { + // ... validate params ... + const meshStore = currentStore().mesh + const id = 100 + meshStore.dynamic.length + meshStore.dynamic.push({ + // ... message shape matching real daemon's mesh message schema ... + }) + return res.json({ result: { ... } }) +} +``` +**Analog — "mirror the daemon, cite the source" comment convention** +(`mock-backend.js:4312-4317`, immediately above `mesh.transport-advice`): +```javascript +// Mirrors the real daemon's size-based tier logic +// (typed_messages.rs handle_mesh_transport_advice) so the demo shows the +// SAME modals a real node would — the chooser only appears in the narrow +// fits-both band, never unconditionally. +``` +Apply both patterns verbatim to the FED-04 gaps: +- `mesh.contacts-list`/`mesh.contacts-save` — currently **absent** (404s), + add cases that read/write a per-session contacts bucket the same way + `meshStore.dynamic` is a per-session bucket (`currentStore().mesh`), citing + `typed_messages.rs:1180-1371` as source of truth per the comment convention. +- `mesh.send-reaction`/`send-reply`/`edit-message`/`delete-message`/ + `forward-message` — currently bare `{ok:true}` acks + (`mock-backend.js:4479-4490`, cited verbatim in RESEARCH.md) — replace each + with a `meshStore.dynamic` mutation (find message by id, mutate reactions + array / set edited text / mark deleted / push a forwarded copy), citing + `typed_messages.rs:637-976` (reply/reaction) and `:1065-1180` (edit/delete) + as source of truth. + +--- + +### `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05) + +**Analog 1 — modal shell:** `neode-ui/src/components/BaseModal.vue:1-40` +```vue + + +
+
+