Files
archy/.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
T

47 KiB

Phase 1: Federation & Mesh Hardening - Research

Researched: 2026-07-29 Domain: Federation node sync/removal (Rust/Tokio async daemon), mesh RPC parity (Rust + Node.js mock backend), Lightning channel-open UX (Vue 3), on-brand success animation (Vue 3/CSS) Confidence: HIGH (backend federation/mesh code — read directly, git-blamed); MEDIUM (FED-05 Lightning-URI UX — net-new surface, no prior art in repo); HIGH (FED-06 — both source components read directly)

<user_constraints>

User Constraints (from CONTEXT.md)

No CONTEXT.md exists for this phase (not yet run through /gsd-discuss-phase). No locked decisions or discretion areas to honor beyond REQUIREMENTS.md and the phase description supplied by the orchestrator. Treat all implementation choices below as recommendations for the planner, not locked decisions — the planner should flag any of these that warrant a user check-in (see ## Assumptions Log). </user_constraints>

Summary

The federation and mesh code is more mature than CONCERNS.md suggests — several concerns it lists (tombstone-write-swallowed, DID-join without signature verification) were already fixed in commit 01cbec27 (2026-07-02) and the handle_federation_peer_joined signature-verification path respectively. Do not treat CONCERNS.md as current truth for this phase; the structured review (FED-03) must re-verify each claim against the code read in this research before acting on it.

The real, currently-live bug class behind the user's "nodes reappear / sync issues" reports is almost certainly a concurrency race on federation/nodes.json: federation/storage.rs has zero locking (no Mutex, no atomic temp-file+rename) around load_nodes() → mutate → save_nodes(), yet the daemon runs two independent, overlapping periodic federation-sync loops (server.rs ~line 497, every 90s; server.rs ~line 840, every 1800s) plus the manual federation.sync-state RPC and federation.remove-node RPC — all of which do their own read-modify-write cycle against the same file with no coordination. A remove_node() call racing against an in-flight sync_with_peer()'s update_node_state() (which read the node list before the removal landed) will have the sync's stale read clobber the just-written removal when it saves — the removed node reappears with no error, exactly matching the reported symptom, and it is invisible to logs because both loops only debug!() on failure. This is the primary hypothesis to design a fix and a regression test around for FED-01/FED-02.

Mesh attachment-send parity (FED-04) was fixed just before this phase started (commit c2ce71c6, uncommitted → committed by another concurrent agent during this research session): mock-backend.js now implements mesh.send-content-inline / mesh.send-content / mesh.fetch-content / mesh.transport-advice mirroring the daemon's real tier logic. What remains for full "rest of the mesh chat surface" parity: mesh.contacts-list / mesh.contacts-save (peer aliasing, called live from Mesh.vue on mount and on rename) are not implemented in mock-backend.js at all and will 404 with "Method not found" on the demo; and mesh.send-reaction / send-reply / edit-message / delete-message / forward-message are stubbed as bare {ok:true} acks that never mutate meshStore.dynamic, so reactions/edits/deletes silently don't render on the demo even though the RPC call "succeeds."

FED-05 (Lightning channel-open UX) is greenfield: no RPC anywhere in the codebase currently returns this node's own Lightning identity_pubkey/uris (LND's own /v1/getinfo provides both, but handle_lnd_getinfo in core/archipelago/src/api/rpc/lnd/info.rs doesn't parse or forward them), and NodeStateSnapshot (the federation sync payload) carries no Lightning fields for a peer's pubkey/host, so there is no way today to look up a federated peer's channel-open target. handle_lnd_openchannel (channels.rs) already accepts pubkey + optional address + amount/fee params and does the connect-then-open sequence correctly — reuse it as-is. "Public nodes" browse/request has no existing data source in this codebase (no LN graph query, no curated list) and needs a scope decision from the user before planning task breakdown.

FED-06 is a straightforward swap: ScreensaverRing.vue (compact size = 240px/320px) renders only the radiating EQ segments (no circle of its own — the "circle" is the separately-layered content in the center, exactly as Screensaver.vue does with ScreensaverLogo). SendBitcoinModal.vue's .send-success-burst is 112px (7rem) with 3 CSS-ripple .burst-ring elements plus a .burst-core circle+checkmark — swap the .burst-ring elements for <ScreensaverRing size="compact" />, keep .burst-core+checkmark centered on top, and reconcile the size mismatch (ring is 2-3x larger than the current burst container; either scale it down via CSS transform: scale() or accept the larger footprint since the modal is max-w-2xl). WalletScanModal.vue has a second, simpler "paid tick" (.success-ring, no ripple animation at all) that the phase's "wherever else the paid tick appears" clause covers — plan to update both.

Primary recommendation: Start FED-03's structured review by (1) auditing every federation::storage read-modify-write call site for the missing-lock race described above and design a fix (a tokio::sync::Mutex per data_dir, or collapsing the two periodic sync loops into one), (2) re-verifying every CONCERNS.md federation/mesh claim against current code before acting on it, (3) filling the two demo-parity gaps in mock-backend.js (contacts-list/save + stateful reaction/edit/delete), (4) treating FED-05 as new RPC surface (own-node Lightning URI, peer Lightning info propagation, and a scoped "public nodes" answer) before any UI work, and (5) the FED-06 CSS/component swap.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Federation node list, tombstones, sync loops API / Backend (core/archipelago/src/federation/) Disk-persisted state; must be race-free at the storage layer, not patched in the RPC or UI layer
Federation removal propagation to mesh chat API / Backend (core/archipelago/src/mesh/mod.rs::purge_federation_peer) Frontend (Pinia stores/mesh.ts) Backend already purges peer/messages/contacts server-side; frontend must not cache a stale contact after the WebSocket state bump
Mesh RPC surface (attachments, reactions, contacts) API / Backend (core/archipelago/src/api/rpc/mesh/) Dev tooling (neode-ui/mock-backend.js) The demo backend is a parity shim over the same RPC surface — it must mirror backend behavior, never invent its own contract
FIPS/Tor transport dial + fallback API / Backend (core/archipelago/src/fips/dial.rs, transport/) Transport selection is a backend concern; UI only displays the resulting badge (last_transport)
Lightning node URI (own + peer) API / Backend (new: lnd.getinfo extension, federation sync payload extension) Frontend (new modal) LND is the source of truth for identity_pubkey/uris; federation sync is the transport for sharing a peer's LN info
Channel-open UX (initiate) Frontend (new modal, Teleport-to-body, house style) API / Backend (lnd.openchannel — already exists) Backend channel-open RPC is complete; only the UI (URI share, trusted-peer picker, public-node browse) is missing
Paid-tick success animation Frontend (SendBitcoinModal.vue, WalletScanModal.vue, ScreensaverRing.vue) Pure presentation; no backend involvement

Standard Stack

This phase does not introduce new external dependencies. It is a hardening + UI-surface pass over an existing Rust (Tokio/Hyper/reqwest/serde) backend and Vue 3 (Pinia, Vue Router, Teleport) frontend, plus a Node.js/Express demo backend (neode-ui/mock-backend.js). No new libraries are needed for any of FED-01 through FED-06 — ScreensaverRing.vue and handle_lnd_openchannel already exist and should be reused, not reimplemented.

Core (existing, reused)

Library Version Purpose Why Standard
tokio (workspace pin) Async runtime, interval()/spawn() for the periodic sync loops Already the project's async foundation
reqwest (workspace pin) HTTP client for FIPS/Tor peer dial and LND REST calls Already used throughout fips/dial.rs and api/rpc/lnd/
serde/serde_json (workspace pin) Wire format for NodeStateSnapshot, RPC params Project-wide convention
Vue 3 + Pinia (package.json pin) Frontend reactivity/state Existing frontend stack

Version verification: No new packages are being added; skip registry verification per protocol (nothing to verify). If the planner introduces any new crate/npm package during execution, verify it then.

Package Legitimacy Audit

No external packages are being introduced by this phase — this section is not applicable. If a later plan step decides to add a dependency (e.g., a curated public-LSP list requires a small crate), run the Package Legitimacy Gate at that time.

Architecture Patterns

System Architecture Diagram

                    ┌─────────────────────────────────────────┐
                    │        Federation node list (disk)        │
                    │  federation/{nodes,removed-nodes}.json    │
                    │  NO LOCK — read-modify-write per call      │
                    └───────────────┬─────────────────────────┘
                                    │  load_nodes() / save_nodes()
        ┌───────────────────────────┼───────────────────────────┬─────────────────────────┐
        │                           │                            │                         │
        ▼                           ▼                            ▼                         ▼
┌───────────────┐         ┌──────────────────┐          ┌──────────────────┐    ┌─────────────────────┐
│ 90s auto-sync  │         │ 1800s auto-sync   │          │ RPC: sync-state,  │    │ RPC: remove-node,    │
│ loop (server.rs│         │ loop (server.rs   │          │ (manual "Sync")   │    │ set-trust, join,      │
│ ~L497)         │         │ ~L840)             │          │                   │    │ peer-joined           │
└───────┬───────┘         └────────┬──────────┘          └────────┬──────────┘    └───────────┬─────────┘
        │  sync_with_peer()          │  sync_with_peer()             │                          │
        │  → update_node_state()     │  → update_node_state()        │                          │
        └──────────────┬─────────────┴──────────────┬─────────────────┘                          │
                        ▼                             ▼                                            ▼
              (race: stale in-memory list from an in-flight sync's earlier load_nodes()
               overwrites a concurrent remove_node()'s just-saved tombstoned list)
                                    │
                                    ▼
                    ┌─────────────────────────────────────┐
                    │  mesh/mod.rs: purge_federation_peer   │
                    │  (peers, messages, contacts, presence)│
                    └───────────────┬───────────────────────┘
                                    ▼
                    ┌─────────────────────────────────────┐
                    │  StateManager broadcast → WebSocket   │
                    │  → Pinia stores → Federation.vue /    │
                    │    Mesh.vue re-render                 │
                    └─────────────────────────────────────┘

Mesh attachment/parity path (FED-04):
  Frontend attach flow → mesh.transport-advice → {auto-mesh|choose|tor-only}
    → mesh.send-content-inline (small) | mesh.send-content (large, via /api/blob)
    real daemon: api/rpc/mesh/typed_messages.rs      demo: mock-backend.js (mirrors tier logic — DONE)
  Frontend contacts/reactions/edit/delete
    real daemon: api/rpc/mesh/typed_messages.rs (contacts-list/save, send-reaction, edit-message, ...)
    demo: mock-backend.js — contacts-list/save MISSING (404); reaction/edit/delete are no-op acks (GAP)

No new directories needed. Touch points:

core/archipelago/src/federation/storage.rs   # add locking around load/save
core/archipelago/src/server.rs               # collapse or coordinate the two sync loops
core/archipelago/src/api/rpc/lnd/info.rs     # extend handle_lnd_getinfo with identity_pubkey/uris
core/archipelago/src/federation/types.rs     # (maybe) add lightning fields to NodeStateSnapshot
core/archipelago/src/api/rpc/federation/handlers.rs  # (maybe) new RPC to fetch a peer's LN info
neode-ui/mock-backend.js                     # add mesh.contacts-list/save, stateful reaction/edit/delete
neode-ui/src/components/LightningChannelModal.vue    # NEW — FED-05 (name TBD by planner)
neode-ui/src/components/SendBitcoinModal.vue          # FED-06 swap
neode-ui/src/components/WalletScanModal.vue           # FED-06 swap (secondary paid-tick site)

Pattern 1: Federation removal is already "belt and suspenders" — reuse, don't rewrite

What: handle_federation_remove_node (handlers.rs:273) captures the peer's pubkey before calling federation::remove_node, then after removal calls mesh::purge_federation_peer to drop the synthetic mesh contact, its messages, presence, and persisted mesh-contacts entry. federation::remove_node (storage.rs:180) already tombstones the DID before saving the filtered node list and propagates a tombstone-write failure as an error (fixed in 01cbec27). When to use: This is the correct pattern for FED-01 already. Don't redesign it — the actual gap is the concurrency race in the storage layer underneath it (see Pitfall 1), not the removal logic itself. Example:

// Source: core/archipelago/src/federation/storage.rs:180-198 (already fixed, 01cbec27)
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
    let mut nodes = load_nodes(data_dir).await?;
    let before = nodes.len();
    nodes.retain(|n| n.did != did);
    if nodes.len() == before {
        anyhow::bail!("No federated node with DID {}", did);
    }
    // Tombstone FIRST and propagate failure — a remove whose tombstone
    // never landed isn't a remove.
    tombstone_did(data_dir, did).await.context("persist removal tombstone")?;
    save_nodes(data_dir, &nodes).await?;
    Ok(nodes)
}

Pattern 2: Transitive sync already respects tombstones — verify, don't re-add protection

What: merge_transitive_peers (sync.rs:120) loads load_removed_dids() and skips any hint whose DID is tombstoned, and handle_federation_peer_joined (handlers.rs:641) independently rejects a peer-joined callback for a tombstoned DID. Both paths that could resurrect a removed node already check the tombstone list. When to use: The FED-03 review should write a test that concurrently exercises remove + an in-flight sync (see Pitfall 1) rather than re-deriving the (already-correct) tombstone-check logic.

Pattern 3: Demo backend must be a byte-for-byte RPC mirror, not a "close enough" mock

What: mock-backend.js's mesh.transport-advice case (line 4318) explicitly duplicates the daemon's size thresholds (MESH_AUTO_MAX = 1024, MESH_HARD_MAX = 2300) with a comment pointing at typed_messages.rs handle_mesh_transport_advice as the source of truth. When to use: Apply the same pattern to mesh.contacts-list/mesh.contacts-save and to the reaction/edit/delete stubs — read the real handler in core/archipelago/src/api/rpc/mesh/typed_messages.rs (lines 1180-1371 for contacts/presence, 637-976 for reply/reaction/receipt/forward, 1065-1180 for edit/delete) and mirror its actual state transitions in meshStore.dynamic, not just an {ok:true} ack.

Anti-Patterns to Avoid

  • Unlocked read-modify-write on shared JSON files: federation/storage.rs has none of load_nodes()/save_nodes() behind a mutex, and writes are a direct fs::write() (not atomic temp+rename). Any new federation code must NOT add a third code path that does its own read-modify-write without going through a shared lock — that widens the race window instead of closing it.
  • Silent debug!() on periodic-loop errors: Both sync loops in server.rs log sync failures at debug! level only (not surfaced to the state broadcast, not visible in the UI). FED-02 explicitly requires operator-visible sync errors — don't add a third silent loop; extend the existing ones to persist a last_sync_error alongside last_seen.
  • Reinventing handle_lnd_openchannel's connect-then-open sequence: It already does perm=false synchronous peer connect before opening (with a documented reason: perm=true races and fails with "peer is not online"). Reuse it; do not write a second Lightning-channel RPC.
  • Assuming ScreensaverRing is pre-sized for a 112px success badge: its compact class is 240px (mobile) / 320px (≥768px) — 2-3x the current .send-success-burst. Naive drop-in will overflow the modal card; must be explicitly scaled or the surrounding layout redesigned.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Connecting to + opening a channel with an LN peer A new RPC lnd.openchannel (api/rpc/lnd/channels.rs:238) Already validates pubkey format, amount bounds, fee params, and does the connect-before-open sequence correctly with the documented perm=false fix
Paid/success radial visual A new ring/particle component ScreensaverRing.vue (compact size) Explicitly required by FED-06; also already ships a reduced-motion-friendly animation pattern to copy for consistency
Peer transport badge (FIPS/Tor) New transport-tracking logic FederatedNode.last_transport/last_transport_at (already written by record_peer_transport) Already ground-truth (records what was actually used, not predicted) — FED-05's peer picker can reuse this field to show reachability
Federated-peer list for the "trusted nodes" picker A new RPC federation.list-nodes (already returns did, name, onion, trust_level, last_seen) FED-05 only needs to ADD Lightning fields to this payload/peer lookup, not build a parallel peer list

Key insight: Almost every piece of infrastructure FED-01/02/04/05 need already exists in some form — the gaps are narrow (a missing lock, a missing mock method, a missing struct field) rather than missing subsystems. Resist the urge to redesign the federation sync architecture; patch the specific race and the specific parity/field gaps identified here.

Common Pitfalls

Pitfall 1: Unlocked concurrent read-modify-write on federation/nodes.json (PRIMARY SUSPECT for FED-01/FED-02)

What goes wrong: A federated node the operator removes reappears after "some sync cycles," with no error anywhere — exactly the symptom reported. federation::remove_node() and federation::sync::update_node_state() (called from sync_with_peer()) both do load_nodes() → mutate in memory → save_nodes() with zero mutex and a non-atomic fs::write(). Two async tasks (e.g., the 90s auto-sync loop mid-flight for peer X, and a federation.remove-node RPC for the same peer X arriving concurrently) can interleave: the sync task's load_nodes() snapshot (taken before the removal) still contains X; the removal completes and saves a list without X; the sync task then finishes and calls save_nodes() with its stale in-memory list, silently restoring X. Why it happens: No Mutex/RwLock guards the federation JSON files, and there are TWO independent periodic sync loops (server.rs ~line 497, every 90s; ~line 840, every 1800s — the second loop's comment even says "every 30 min" while the interval literal is Duration::from_secs(1800), i.e. that arithmetic is correct but the redundancy with the 90s loop is not otherwise explained or justified anywhere in the code) plus manual "Sync All" and remove/join/set-trust RPCs, all writing the same file. How to avoid: Add a per-data-dir tokio::sync::Mutex<()> (or an Arc<Mutex<Vec<FederatedNode>>> cache) that every federation::storage read-modify-write function acquires for the duration of its load+mutate+save; consider switching save_nodes to atomic temp-file+rename to avoid partial-write corruption on crash. Separately, evaluate collapsing the two periodic sync loops into one (the 90s loop already does everything the 1800s loop does, plus asymmetry self-heal) — the 1800s loop appears vestigial/redundant and doubles the race exposure for no described benefit. Warning signs: tests/multinode/smoke.sh's "removed-node tombstone" section (already covers the transitive-reappear case) intermittently fails only under load/timing variance, or a removed node's last_seen timestamp updates after a federation.remove-node call succeeded — that's the race manifesting as reappearance without any logged error.

Pitfall 2: Trusting CONCERNS.md as current state for this phase

What goes wrong: Re-fixing an already-fixed bug (tombstone-write-swallowed was fixed in 01cbec27, 2026-07-02) wastes the FED-03 review's time and risks reintroducing a regression if the "fix" reverts working code. Why it happens: CONCERNS.md was generated 2026-07-29 from a static codebase snapshot/analysis pass that in at least two documented cases (tombstone swallow, DID-join-without-verification) predates fixes already on main. How to avoid: For every CONCERNS.md federation/mesh item, git log -p the referenced file/line range before deciding it's still open. Two items already verified fixed in this research: "Federation node removal tombstone gap" (fixed 01cbec27) and part of "Federation DID validation incomplete" (the peer-joined RPC does require and verify an ed25519 signature — handlers.rs:588-607). The remaining un-verified part of that concern — no proof-of-ownership check on the original DID mint, i.e. can anyone claim any DID string on first contact — may still be valid; verify it during the review rather than assuming either way. Warning signs: A "finding" in the FED-03 review that exactly matches a CONCERNS.md bullet without a fresh code read is a signal to re-verify before filing it.

Pitfall 3: Demo mock silently no-ops instead of erroring on unmirrored RPCs

What goes wrong: mesh.contacts-list/mesh.contacts-save are called live from Mesh.vue (lines 113, 896) but have no case in mock-backend.js's switch — they fall through to the default case which returns a proper JSON-RPC error (Method not found), but the frontend call sites wrap them in try {} catch { /* non-fatal */ }, so the failure is invisible during manual demo testing unless you watch the browser console or server log (console.log('[RPC] Unknown method: ...')). Why it happens: New frontend RPC call sites get added over time; mock-backend.js parity is manual and easy to miss for methods that aren't on the "main" flow (aliasing a peer is a secondary action, not part of onboarding/attach-file). How to avoid: Grep neode-ui/src/api/rpc-client.ts for every mesh.*/federation.* method string and cross-reference against mock-backend.js's switch cases as an explicit FED-03/FED-04 checklist item, not just the attachment-send path already fixed. Warning signs: Browser console shows [RPC] Unknown method: mesh.contacts-list while testing the demo at :8100.

Pitfall 4: ScreensaverRing's size classes don't have a "success-badge" variant

What goes wrong: Dropping <ScreensaverRing size="compact" /> directly into .send-success-burst (currently 112px) either overflows the card or looks disproportionate at 240-320px without adjusting the surrounding layout. Why it happens: ScreensaverRing.vue's two size classes (viz-ring-default, viz-ring-compact) were designed for full-screen screensaver and settings-panel contexts (SystemDangerZone.vue), not for an inline modal success pane. How to avoid: Either (a) wrap the component in a container with transform: scale(0.5) (112/240 ≈ 0.47) and compensate for the transform not affecting layout box size (use negative margins or a fixed wrapping box), or (b) add a third compact-sm/badge size variant to ScreensaverRing.vue sized for this use case (cleaner, and reusable for WalletScanModal.vue's .success-ring too). Confirm the choice with a UI-spec/sketch before implementation given this affects two components. Warning signs: Visual QA on :8100 shows the ring clipped by the modal's max-h-[90vh] overflow-y-auto container or the checkmark badge floating disconnected from the ring's visual center.

Pitfall 5: FED-05 has no backend field for a peer's Lightning identity

What goes wrong: Building the "trusted nodes by hostname, one-click channel open" UI before the backend can supply a federated peer's LN pubkey/host:port results in a UI that can list names but has nothing to pass to lnd.openchannel. Why it happens: NodeStateSnapshot (the payload federation.get-state/sync exchanges) has no Lightning fields at all — it was designed for app/CPU/mem/tor status, not payment-channel metadata. How to avoid: Plan FED-05 backend-first: (1) extend handle_lnd_getinfo to parse and return identity_pubkey + uris from LND's real /v1/getinfo response (both fields already exist in LND's REST API — the daemon's LndGetInfoResponse struct just doesn't deserialize them yet), (2) add optional lightning_pubkey/lightning_uri fields to NodeStateSnapshot so a synced peer's info includes it (defaulted via #[serde(default)] for backward compat, matching every other optional field in that struct), (3) decide and scope the "public nodes" browse/request feature — no existing data source; recommend a small curated static list (documented, versioned) rather than a live LN graph query (DescribeGraph is heavy and not currently proxied anywhere in this codebase) unless the user specifically wants live graph browsing. Warning signs: A plan step that starts building LightningChannelModal.vue before a corresponding backend RPC/field change is scoped — check the plan's task ordering.

Code Examples

Reuse: opening a channel (backend already correct)

// Source: core/archipelago/src/api/rpc/lnd/channels.rs:238-336 (excerpted)
// Params: { pubkey: <66-hex>, amount: <sats>, address?: <host:port>, private?, target_conf?, sat_per_vbyte? }
// Validates pubkey format + amount bounds (20,000..=16,777,215 sats) before touching LND.
// Connects to the peer synchronously (perm=false) before opening so "peer not online" is
// surfaced deterministically instead of racing the open.

Reuse: transport badge already ground-truth per peer

// Source: core/archipelago/src/federation/storage.rs:120-147
// record_peer_transport() writes last_transport/last_transport_at after every
// successful PeerRequest — the FED-05 peer picker can show "reachable via FIPS"
// / "reachable via Tor" per trusted node without any new plumbing.

Gap: demo mesh chat action stubs don't mutate state

// Source: neode-ui/mock-backend.js:4479-4490 (current — needs to become stateful)
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 } })  // no meshStore.dynamic mutation
}

State of the Art

Old Approach Current Approach When Changed Impact
Tombstone write silently dropped (let _ = tombstone_did(...)) Tombstone write propagated as a hard error, written before the node-list save 2026-07-02, 01cbec27 A failed tombstone write now fails the whole remove — matches FED-01's "a failed removal surfaces an error" requirement already, at the single-call level (the remaining gap is the cross-call race in Pitfall 1)
Mesh attachment send: demo threw "Method not found" and force-opened a demo-only chooser modal mock-backend.js implements the same RPC surface + mirrors the real size-tier logic 2026-07-29, c2ce71c6 FED-04's core attachment-parity requirement is met; remaining gaps are contacts and reaction/edit/delete (see Pitfall 3)

Deprecated/outdated: None specific to this phase's tech; no framework/library version churn involved.

Assumptions Log

# Claim Section Risk if Wrong
A1 The unlocked read-modify-write race on federation/nodes.json (Pitfall 1) is the primary cause of the user-reported "nodes reappear / sync issues" — this is a code-derived hypothesis, not confirmed via a reproduced failure in this research session Summary, Pitfall 1 If wrong, the planner should still fix the race (it's a real bug regardless) but should budget review time for other causes too — start FED-03 with a broader look, not a narrow patch-and-close on this one hypothesis
A2 The 1800s periodic federation sync loop (server.rs ~line 840) is redundant given the 90s loop and safe to remove/collapse Pitfall 1, Anti-Patterns If a maintainer added it for a specific reason not documented in the surrounding comments (e.g. covering a case the 90s loop misses), removing it could regress that unstated behavior — confirm via git log -p / git blame on that block before deleting
A3 "Public nodes" for channel-open browse/request (FED-05) should be a small curated static list rather than a live LN network graph query Pitfall 5 If the user actually wants live graph discovery, the curated-list approach under-delivers; this needs explicit user confirmation before FED-05 backend work starts
A4 ScreensaverRing's size mismatch with .send-success-burst should be solved with a CSS scale-down rather than a new component size variant Pitfall 4 Either approach works technically; scale-down is faster but may look slightly different under prefers-reduced-motion; a new size variant is cleaner but touches the shared component. Low risk either way — a UI sketch/spec pass can decide before implementation
A5 The remaining "Federation DID validation incomplete" concern (no proof-of-ownership check on first DID mint) is still an open gap, not yet fixed like its sibling claims Pitfall 2 Not independently re-verified in this session (only the peer-joined signature check was confirmed); FED-03 should explicitly re-check this specific sub-claim before filing or dismissing it

If this table is empty: N/A — see rows above.

Open Questions

  1. Is the two-loop federation sync redundancy intentional?

    • What we know: Both loops call sync_with_peer over all Trusted/Observer nodes; only the 90s loop does the "asymmetry self-heal" notify_join re-assertion; the 1800s loop additionally calls refresh_federation_mesh_peers() after its full pass (the 90s loop does not).
    • What's unclear: Whether the 1800s loop's refresh_federation_mesh_peers() call covers a gap the 90s loop leaves (e.g. name/roster propagation to mesh chat), which would mean simply deleting it regresses something.
    • Recommendation: git log -p / blame both loop-insertion commits during FED-03; if the mesh-peer-refresh behavior is the only unique value of the 1800s loop, move that single call into the 90s loop's completion and delete the 1800s loop entirely, closing half the race window.
  2. What UI/UX should "browse/request channels with public nodes" (FED-05) actually look like?

    • What we know: No existing data source; lnd.openchannel supports a manual pubkey+address entry today (a user could theoretically paste a public node's URI already, just with no picker/browse UI).
    • What's unclear: Whether "public nodes" means (a) a curated list Archipelago ships/updates, (b) a live query against some LSP directory API, or (c) simply a well-labeled manual-paste field with format help (lowest-effort, matches what the backend already supports).
    • Recommendation: Flag for /gsd-discuss-phase or a direct user check-in before FED-05 planning — this is a scope decision, not a technical one.
  3. Does federation.get-state's federated_peers hint list need a Lightning field, or should peer LN info be a separate on-demand RPC?

    • What we know: NodeStateSnapshot.federated_peers already carries a lightweight FederationPeerHint (did/pubkey/onion/name/fips_npub) shared during sync; adding lightning_uri there means every synced peer's LN info is cached locally without an extra round-trip.
    • What's unclear: Whether peers want to opt out of advertising their LN URI transitively (privacy consideration, similar to the existing shared_location opt-in pattern for lat/lon).
    • Recommendation: Follow the shared_location precedent (Option<(f64,f64)> only sent when the node opts in via server.set-location) — add an explicit opt-in setting for Lightning URI sharing rather than defaulting it on, since exposing a payment channel target more broadly than intended has real-money implications.

Environment Availability

Dependency Required By Available Version Fallback
Rust toolchain / cargo (from core/) FED-01/02/03/04 backend work Not probed this session (assume present per CLAUDE.md build instructions)
Node.js / npm (neode-ui/) FED-04/05/06 frontend + mock-backend.js work Not probed this session (assume present per CLAUDE.md build instructions)
:8100 dev preview proxying to archi-dev FED-05/06 required verification step per phase description Not probed this session — verify at execution time per docs/../reference_neode_ui_dev_testing.md (mock=5959, pw password123)
LND REST API (LND_REST_BASE_URL, local macaroon) FED-05 lnd.getinfo extension + lnd.openchannel reuse Assumed present on real nodes per existing channels.rs/info.rs code; demo backend has no real LND — FED-05 UI must be exercised against archi-dev (real LND) per phase description, not the pure-mock demo Demo-only mock stub for lnd.getinfo identity fields if archi-dev is unavailable during a work session
tests/multinode/smoke.sh Regression coverage for FED-01/02 fix Present, already covers removed-node tombstone + transitive-reappear scenarios; does NOT currently exercise the concurrent-race scenario (Pitfall 1) Extend smoke.sh with a concurrent remove+sync test, or add a Rust-level #[tokio::test] in federation/storage.rs that spawns concurrent remove/save calls

Missing dependencies with no fallback: None identified — this phase is code-only, no new external services.

Missing dependencies with fallback: LND-backed FED-05 verification (see row above) — use archi-dev per phase instructions; demo-only stubbing is a fallback if archi-dev is temporarily unavailable, but the phase's own success criteria require verification against archi-dev before deploy, so this fallback should not be treated as sufficient sign-off.

Validation Architecture

Test Framework

Property Value
Framework (backend) cargo test (Rust, workspace root core/) — federation/storage.rs and federation/sync.rs already have #[tokio::test] unit coverage
Framework (frontend) Vitest (neode-ui/vitest.config.ts, vitest run)
Config file core/Cargo.toml (workspace); neode-ui/vitest.config.ts
Quick run command cd core && cargo test -p archipelago federation:: --lib (backend); cd neode-ui && npx vitest run src/components/__tests__/ (frontend, scope to touched files)
Full suite command cd core && CARGO_INCREMENTAL=0 cargo test (backend, full); cd neode-ui && npm run test (frontend, full); tests/multinode/smoke.sh (cross-node, requires 2+ live nodes, run on-node per CLAUDE.md gate policy)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
FED-01 Removed node never reappears, incl. under concurrent sync unit + integration cargo test -p archipelago federation::storage::tests (existing) + NEW concurrent-race test existing tests / Wave 0 for the new race test
FED-01 Failed removal surfaces an error, not a silent no-op unit cargo test -p archipelago federation::storage::tests::test_remove_nonexistent_node_errors (existing, covers the "not found" case; add one for a simulated tombstone-write I/O failure) existing / Wave 0 for I/O-failure case
FED-02 Sync converges; fleet nodes agree on node list integration (multinode) tests/multinode/smoke.sh section "federation pairing" + "removed-node tombstone" (existing)
FED-02 Sync errors are operator-visible manual / UI No automated test yet — requires a UI element (e.g. per-node "last sync error" badge) that doesn't exist yet Wave 0 (needs the field to exist first)
FED-03 Structured review findings fixed or deferred with reason N/A (process requirement) N/A — tracked via the plan's findings list, not a single automated test
FED-04 Attachment send parity demo vs real manual (visual) + existing mock-backend.js logic mirrors daemon tier thresholds Manual walk-through on :8100 per phase description; consider a Vitest test asserting mesh.transport-advice tier boundaries match MESH_AUTO_MAX/MESH_HARD_MAX constants Wave 0 (no existing frontend test pins these thresholds)
FED-04 Contacts list/save + reaction/edit/delete parity manual + NEW mock-backend.js stateful behavior Manual on :8100; no existing automated coverage of mock-backend.js behavior (it's a dev tool, not covered by npm run test) Wave 0 if automated coverage is wanted; otherwise manual-only is acceptable for a demo shim
FED-05 Own node Lightning URI is shareable unit (backend) + manual (UI) NEW cargo test for handle_lnd_getinfo's identity_pubkey/uris parsing (mock LND response fixture); manual UI check on archi-dev Wave 0
FED-05 Trusted-node picker + channel open flow manual (UI, requires archi-dev + a live peer) Manual per phase description ("tested live on the :8100 dev preview against archi-dev") Wave 0 — inherently a live/manual check per the phase's own success criteria
FED-06 Paid-tick animation matches screensaver ring everywhere it appears manual (visual) Manual visual check of SendBitcoinModal.vue + WalletScanModal.vue on :8100 Wave 0 — visual-only requirement, no meaningful automated assertion beyond "component renders"

Sampling Rate

  • Per task commit: Backend: cargo test -p archipelago federation:: mesh:: (scoped). Frontend: npx vitest run scoped to touched component test files, or a full quick run if none exist yet for touched files.
  • Per wave merge: Full cargo test (backend) + npm run test (frontend).
  • Phase gate: Full backend + frontend suites green, plus tests/multinode/smoke.sh federation sections green on a real 2-node pair, plus a manual FED-05/FED-06 walkthrough on :8100 against archi-dev before any deploy (per phase description, "fixed there before any deploy").

Wave 0 Gaps

  • New #[tokio::test] in core/archipelago/src/federation/storage.rs (or a new integration test) that spawns concurrent remove_node() + update_node_state()/sync_with_peer-equivalent calls against the same data_dir and asserts the removed node stays removed — this is the regression test for Pitfall 1 and does not exist today.
  • Test/fixture for handle_lnd_getinfo parsing identity_pubkey/uris from a mocked LND /v1/getinfo JSON response (FED-05) — no existing test touches this handler's response shape.
  • Decide whether mock-backend.js behavior warrants automated (Vitest/Playwright-against-mock) coverage, or manual-only is acceptable given it's a dev-preview tool, not shipped code — recommend manual-only unless the team already has a pattern for testing the mock backend elsewhere (none found in this research).
  • Framework install: none — all frameworks (cargo test, Vitest) are already configured and running.

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication Partial Federation peer identity is DID/ed25519-key-based, not password auth; peer-joined/peer-did-changed/peer-address-changed all require and verify an ed25519 signature over a canonical message before mutating state — already correct, verify no new RPC bypasses this
V3 Session Management No Not applicable — federation/mesh RPCs are peer-signed, not session-cookie based
V4 Access Control Yes is_peer_allowed_path() (server.rs:1270, tested at server.rs:2075+) restricts which HTTP paths a peer-only listener will serve — any new FED-05 RPC (e.g. "fetch peer's Lightning URI") that's meant to be peer-reachable must be added to this allow-list explicitly, not left to fall through
V5 Input Validation Yes lnd.openchannel already validates pubkey format (66-hex) and amount bounds server-side (channels.rs:252-268) — reuse, and apply the same rigor to any new Lightning-URI-sharing field (validate the URI format before persisting/displaying it)
V6 Cryptography Yes ed25519 signature verification via identity::NodeIdentity::verify — never hand-roll signature checks; reuse this existing verification path if FED-05 needs to authenticate a peer's advertised Lightning info

Known Threat Patterns for this stack

Pattern STRIDE Standard Mitigation
Federation peer spoofing a DID they don't control Spoofing ed25519 signature verification (already implemented for join/address-change/did-rotation — confirm any new FED-05 peer-info exchange follows the same pattern)
Concurrent-write race corrupting/reverting federation state (Pitfall 1) Tampering (unintentional, but security-relevant since it undermines the "removal sticks" guarantee — a removed/untrusted peer regaining federation membership is a real access-control regression) Add locking around the storage layer (see Pitfall 1's fix)
Unbounded transitive federation exposure (a Trusted peer's peer list auto-added as Observer) Elevation of Privilege (bounded) Already mitigated — merge_transitive_peers only runs for Trusted-level sources and only adds new peers as Observer (never auto-escalates to Trusted); this is intentional and correct, don't loosen it
Advertising this node's Lightning payment-channel target more broadly than intended (FED-05 new surface) Information Disclosure Follow the existing shared_location opt-in pattern — do not default Lightning URI sharing to "on" for all federated peers (see Open Question 3)

Sources

Primary (HIGH confidence)

  • core/archipelago/src/federation/storage.rs, sync.rs, types.rs, invites.rs — read directly, current main
  • core/archipelago/src/api/rpc/federation/handlers.rs — read directly, current main
  • core/archipelago/src/server.rs (periodic sync loop sections, is_peer_allowed_path) — read directly
  • core/archipelago/src/mesh/mod.rs (purge_federation_peer, upsert_federation_peer, seed_federation_peers_into_mesh) — read directly
  • core/archipelago/src/api/rpc/lnd/channels.rs, info.rs — read directly
  • core/archipelago/src/fips/dial.rs — read directly
  • neode-ui/mock-backend.js (mesh RPC switch cases) — read directly, current main (post commit c2ce71c6)
  • neode-ui/src/views/Federation.vue, neode-ui/src/api/rpc-client.ts, neode-ui/src/components/ScreensaverRing.vue, neode-ui/src/components/Screensaver.vue, neode-ui/src/components/SendBitcoinModal.vue, neode-ui/src/components/WalletScanModal.vue, neode-ui/src/views/Mesh.vue — read directly
  • git log -p on core/archipelago/src/federation/storage.rs (commit 01cbec27) and git show c2ce71c6 — verified fix history directly, not from documentation
  • tests/multinode/smoke.sh — read directly for existing federation test coverage
  • .planning/REQUIREMENTS.md, .planning/STATE.md, .planning/codebase/ARCHITECTURE.md, .planning/codebase/CONCERNS.md — project-provided context (CONCERNS.md's federation claims were then verified/refuted against live code per Pitfall 2)

Secondary (MEDIUM confidence)

  • None — this research relied entirely on direct codebase reads and git history, not external web sources, since the phase is about hardening this specific project's existing code rather than adopting new external technology.

Tertiary (LOW confidence)

  • None.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — no new dependencies; existing stack confirmed by direct file reads
  • Architecture (FED-01/02/03/04): HIGH — read the actual implementation, confirmed fix history via git log, identified a concrete unverified race condition with file:line citations
  • Architecture (FED-05): MEDIUM — greenfield UI/RPC surface; confirmed what's missing (no existing Lightning-URI field/RPC) but the design (opt-in sharing, public-nodes scope) needs a user decision, not just engineering judgment
  • Pitfalls: HIGH for Pitfalls 1-3 (backend/demo, code-verified); MEDIUM for Pitfalls 4-5 (frontend sizing and FED-05 scope, judgment calls flagged in Assumptions Log)

Research date: 2026-07-29 Valid until: 2026-08-12 (14 days — this is a fast-moving area of an actively-developed codebase; other agents were committing federation/mesh-adjacent changes during this very research session, per the mock-backend.js commit observed mid-session)