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

21 KiB

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):

/// 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<String, Arc<tokio::sync::Mutex<()>>> 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):

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):

/// 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<f64>,
#[serde(default)]
pub lon: Option<f64>,

Add lightning_uri: Option<String> (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):

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):

"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):

pub(in crate::api::rpc) async fn handle_mesh_contacts_list(
    &self,
    _params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
    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().awaitshared_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):

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):

// 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

<Teleport to="body">
  <Transition name="modal">
    <div v-if="show" class="fixed inset-0 flex items-center justify-center p-4" @click.self="close">
      <div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
      <div class="glass-card p-6 w-full relative z-10 flex flex-col" role="dialog" aria-modal="true" @click.stop>
        <div class="flex items-start justify-between gap-4 mb-4 shrink-0">
          <h3 class="text-xl font-semibold text-white">{{ title }}</h3>
          <button @click="close" aria-label="Close">...</button>
        </div>
        <div v-if="$slots.header" class="shrink-0"><slot name="header" /></div>
        <div class="flex-1 min-h-0 overflow-y-auto">...</div>

Hard rule per CONTEXT.md/UI-SPEC.md: every new modal MUST use BaseModal.vue or replicate this exact Teleport + fixed inset-0 + @click.self="close" structure — never nest inside a transform-affected ancestor.

Analog 2 — manual URI form + error/startup-notice treatment: neode-ui/src/components/LightningChannelsPanel.vue

<!-- line 258-261 -->
placeholder="pubkey@host:port"
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
<!-- line 329-336 -->
<div v-if="openError" :class="isStartupNotice(openError) ? amberClasses : 'alert-error'">
  <span v-if="isStartupNotice(openError)" class="mr-1"></span>{{ openError }}
</div>
// line 599-624 (validation-before-RPC pattern)
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }

Copy this validate-before-RPC-call, openError ref, isStartupNotice() amber-vs-red distinction pattern verbatim into the new modal's "Paste URI Manually" fallback path.

Analog 3 — request flow (meshed peer "Request Channel"): neode-ui/src/components/federation/PeerRequestModal.vue

<!-- line 34-37 -->
<button :disabled="sending" @click="$emit('send', message.trim() || undefined)">
  {{ sending ? 'Sending…' : 'Send Request' }}
</button>

Per UI-SPEC.md's Copywriting Contract, reuse this component's pattern directly (optional message field, sending/Sending… busy state, $emit('send', ...) / $emit('cancel') contract) rather than building a new request-modal component.

Analog 4 — picker row layout (trusted-nodes / meshed-LN-peers lists): neode-ui/src/views/federation/NodeList.vue

<!-- line 56-65 -->
<span v-if="transportBadge(node)" :class="transportBadge(node)!.cls" :title="transportBadge(node)!.title">
  {{ transportBadge(node)!.label }}
</span>
<span :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
// line 158-159
const trustedNodes = computed(() => props.nodes.filter(n => n.trust_level === 'trusted'))
const peerNodes = computed(() => props.nodes.filter(n => n.trust_level !== 'trusted'))

Mirror this row layout (name + transport badge + trust/status badge + action button) for both the trusted-nodes and meshed-LN-peers picker columns; reuse transportBadge()'s FIPS/Tor logic as-is per Don't Hand-Roll in RESEARCH.md (no new transport-tracking needed).


neode-ui/src/components/ScreensaverRing.vue (new badge size variant)

Analog: itself — existing compact/default size-class pattern (lines 15-66).

const props = withDefaults(defineProps<{
  size?: 'default' | 'compact'
  ...
}>(), { size: 'default', segmentCount: 48 })
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
.viz-ring-compact { /* diameter/--viz-radius rules, lines 60-66 incl. breakpoint */ }

Add a third 'badge' union member + viz-ring-badge CSS class following the exact same shape (mobile diameter, ≥768px breakpoint diameter, --viz-radius custom property), sized per UI-SPEC.md's table (160px/192px, --viz-radius 80px/96px). Also add the missing @media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } } guard inside this component (UI-SPEC.md flagged this as a real, currently absent gap — contrast with SendBitcoinModal.vue's existing .burst-ring reduced-motion guard, which should be the copy source for the exact media query syntax).

Composition analog (how to layer center content over the ring): neode-ui/src/components/Screensaver.vue's existing ScreensaverRing + ScreensaverLogo centered-absolute layering — reuse this position: relative wrapper + position: absolute; inset: 0 inner-content pattern for both SendBitcoinModal.vue's .burst-core and WalletScanModal.vue's success-ring inner content.


neode-ui/src/api/rpc-client.ts (new method wrappers)

Analog: existing mesh.contacts-list/contacts-save wrappers (lines 804, 813):

return this.call({ method: 'mesh.contacts-list', params: {} })
...
return this.call({ method: 'mesh.contacts-save', params })

New wrappers (own Lightning URI fetch, channel-open-request send) follow this exact this.call({ method: '<namespace>.<verb>', params }) one-liner convention — no custom fetch/axios logic, no new client class.

Shared Patterns

Async static lock for a racy on-disk resource

Source: core/archipelago/src/update.rs:35 (UPDATE_OP_LOCK) Apply to: federation/storage.rs's load_nodes/save_nodes/remove_node/update_node_state call sites (FED-01/FED-02 core fix)

static <NAME>_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
// acquire with .lock().await (not try_lock — federation writes should queue, not reject)

Optional opt-in shared field on NodeStateSnapshot

Source: core/archipelago/src/federation/types.rs:124-131 (shared_location) Apply to: New lightning_uri/lightning_pubkey field (FED-05)

#[serde(default)]
pub lat: Option<f64>,

Teleport-to-body modal shell

Source: neode-ui/src/components/BaseModal.vue:1-40 Apply to: All new FED-05 UI (hard rule per CONTEXT.md/UI-SPEC.md)

Mock backend must mirror real daemon logic, with a comment citing the source file/lines

Source: neode-ui/mock-backend.js:4312-4317 (comment above mesh.transport-advice) Apply to: All FED-04 mock-backend.js gap fills (contacts-list/save, reaction/reply/edit/delete/forward)

One-line RPC dispatcher registration

Source: core/archipelago/src/api/rpc/dispatcher.rs:125,128,349 Apply to: Any new backend RPC method added for FED-05 (own LN URI, channel-open-request)

No Analog Found

File Role Data Flow Reason
Mesh peer "has lightning" capability advertisement (new field on mesh peer/contact struct + propagation) model + event-driven No existing capability-advertisement mechanism for mesh (as opposed to federation) peers exists in the codebase — RESEARCH.md confirms this is genuinely greenfield. Nearest structural precedent is the read/broadcast handler shapes in typed_messages.rs (see Pattern Assignments above), not a field-level analog. Planner should design this as a new optional field on whatever struct already carries mesh peer capability info (check mesh/mod.rs peer struct at implementation time), following the same #[serde(default)] Option<T> back-compat convention used everywhere else in this codebase.

Metadata

Analog search scope: core/archipelago/src/{federation,mesh,api/rpc,server.rs,update.rs,container,content_invoice.rs}, neode-ui/src/{components,views/federation,api}, neode-ui/mock-backend.js Files scanned: ~25 (targeted reads/greps; RESEARCH.md's existing file:line citations reused where already verified) Pattern extraction date: 2026-07-29