docs(01): create Phase 1 federation & mesh hardening plans (10 plans, 6 waves)

This commit is contained in:
archipelago 2026-07-29 12:16:39 -04:00
parent db25545a9a
commit bf96378d67
11 changed files with 2726 additions and 2 deletions

View File

@ -40,7 +40,19 @@ signed/decentralized registry and a user installs it on their node.
4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal)
5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy
6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears
**Plans**: TBD
**Plans**: 10 plans
Plans:
- [ ] 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)
- [ ] 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)
- [ ] 01-07-PLAN.md — Channel-open request messaging over the mesh (FED-05)
- [ ] 01-08-PLAN.md — Channel-open UX: own URI, trusted-node picker, meshed-peer requests (FED-05)
- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03)
- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06)
**UI hint**: yes
### Phase 2: UI Performance
@ -131,7 +143,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Federation & Mesh Hardening | 0/TBD | Not started | - |
| 1. Federation & Mesh Hardening | 0/10 | Planned | - |
| 2. UI Performance | 0/TBD | Not started | - |
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |

View File

@ -0,0 +1,253 @@
---
phase: 01-federation-mesh-hardening
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- core/archipelago/src/federation/storage.rs
autonomous: true
requirements: [FED-01]
must_haves:
truths:
- "A federation removal issued while an auto-sync pass is in flight leaves the peer removed — a sync's pre-removal node-list snapshot can no longer re-save the removed peer (FED-01 adjacency edge)"
- "Two concurrent federation node writes both persist — neither silently loses the other's update (the lost-update class behind the reported 'removed nodes reappear' symptom)"
- "Removing the last remaining federated node succeeds, leaves an empty node list, and returns Ok with an empty Vec (FED-01 empty edge)"
- "A removal whose tombstone write fails returns Err to the caller instead of reporting success (FED-01 failure-surfacing)"
- "The tombstone is durably written before the filtered node list is saved, so an interruption between the two never resurrects the removed peer (FED-01 ordering edge)"
- "A partially-written federation node list can never be observed by a concurrent reader — the list is written to a sibling temp file and renamed into place"
prohibitions:
- statement: "Removing a federation node MUST NOT delete or destroy that peer's local data — no app data directory under /var/lib/archipelago, no mesh message history, no credential store is erased by unfederating; removal revokes trust, it never destroys operator data"
category: safety
artifacts:
- path: core/archipelago/src/federation/storage.rs
provides: "Serialized, crash-safe federation node store"
contains: "FEDERATION_STORE_LOCK"
key_links:
- from: core/archipelago/src/federation/storage.rs
to: core/archipelago/src/federation/sync.rs
via: "update_node_state acquires FEDERATION_STORE_LOCK for its whole load-mutate-save cycle, so a sync pass cannot interleave with remove_node"
pattern: "FEDERATION_STORE_LOCK"
---
<objective>
Close the concurrency race that lets a removed federation node come back: serialize every
read-modify-write against `federation/nodes.json` behind one async lock, and make the node-list
write atomic.
Purpose: FED-01 — "removing a federation node sticks" is the reason this phase exists. RESEARCH.md
identifies an unlocked read-modify-write on `federation/nodes.json` as the primary suspect: the 90s
auto-sync loop, the 1800s auto-sync loop, `federation.sync-state`, and `federation.remove-node` all
load → mutate → save the same file with zero coordination, so a sync task holding a pre-removal
snapshot silently re-saves the peer the operator just removed — with no error logged anywhere.
Output: `federation/storage.rs` with a module-level `FEDERATION_STORE_LOCK`, inner/outer function
split to avoid re-entrancy deadlock, an atomic temp-file+rename node-list write, and three new
regression tests that fail without the lock.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@core/archipelago/src/federation/storage.rs
@core/archipelago/src/update.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `FEDERATION_STORE_LOCK` | `static tokio::sync::Mutex<()>` | `core/archipelago/src/federation/storage.rs` |
| `save_nodes_inner` | private async fn (no lock; atomic temp+rename write) | same |
| `load_nodes_inner` | private async fn (no lock) | same |
| `tombstone_did_inner` / `untombstone_did_inner` | private async fns (no lock) | same |
| `test_concurrent_writes_do_not_lose_updates` | `#[tokio::test]` | same (`mod tests`) |
| `test_remove_survives_concurrent_state_sync` | `#[tokio::test]` | same (`mod tests`) |
| `test_remove_last_node_leaves_empty_list` | `#[tokio::test]` | same (`mod tests`) |
| `test_remove_errors_when_tombstone_write_fails` | `#[tokio::test]` | same (`mod tests`) |
Public function signatures of `load_nodes`, `save_nodes`, `add_node`, `remove_node`,
`set_trust_level`, `update_node`, `update_node_state`, `record_peer_transport`, `tombstone_did`,
`untombstone_did`, `load_removed_dids` are **unchanged** — callers in `sync.rs`, `handlers.rs`,
`server.rs`, and `mesh/mod.rs` compile untouched.
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a removal survives an in-flight sync (lock + atomic write)</name>
<reversibility rating="reversible">A module-private lock and a temp+rename write are internal
to storage.rs behind unchanged public signatures; reverting is a single-file change with no
on-disk format change.</reversibility>
<files>core/archipelago/src/federation/storage.rs</files>
<read_first>
- `core/archipelago/src/federation/storage.rs` — the whole file (532 lines). Note in particular:
`load_nodes` (L51), `record_peer_transport` (L120), `save_nodes` (L149), `add_node` (L161,
calls `untombstone_did`), `remove_node` (L180, calls `tombstone_did`), `tombstone_did` (L214),
`untombstone_did` (L237), `set_trust_level` (L256), `update_node` (L272), `update_node_state`
(L292), and the existing `#[cfg(test)] mod tests` (L341) with its `make_node(did, onion)`
helper and `tempfile::tempdir()` convention.
- `core/archipelago/src/update.rs` lines 25-40 — `UPDATE_OP_LOCK`, the in-repo precedent for
"two async call sites race on one on-disk resource". Copy its doc-comment style (name the
concrete historical incident, then state the acquisition policy).
- `.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md` — section "Async static lock
for a racy on-disk resource" and "core/archipelago/src/federation/storage.rs (locking fix)".
</read_first>
<behavior>
- `test_concurrent_writes_do_not_lose_updates`: under `#[tokio::test(flavor = "multi_thread", worker_threads = 4)]`,
seed one node, then `tokio::join!` an `add_node(B)` with a `set_trust_level(A, Observer)`;
afterwards `load_nodes` returns 2 nodes AND node A's trust level is Observer. Without the
lock one of the two writes is lost.
- `test_remove_survives_concurrent_state_sync`: under the multi-thread flavor, loop 50 times:
fresh tempdir, seed nodes A and B, `tokio::join!(remove_node(A), update_node_state(A, snapshot))`,
then assert `load_nodes` contains no entry whose `did` is A and `load_removed_dids` contains A.
Without the lock this reliably fails within 50 iterations.
- `test_remove_last_node_leaves_empty_list`: seed exactly one node, remove it, assert the
returned Vec is empty, `load_nodes` returns an empty Vec (not an error), and the DID is
tombstoned.
</behavior>
<action>
Write the three tests FIRST in the existing `mod tests` block and confirm they fail (run the
verify command and capture the failure) before writing the fix.
Then add at module scope, immediately after the existing `use` block:
`static FEDERATION_STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());`
with a doc comment that names the failure it prevents — a `federation.remove-node` RPC racing
the 90s auto-sync pass, whose pre-removal `load_nodes` snapshot re-saves the removed peer with
no error logged — and states the acquisition policy: acquire with `.lock().await`, never
`try_lock`. Reject-on-contention is wrong here: a rejected federation write reproduces the very
lost-write symptom the lock exists to stop, unlike `update.rs` where rejecting a second
concurrent download is the desired UX.
Restructure so no public function can deadlock on itself. For each function that both takes the
lock and calls another locking function, extract the body into a private `*_inner` fn that does
NOT acquire, and make the public fn a thin wrapper: acquire the guard, call the inner(s), drop.
Required inner fns for this task: `load_nodes_inner`, `save_nodes_inner`, `tombstone_did_inner`,
`untombstone_did_inner`. `remove_node` (which calls `tombstone_did`) and `add_node` (which calls
`untombstone_did`) must call the `*_inner` variants under a single held guard so tombstone +
node-list save are one critical section.
Convert the node-list write in `save_nodes_inner` to atomic replace: serialize to a sibling path
formed by appending a `.tmp` suffix to the resolved nodes file path in the same directory, write
it with `tokio::fs::write`, then `tokio::fs::rename` it onto the real path. Keep the existing
`.context(...)` error strings so callers' messages are unchanged. Same-directory rename is
required — a cross-filesystem rename is not atomic.
In THIS task route `load_nodes`, `save_nodes`, `remove_node`, `tombstone_did`,
`untombstone_did`, and `update_node_state` through the lock. The remaining mutators are Task 2.
Preserve `remove_node`'s existing ordering exactly: the retain/`bail!`-on-not-found check, then
the tombstone write with its failure propagated via `.context("persist removal tombstone")?`,
then the node-list save. Do not weaken that ordering or its error propagation.
Build gotcha from CLAUDE.md: if the build hits `rust-lld: undefined hidden symbol`, that is
incremental-cache corruption — re-run with `CARGO_INCREMENTAL=0`.
</action>
<verify>
<automated>cd core && cargo test -p archipelago federation::storage</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation::storage` exits 0.
- `grep -c 'FEDERATION_STORE_LOCK' core/archipelago/src/federation/storage.rs` is at least 7.
- `grep -c 'tokio::sync::Mutex::const_new' core/archipelago/src/federation/storage.rs` equals 1.
- `grep -c 'fs::rename' core/archipelago/src/federation/storage.rs` is at least 1.
- `grep -Eq 'async fn test_remove_survives_concurrent_state_sync' core/archipelago/src/federation/storage.rs` succeeds.
- `grep -Eq 'async fn test_concurrent_writes_do_not_lose_updates' core/archipelago/src/federation/storage.rs` succeeds.
- `grep -Eq 'async fn test_remove_last_node_leaves_empty_list' core/archipelago/src/federation/storage.rs` succeeds.
- `grep -Eq 'flavor = "multi_thread"' core/archipelago/src/federation/storage.rs` succeeds (the
race tests are useless on the single-threaded default runtime).
- The SUMMARY records the captured pre-fix failure output for at least one of the three tests.
</acceptance_criteria>
<done>The removal-vs-sync race is closed at the storage layer and proven by a test that fails without the lock; the node list is written atomically.</done>
</task>
<task type="auto">
<name>Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure</name>
<files>core/archipelago/src/federation/storage.rs</files>
<read_first>
- `core/archipelago/src/federation/storage.rs` as left by Task 1 — specifically the four
mutators not yet routed through the lock: `record_peer_transport` (L120 pre-change),
`add_node`, `set_trust_level`, `update_node`.
- The existing test `test_remove_nonexistent_node_errors` (L445 pre-change) — mirror its
assertion style for the new failure test.
</read_first>
<action>
Route `add_node`, `set_trust_level`, `update_node`, and `record_peer_transport` through
`FEDERATION_STORE_LOCK` using the same wrapper + `*_inner` split established in Task 1. Every
public function in this module that performs a load → mutate → save cycle must hold the guard
for the whole cycle; none may call another lock-acquiring public function while holding it.
Add `test_remove_errors_when_tombstone_write_fails`: seed a node, then make the tombstone write
fail by pre-creating the removed-nodes path as a directory (a directory cannot be replaced by a
file write), call `remove_node`, and assert the result is `Err` AND that `load_nodes` still
contains the node — a removal whose tombstone never landed must not have half-applied. This is
the FED-01 "a failed removal surfaces an error instead of silently no-opping" criterion at the
storage layer.
Do not change any public signature and do not touch `load_invites`/`save_invites` (a separate
file with no cross-writer).
</action>
<verify>
<automated>cd core && cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation` exits 0.
- `grep -Eq 'async fn test_remove_errors_when_tombstone_write_fails' core/archipelago/src/federation/storage.rs` succeeds.
- `grep -c 'FEDERATION_STORE_LOCK.lock().await' core/archipelago/src/federation/storage.rs` is at least 9.
- `cd core && cargo build -p archipelago` exits 0 with no new warnings in `federation::storage`
(dead-code warnings on unused `*_inner` fns mean a mutator was missed).
- `cd core && cargo test -p archipelago` exits 0 — no caller in `sync.rs`, `handlers.rs`,
`server.rs`, or `mesh/mod.rs` was broken by the refactor.
</acceptance_criteria>
<done>Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| federated peer → `federation::sync` → node store | A remote peer's state snapshot crosses into local persisted trust state |
| operator RPC (`federation.remove-node`) → node store | An authenticated local operator action mutates trust membership |
| process → `federation/nodes.json` on disk | Multiple concurrent async tasks write one file; a crash can leave it partial |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-01 | Tampering | `federation::storage` concurrent read-modify-write | high | mitigate | `FEDERATION_STORE_LOCK` held across the whole load-mutate-save cycle in every mutator (Tasks 1-2); regression test proves a removal survives a concurrent sync |
| T-01-02 | Elevation of Privilege | a removed (untrusted) peer regaining federation membership via the race | high | mitigate | Same lock; plus the pre-existing tombstone check in `merge_transitive_peers` and `handle_federation_peer_joined` is left intact and re-verified by `cargo test -p archipelago federation` |
| T-01-03 | Denial of Service | a partial `nodes.json` write on crash making the node list unreadable | medium | mitigate | Atomic temp-file + same-directory `fs::rename` in `save_nodes_inner` (Task 1) |
| T-01-04 | Denial of Service | lock contention stalling the federation RPC surface | low | accept | Federation writes are infrequent (90s loop + operator actions); `.lock().await` queues rather than rejects, and every critical section is a bounded file read+write |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago federation` — green.
- `cd core && cargo test -p archipelago` — green (no caller regressions).
- The pre-fix failure of `test_remove_survives_concurrent_state_sync` is recorded in the SUMMARY as
evidence the test is fail-first and not vacuous.
</verification>
<success_criteria>
- Every read-modify-write in `federation/storage.rs` is serialized behind one module-level async mutex with no re-entrancy path.
- The node list is written atomically (temp file + same-directory rename).
- Four new tests exist and pass; at least one is demonstrated to fail without the lock.
- Public signatures unchanged; the full `archipelago` test suite is green.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md` when done.
Commit with `git add` by explicit path (another agent shares this tree — never `git add -A`), then
push per CLAUDE.md: `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,301 @@
---
phase: 01-federation-mesh-hardening
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/mock-backend.js
- neode-ui/scripts/mock-rpc-parity.mjs
- neode-ui/package.json
autonomous: true
requirements: [FED-04]
must_haves:
truths:
- "Every mesh.* and federation.* RPC method the neode-ui frontend calls has a matching handler in mock-backend.js — the demo never answers a UI call with 'Method not found'"
- "Renaming a mesh peer on the demo persists: mesh.contacts-save then mesh.contacts-list returns the saved alias, mirroring the daemon's handle_mesh_contacts_save/list behavior"
- "A reaction, reply, edit, delete, or forward performed on the demo mutates the demo message store and is visible on the next mesh.messages read — it is not a bare ok acknowledgement"
- "The demo's transport decision for an attachment matches the daemon's size tiers (auto under 1024 bytes, chooser in the 1024..2300 band, tor-only above 2300) — no demo-only chooser modal"
- "An automated parity check fails when a UI-called mesh.*/federation.* method has no mock-backend handler, so the gap class is caught before manual demo testing"
prohibitions:
- statement: "The demo/mock backend MUST NOT gain behavior that diverges from the real daemon — it must never invent a demo-only modal, a demo-only response shape, or a success path a real node does not produce; every mirrored handler cites the daemon source file and line range it mirrors"
category: transparency
artifacts:
- path: neode-ui/scripts/mock-rpc-parity.mjs
provides: "Static UI-call vs mock-handler cross-reference plus a live RPC smoke sequence"
min_lines: 60
- path: neode-ui/mock-backend.js
provides: "mesh.contacts-list/save, stateful message-mutation handlers, and the 10 previously-missing UI-called methods"
contains: "mesh.contacts-list"
key_links:
- from: neode-ui/scripts/mock-rpc-parity.mjs
to: neode-ui/mock-backend.js
via: "spawns mock-backend.js on MOCK_BACKEND_PORT and posts a scripted JSON-RPC sequence"
pattern: "MOCK_BACKEND_PORT"
---
<objective>
Finish demo/real mesh parity: the demo backend answers every mesh and federation RPC the UI calls,
and the message-mutation calls actually mutate demo state instead of returning a bare acknowledgement.
Purpose: FED-04. Attachment-send parity already landed on main (`c2ce71c6`) — `mesh.send-content-inline`
/ `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` now mirror the daemon's tier
logic. RESEARCH.md and a fresh cross-reference of `neode-ui/src/**` against `mock-backend.js` show
what remains: **12 methods the UI calls that have no case at all** (they fall through to a
`Method not found` error the frontend swallows in `try/catch`), and **six ack-only stubs** that
never touch the demo message store, so reactions/edits/deletes silently do not render on the demo.
Output: those gaps closed, plus a repeatable parity harness so this class of drift is caught by a
command instead of by squinting at the browser console.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@neode-ui/mock-backend.js
@core/archipelago/src/api/rpc/mesh/typed_messages.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `neode-ui/scripts/mock-rpc-parity.mjs` | new node script (static cross-reference + live smoke) | new file |
| `test:mock-parity` | npm script | `neode-ui/package.json` |
| `MOCK_BACKEND_PORT` | env var override for the mock's listen port | `neode-ui/mock-backend.js` |
| `mesh.contacts-list`, `mesh.contacts-save` | new mock RPC cases | `neode-ui/mock-backend.js` |
| `mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`, `mesh.assistant-status`, `mesh.assistant-configure` | new mock RPC cases | same |
| `federation.nodes`, `federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request` | new mock RPC cases | same |
| `store.mesh.contacts`, `store.mesh.scheduled` | new per-session mock store buckets | same |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end — alias a mesh peer on the demo and it sticks, proven by a parity harness</name>
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs, neode-ui/package.json</files>
<read_first>
- `neode-ui/mock-backend.js` lines 4300-4500 — the `mesh.transport-advice` case and the comment
block above it (the house convention: mirror the daemon and cite the source file), the
`mesh.send-content-inline` case for how a handler mutates `currentStore().mesh.dynamic`, and
the ack-only stub block at the end of the mesh cases.
- `neode-ui/mock-backend.js` lines 5495-5530 — the per-session store shape (`mesh: { dynamic: [], blobs: {} }`)
and `currentStore()`.
- `neode-ui/mock-backend.js` lines 80-90 and 5710-5730 — the `PORT` constant and the `server.listen` call.
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs``handle_mesh_contacts_list` (from L1218)
and `handle_mesh_contacts_save` (from L1253): the real merge of `state.contacts` (a
`ContactEntry` map with `alias`, `notes`, `pinned`, `blocked`) over `state.peers`, and the
exact response shape the UI consumes.
- `neode-ui/src/api/rpc-client.ts` lines 795-820 — the `mesh.contacts-list` / `mesh.contacts-save`
wrappers and their params shape.
- `neode-ui/src/views/Mesh.vue` — the two call sites (on mount, and on peer rename) to confirm
which response fields are read.
</read_first>
<action>
Add a `contacts` bucket (a plain object keyed by peer contact id) to the per-session mock store
alongside the existing `dynamic` and `blobs` keys.
Implement `mesh.contacts-save`: accept the same params the daemon's handler takes, upsert
`{ alias, notes, pinned, blocked }` for the given peer key into the session `contacts` bucket,
and return the same result shape the real handler returns. Implement `mesh.contacts-list`:
merge the session `contacts` bucket over the demo's `mesh.peers` list exactly as the daemon
merges `state.contacts` over `state.peers`, and return the same field names. Follow the house
convention already used above `mesh.transport-advice`: a comment naming
`typed_messages.rs handle_mesh_contacts_list` / `handle_mesh_contacts_save` as the source of
truth, so a future reader knows where to re-check parity.
Change the hardcoded listen port to read an env override first, defaulting to the existing
value, so a harness can bind an ephemeral port without colliding with a running dev preview.
Use the env var name `MOCK_BACKEND_PORT`.
Create `neode-ui/scripts/mock-rpc-parity.mjs` with two stages and a non-zero exit on any failure:
(1) STATIC — scan `neode-ui/src/**` for every `'mesh.<verb>'` / `'federation.<verb>'` string
literal, scan `mock-backend.js` for every `case '<method>':`, and report methods called by the UI
with no mock case. Print the offending method names. (2) LIVE — spawn `node mock-backend.js`
with `MOCK_BACKEND_PORT` set to a free port, poll `/rpc/v1` until ready (bounded ~10s), then POST
a scripted JSON-RPC sequence and assert on the responses: `mesh.contacts-save` with an alias,
then `mesh.contacts-list` returns that alias for that peer. Kill the child in a `finally` block.
Do not use `|| echo`-style fallbacks anywhere in the script or its npm wiring — a failed spawn,
a failed fetch, or a missing field must propagate as a non-zero exit, never a passing run that
measured nothing.
Register it as the `test:mock-parity` npm script in `neode-ui/package.json`.
In this task the STATIC stage is expected to still report the other missing methods; make it
print them and exit non-zero only when the LIVE stage fails or when a method from an explicit
`KNOWN_GAPS` array is missing. Task 2 empties `KNOWN_GAPS` to zero entries.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; node --check mock-backend.js &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && node --check mock-backend.js` exits 0.
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 and its output contains the alias
round-trip assertion result.
- `grep -c "case 'mesh.contacts-list'" neode-ui/mock-backend.js` equals 1.
- `grep -c "case 'mesh.contacts-save'" neode-ui/mock-backend.js` equals 1.
- `grep -c 'MOCK_BACKEND_PORT' neode-ui/mock-backend.js` is at least 1.
- `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 2 (the pre-existing
transport-advice citation plus the new contacts citation).
- `node -e "process.exit(require('./neode-ui/package.json').scripts['test:mock-parity'] ? 0 : 1)"` exits 0.
- Killing the harness leaves no stray listener: `cd neode-ui && node scripts/mock-rpc-parity.mjs && node scripts/mock-rpc-parity.mjs` exits 0 twice in a row.
</acceptance_criteria>
<done>Peer aliasing works end-to-end on the demo and a single command proves it, with the remaining method gaps enumerated by name.</done>
</task>
<task type="auto">
<name>Task 2: Close the remaining ten UI-called methods with no mock handler</name>
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
<read_first>
- The STATIC-stage output from Task 1 — the authoritative live list. As of planning it is:
`mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`,
`mesh.assistant-status`, `mesh.assistant-configure`, `federation.nodes`,
`federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request`.
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the real handler names each of
these methods dispatches to, so the mock mirrors the right handler.
- `neode-ui/mock-backend.js` — the existing `federation.list-nodes`, `federation.list-pending-requests`,
`federation.approve-request`, and `federation.reject-request` cases, for the response shapes
the sibling federation methods must match.
</read_first>
<action>
Add a case for each remaining method, mirroring the real handler's response shape (read the
Rust handler named by the dispatcher before writing each one) and citing it in a comment the way
the contacts handlers do.
Behavioral requirements, not bare acknowledgements: `mesh.clear-all` empties the session
`dynamic` message array; `mesh.schedule-message` pushes into a new session `scheduled` bucket
and returns the created entry's id; `mesh.list-scheduled` returns that bucket;
`mesh.cancel-scheduled` removes by id and reports whether an entry was actually removed;
`federation.nodes` returns the same node array `federation.list-nodes` returns (the UI treats
them as aliases); `federation.cancel-request` removes the request from the pending-requests
bucket the existing approve/reject cases operate on.
Then set the harness's `KNOWN_GAPS` array to empty so the STATIC stage exits non-zero on ANY
UI-called method without a mock case, and extend the LIVE stage with one assertion per newly
stateful method that has observable state: schedule a message then list it and assert it is
present; cancel it and assert it is gone; clear-all then read `mesh.messages` and assert the
dynamic messages are gone.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; node --check mock-backend.js &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero reported missing methods.
- `cd neode-ui && grep -c 'KNOWN_GAPS' scripts/mock-rpc-parity.mjs` is at least 1 and the array
literal it is assigned is empty.
- Each of the ten method names appears exactly once as a `case '<method>':` in `mock-backend.js`.
- Deliberately deleting one `case` line makes `node scripts/mock-rpc-parity.mjs` exit non-zero
(fail-first proof); restore the line afterward and record the check in the SUMMARY.
</acceptance_criteria>
<done>The demo answers every mesh and federation RPC the UI calls, and the parity harness is proven to fail when it does not.</done>
</task>
<task type="auto">
<name>Task 3: Make the message-mutation stubs mutate demo state</name>
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
<read_first>
- `neode-ui/mock-backend.js` — the ack-only stub block covering `mesh.send-reaction`,
`mesh.send-reply`, `mesh.send-read-receipt`, `mesh.edit-message`, `mesh.delete-message`,
`mesh.forward-message`, `mesh.send-channel` (currently a shared bare-acknowledgement case),
and the `mesh.send-content-inline` case above it for the message-object shape pushed into
`currentStore().mesh.dynamic`.
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-976 — reply / reaction /
read-receipt / forward handlers, and lines 1065-1180 — edit / delete. Note the stable
`sender_pubkey` + `sender_seq` message key these operate on, not the local `id`.
- `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` field set the demo
objects must match (`id`, `direction`, `peer_contact_id`, `peer_name`, `plaintext`,
`timestamp`, `delivered`, `encrypted`, `transport`, `message_type`, `typed_payload`,
`sender_pubkey`, `sender_seq`).
- `neode-ui/src/views/Mesh.vue` and `neode-ui/src/stores/mesh.ts` — how the UI reads reactions,
edited text, and deleted markers, so the mutated shape is the one that renders.
</read_first>
<action>
Split the shared acknowledgement case into individual cases that mutate `currentStore().mesh.dynamic`:
`mesh.send-reaction` — locate the target message by the same key the daemon uses and append or
toggle the emoji in its reactions collection. `mesh.send-reply` — push a new message whose
payload carries the replied-to message key, so the UI renders the quote block.
`mesh.send-read-receipt` — mark the target message read. `mesh.edit-message` — replace the
target's text and set the edited marker the UI reads. `mesh.delete-message` — apply the same
deletion representation the daemon applies (tombstone marker vs removal — read the handler and
mirror it, do not choose independently). `mesh.forward-message` — push a copy addressed to the
destination peer. `mesh.send-channel` — push a channel-addressed message.
Leave `mesh.refresh` and `mesh.reboot-radio` as acknowledgements — the daemon's handlers have no
message-store effect either, so mirroring means leaving them alone. Add a comment on that pair
stating why they remain acknowledgements, so a later reader does not "fix" them into divergence.
Extend the harness's LIVE stage: send a message, react to it, and assert `mesh.messages` shows
the reaction; edit it and assert the text changed and the edited marker is set; delete it and
assert the daemon-matching representation; forward it and assert a copy exists for the
destination peer.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; node --check mock-backend.js &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with the reaction, edit, delete, and
forward assertions all reported as passing.
- `grep -c "case 'mesh.send-reaction':" neode-ui/mock-backend.js` equals 1 and that case is no
longer part of a shared fall-through group with `mesh.refresh`.
- `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 4 (each mirrored family
cites its daemon source).
- `cd neode-ui && npm run build` exits 0 (the mock is dev-only, but the build must not regress).
</acceptance_criteria>
<done>Reactions, replies, edits, deletes, and forwards render on the demo exactly as on a real node, proven by the live harness.</done>
</task>
</tasks>
## Planner Assumptions (flagged, unresolved)
- **FED-04 / spec-less probe, category `unclassified`:** the probe could not classify an edge for
FED-04, and no acceptance criterion was invented for it. The parity harness covers the *known*
drift class (missing handler, non-mutating handler); it does NOT cover response-shape drift where
a mock case exists and returns a differently-shaped success object than the daemon. That residual
class is surfaced here rather than silently dropped, and is a candidate finding for the FED-03
review in plan 01-07.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → mock backend `/rpc/v1` | Developer-local demo surface; accepts unauthenticated JSON-RPC on a loopback-bound dev port |
| harness child process → mock backend | The parity script spawns and drives the mock on an ephemeral port |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-05 | Spoofing | mock backend impersonating real daemon behavior in a way that hides a real-node bug | medium | mitigate | Every mirrored handler cites the daemon file and line range it mirrors; the parity harness asserts observable state transitions, not acknowledgements |
| T-01-06 | Information Disclosure | mock backend binding a non-loopback interface on a developer machine | low | accept | Pre-existing `0.0.0.0` bind is unchanged by this plan; the mock serves only synthetic demo data and ships in no release artifact |
| T-01-07 | Tampering | the parity harness leaving an orphaned server process holding a port | low | mitigate | The child is killed in a `finally` block and the acceptance criteria require two consecutive clean runs |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages are added by this plan — the harness uses only Node built-ins (`node:child_process`, `fetch`, `node:fs`). If any dependency becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
- `cd neode-ui && npm run build` — green.
- Fail-first proof recorded: deleting a `case` line makes the harness exit non-zero.
</verification>
<success_criteria>
- Zero mesh.*/federation.* methods called by the UI lack a mock handler.
- Peer aliasing, reactions, replies, edits, deletes, and forwards all change demo state and render.
- A single command reproduces the parity verdict and is proven fail-first.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` when done.
Commit staged by explicit path only (a second agent shares this tree), then `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,246 @@
---
phase: 01-federation-mesh-hardening
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/src/components/ScreensaverRing.vue
- neode-ui/src/components/SendBitcoinModal.vue
- neode-ui/src/components/WalletScanModal.vue
- neode-ui/src/components/__tests__/ScreensaverRing.test.ts
- neode-ui/src/components/__tests__/PaidTick.test.ts
autonomous: true
requirements: [FED-06]
must_haves:
truths:
- "The payment-success tick in SendBitcoinModal renders the screensaver EQ-segment ring, not a CSS ripple burst"
- "The payment-success tick in WalletScanModal renders the same EQ-segment ring, so the paid tick is identical on every surface it appears"
- "ScreensaverRing exposes a third badge size variant sized 160px on mobile and 192px from 768px up, with --viz-radius 80px/96px, alongside the untouched default and compact variants"
- "The badge ring fits inside the modal card without clipping — the success pane's ring container is no larger than the badge diameter at either breakpoint"
- "The success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changes"
- "SystemDangerZone and Screensaver continue to render the compact and default variants unchanged"
- statement: "ScreensaverRing's segment animation is disabled under prefers-reduced-motion for every size variant including the new badge, matching the site-wide reduced-motion convention"
verification: backstop
prohibitions:
- statement: "The paid-tick change MUST NOT alter what the success pane asserts about the payment — the ring is decoration; it must never render a success state for a payment that has not actually settled, and no success-gating condition may be relaxed to make the animation easier to trigger"
category: safety
artifacts:
- path: neode-ui/src/components/ScreensaverRing.vue
provides: "badge size variant + reduced-motion guard"
contains: "viz-ring-badge"
- path: neode-ui/src/components/__tests__/PaidTick.test.ts
provides: "Assertions that both paid-tick surfaces render the badge ring"
min_lines: 25
key_links:
- from: neode-ui/src/components/SendBitcoinModal.vue
to: neode-ui/src/components/ScreensaverRing.vue
via: "success pane renders <ScreensaverRing size=\"badge\" /> layered under the checkmark core"
pattern: "ScreensaverRing"
- from: neode-ui/src/components/WalletScanModal.vue
to: neode-ui/src/components/ScreensaverRing.vue
via: "success pane renders <ScreensaverRing size=\"badge\" /> in place of the plain circle"
pattern: "ScreensaverRing"
---
<objective>
Make the invoice/payment "paid" tick on-brand: the circle around the checkmark becomes the
screensaver ring with its outer EQ-segment lines, everywhere the paid tick appears.
Purpose: FED-06, locked by the user in CONTEXT.md — the paid-tick circle is the ScreensaverRing
style, applied consistently to every paid/success tick surface. RESEARCH.md flagged that a naive
drop-in overflows the modal card (the existing compact variant is 240-320px against a 96-112px
badge); 01-UI-SPEC.md resolved that by deciding on a new `badge` size variant rather than a
transform hack, and also recorded that `ScreensaverRing` has no `prefers-reduced-motion` guard at
all today — a real gap this phase must close.
Output: a third size variant plus a reduced-motion guard in the shared component, both paid-tick
call sites swapped, and component tests pinning the result.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@neode-ui/src/components/ScreensaverRing.vue
@neode-ui/src/components/Screensaver.vue
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `size: 'default' \| 'compact' \| 'badge'` | widened prop union | `neode-ui/src/components/ScreensaverRing.vue` |
| `.viz-ring-badge` | CSS class (160px / 192px, `--viz-radius` 80px / 96px) | same |
| reduced-motion media guard on `.viz-segment` | CSS | same |
| `neode-ui/src/components/__tests__/ScreensaverRing.test.ts` | new vitest suite | new file |
| `neode-ui/src/components/__tests__/PaidTick.test.ts` | new vitest suite | new file |
<!-- planner-discipline-allow: burst-ring -->
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — the badge ring variant renders as the payment-success tick</name>
<files>neode-ui/src/components/ScreensaverRing.vue, neode-ui/src/components/SendBitcoinModal.vue, neode-ui/src/components/__tests__/ScreensaverRing.test.ts, neode-ui/src/components/__tests__/PaidTick.test.ts</files>
<read_first>
- `neode-ui/src/components/ScreensaverRing.vue` — the whole file (about 115 lines): the
`withDefaults(defineProps<{ size?: ... }>())` union, the `sizeClass` computed, the two
existing size CSS classes with their `min-width: 768px` breakpoints and `--viz-radius`
custom properties, and the `segment-pulse` keyframes.
- `neode-ui/src/components/SendBitcoinModal.vue` lines 1-30 (the success pane markup: the
success-burst container, its three ripple span elements, and the core circle plus checkmark)
and lines 680-740 (the corresponding CSS block, including the existing
`@media (prefers-reduced-motion: reduce)` rule — copy that exact media-query syntax into
ScreensaverRing).
- `neode-ui/src/components/Screensaver.vue` — the existing `ScreensaverRing` + `ScreensaverLogo`
centred-absolute layering pattern (`position: relative` wrapper, `position: absolute; inset: 0`
inner content) to reuse for the checkmark core.
- `neode-ui/src/components/__tests__/BaseModal.test.ts` — the house vitest + `@vue/test-utils`
conventions for mounting a component in this repo.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-06 Sizing Decision"
table (exact diameters and radii) and the "UI Considerations" rows for the paid-tick ring.
</read_first>
<behavior>
- `ScreensaverRing.test.ts`: mounting with `size="badge"` puts `viz-ring-badge` on the root
element; mounting with `size="compact"` still yields `viz-ring-compact`; the default mount
still yields `viz-ring-default`; the rendered segment count matches the `segmentCount` prop.
- `PaidTick.test.ts`: SendBitcoinModal driven into its payment-success state renders exactly one
`ScreensaverRing` with `size="badge"`, renders the checkmark core, and renders zero ripple
elements; the success amount text is unchanged.
</behavior>
<action>
Write both test files first and confirm they fail before implementing.
In `ScreensaverRing.vue`: widen the `size` prop union with a third member `'badge'`, extend
`sizeClass` to map it to `viz-ring-badge`, and add a `.viz-ring-badge` CSS rule following the
exact shape of the existing two — `width`/`height` 160px and `--viz-radius: 80px` at mobile,
then a `@media (min-width: 768px)` block with 192px and `--viz-radius: 96px`. Do not touch
`.viz-ring-default` or `.viz-ring-compact`; `Screensaver.vue` and `SystemDangerZone.vue` must
keep their current rendering.
Also inside `ScreensaverRing.vue`, add the missing motion guard so it applies to every variant:
a `@media (prefers-reduced-motion: reduce)` block that sets `animation: none` and a static
reduced opacity on `.viz-segment`. Use the same media-query syntax as the guard already present
in `SendBitcoinModal.vue` so the two read identically.
In `SendBitcoinModal.vue`'s payment-success pane: import `ScreensaverRing`, replace the three
ripple span elements with `<ScreensaverRing size="badge" />`, keep the existing core circle and
checkmark markup untouched, and wrap the pair in the Screensaver-style layering (a
`position: relative` container sized to the badge diameter, with the core absolutely centred over
the ring). Remove the ripple elements' now-dead CSS rules and their keyframes; keep the core and
checkmark rules, and keep the existing reduced-motion rule but drop the clause that referenced
the removed elements. Do not change the success amount numerals, the SENT copy, the Done button,
or any condition that decides when the success pane is shown.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; test -f src/components/__tests__/ScreensaverRing.test.ts &amp;&amp; test -f src/components/__tests__/PaidTick.test.ts &amp;&amp; npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts</automated>
</verify>
<acceptance_criteria>
- Both test files exist and `npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts` exits 0 (the explicit `test -f` guards are required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would otherwise pass vacuously).
- `grep -c 'viz-ring-badge' neode-ui/src/components/ScreensaverRing.vue` is at least 2 (computed mapping + CSS rule).
- `grep -c 'prefers-reduced-motion' neode-ui/src/components/ScreensaverRing.vue` equals 1.
- `grep -Eq '160px' neode-ui/src/components/ScreensaverRing.vue` and `grep -Eq '192px' neode-ui/src/components/ScreensaverRing.vue` both succeed.
- `grep -c 'viz-ring-compact' neode-ui/src/components/ScreensaverRing.vue` is unchanged from before the edit (the compact variant is untouched).
- `grep -c 'ScreensaverRing' neode-ui/src/components/SendBitcoinModal.vue` is at least 2 (import + usage).
- `grep -c 'burst-ring' neode-ui/src/components/SendBitcoinModal.vue` equals 0.
- `cd neode-ui && npx vitest run` exits 0 — no existing suite regressed.
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'viz-ring-badge' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md: the build can silently no-op, so grep the built bundle for the new string).
</acceptance_criteria>
<done>The badge variant exists, the send-payment success tick renders it, and both are pinned by tests that failed before the change.</done>
</task>
<task type="auto">
<name>Task 2: Bring the scan-modal paid tick to the same ring</name>
<files>neode-ui/src/components/WalletScanModal.vue, neode-ui/src/components/__tests__/PaidTick.test.ts</files>
<read_first>
- `neode-ui/src/components/WalletScanModal.vue` around line 232 (the success circle markup — a
fixed 24-unit inline-flex circle with the success-ring class) and around line 861 (its CSS
rule). Note it has no ripple animation at all today, unlike the send modal.
- `neode-ui/src/components/SendBitcoinModal.vue` as left by Task 1 — the layering wrapper to
copy verbatim.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the FED-06 sizing table row
confirming this call site also uses the badge variant.
</read_first>
<action>
Replace WalletScanModal's fixed success circle with the same composition Task 1 established:
a `position: relative` container sized to the badge diameter holding `<ScreensaverRing size="badge" />`
with the existing checkmark content absolutely centred over it. Import `ScreensaverRing`. Drop
the now-unused fixed-size utility classes and the plain-circle CSS rule; keep the checkmark
glyph, its colour, and the surrounding copy exactly as they are.
Extend `PaidTick.test.ts` with a WalletScanModal case asserting its success state renders one
`ScreensaverRing` with `size="badge"` and still renders the checkmark.
Verify on the dev preview before considering this done, per the user requirement recorded in
CONTEXT.md: run the dev preview and confirm neither ring is clipped by the modal card's
scrolling container at a narrow viewport and at desktop width. Record the observation in the
SUMMARY. The blocking human sign-off for this is consolidated into plan 01-07.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/components/__tests__/PaidTick.test.ts &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts` exits 0 and the suite contains both a SendBitcoinModal case and a WalletScanModal case.
- `grep -c 'ScreensaverRing' neode-ui/src/components/WalletScanModal.vue` is at least 2.
- `grep -c 'success-ring' neode-ui/src/components/WalletScanModal.vue` equals 0.
- `cd neode-ui && npx vitest run` exits 0.
- `cd neode-ui && npm run build` exits 0.
- The SUMMARY records the dev-preview observation for both surfaces at a narrow and a desktop viewport.
</acceptance_criteria>
<done>Both paid-tick surfaces render the identical branded ring, with no clipping at either breakpoint.</done>
</task>
</tasks>
## Planner Assumptions (flagged, unresolved)
- **FED-06 / spec-less probe, category `unclassified`:** the probe surfaced an unclassified edge for
FED-06 that no defensible acceptance criterion covers. Surfaced rather than dropped: the phase
requirement says the ring applies "everywhere the paid tick appears", and a repo-wide grep found
exactly two paid-tick surfaces (`SendBitcoinModal.vue`, `WalletScanModal.vue`). If a third
success-tick surface is added between planning and execution — or exists under markup this grep
did not match — it will not be covered by this plan. The FED-03 review in plan 01-07 re-runs the
grep as a check.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| payment result → success pane render | The only security-relevant edge: what the UI asserts about a payment's settlement |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-08 | Spoofing | success pane rendered for a payment that has not settled | high | mitigate | This plan changes decoration only; the acceptance criteria forbid touching any condition that gates the success pane, and `npx vitest run` on the existing suites must stay green |
| T-01-09 | Denial of Service | 48 animated segments rendered inside a modal degrading low-power devices | low | mitigate | The badge variant is the smallest of the three; the new `prefers-reduced-motion` guard disables the animation entirely for users who ask for it |
| T-01-10 | Repudiation | the success amount or recipient text changing as a side effect of the swap | medium | mitigate | Tests assert the success amount text is unchanged; the action forbids touching the numerals and copy |
</threat_model>
<verification>
- `cd neode-ui && npx vitest run` — green.
- `cd neode-ui && npm run build` — green, and the built bundle contains the new class name.
- Dev-preview observation recorded for both modals at narrow and desktop widths.
</verification>
<success_criteria>
- A third `badge` size variant exists on the shared ring component; existing variants and their consumers are untouched.
- Both paid-tick surfaces render the branded ring with the checkmark layered centred.
- A reduced-motion guard covers every variant.
- Component tests pin all of the above and were proven to fail before the change.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,295 @@
---
phase: 01-federation-mesh-hardening
plan: 04
type: execute
wave: 1
depends_on: []
files_modified:
- core/archipelago/src/api/rpc/lnd/info.rs
- core/archipelago/src/mesh/message_types.rs
- core/archipelago/src/mesh/types.rs
- core/archipelago/src/mesh/listener/dispatch.rs
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
- core/archipelago/src/api/rpc/dispatcher.rs
autonomous: true
requirements: [FED-05]
must_haves:
truths:
- "lnd.getinfo returns this node's Lightning identity_pubkey and its advertised connection URIs, so the UI has something real to copy and share"
- "A node with no reachable LND, or an LND that advertises no URI, yields an absent identity rather than a fabricated one — the caller can tell 'not available' from 'available'"
- "A meshed peer that advertises Lightning is recorded with its URI on the mesh peer record and is listed by mesh.lightning-peers"
- "mesh.lightning-peers returns an empty list, not an error, when no meshed peer has advertised Lightning (FED-05 empty edge, mesh half)"
- "A peer that advertises Lightning twice appears once in mesh.lightning-peers, with the most recent URI (FED-05 adjacency edge, mesh half)"
- "mesh.lightning-peers returns peers in a deterministic order so the picker list does not reshuffle between reads (FED-05 ordering edge, mesh half)"
- "An inbound Lightning advertisement whose URI is not well-formed is rejected and does not overwrite a previously known good URI for that peer"
prohibitions:
- statement: "A node's Lightning URI MUST NOT be advertised to parties the operator has not chosen to reach — the advertisement is sent on an explicit send, never auto-broadcast to every radio contact in range, and a received URI is never re-broadcast onward to third parties"
category: privacy
artifacts:
- path: core/archipelago/src/api/rpc/lnd/info.rs
provides: "identity_pubkey + uris on the lnd.getinfo response"
contains: "identity_pubkey"
- path: core/archipelago/src/mesh/message_types.rs
provides: "LightningInfo typed message + payload"
contains: "LightningInfo"
key_links:
- from: core/archipelago/src/mesh/listener/dispatch.rs
to: core/archipelago/src/mesh/types.rs
via: "inbound LightningInfo envelope writes MeshPeer.lightning_uri"
pattern: "lightning_uri"
- from: core/archipelago/src/api/rpc/dispatcher.rs
to: core/archipelago/src/api/rpc/mesh/typed_messages.rs
via: "mesh.lightning-peers and mesh.send-lightning-info match arms"
pattern: "mesh.lightning-peers"
---
<objective>
Give the platform the two Lightning facts the channel-open UI needs from the mesh side: **this node's
own shareable URI**, and **which meshed peers have Lightning installed and what their URI is**.
Purpose: FED-05, whose scope is LOCKED in CONTEXT.md — the "public/other" list in the channel-open
picker is *meshed peer nodes that have Lightning installed*, not `lnd listpeers`, not a curated
directory, not a live LN-graph query. That requires peers to advertise a Lightning capability plus
their URI over the mesh. RESEARCH.md Pitfall 5 confirms neither datum exists today: `handle_lnd_getinfo`
fetches LND's `/v1/getinfo` but its response struct does not deserialize `identity_pubkey` or `uris`,
and PATTERNS.md records that mesh peer capability advertisement has **no analog** in the codebase —
it is genuinely new surface, to be built on the existing typed-envelope pattern.
Output: an extended `lnd.getinfo`, a new `LightningInfo` typed mesh message, a `lightning_uri` field
on `MeshPeer`, and two new RPCs (`mesh.lightning-peers`, `mesh.send-lightning-info`).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@core/archipelago/src/mesh/message_types.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `LndInfo.identity_pubkey: Option<String>` | new response field on `lnd.getinfo` | `core/archipelago/src/api/rpc/lnd/info.rs` |
| `LndInfo.uris: Vec<String>` | new response field on `lnd.getinfo` | same |
| `LndGetInfoResponse.identity_pubkey` / `.uris` | new deserialized LND REST fields | same |
| `MeshMessageType::LightningInfo = 26` (label `lightning_info`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` |
| `LightningInfoPayload { uri, alias }` | new CBOR payload struct | same |
| `MeshPeer.lightning_uri: Option<String>` | new optional peer field | `core/archipelago/src/mesh/types.rs` |
| `handle_mesh_lightning_peers` | new RPC handler (`mesh.lightning-peers`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` |
| `handle_mesh_send_lightning_info` | new RPC handler (`mesh.send-lightning-info`) | same |
| `mesh.lightning-peers`, `mesh.send-lightning-info` | dispatcher match arms | `core/archipelago/src/api/rpc/dispatcher.rs` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary</name>
<reversibility rating="reversible">Two additive optional fields on an internal RPC response; no
consumer breaks if they are removed again.</reversibility>
<files>core/archipelago/src/api/rpc/lnd/info.rs</files>
<read_first>
- `core/archipelago/src/api/rpc/lnd/info.rs` lines 1-110 — the `LndInfo` serialize struct, the
`LndGetInfoResponse` deserialize struct (which currently declares only `alias`,
`num_active_channels`, `num_peers`, `synced_to_chain`, `block_height`), and how
`handle_lnd_getinfo` maps one into the other with `unwrap_or_default()`.
- `core/archipelago/src/api/rpc/lnd/channels.rs` around `handle_lnd_openchannel` (from L238) —
the sibling handler's pubkey validation (66 hex chars) and error-shaping style to mirror.
</read_first>
<behavior>
- Deserializing an LND `/v1/getinfo` body that contains `identity_pubkey` and a non-empty `uris`
array yields both on the mapped response.
- Deserializing a body with neither field present succeeds and yields `identity_pubkey: None`
and an empty `uris` vector — never a fabricated or placeholder identity.
- A body whose `identity_pubkey` is not 66 hex characters yields `identity_pubkey: None` rather
than propagating a malformed key that `lnd.openchannel` would later reject.
</behavior>
<action>
Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a
`serde_json::from_str::<LndGetInfoResponse>(...)` over three fixture bodies (full, empty,
malformed pubkey) plus the mapping function. Extract the `LndGetInfoResponse``LndInfo`
identity mapping into a small pure function so it is testable without an HTTP call; keep the
existing HTTP flow otherwise untouched.
Add `identity_pubkey: Option<String>` and `uris: Vec<String>` to `LndGetInfoResponse` with
`#[serde(default)]`, and the corresponding `identity_pubkey: Option<String>` and
`uris: Vec<String>` to the serialized `LndInfo`. Validate the pubkey shape the same way
`handle_lnd_openchannel` does (66 hexadecimal characters) before forwarding it; on failure
forward `None`, and log at `warn!` naming the field.
Do not change any existing `LndInfo` field name or type — `HomeWalletCard.vue`, `Server.vue`,
and `Web5Wallet.vue` all read this response.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago lnd::info</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago lnd::info` exits 0 with at least 3 test cases.
- `grep -c 'identity_pubkey' core/archipelago/src/api/rpc/lnd/info.rs` is at least 4.
- `grep -c 'uris' core/archipelago/src/api/rpc/lnd/info.rs` is at least 3.
- `cd core && cargo build -p archipelago` exits 0.
- The SUMMARY records the pre-implementation failing output of the fixture tests.
</acceptance_criteria>
<done>`lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests.</done>
</task>
<task type="auto">
<name>Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored</name>
<reversibility rating="costly">`MeshMessageType` is a radio wire format shared with every fleet
node; the new discriminant and its CBOR payload shape become readable by deployed peers after the
next OTA, so changing the payload later needs a coordinated fleet upgrade. Kept additive (unused
discriminant, optional payload fields) so old nodes simply ignore it.</reversibility>
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
<read_first>
- `core/archipelago/src/mesh/message_types.rs` lines 28-200 — the `#[repr(u8)] MeshMessageType`
enum (highest current discriminant is `AssistResponse = 25`), and the three places every new
variant must be added: the enum, `from_u8`, `from_label`, and `label`. Also read
`ReactionPayload` (from L533) and `PresencePayload` (from L727) for payload struct conventions,
and the `TypedEnvelope` doc comment about `compact_bytes` (a plain derived `Vec<u8>` bloats
every message on the wire — this matters on LoRa).
- `core/archipelago/src/mesh/types.rs` lines 60-118 — the `MeshPeer` struct and its
`#[serde(default)]` optional-field convention (see `lat`/`lon`, `pkc_capable`).
- `core/archipelago/src/mesh/listener/dispatch.rs` around lines 430-490 — the
`Some(MeshMessageType::Reaction)` and `Some(MeshMessageType::Presence)` inbound arms: how a
decoded envelope is matched, its payload deserialized, and peer/message state mutated.
</read_first>
<action>
Add `LightningInfo = 26` to `MeshMessageType` with a doc comment stating what it advertises and
that it is only ever sent on an explicit operator action. Register it in `from_u8` (26),
`from_label` ("lightning_info"), and `label`.
Add `LightningInfoPayload` next to the other payload structs: a required `uri: String` (the
`pubkey@host:port` form) and an optional `alias: Option<String>` with `#[serde(default)]`.
Follow the surrounding payload structs' serde conventions.
Add `#[serde(default)] pub lightning_uri: Option<String>` to `MeshPeer`, with a doc comment
saying it is set only from a received `LightningInfo` advertisement (or federation seeding in a
later plan) and is what the channel-open picker offers as a request target.
Add an inbound arm in `dispatch.rs` for the new type, mirroring the shape of the `Reaction` and
`Presence` arms: deserialize the payload, validate the URI before storing (a `pubkey@host` form
whose pubkey part is 66 hex characters; the `:port` suffix is optional), and on success write it
onto the resolved `MeshPeer`. On a malformed URI, log at `warn!` and return without touching a
previously stored value. Store the newest advertisement when a peer advertises more than once —
overwrite, do not accumulate.
Add unit tests in `message_types.rs` covering the round-trip of the new discriminant through
`from_u8`/`from_label`/`label`, and a `dispatch.rs`-level test (or a pure helper test if
`dispatch.rs` has no test harness) asserting that a malformed URI leaves a previously stored good
URI intact.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago mesh::message_types mesh::listener</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener` exits 0.
- `grep -c 'LightningInfo' core/archipelago/src/mesh/message_types.rs` is at least 5 (enum, from_u8, from_label, label, payload doc).
- `grep -Eq 'lightning_info' core/archipelago/src/mesh/message_types.rs` succeeds.
- `grep -c 'lightning_uri' core/archipelago/src/mesh/types.rs` is at least 1.
- `grep -c 'LightningInfo' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
- `cd core && cargo test -p archipelago` exits 0.
</acceptance_criteria>
<done>The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI.</done>
</task>
<task type="auto">
<name>Task 3: Expose the meshed Lightning peers and the send path over RPC</name>
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
<read_first>
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` around `handle_mesh_contacts_list`
(from L1218) — the canonical read-handler shape: `self.mesh_service.read().await`, the
"Mesh service not running" error, `shared_state()`, then `.read().await` on the relevant map.
- The same file around `handle_mesh_send_reaction` (in the L637-976 family) — the canonical
send-handler shape: build a payload, wrap in `TypedEnvelope::new(...).with_seq(seq)`, send.
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line `"mesh.<verb>" =>
self.handle_...(params).await,` registration convention.
- `core/archipelago/src/server.rs` around `is_peer_allowed_path` (from L1270) — confirm whether
the new RPCs need peer reachability. They are operator-local calls over `/rpc/v1`, which is
already in the allow-list; do NOT widen that list.
</read_first>
<action>
Add `handle_mesh_lightning_peers`: read the mesh peer map, keep only peers whose `lightning_uri`
is set, collapse duplicates by the peer's authenticating key (the `MeshPeer` accessor that
prefers the verified archipelago identity key over the firmware routing key) keeping the most
recently heard entry, and return a stable-sorted array — sort by display name, then by contact
id as the tiebreak, so the picker list does not reshuffle between reads. Each entry carries at
minimum: contact id, display name, `lightning_uri`, `last_heard`, `reachable`, and `hops`.
Returning zero matching peers is an empty array with a success result, never an error.
Add `handle_mesh_send_lightning_info`: take a target peer identifier in params, read this node's
own URI from the `lnd.getinfo` path built in Task 1, refuse with a clear error when no URI is
available (LND down, or no advertised URI) rather than sending an empty advertisement, then send
a `LightningInfo` envelope to that peer only. It must not broadcast to all contacts: the target
is required, and the handler returns an error when it is absent.
Register both in the dispatcher as `"mesh.lightning-peers"` and `"mesh.send-lightning-info"`,
following the existing one-line convention.
Add tests covering: empty peer map yields an empty array; two advertisements from the same peer
yield one entry with the newer URI; ordering is stable across two consecutive calls over the
same peer set; `handle_mesh_send_lightning_info` with no target errors.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago mesh</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago mesh` exits 0.
- `grep -c '"mesh.lightning-peers"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c '"mesh.send-lightning-info"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1.
- `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened).
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`.
</acceptance_criteria>
<done>The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| radio peer → typed-envelope decode → `MeshPeer` | Untrusted, unauthenticated-by-default RF input mutates local peer state |
| LND REST (`/v1/getinfo`) → daemon | Local service response parsed into an RPC payload the UI displays and copies |
| operator RPC → outbound mesh send | An operator action that discloses this node's payment endpoint to a chosen peer |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-11 | Spoofing | a radio peer advertising someone else's Lightning URI to redirect a channel open | high | mitigate | The advertisement is stored against the peer's authenticating key (the verified archipelago identity key, never the firmware routing key — see `MeshPeer`'s auth-key accessor doc); the UI in plan 01-06 labels these peers as *request* targets, not trusted opens |
| T-01-12 | Tampering | a malformed or oversized URI corrupting stored peer state | high | mitigate | URI shape validated before store (66-hex pubkey part); invalid input leaves any previously stored value untouched; test asserts this |
| T-01-13 | Information Disclosure | this node's payment endpoint leaking to every radio contact in range | high | mitigate | `mesh.send-lightning-info` requires an explicit target and errors without one; there is no broadcast path, and a received URI is never re-advertised onward |
| T-01-14 | Denial of Service | advertisement flooding growing the peer map unboundedly | medium | accept | The advertisement writes a field on an existing peer record rather than creating records; peer-map growth is governed by the pre-existing contact-discovery limits, unchanged here |
| T-01-15 | Elevation of Privilege | a new RPC becoming peer-reachable and letting a remote peer enumerate Lightning peers | high | mitigate | Both RPCs ride the existing `/rpc/v1` operator surface; the acceptance criteria assert `is_peer_allowed_path` is not widened |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates are introduced. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
- Fixture-test failure output captured before the Task 1 implementation.
</verification>
<success_criteria>
- `lnd.getinfo` exposes a real identity pubkey and URI list, or an honest absence.
- A `LightningInfo` mesh message exists, is validated on receipt, and populates `MeshPeer.lightning_uri`.
- `mesh.lightning-peers` returns a deduplicated, deterministically ordered list and an empty array when there are none.
- `mesh.send-lightning-info` requires an explicit target and refuses to send an empty advertisement.
- The peer HTTP allow-list is unchanged.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,303 @@
---
phase: 01-federation-mesh-hardening
plan: 05
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- core/archipelago/src/federation/types.rs
- core/archipelago/src/federation/storage.rs
- core/archipelago/src/federation/sync.rs
- core/archipelago/src/api/rpc/federation/handlers.rs
- core/archipelago/src/server.rs
- neode-ui/src/views/federation/types.ts
- neode-ui/src/views/federation/NodeList.vue
autonomous: true
requirements: [FED-02]
must_haves:
truths:
- "A federation sync failure is recorded on the peer's node record and surfaced through federation.list-nodes, so the operator sees it in the UI instead of it existing only as a debug log line"
- "A successful sync clears a previously recorded sync error for that peer — the badge does not persist after the peer recovers (FED-02 adjacency edge)"
- "A periodic sync pass over zero federated nodes is a clean no-op: no error is recorded, nothing is written, and no error surfaces in the UI (FED-02 empty edge)"
- "A state snapshot older than the one already stored for a peer does not overwrite the newer one — out-of-order sync responses cannot move a peer's status backwards (FED-02 ordering edge)"
- "Exactly one periodic federation sync loop runs in the daemon; the redundant second loop is gone and every behavior unique to it is preserved in the surviving loop"
- "Duplicate node entries do not accumulate across sync cycles — after sync settles the node list has one entry per federated node"
prohibitions:
- statement: "Making sync errors visible MUST NOT expose a peer's onion address, DID, or any transport secret in an error string rendered to a surface wider than the operator's own dashboard — a sync error message names what failed, never credential material"
category: privacy
artifacts:
- path: core/archipelago/src/federation/types.rs
provides: "last_sync_error / last_sync_error_at on FederatedNode"
contains: "last_sync_error"
- path: neode-ui/src/views/federation/NodeList.vue
provides: "Operator-visible sync-error badge on a node row"
contains: "last_sync_error"
key_links:
- from: core/archipelago/src/server.rs
to: core/archipelago/src/federation/storage.rs
via: "the periodic sync loop calls record_sync_result after each peer attempt instead of only debug-logging"
pattern: "record_sync_result"
- from: core/archipelago/src/api/rpc/federation/handlers.rs
to: neode-ui/src/views/federation/NodeList.vue
via: "federation.list-nodes emits last_sync_error, the node row renders it as a badge"
pattern: "last_sync_error"
---
<objective>
Make federation sync converge and stop failing silently: one sync loop instead of two, a per-peer
sync error persisted and shown to the operator, and out-of-order snapshots unable to move a peer's
state backwards.
Purpose: FED-02. RESEARCH.md's anti-pattern list is explicit — both periodic sync loops in
`server.rs` log failures at `debug!` only, so a peer that has not synced in days looks identical to
one that synced a minute ago. The same section notes the two loops (90s at ~L497, 1800s at ~L840)
are redundant apart from one tail call, and that the redundancy doubles the write-race exposure that
plan 01-01 just locked down. Open Question 1 asks the reviewer to `git log -p` both loop-insertion
commits before deleting either — that check is a required step here, not an optional one.
Output: `last_sync_error` plumbed store → loop → RPC → UI badge, one surviving loop, and a
monotonicity guard on `update_node_state`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md
@core/archipelago/src/federation/types.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `FederatedNode.last_sync_error: Option<String>` | new optional field | `core/archipelago/src/federation/types.rs` |
| `FederatedNode.last_sync_error_at: Option<String>` | new optional field | same |
| `record_sync_result` | new pub async fn (records or clears a peer's sync error under the store lock) | `core/archipelago/src/federation/storage.rs` |
| `last_sync_error`, `last_sync_error_at` on `federation.list-nodes` | new response fields | `core/archipelago/src/api/rpc/federation/handlers.rs` |
| `FederatedNode.last_sync_error?` / `.last_sync_error_at?` | new TS interface fields | `neode-ui/src/views/federation/types.ts` |
| sync-error badge on a node row | Vue markup + class | `neode-ui/src/views/federation/NodeList.vue` |
| the 1800s periodic federation sync loop | **deleted** (its unique tail call moved into the 90s loop) | `core/archipelago/src/server.rs` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a failed federation sync becomes visible to the operator</name>
<files>core/archipelago/src/federation/types.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/server.rs, neode-ui/src/views/federation/types.ts, neode-ui/src/views/federation/NodeList.vue</files>
<read_first>
- `core/archipelago/src/federation/types.rs` lines 50-146 — `FederatedNode`'s existing optional
fields and the doc-comment convention on `last_transport` / `last_transport_at` (a result
field written back after each attempt). The new pair mirrors that shape for the failure side.
- `core/archipelago/src/federation/storage.rs` as left by plan 01-01 — `record_peer_transport`
(the existing "write a result field back after an attempt" function) and the
`FEDERATION_STORE_LOCK` wrapper + `*_inner` split convention the new function must follow.
- `core/archipelago/src/api/rpc/federation/handlers.rs` `handle_federation_list_nodes`
(from L220) — the `serde_json::json!` node object and the `if let Some(...)` conditional-field
pattern the new fields must follow.
- `core/archipelago/src/server.rs` lines 497-600 — the 90s periodic federation sync loop, its
per-peer `sync_with_peer` call and the `debug!(peer = %node.did, error = %e, ...)` arm that
currently swallows failures.
- `neode-ui/src/views/federation/types.ts` lines 19-33 — the `FederatedNode` TS interface.
- `neode-ui/src/views/federation/NodeList.vue` lines 40-140 — the trusted-node and peer rows,
`transportBadge()` (L166) and `trustBadgeClass()` for the badge idiom to mirror, and the
existing loading row.
- `neode-ui/src/views/federation/__tests__/NodeList.test.ts` — the existing suite's mount
conventions.
</read_first>
<behavior>
- `record_sync_result(data_dir, did, Err("..."))` sets `last_sync_error` to the message and
`last_sync_error_at` to an RFC 3339 timestamp on that node only.
- `record_sync_result(data_dir, did, Ok(()))` clears both fields on that node.
- `record_sync_result` for a DID that is not in the node list is a no-op returning Ok — a peer
removed mid-pass must not be resurrected by an error write.
- `federation.list-nodes` emits both fields when set and omits them when unset.
- NodeList renders a sync-error badge on a node whose `last_sync_error` is set, and renders no
such badge when it is unset.
</behavior>
<action>
Write the Rust tests and the NodeList component test first and confirm they fail.
Add `#[serde(default)] pub last_sync_error: Option<String>` and
`#[serde(default)] pub last_sync_error_at: Option<String>` to `FederatedNode`, with a doc comment
modelled on `last_transport`: these record the outcome of the most recent sync attempt so the
operator can tell a stale peer from a healthy one, replacing a debug-only log line. Update the
`make_node` test helper in `storage.rs`'s test module so the struct literal still compiles.
Add `record_sync_result(data_dir: &Path, did: &str, outcome: Result<(), String>) -> Result<()>`
to `storage.rs`, acquiring `FEDERATION_STORE_LOCK` and using the `*_inner` load/save functions
established in 01-01. Missing DID is a silent Ok. Never create a node entry.
In `server.rs`'s 90s loop, replace the debug-only failure arm with a call to `record_sync_result`
carrying the error's display string, and call it with a success outcome on the success arm.
Truncate the recorded message to a bounded length (256 characters) so a pathological error
cannot bloat the node file. Keep the existing `debug!` line as well — persisting is additive,
not a replacement for logs.
In `handle_federation_list_nodes`, emit the two fields onto the node object using the same
`if let Some(...)` conditional-insert pattern the existing optional fields use. Add the matching
optional fields to the TS `FederatedNode` interface.
In `NodeList.vue`, add a badge on the node row shown only when `last_sync_error` is set: red
family (`alert-error`-adjacent classes already in the house style), short label, and the full
message plus the timestamp in the element's `title` attribute — the row must stay single-line, so
apply the same `truncate` + `:title` treatment the node-name span already uses. Place it beside
the existing transport badge, not in place of it. Do not add a new nav entry, card, or view —
only this badge inside the existing row.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation &amp;&amp; cd ../neode-ui &amp;&amp; test -f src/views/federation/__tests__/NodeList.test.ts &amp;&amp; npx vitest run src/views/federation/__tests__/NodeList.test.ts</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation` exits 0 and includes a test named for the clear-on-success behavior and one for the missing-DID no-op.
- `grep -c 'last_sync_error' core/archipelago/src/federation/types.rs` is at least 2.
- `grep -c 'record_sync_result' core/archipelago/src/federation/storage.rs` is at least 1.
- `grep -c 'record_sync_result' core/archipelago/src/server.rs` is at least 2 (the failure arm and the success arm).
- `grep -c 'last_sync_error' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
- `grep -c 'last_sync_error' neode-ui/src/views/federation/types.ts` is at least 1.
- `grep -c 'last_sync_error' neode-ui/src/views/federation/NodeList.vue` is at least 1.
- `cd neode-ui && npx vitest run src/views/federation/__tests__/NodeList.test.ts` exits 0 with a case asserting the badge is absent when the field is unset (the guard against a badge that always renders).
- `cd neode-ui && npm run build` exits 0.
- The SUMMARY records the pre-implementation failing output for both the Rust and the component test.
</acceptance_criteria>
<done>A sync failure is persisted per peer, travels through the RPC, and renders as a badge the operator can see — and clears when the peer recovers.</done>
</task>
<task type="auto">
<name>Task 2: Collapse the two periodic sync loops into one</name>
<reversibility rating="costly">Deleting a background loop changes daemon runtime behavior across
the whole fleet on the next OTA; restoring it means re-deriving code that is gone from the tree
rather than flipping a flag. Mitigated by moving — not discarding — the loop's unique tail call
and by the required git-history check below.</reversibility>
<files>core/archipelago/src/server.rs</files>
<read_first>
- `core/archipelago/src/server.rs` lines 497-600 (the 90s loop, including its asymmetry
self-heal `notify_join` re-assertion) and lines 840-910 (the 1800s loop, whose unique tail
call is `rpc.refresh_federation_mesh_peers()`).
- The output of `git log -p -L 840,910:core/archipelago/src/server.rs` and
`git log -p -L 497,600:core/archipelago/src/server.rs` — RESEARCH.md Assumption A2 flags that
the 1800s loop may exist for an undocumented reason. Run this BEFORE deleting anything and
record the finding in the SUMMARY.
- `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — "Open Questions" item 1.
</read_first>
<action>
First run the two `git log -p -L` commands above and write the answer to Open Question 1 into the
SUMMARY: does the 1800s loop do anything the 90s loop does not, beyond
`refresh_federation_mesh_peers()`? If the history shows a documented reason to keep it, STOP,
do not delete it, and record that as a finding for the FED-03 review instead — the phase then
keeps two loops and this task's remaining work is limited to routing both through
`record_sync_result`.
Otherwise: move the `refresh_federation_mesh_peers()` call to the tail of the 90s loop's
completed pass (after the per-peer iteration, alongside the existing pass-complete log), thread
whatever handle it needs into that task's captured state, and delete the entire 1800s
`tokio::spawn` block. Keep the 90s loop's startup settle delay and its asymmetry self-heal
unchanged.
Make the surviving loop's zero-node case an explicit clean no-op: when `load_nodes` returns an
empty list the pass continues to the next tick without writing anything and without recording a
sync error against anyone.
</action>
<verify>
<automated>cd core &amp;&amp; cargo build -p archipelago &amp;&amp; cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo build -p archipelago` exits 0.
- `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'federation::sync_with_peer'` equals 1 (comment lines stripped so a doc comment cannot satisfy the gate).
- `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'refresh_federation_mesh_peers'` equals 1.
- `grep -c 'from_secs(1800)' core/archipelago/src/server.rs` equals 0 <!-- planner-discipline-allow: from_secs(1800) -->
- `cd core && cargo test -p archipelago` exits 0.
- The SUMMARY contains the `git log -p -L` finding answering RESEARCH.md Open Question 1, and states explicitly whether the loop was deleted or kept.
</acceptance_criteria>
<done>One periodic federation sync loop remains, its predecessor's unique behavior preserved, with the history check recorded.</done>
</task>
<task type="auto">
<name>Task 3: Stop out-of-order snapshots and duplicates from breaking convergence</name>
<files>core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs</files>
<read_first>
- `core/archipelago/src/federation/storage.rs` `update_node_state` (from L292 pre-01-01) — it
currently overwrites `last_seen`, `name`, `fips_npub`, and `last_state` unconditionally from
whatever snapshot arrives, with no comparison against what is already stored.
- `core/archipelago/src/federation/types.rs``NodeStateSnapshot.timestamp` is an RFC 3339
string; note that a lexicographic compare is only safe for same-offset RFC 3339, so parse it.
- `core/archipelago/src/federation/storage.rs``dedup_nodes_by_onion` and its two existing
tests, for the convergence behavior already present.
- `core/archipelago/src/federation/sync.rs``merge_transitive_peers` (from L120) and its
tombstone check, to confirm the guard added here does not conflict with it.
</read_first>
<action>
Add a monotonicity guard to `update_node_state`: parse the incoming snapshot's timestamp and the
stored `last_state`'s timestamp with `chrono::DateTime::parse_from_rfc3339`; if the incoming one
is strictly older, return Ok without mutating the node — a slow sync response that lands after a
newer one must not move the peer's status backwards. When either timestamp fails to parse, fall
back to the current accept-newest behavior so a peer with a malformed clock is not frozen out,
and log at `debug!`. Learning a peer's `fips_npub` is exempt: a stale snapshot may still carry
the only copy of a FIPS key this node has, so apply that one field even on a rejected snapshot,
and say so in a comment.
Add tests: a strictly-older snapshot leaves `last_state` and `last_seen` unchanged; an equal
timestamp is accepted (idempotent re-sync); a newer snapshot is accepted; a stale snapshot
carrying a `fips_npub` this node lacks still populates it; an unparseable timestamp is accepted.
Add a convergence test asserting that repeatedly applying the same peer's snapshot plus a
transitive-peer merge does not grow the node list — one entry per federated node after N cycles.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation` exits 0 with the five snapshot-ordering cases and the convergence case present by name.
- `grep -c 'parse_from_rfc3339' core/archipelago/src/federation/storage.rs` is at least 1.
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
</acceptance_criteria>
<done>Out-of-order sync responses cannot regress a peer's state, and repeated sync cycles converge to one entry per node.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| federated peer → `sync_with_peer` → node record | A remote peer's snapshot and its timestamp drive local persisted state |
| daemon → operator dashboard | A sync error string crosses from the daemon into rendered UI |
| background loop → federation node store | The surviving periodic loop is now a writer of error state, not only a reader |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-16 | Tampering | a peer replaying an old snapshot to roll a node's status backwards | high | mitigate | The `update_node_state` monotonicity guard rejects strictly-older snapshots (Task 3), with tests |
| T-01-17 | Information Disclosure | a sync error string carrying a peer onion address or transport credential into the UI | medium | mitigate | The recorded message is the error's display string truncated to 256 characters and rendered only on the operator's own dashboard; the prohibition above states the constraint and it is re-checked in the FED-03 review |
| T-01-18 | Denial of Service | an unbounded error message bloating `nodes.json` on every failed pass | medium | mitigate | 256-character truncation before persistence (Task 1) |
| T-01-19 | Repudiation | a silently-failing sync leaving no record of when a peer was last reachable | high | mitigate | `last_sync_error_at` is written on every attempt outcome; the badge makes staleness visible |
| T-01-20 | Denial of Service | deleting the 1800s loop dropping a behavior the fleet depends on | high | mitigate | Mandatory `git log -p -L` history check before deletion, the unique tail call moved rather than dropped, and an explicit STOP path if the history shows a documented reason |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd neode-ui && npx vitest run && npm run build` — green.
- The SUMMARY answers RESEARCH.md Open Question 1 with git evidence.
</verification>
<success_criteria>
- A sync failure is persisted, exposed over RPC, and rendered as an operator-visible badge that clears on recovery.
- Exactly one periodic federation sync loop remains, with the deleted loop's unique behavior preserved.
- Out-of-order snapshots cannot regress a peer's state; repeated cycles converge to one entry per node.
- A zero-node sync pass writes nothing and records no error.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,287 @@
---
phase: 01-federation-mesh-hardening
plan: 06
type: execute
wave: 3
depends_on: ["01-05"]
files_modified:
- core/archipelago/src/federation/types.rs
- core/archipelago/src/federation/sync.rs
- core/archipelago/src/federation/storage.rs
- core/archipelago/src/api/rpc/federation/handlers.rs
autonomous: false
requirements: [FED-05]
must_haves:
truths:
- "A trusted federated peer's Lightning URI is known locally after sync and is emitted by federation.list-nodes, so the picker can offer a one-click channel open by hostname"
- "The Lightning field on the federation sync payload is optional with a serde default, so a node running an older build syncs with a newer one in both directions without error"
- "A federated peer that advertises no Lightning URI is emitted without the field rather than with an empty string — the picker can tell 'no Lightning' from 'Lightning at an unknown address'"
- "An inbound Lightning URI that is not well-formed is rejected at sync time and never persisted or rendered"
- "A stale sync snapshot cannot clear a peer's previously known Lightning URI, consistent with the snapshot-ordering guard from plan 01-05"
prohibitions:
- statement: "A node's Lightning URI MUST NOT reach a party the operator has not federated with — it must never be re-exported in this node's own outbound peer hints on behalf of a third-party peer, so a peer-of-a-peer cannot harvest payment endpoints by federating one hop away"
category: privacy
- statement: "Lightning URI sharing MUST NOT be silently enabled in a way the operator cannot see or reverse — whatever default ships, the current sharing state is discoverable from the node's own settings surface and changing it takes effect on the next sync without a data migration"
category: transparency
artifacts:
- path: core/archipelago/src/federation/types.rs
provides: "Lightning identity field(s) on NodeStateSnapshot (and FederationPeerHint only if the decision selects it)"
contains: "lightning"
key_links:
- from: core/archipelago/src/federation/sync.rs
to: core/archipelago/src/federation/types.rs
via: "build_local_state populates the Lightning field from this node's lnd.getinfo identity"
pattern: "lightning"
- from: core/archipelago/src/federation/storage.rs
to: core/archipelago/src/api/rpc/federation/handlers.rs
via: "update_node_state persists the peer's Lightning URI; federation.list-nodes emits it"
pattern: "lightning"
---
<objective>
Carry a federated peer's Lightning URI over the federation sync payload, so the channel-open picker
can list **trusted nodes by hostname** and open a channel with one click.
Purpose: FED-05's primary list. RESEARCH.md Pitfall 5 is blunt: building the picker before the
backend can supply a federated peer's Lightning pubkey/host produces a UI that lists names and has
nothing to pass to `lnd.openchannel`. `NodeStateSnapshot` — the payload `federation.get-state` and
sync exchange — carries no Lightning fields at all today.
Output: the sync payload extended, the peer's URI persisted and emitted by `federation.list-nodes`,
and the sharing default explicitly chosen by the operator rather than assumed.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md
@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md
@core/archipelago/src/federation/types.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| Lightning identity field(s) on `NodeStateSnapshot` | new optional serde-default field(s); exact names fixed by the Task 1 decision | `core/archipelago/src/federation/types.rs` |
| Lightning identity field(s) on `FederationPeerHint` | added **only if** the decision selects transitive sharing | same |
| `FederatedNode.lightning_uri: Option<String>` | new persisted field on the local node record | same |
| `build_local_state` Lightning parameter | changed fn signature in the federation sync builder | `core/archipelago/src/federation/sync.rs` |
| `share_lightning_uri` | server setting + its accessor, **only if** the decision selects opt-in gating | `core/archipelago/src/api/rpc/federation/handlers.rs` (+ server info) |
| `lightning_uri` on `federation.list-nodes` | new response field | `core/archipelago/src/api/rpc/federation/handlers.rs` |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide the Lightning field shape and sharing default on the federation sync payload</name>
<decision>What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in?</decision>
<context>
This writes a new field into `NodeStateSnapshot`, the payload every federated node exchanges on
every sync. Once a release carrying it reaches the fleet, deployed peers parse that shape — a
later change to the field name, the split, or the sharing scope needs a coordinated fleet
upgrade plus a cleanup of URIs already cached in every peer's `nodes.json`. That is a one-way
door, and the sources disagree about which way to walk through it:
- `01-CONTEXT.md` records "a federated peer's Lightning URI rides the federation sync payload
**by default** — federation trust is already bilateral and explicit", and marks this
**Claude's discretion, revisable** — not locked.
- `01-RESEARCH.md` Open Question 3 recommends the opposite: follow the `shared_location`
precedent (opt-in, default off) "since exposing a payment channel target more broadly than
intended has real-money implications."
A second, related question rides along: `NodeStateSnapshot.federated_peers` carries a
`FederationPeerHint` for each of this node's trusted peers, used for transitive discovery. If the
Lightning field goes on the hint too, then Alice syncing with Bob learns Bob's *peers'* Lightning
URIs — a payment endpoint reaching a party that node never federated with. Options B and C below
keep the field off the hint; only choose otherwise deliberately.
</context>
<options>
<option id="option-a">
<name>Single `lightning_uri` on the snapshot AND on the peer hint, shared by default</name>
<pros>Widest picker coverage — a peer-of-a-peer's URI is available without an extra sync hop; simplest single field; matches CONTEXT.md's default-on stance</pros>
<cons>Sends a payment endpoint to nodes the operator never federated with, which the plan's own privacy prohibition forbids; hardest to walk back once cached across the fleet</cons>
</option>
<option id="option-b">
<name>Single `lightning_uri` on the snapshot only, shared by default with direct federated peers (CONTEXT.md's stated default, narrowed)</name>
<pros>Implements CONTEXT.md's recorded discretion default; bilateral federation trust is already explicit, so no new consent surface is needed; one field, one hop, no transitive leak; ships the picker with real data on day one</pros>
<cons>Every existing federated pair starts sharing a payment endpoint on the OTA that carries it, with no per-operator prompt; reversing later means shipping an opt-out and waiting for peers to re-sync</cons>
</option>
<option id="option-c">
<name>Single `lightning_uri` on the snapshot only, gated behind an explicit opt-in setting defaulting off (RESEARCH.md Open Question 3)</name>
<pros>Mirrors the proven `shared_location` pattern exactly; no operator starts sharing a payment endpoint without acting; safest given real-money implications; the field itself stays additive so flipping the default later is a one-line change</pros>
<cons>The trusted-node picker is empty until both sides opt in, so the FED-05 flow needs a discoverable "turn on Lightning sharing" path or it looks broken; more surface to build in this plan</cons>
</option>
</options>
<resume-signal>Select: option-a, option-b, or option-c. If you pick option-c, also say where the toggle lives (a new row in the existing federation settings surface is the default assumption).</resume-signal>
</task>
<task type="tracer" tdd="true">
<name>Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes</name>
<reversibility rating="one-way">This adds a field to `NodeStateSnapshot`, the wire payload every
fleet node parses on every sync; after the OTA carrying it, changing the field's name, split, or
sharing scope requires a coordinated fleet upgrade and a cleanup of URIs already cached in peers'
node files.</reversibility>
<precondition>`lnd.getinfo` returns `identity_pubkey` and `uris` (delivered by plan 01-04, Task 1) — confirm by reading `core/archipelago/src/api/rpc/lnd/info.rs` for both field names before starting.</precondition>
<files>core/archipelago/src/federation/types.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs</files>
<read_first>
- `core/archipelago/src/federation/types.rs` lines 104-146 — the `shared_location` (`lat`/`lon`)
opt-in field pair with its doc comment explaining absent-vs-null, and the `FederationPeerHint`
struct with its `pubkey`/`onion` split. These are the exact patterns to mirror.
- `core/archipelago/src/api/rpc/federation/handlers.rs` around lines 470-485 — the
`shared_location` gating block (`if data.server_info.share_location { ... } else { None }`)
and how it is threaded into `federation::build_local_state`.
- `core/archipelago/src/federation/sync.rs` around lines 225-265 — `build_local_state`'s
signature and where `shared_location` is mapped into the snapshot at construction time.
- `core/archipelago/src/federation/storage.rs` `update_node_state` as left by plan 01-05,
including the monotonicity guard and the `fips_npub` exemption comment — the new field follows
the same "learn from the peer's snapshot" treatment.
- `core/archipelago/src/api/rpc/lnd/info.rs` as left by plan 01-04 — the `identity_pubkey` /
`uris` field names and the 66-hex validation helper to reuse.
- `core/archipelago/src/api/rpc/lnd/channels.rs` `handle_lnd_openchannel` (from L238) — the
exact URI/pubkey/address parsing the picker will feed, so the persisted format matches what
that handler accepts.
</read_first>
<behavior>
- `build_local_state` called with a Lightning URI puts it on the produced snapshot; called
without one produces a snapshot with the field absent (not an empty string).
- A snapshot deserialized from a payload that has no Lightning field succeeds with the field
`None` — an older peer syncs fine.
- `update_node_state` with a snapshot carrying a well-formed URI persists it onto the
`FederatedNode`; with a malformed URI it leaves any previously stored value untouched.
- A snapshot rejected by the plan-01-05 monotonicity guard does not clear an already-known URI.
- `federation.list-nodes` emits `lightning_uri` for a node that has one and omits it otherwise.
</behavior>
<action>
Implement exactly the option selected in Task 1 — do not substitute a different shape, and do not
add the field to `FederationPeerHint` unless option-a was chosen. Record the chosen option id in
the SUMMARY.
Write the tests first and confirm they fail.
Add the Lightning field(s) to `NodeStateSnapshot` with `#[serde(default)]` and a doc comment that
states the sharing rule chosen in Task 1 and explicitly notes where it differs from the
`shared_location` analog directly above it. Add `#[serde(default)] pub lightning_uri: Option<String>`
to `FederatedNode` for the locally-persisted peer value, and update the `make_node` test helper
so the struct literal still compiles.
Thread the value into `build_local_state` the same way `shared_location` is threaded: an added
parameter, mapped into the snapshot at construction. At the `handlers.rs` call site, source it
from this node's own `lnd.getinfo` identity (prefer the first entry of `uris`; fall back to
composing `identity_pubkey` with the node's reachable host when `uris` is empty), gated per the
Task 1 decision. An LND that is down or has no URI yields `None`, never an empty string and never
a fabricated address.
In `update_node_state`, learn the peer's URI from the snapshot: validate the shape before storing
(the pubkey part is 66 hexadecimal characters; the `@host[:port]` remainder is optional, matching
what `handle_lnd_openchannel` accepts), and on a malformed value log at `debug!` and leave the
prior value alone.
In `handle_federation_list_nodes`, emit `lightning_uri` with the same `if let Some(...)`
conditional-insert pattern the other optional fields use.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation` exits 0 with the five behaviors above present as named cases.
- `grep -c 'lightning' core/archipelago/src/federation/types.rs` is at least 3.
- `grep -c 'serde(default)' core/archipelago/src/federation/types.rs` increased by at least 2 relative to the pre-change file.
- A round-trip test proves back-compat in both directions: a snapshot JSON with no Lightning key deserializes to `None`, and a snapshot serialized with the field deserializes cleanly after being stripped of unknown keys.
- `grep -c 'lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
- If and only if option-a was selected: `grep -c 'lightning' core/archipelago/src/federation/types.rs` includes an occurrence inside the `FederationPeerHint` struct. Otherwise `FederationPeerHint` has none — assert this either way and state which in the SUMMARY.
- `cd core && cargo test -p archipelago` exits 0.
</acceptance_criteria>
<done>A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets.</done>
</task>
<task type="auto">
<name>Task 3: Make the sharing state visible and reversible</name>
<files>core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/federation/sync.rs</files>
<read_first>
- The Task 1 decision as recorded in the Task 2 SUMMARY notes.
- `core/archipelago/src/api/rpc/federation/handlers.rs` — the `share_location` server-info flag
and the `server.set-location` RPC that toggles it, for the accessor + persistence pattern.
- `core/archipelago/src/federation/sync.rs``build_local_state`'s tests (from L335) for the
assertion style.
</read_first>
<action>
Under option-c: add the `share_lightning_uri` server setting with a default of off, an RPC to
read and set it following the `server.set-location` shape, and make `build_local_state`'s
Lightning parameter `None` whenever the flag is off. Add tests: flag off produces a snapshot with
no Lightning field even when LND has one; flag on produces it; toggling the flag off then
re-syncing produces a snapshot without it.
Under option-a or option-b: add a read-only surface reporting the current sharing state and the
URI actually being shared, so the operator can see what is going out; and make the outbound value
`None` whenever this node's own Lightning is not installed or not reachable. Add tests: no LND
produces no Lightning field; a present LND produces the URI; the reported state matches what
`build_local_state` actually emits.
In both cases, add a test asserting a third-party peer's Lightning URI is never re-exported in
this node's own outbound peer hints — build a local state while holding a peer whose URI is
known, serialize it, and assert that URI string does not appear in the outbound payload's peer
hint section. This test is the mechanical form of this plan's privacy prohibition and must exist
regardless of which option was chosen.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation::sync</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation::sync` exits 0.
- A test named for the third-party-URI-not-re-exported behavior exists and passes; temporarily injecting the peer URI into the outbound hint makes it fail (fail-first proof recorded in the SUMMARY).
- Under option-c only: `grep -c 'share_lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 2.
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
</acceptance_criteria>
<done>The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| this node → federated peer (outbound snapshot) | This node's payment endpoint crosses to a remote party |
| federated peer → this node (inbound snapshot) | A remote party's claimed payment endpoint is persisted and later fed to `lnd.openchannel` |
| transitive peer hint | A third party's identity data can ride this node's outbound payload to a party it never federated with |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-21 | Information Disclosure | this node's Lightning payment endpoint reaching a non-federated party | high | mitigate | The Task 1 decision fixes the sharing scope explicitly; Task 3 adds the test proving a third-party URI is never re-exported in outbound peer hints, plus a fail-first proof |
| T-01-22 | Spoofing | a peer advertising a Lightning URI it does not control, redirecting a channel open and its funds | high | mitigate | The snapshot arrives over the existing ed25519-signature-verified federation path (unchanged); the URI is bound to that verified peer record and validated for shape before persistence. Not re-implemented here — the existing `identity::NodeIdentity::verify` path is reused, per RESEARCH.md V6 |
| T-01-23 | Tampering | a malformed or oversized URI corrupting the persisted node record | high | mitigate | 66-hex pubkey validation before persist; malformed input leaves the prior value untouched, with a test |
| T-01-24 | Tampering | a replayed older snapshot clearing a known Lightning URI | medium | mitigate | The plan-01-05 monotonicity guard rejects strictly-older snapshots; a test asserts a rejected snapshot does not clear the URI |
| T-01-25 | Repudiation | the operator unable to tell what identity their node is sharing | medium | mitigate | Task 3 adds the visible sharing state (a setting under option-c, a read-only report otherwise) |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- Back-compat round-trip proven in both directions against a payload lacking the new field.
- The chosen option id is recorded in the SUMMARY, together with the fail-first proof for the no-re-export test.
</verification>
<success_criteria>
- The Lightning identity field exists on the federation sync payload in exactly the shape the operator chose, additively and back-compatibly.
- A trusted peer's URI is validated, persisted, and emitted by `federation.list-nodes`.
- A third party's URI provably never leaves this node in its own peer hints.
- The current sharing state is visible to the operator.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,251 @@
---
phase: 01-federation-mesh-hardening
plan: 07
type: execute
wave: 2
depends_on: ["01-04"]
files_modified:
- core/archipelago/src/mesh/message_types.rs
- core/archipelago/src/mesh/listener/dispatch.rs
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
- core/archipelago/src/api/rpc/dispatcher.rs
autonomous: true
requirements: [FED-05]
must_haves:
truths:
- "A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message"
- "A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path"
- "Sending a channel-open request requires an explicit target peer — there is no broadcast form"
- "A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message"
- "Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)"
- "A request is never rendered or reported as an opened or funded channel — it carries no channel state"
prohibitions:
- statement: "A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count"
category: transparency
- statement: "Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision"
category: safety
artifacts:
- path: core/archipelago/src/mesh/message_types.rs
provides: "ChannelOpenRequest typed message + payload"
contains: "ChannelOpenRequest"
key_links:
- from: core/archipelago/src/api/rpc/dispatcher.rs
to: core/archipelago/src/api/rpc/mesh/typed_messages.rs
via: "mesh.request-channel match arm"
pattern: "mesh.request-channel"
- from: core/archipelago/src/mesh/listener/dispatch.rs
to: core/archipelago/src/mesh/types.rs
via: "inbound ChannelOpenRequest is stored as a MeshMessage with its typed payload"
pattern: "ChannelOpenRequest"
---
<objective>
Give a meshed Lightning peer a way to be *asked* for a channel: a typed mesh message carrying the
requester's Lightning URI and an optional note, sent to one chosen peer and rendered in the
recipient's conversation.
Purpose: FED-05's second list. CONTEXT.md locks the semantics — meshed peers with Lightning
installed are nodes you "request to open a channel with", not nodes you open against directly,
because mesh peers are not bilaterally trusted the way federated nodes are. 01-UI-SPEC.md fixes the
UI verb ("Request Channel", reusing `PeerRequestModal.vue`'s message field and busy states).
PATTERNS.md records that the send/receive shape for this is `typed_messages.rs`'s existing
reaction/reply family — a struct-per-message-type serialized into the standard envelope — and that
no capability/request mechanism exists yet to extend.
Output: `MeshMessageType::ChannelOpenRequest`, its payload, an inbound arm that stores it as a
conversation message, and a `mesh.request-channel` RPC.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md
@core/archipelago/src/mesh/message_types.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `MeshMessageType::ChannelOpenRequest = 27` (label `channel_open_request`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` |
| `ChannelOpenRequestPayload { uri, amount_sats, message }` | new CBOR payload struct | same |
| inbound `ChannelOpenRequest` arm | listener dispatch arm storing the request as a `MeshMessage` | `core/archipelago/src/mesh/listener/dispatch.rs` |
| `handle_mesh_request_channel` | new RPC handler (`mesh.request-channel`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` |
| `mesh.request-channel` | dispatcher match arm | `core/archipelago/src/api/rpc/dispatcher.rs` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation</name>
<reversibility rating="costly">`MeshMessageType` is a radio wire format read by every fleet node
after the next OTA; the discriminant and payload shape become externally visible, so a later change
needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default
optional payload fields, so older nodes ignore it rather than erroring.</reversibility>
<precondition>`MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number.</precondition>
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/listener/dispatch.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
<read_first>
- `core/archipelago/src/mesh/message_types.rs` — the enum and the four places every variant is
registered (`enum`, `from_u8`, `from_label`, `label`), `InvoicePayload` (from L413) as the
closest payload analog (it also carries a payment-ish string plus an optional amount), and the
`TypedEnvelope` doc comment on `compact_bytes` and LoRa frame size.
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-760 — `handle_mesh_send_reply`
and `handle_mesh_send_reaction`: param extraction, target-peer resolution, sequence-number
allocation, `TypedEnvelope::new(...).with_seq(seq)`, and the send call.
- `core/archipelago/src/mesh/listener/dispatch.rs` lines 430-500 — the `Reaction` and `Presence`
inbound arms, and how an inbound typed message is turned into a stored `MeshMessage` with
`message_type` and `typed_payload` set.
- `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` fields the stored
request must populate (`plaintext` is the human-readable fallback shown in list views).
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line registration convention.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract row
for "Primary CTA — meshed Lightning peer", which fixes what the UI in plan 01-08 will send.
</read_first>
<behavior>
- `MeshMessageType` round-trips the new variant through `from_u8`, `from_label`, and `label`.
- `handle_mesh_request_channel` with no target peer in params returns an error; with an unknown
target peer it returns an error naming the peer.
- `handle_mesh_request_channel` with a target sends exactly one envelope of the new type, whose
payload carries this node's own Lightning URI and the caller's optional amount and message.
- Two consecutive calls to the same target allocate two different sequence numbers.
- An inbound envelope of the new type with a well-formed URI is stored as a `MeshMessage` whose
`message_type` is the new label and whose `typed_payload` carries the request fields.
- An inbound envelope whose payload URI is malformed stores nothing and logs a warning.
</behavior>
<action>
Write the tests first and confirm they fail.
Add `ChannelOpenRequest = 27` to `MeshMessageType` (confirm 27 is unused first) with a doc comment
stating that this is a *request*, that it carries no channel state, and that receiving one never
causes the node to act. Register it in `from_u8`, `from_label` ("channel_open_request"), and
`label`.
Add `ChannelOpenRequestPayload` beside the other payload structs: a required `uri: String` (the
requester's own `pubkey@host:port`), plus `#[serde(default)] amount_sats: Option<u64>` and
`#[serde(default)] message: Option<String>`. Bound the optional message length before send so a
long note cannot blow past the LoRa framing budget the `TypedEnvelope` doc comment warns about;
truncate at the send side rather than rejecting, and say so in a comment.
Add `handle_mesh_request_channel` following the `handle_mesh_send_reply` shape: require a target
peer identifier in params and error without one (there is no broadcast form); read this node's
own Lightning URI via the identity path plan 01-04 added to `lnd.getinfo`, and error with a clear
message when it is unavailable rather than sending an empty request; build the payload, wrap it in
a `TypedEnvelope` with a freshly allocated sequence number, and send it to that peer only.
Register it in the dispatcher as `"mesh.request-channel"`.
Add the inbound arm in `dispatch.rs` mirroring the `Reaction` arm: deserialize the payload,
validate the URI shape (66-hex pubkey part, optional `@host[:port]`), and on success store a
`MeshMessage` with the new label as `message_type`, the payload as `typed_payload`, and a
human-readable `plaintext` summary naming the requester and the requested amount when present.
On a malformed URI, log at `warn!` and store nothing.
Do not add any code path that connects to, opens a channel with, or funds the requester on
receipt. The inbound arm's only effect is storing a message.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh` exits 0 with the six behaviors above present as named cases.
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/message_types.rs` is at least 5.
- `grep -c 'channel_open_request' core/archipelago/src/mesh/message_types.rs` is at least 2.
- `grep -c '"mesh.request-channel"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
- The inbound arm contains no call to any `openchannel`, `connectpeer`, or send-funds path — verified by reading the arm and recorded in the SUMMARY.
- `cd core && cargo test -p archipelago` exits 0.
- The SUMMARY records the pre-implementation failing test output.
</acceptance_criteria>
<done>A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message.</done>
</task>
<task type="auto">
<name>Task 2: Harden the request path against duplicates, oversize, and misuse</name>
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
<read_first>
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` as left by Task 1, plus the
`handle_mesh_send_content_inline` size-tier logic for how this codebase bounds payload size
before a send.
- `core/archipelago/src/mesh/outbox.rs` — whether an outbound send is queued and retried, so the
duplicate-suppression window is placed where it will actually see both attempts.
- `core/archipelago/src/mesh/types.rs``MeshPeer`'s authenticating-key accessor doc comment
(never use the firmware routing key for authentication).
</read_first>
<action>
Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the
same target peer within a bounded interval returns a distinct, non-error result reporting that a
request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also
disables its button while a send is in flight, but a backend guard is what actually stops a
double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated
by more than the window must both go out — the window suppresses accidental duplicates, not
legitimate repeat requests. Add tests for both sides of the window.
Bound the inbound side too: reject an inbound payload whose message field exceeds the same
length bound the send side truncates at, and reject an `amount_sats` outside the range
`handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can
never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds
rather than restating them from memory.
Attribute the stored inbound message to the peer's authenticating identity key, not the firmware
routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be
from a trusted peer must be attributable.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago mesh api::rpc::mesh</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case.
- A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them.
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`.
</acceptance_criteria>
<done>Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| radio peer → typed-envelope decode → stored message | Untrusted RF input becomes a conversation entry naming a payment endpoint |
| operator RPC → outbound request | An operator action discloses this node's payment endpoint to a chosen mesh peer |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-26 | Spoofing | a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI | high | mitigate | The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human |
| T-01-27 | Elevation of Privilege | a received request causing an automatic channel open or fund movement | high | mitigate | The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call |
| T-01-28 | Denial of Service | request flooding filling a peer's conversation or saturating a LoRa link | high | mitigate | Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2) |
| T-01-29 | Information Disclosure | broadcasting this node's payment endpoint to every contact in range | high | mitigate | A target peer is required; the handler errors without one and there is no broadcast form |
| T-01-30 | Tampering | an oversize payload fragmenting into unreassemblable LoRa chunks | medium | mitigate | The message field is truncated at the send side against the framing budget the `TypedEnvelope` doc comment describes; inbound oversize is rejected |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
- The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action.
</verification>
<success_criteria>
- A new typed mesh message carries a channel-open request to one named peer.
- Receiving one stores a conversation message and does nothing else.
- Duplicates within a short window are suppressed; legitimate repeats are not.
- Malformed URIs, oversize notes, and out-of-range amounts are refused.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,348 @@
---
phase: 01-federation-mesh-hardening
plan: 08
type: execute
wave: 4
depends_on: ["01-02", "01-06", "01-07"]
files_modified:
- neode-ui/src/components/LightningChannelModal.vue
- neode-ui/src/components/LightningChannelsPanel.vue
- neode-ui/src/api/rpc-client.ts
- neode-ui/mock-backend.js
- neode-ui/src/components/__tests__/LightningChannelModal.test.ts
autonomous: true
requirements: [FED-05]
must_haves:
truths:
- "A user can copy their own node's Lightning URI from the channel-open surface; the copy button label flips to Copied! for about two seconds"
- "The displayed own-node URI truncates to its container with the full value in a title tooltip, and the full untruncated value is what reaches the clipboard"
- "Trusted federated nodes that advertise Lightning are listed by hostname with a one-click Open Channel action"
- "Meshed peers that have Lightning installed are listed separately with a Request Channel action, never a direct open — they are not bilaterally trusted"
- "A peer that is both a trusted federated node and a meshed Lightning peer appears exactly once, in the trusted list (FED-05 adjacency edge)"
- "Both picker lists render in a deterministic order that does not reshuffle between refreshes (FED-05 ordering edge)"
- "When both lists are empty a single shared empty state renders once — not one per column"
- "Both lists show the house loading treatment while fetching and the house error row on failure, matching the existing federation node list and Lightning channels panel conventions"
- "Each list row shows the node name with truncation and a title tooltip, its trust badge, and its transport badge, mirroring the existing federation node row"
- "Clicking Open Channel twice, or opening two channels to the same peer at once, results in one open attempt — the action is disabled while a request is in flight (FED-05 concurrency edge)"
- "A manually pasted pubkey with no host still works, falling back to the address-less open path the Lightning channels panel already relies on"
- "The manual-paste field is reached through a de-emphasised Paste URI Manually entry point below both lists, not as a third equal-weight column"
- "The modal renders through the house modal shell so its backdrop covers the full screen and a click outside closes it"
- "The request flow reuses the existing peer-request modal pattern — optional message field, Send Request submit, Sending… busy label"
- statement: "When both lists are empty the shared empty state renders exactly once rather than once per list"
verification: backstop
- statement: "A manually pasted URI that is not in pubkey@host:port form is rejected client-side with a format message before any open call is made"
verification: backstop
prohibitions:
- statement: "A channel-open request sent to a meshed peer MUST NOT be displayed as an open, pending-funding, or connected channel anywhere in the UI — until the recipient acts, it is a sent request and nothing more"
category: transparency
- statement: "The picker MUST NOT present a meshed peer's advertised URI with the same visual authority as a bilaterally-trusted federated node — the two lists stay visually distinct and the meshed action stays a request, so a user cannot mistake an unverified advertisement for a trusted target"
category: safety
artifacts:
- path: neode-ui/src/components/LightningChannelModal.vue
provides: "Own-URI share, trusted-node picker, meshed-peer request picker, manual-paste fallback"
min_lines: 150
- path: neode-ui/src/components/__tests__/LightningChannelModal.test.ts
provides: "State coverage for empty, loading, error, populated, dedup, ordering, and double-click"
min_lines: 60
key_links:
- from: neode-ui/src/components/LightningChannelsPanel.vue
to: neode-ui/src/components/LightningChannelModal.vue
via: "the panel's existing Open Channel button opens the new picker modal"
pattern: "LightningChannelModal"
- from: neode-ui/src/components/LightningChannelModal.vue
to: neode-ui/src/api/rpc-client.ts
via: "federation.list-nodes, mesh.lightning-peers, lnd.getinfo, lnd.openchannel, mesh.request-channel"
pattern: "lightning-peers"
---
<objective>
Make channel opening between nodes first-class UI: share your node's Lightning URI, open a channel
with a trusted federated node in one click, and request a channel from a meshed peer that has
Lightning installed.
Purpose: FED-05's user-facing half, with scope locked in CONTEXT.md (the second list is *meshed peer
nodes that have Lightning installed* — not `lnd listpeers`, not a curated directory, not a live
LN-graph query) and its visuals fixed by 01-UI-SPEC.md (copy, colours, spacing, the shared empty
state, the de-emphasised manual-paste fallback, and the hard modal rule).
Output: a new picker modal built from the house design system, reached from the Lightning panel's
existing Open Channel button, working against the demo and against archi-dev.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
@.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md
@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md
@neode-ui/src/components/BaseModal.vue
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `LightningChannelModal.vue` | new Vue component (picker modal) | `neode-ui/src/components/LightningChannelModal.vue` |
| `meshLightningPeers()`, `requestChannel()`, `sendLightningInfo()` | new rpc-client wrappers | `neode-ui/src/api/rpc-client.ts` |
| `mesh.lightning-peers`, `mesh.send-lightning-info`, `mesh.request-channel` | new mock RPC cases | `neode-ui/mock-backend.js` |
| `identity_pubkey` / `uris` on the mock `lnd.getinfo` result | extended mock response | same |
| `lightning_uri` on the mock `federation.list-nodes` nodes | extended mock response | same |
| `LightningChannelModal.test.ts` | new vitest suite | `neode-ui/src/components/__tests__/LightningChannelModal.test.ts` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — open a channel with a trusted federated node in one click</name>
<precondition>`federation.list-nodes` emits `lightning_uri` (plan 01-06) and `mesh.lightning-peers` is registered in the dispatcher (plan 01-04) — confirm both by grepping `core/archipelago/src/api/rpc/federation/handlers.rs` and `core/archipelago/src/api/rpc/dispatcher.rs` before starting.</precondition>
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/api/rpc-client.ts, neode-ui/mock-backend.js, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/src/components/LightningChannelsPanel.vue</files>
<read_first>
- `neode-ui/src/components/BaseModal.vue` — the whole file. It already wraps `Teleport to="body"`,
the full-screen `bg-black/60 backdrop-blur-md` backdrop, `@click.self` close, the pinned
`text-xl font-semibold` title, and the scrolling body. The new modal MUST use it; never nest a
modal inside a transform-affected ancestor.
- `neode-ui/src/components/LightningChannelsPanel.vue` lines 246-360 (its current bespoke
open-channel modal, its `openError` ref, `isStartupNotice()` amber-vs-red distinction, and the
fee-preset block) and lines 505-630 (`showOpenModal`, `defaultOpenForm()`, `openForm`,
`openingChannel`, and the validate-before-RPC sequence including the 20,000-sat minimum and the
`pubkey@host:port` split with an optional address).
- `neode-ui/src/views/federation/NodeList.vue` lines 40-140 and 155-190 — the row layout to
mirror (truncated name with `:title`, transport badge, trust badge, action button), the
`trustedNodes` / `peerNodes` computed filters, and the "Loading nodes..." row.
- `neode-ui/src/api/rpc-client.ts` lines 795-850 — the one-line
`this.call({ method: '<ns>.<verb>', params })` wrapper convention and the existing
`federation.list-nodes` wrapper.
- `neode-ui/mock-backend.js` — the `lnd.getinfo`, `lnd.openchannel`, and `federation.list-nodes`
cases, and the parity harness added by plan 01-02.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — Design System, Spacing,
Typography, Color, and the full Copywriting Contract. Every string in this modal comes from
that table verbatim.
</read_first>
<behavior>
- Mounted with a node list containing one trusted node that has a Lightning URI and one that does
not, the trusted list renders exactly one row.
- The row shows the node name (truncated, with a `title`), its trust badge, its transport badge,
and an Open Channel button.
- Clicking Open Channel calls the open RPC once with that node's URI; clicking it twice in
immediate succession still results in exactly one call, and the button is disabled while in
flight.
- While the node list is loading, the loading treatment renders and no empty state renders.
- When the node fetch rejects, the error row renders with the contract's error copy.
- The modal root renders through the house modal shell, so the backdrop is a full-screen sibling
of the card rather than a child of a transformed ancestor.
</behavior>
<action>
Write the test file first and confirm it fails.
Add rpc-client wrappers for `mesh.lightning-peers`, `mesh.send-lightning-info`, and
`mesh.request-channel` following the existing one-line convention. Extend the mock backend so the
demo answers all three, so `lnd.getinfo` returns an `identity_pubkey` and a `uris` array, and so
`federation.list-nodes` nodes carry `lightning_uri` — mirroring the real handlers per the mock's
established "cite the daemon source" comment convention. The plan-01-02 parity harness must stay
green.
Create `LightningChannelModal.vue` using `BaseModal` as its shell, title "Open Lightning Channel".
In this task implement the trusted-node section only: fetch the federated node list, keep nodes
whose trust level is trusted AND which have a Lightning URI, sort by display name with a stable
tiebreak so the order does not shuffle between refreshes, and render each as a row mirroring the
federation node row — truncated name with a `title` tooltip, the transport badge reusing
NodeList's existing FIPS/Tor logic, the trust badge, and an Open Channel button on the right.
Wire Open Channel to the existing open-channel RPC, reusing the panel's proven sequence: validate
before calling, keep the 20,000-sat minimum, split the URI into pubkey and optional address, and
reuse the `openError` ref plus the `isStartupNotice()` amber-vs-red distinction rather than
inventing a new error idiom. Guard against double submission with an in-flight flag keyed to the
target so the button is disabled and a second click is a no-op.
Point the Lightning panel's **existing** Open Channel button at this modal instead of its bespoke
one. Do not add a new nav entry, route, card, or dashboard tile — the user places new entry
points, this plan only upgrades the one that already exists. Leave the panel's channel list,
close-channel flow, and fee presets untouched.
Follow the UI-SPEC tables exactly: spacing on the 4px grid, the two-weight typography scale,
accent orange reserved for the primary action buttons, the bolt icon path already used elsewhere
in the app, and the copy strings verbatim.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; test -f src/components/__tests__/LightningChannelModal.test.ts &amp;&amp; npx vitest run src/components/__tests__/LightningChannelModal.test.ts &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- The test file exists and `npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with all six behaviors present as named cases (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
- A test asserts two immediate clicks produce exactly one open call.
- `grep -c 'BaseModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (import + usage).
- `grep -c 'Open Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1 and the copy matches the UI-SPEC Copywriting Contract verbatim.
- `grep -c 'LightningChannelModal' neode-ui/src/components/LightningChannelsPanel.vue` is at least 2.
- `grep -c "'mesh.lightning-peers'" neode-ui/src/api/rpc-client.ts` equals 1.
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero missing methods.
- `cd neode-ui && npx vitest run` exits 0 and `npm run build` exits 0.
- No new route, nav item, or dashboard card was added — confirmed by `git diff --stat` showing no change to the router or any layout/nav component, recorded in the SUMMARY.
</acceptance_criteria>
<done>A trusted federated node can be picked by hostname and a channel opened with one click, through the house modal shell, on the demo and against a real node.</done>
</task>
<task type="auto">
<name>Task 2: The meshed Lightning peers list and the Request Channel flow</name>
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/mock-backend.js</files>
<read_first>
- `neode-ui/src/components/federation/PeerRequestModal.vue` — the whole file (66 lines): the
optional message field, the `sending` → "Sending…" busy label, and the
`$emit('send', message)` / `$emit('cancel')` contract. Reuse this component rather than
building a second request modal.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-05 Visual Anchor"
section (trusted list is primary, meshed list second) and the empty-state copy rows.
- `neode-ui/src/views/federation/NodeList.vue` — the empty-state block treatment to mirror.
- The plan-01-07 SUMMARY — the exact params `mesh.request-channel` expects.
</read_first>
<action>
Add the meshed-Lightning-peers section below the trusted list: fetch via `mesh.lightning-peers`,
render rows in the same layout with a Request Channel button in place of Open Channel, and sort
with the same stable ordering rule.
Deduplicate across the two lists: a peer that is both a trusted federated node and a meshed
Lightning peer appears only in the trusted list. Match on the identity available in both payloads
(the node's Lightning URI is the reliable common key; fall back to the peer's archipelago identity
key when present). Never match on display name.
Wire Request Channel to `PeerRequestModal` — mount it with the optional message field, and on its
send event call `mesh.request-channel` with the target peer and the message. While a request is
in flight the row's button is disabled and shows the busy label; a second click is a no-op. On
success show a sent-request confirmation on the row. That confirmation must not claim a channel
exists, is pending funding, or is connected; it says a request was sent and nothing more.
Add the shared empty state: when the trusted list and the meshed list are BOTH empty, render the
UI-SPEC's empty heading and body exactly once for the pair — not once per list. When only one is
empty, that section renders nothing rather than its own empty state. Render the house loading
treatment per section while its fetch is in flight, and the contract's error row on a failed
fetch, using the same `openError` / startup-notice idiom as Task 1.
Extend the mock backend so `mesh.lightning-peers` returns a small demo peer set and
`mesh.request-channel` records the request in the session store so the demo shows the same sent
state a real node does.
Extend the test suite: both-empty renders one empty state; one-empty renders none for that
section; a peer present in both lists renders once and in the trusted list; ordering is identical
across two consecutive renders of a shuffled input; a double click on Request Channel produces one
call; the sent confirmation contains no open or connected wording.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/components/__tests__/LightningChannelModal.test.ts &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the six cases above present by name.
- The both-empty case asserts an element count of exactly 1 for the empty-state element, not merely that it is present.
- `grep -c 'PeerRequestModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2.
- `grep -c 'Request Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0.
- `cd neode-ui && npx vitest run && npm run build` exits 0.
</acceptance_criteria>
<done>Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording.</done>
</task>
<task type="auto">
<name>Task 3: Share your own URI, and the manual-paste fallback</name>
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts</files>
<read_first>
- `neode-ui/src/components/SendBitcoinModal.vue` — its `copyDetail` / "Copied!" clipboard
feedback pattern (the label flips for about two seconds). Reuse it; do not invent a new
copy-feedback idiom.
- `neode-ui/src/components/LightningChannelsPanel.vue` lines 250-270 and 595-625 — the
`pubkey@host:port` placeholder, the `Format: pubkey@host:port` helper text, and the
validate-before-RPC sequence including the address-optional split.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract rows
for "Primary CTA — own URI" and "Manual fallback entry point", and the UI Considerations rows
marked backstop for the manual-paste form.
</read_first>
<action>
Add the own-node URI block at the top of the modal: read this node's Lightning identity from
`lnd.getinfo`, display the URI truncated to its container with the full value in a `title`
tooltip, and add a copy button whose label flips to the confirmation string for about two seconds.
The clipboard receives the full untruncated value regardless of visual truncation. When the node
has no Lightning URI available, the block explains that instead of showing an empty field or a
fabricated address.
Add the manual-paste fallback below both lists as a de-emphasised disclosure, not a third
equal-weight column: the entry point reveals a peer URI input with the placeholder and helper text
reused verbatim from the Lightning panel. Validate client-side before calling the open RPC — a
value that is not in `pubkey@host:port` form is rejected with the format message and no RPC is
issued; a bare pubkey with no host is accepted and passes an undefined address through, which is
the behavior the open RPC already supports. Reuse the same error ref and startup-notice treatment.
Extend the test suite: the copy button places the full untruncated URI on the clipboard and its
label flips then reverts; the URI element carries a `title` with the full value; an invalid
pasted value shows the format message and issues no RPC call; a bare pubkey issues the open call
with an undefined address; the no-URI-available state renders its explanation rather than an
empty field.
Then verify on the dev preview against archi-dev before this plan is considered complete, per the
user requirement in CONTEXT.md: the preview at the dev port, the copy button, the trusted list,
the meshed list, the request flow, and the manual paste. Record what was exercised in the SUMMARY.
The blocking human sign-off is consolidated into plan 01-09.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/components/__tests__/LightningChannelModal.test.ts &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the five cases above present by name.
- A test asserts the clipboard receives the full untruncated URI even when the rendered element is truncated.
- A test asserts an invalid pasted value results in zero RPC calls (assert on the call count, not merely on the message being visible).
- `grep -c 'Copy Lightning URI' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
- `grep -c 'Paste URI Manually' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
- `grep -c 'pubkey@host:port' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (placeholder + helper text).
- `cd neode-ui && npx vitest run && npm run build` exits 0.
- The SUMMARY lists the dev-preview steps exercised against archi-dev and what was observed.
</acceptance_criteria>
<done>A user can share their own node's Lightning URI and fall back to a pasted URI with real client-side validation, verified on the dev preview against a real node.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| daemon RPC → browser | Peer-advertised Lightning URIs, some of them from unauthenticated radio peers, are rendered and offered as payment targets |
| browser → clipboard | This node's payment endpoint is copied for the user to share out of band |
| user click → `lnd.openchannel` | A UI action commits real funds to a channel |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-31 | Spoofing | a meshed peer's advertised URI presented with the same authority as a bilaterally-trusted federated node, luring funds to an attacker | high | mitigate | The two lists stay visually and semantically distinct; the meshed action is Request Channel, never a direct open; the prohibition above states this and a test asserts the meshed row's action wording |
| T-01-32 | Tampering | a peer-supplied node name or URI containing markup that renders as UI | high | mitigate | Vue's default text interpolation escapes; the plan uses no `v-html` anywhere. A test asserting a name containing angle brackets renders as text is required before this row can be dispositioned |
| T-01-33 | Repudiation | a sent request being read as an open channel, so a user believes they have inbound liquidity they do not | high | mitigate | The sent confirmation is worded as a request only; a test asserts the confirmation contains no open or connected wording |
| T-01-34 | Denial of Service | a double click or a fast repeat committing two channel opens to the same peer | high | mitigate | An in-flight flag keyed to the target disables the action and makes a second click a no-op, backed by the plan-01-07 backend suppression window; a test asserts exactly one call for two immediate clicks |
| T-01-35 | Information Disclosure | this node's Lightning URI being displayed to a shoulder-surfer or copied in a shared session | low | accept | A Lightning URI is a public payment endpoint by design; it is deliberately shareable and carries no spend authority |
| T-01-36 | Elevation of Privilege | the modal bypassing the open RPC's server-side validation by calling with unvalidated input | medium | mitigate | Client-side validation is additive only; the existing server-side pubkey-format and amount-bounds validation in the open handler is reused unchanged and is the authority |
</threat_model>
<verification>
- `cd neode-ui && npx vitest run` — green.
- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
- `cd neode-ui && npm run build` — green.
- Dev-preview walkthrough against archi-dev recorded in the SUMMARY (own URI copy, trusted open, meshed request, manual paste), per CONTEXT.md's "verified on the dev preview before any deploy" requirement.
</verification>
<success_criteria>
- Own-node URI is displayed, truncated with a tooltip, and copied in full.
- Trusted federated nodes with Lightning are listed by hostname with a one-click open.
- Meshed Lightning peers are listed separately and requestable, deduplicated against the trusted list.
- Shared empty state renders once; loading and error states follow the house conventions.
- Manual paste validates client-side and supports a bare pubkey.
- Double-submission is impossible; a request is never shown as a channel.
- No new route, nav entry, or dashboard card was added.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,275 @@
---
phase: 01-federation-mesh-hardening
plan: 09
type: execute
wave: 5
depends_on: ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08"]
files_modified:
- .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
- core/archipelago/src/federation/storage.rs
- core/archipelago/src/federation/sync.rs
- core/archipelago/src/api/rpc/federation/handlers.rs
- core/archipelago/src/fips/dial.rs
- core/archipelago/src/mesh/mod.rs
- core/archipelago/src/api/rpc/mesh/typed_messages.rs
autonomous: true
requirements: [FED-03, FED-01]
must_haves:
truths:
- "A findings document exists listing every issue the structured review of the federation/fleet area and the mesh area produced, with file and line citations"
- "Every finding carries exactly one disposition — fixed, or deferred with a written reason — and no finding is left without one (FED-03 ordering edge)"
- "Every reviewed area appears in the document, including areas where the review produced no findings, recorded as reviewed with none rather than omitted (FED-03 empty edge)"
- "Every federation and mesh claim in the codebase concerns document is re-verified against current code and git history before being filed as a finding or dismissed, with the evidence cited (FED-03 adjacency edge)"
- "Every finding marked fixed cites the commit and the test or command that demonstrates the fix"
- "The known-fixed claims are recorded as already-fixed with their commit, not re-fixed"
prohibitions:
- statement: "A finding MUST NOT be closed as fixed without evidence a reader can re-run — a disposition of fixed always cites a commit and a verifying command or test name, never an assertion alone"
category: transparency
- statement: "A finding MUST NOT be dropped silently — an item judged out of scope is recorded as deferred with the reason and the phase or requirement that owns it, never deleted from the list"
category: transparency
artifacts:
- path: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
provides: "The FED-03 structured review output with per-finding dispositions"
min_lines: 60
key_links:
- from: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md
to: .planning/codebase/CONCERNS.md
via: "each federation/mesh concern is re-verified and cross-referenced by its finding id"
pattern: "CONCERNS"
---
<objective>
Run the structured code review FED-03 requires over the federation/fleet area and the mesh area, and
close it out: every finding fixed or explicitly deferred with a reason.
Purpose: FED-03. RESEARCH.md Pitfall 2 is the governing constraint — `.planning/codebase/CONCERNS.md`
is NOT current truth for this phase. At least two of its federation claims were already fixed on main
before this phase started (the tombstone-write-swallowed claim was fixed in `01cbec27`; the
peer-joined DID path does verify an ed25519 signature). Re-fixing an already-fixed bug wastes the
review and risks reverting working code, so every claim gets a fresh code read plus a git-history
check before it is filed or dismissed.
Output: `01-REVIEW-FINDINGS.md` with a disposition on every finding, the small findings fixed inline,
and the phase's code deployed to the dev pair so plan 01-10's verification has something to test.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/codebase/CONCERNS.md
@.planning/codebase/ARCHITECTURE.md
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md
@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md
@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `01-REVIEW-FINDINGS.md` | new findings document with per-finding dispositions | `.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md` |
| finding-dependent fixes | code changes in the reviewed areas | files listed in `files_modified` |
| dev-pair deployment | the phase build running on archi-dev-box and x250-dev, sha256-verified | (no repo file) |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end — one finding from discovery to closed disposition</name>
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
<read_first>
- `.planning/codebase/CONCERNS.md` — the federation and mesh entries: the node-removal tombstone
gap (cited at `federation/storage.rs:180-197`), the incomplete federation DID validation, the
unbounded harness curl (cited as a multinode test-harness issue), the node-list dedup scaling
note, and the mesh radio configuration boot race.
- `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — the "Common Pitfalls" section
(especially Pitfall 2's instruction to `git log -p` each cited range before acting) and the
"Assumptions Log" rows A1, A2, and A5. A5 in particular is explicitly NOT independently
re-verified and must be re-checked here.
- The SUMMARYs from plans 01-01, 01-05, and 01-07 — what has already been fixed in this phase,
so those items are recorded as fixed-by-this-phase rather than re-opened.
</read_first>
<action>
Create `01-REVIEW-FINDINGS.md` with a table whose columns are: finding id (`F-01`, `F-02`, …),
area (federation store / federation sync / federation RPC / FIPS-transport dial / mesh core /
mesh RPC surface), severity, the file and line citation, the evidence (what was read and what
`git log -p` or `git blame` showed), the disposition (`fixed` / `already-fixed` / `deferred`), and
for `fixed` the commit plus the verifying command or test name, or for `deferred` the reason and
the owning phase or requirement.
Then take exactly one finding all the way through in this task, to prove the pipeline: re-verify
the codebase-concerns claim about incomplete federation DID validation — specifically the part
RESEARCH.md flags as un-re-verified, whether anything checks proof of ownership of a DID on first
contact, as opposed to the peer-joined path which does verify a signature. Read the add-node and
peer-joined paths in the federation RPC handlers and run `git log -p` on them. File the finding
with its evidence, then either fix it (if the fix is contained and does not touch federation trust
or join cryptography beyond what correctness requires — CONTEXT.md's scope fence) or defer it with
a written reason naming what a fix would touch and why that belongs elsewhere.
Record the two claims RESEARCH.md already verified as fixed with their commits, as `already-fixed`
rows, so a future reader does not re-open them.
</action>
<verify>
<automated>test -f .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md &amp;&amp; grep -Eq '^\| *F-01' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md &amp;&amp; cd core &amp;&amp; cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `01-REVIEW-FINDINGS.md` exists with a header row and at least one `F-NN` row.
- The first finding's row has a non-empty evidence cell naming the command that produced it and a non-empty disposition cell.
- Rows exist recording both already-fixed claims with their commit hashes.
- `cd core && cargo test -p archipelago federation` exits 0 (if the first finding was fixed here, its test is included).
- The SUMMARY quotes the `git log -p` output excerpt that decided the first finding.
</acceptance_criteria>
<done>The findings document exists and one finding has travelled the full path from claim to evidence to disposition.</done>
</task>
<task type="auto">
<name>Task 2: Complete the review across both areas and disposition every finding</name>
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md, core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/fips/dial.rs, core/archipelago/src/mesh/mod.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs</files>
<read_first>
- `core/archipelago/src/federation/``storage.rs`, `sync.rs`, `types.rs`, `invites.rs`, `mod.rs`
as left by plans 01-01, 01-05, and 01-06.
- `core/archipelago/src/api/rpc/federation/handlers.rs` — the full RPC surface, including the
peer-joined, peer-did-changed, and peer-address-changed signature-verification paths.
- `core/archipelago/src/fips/dial.rs` and the transport dial/fallback path — the FIPS-to-Tor
fast-fail behaviour FED-03 names as in scope.
- `core/archipelago/src/mesh/mod.rs``purge_federation_peer`, `upsert_federation_peer`,
`seed_federation_peers_into_mesh`; and `core/archipelago/src/api/rpc/mesh/typed_messages.rs`
as left by plans 01-04 and 01-07.
- `.planning/codebase/CONCERNS.md` — every remaining federation and mesh entry.
- `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` — the mock-parity residual
class that plan flagged as a candidate finding for this review.
- `.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md` — the scope fence: do not touch
federation trust or join cryptography beyond what removal and sync correctness require, and no
data-destroying migrations.
</read_first>
<action>
Review each area in turn and add its findings to the document. For each codebase-concerns claim,
do the fresh read plus `git log -p` on the cited range BEFORE filing or dismissing it, and put
that evidence in the row — a finding that merely restates a concerns bullet without fresh
evidence is not admissible.
Areas to cover, each of which must appear in the document even when it produced no findings —
record those as reviewed with none rather than omitting them: federation store, federation sync,
federation RPC surface, FIPS and transport dial, mesh core, mesh RPC surface.
Required specific checks, each of which becomes a row:
- The mock-parity residual class flagged in the plan 01-02 SUMMARY (a mock case that exists but
returns a differently-shaped success object than the daemon).
- Whether the paid-tick grep from plan 01-03 still finds exactly the two surfaces it found at
planning time, or whether a third has appeared.
- The unbounded-curl concern: confirm it belongs to the multinode test harness and defer it to
the phase that owns that requirement, with that phase named in the reason.
- The node-list dedup scaling note: disposition it with the peer counts this fleet actually runs.
- The mesh radio configuration boot race: confirm against current code and defer if it needs real
LoRa hardware, naming that as the reason.
Fix findings that are contained — a bounded change inside the reviewed area with a test — and
commit each as its own focused commit per CLAUDE.md. Defer anything that would breach the
CONTEXT.md scope fence, require hardware this session lacks, or belong to another phase, and write
the reason and the owner in the row. Every row ends with exactly one disposition.
Finish with a short summary section stating the counts: findings filed, fixed, already-fixed, and
deferred; and a line stating that the counts sum to the number of rows.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago &amp;&amp; cd ../neode-ui &amp;&amp; npx vitest run &amp;&amp; node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago` exits 0.
- `cd neode-ui && npx vitest run` exits 0 and `node scripts/mock-rpc-parity.mjs` exits 0.
- Every row in `01-REVIEW-FINDINGS.md` has a non-empty disposition cell — verify by counting rows and counting non-empty disposition cells and asserting the two numbers match; record both numbers in the SUMMARY.
- All six named areas appear in the document.
- The summary section's counts sum to the row count.
- Every `fixed` row cites a commit hash and a verifying command or test name.
- Every `deferred` row has a non-empty reason and names an owning phase or requirement.
</acceptance_criteria>
<done>Both areas are reviewed, every finding has exactly one evidenced disposition, and the contained fixes are committed.</done>
</task>
<task type="auto">
<name>Task 3: Build and deploy the phase to the dev pair, sha256-verified</name>
<precondition>archi-dev-box and x250-dev are reachable over the fleet network — confirm with a bounded connectivity probe to each before starting; if either is unreachable, halt rather than deploying to a partial pair.</precondition>
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
<read_first>
- `scripts/deploy-to-target.sh` — the established deploy path and the environment variable it
takes for the target. Read it fully before running it; do not hand-roll a deploy.
- `CLAUDE.md` — the build instructions (cargo from `core/`; frontend build outputs to
`web/dist/neode-ui/`; grep the built bundle for new strings because the build can silently
no-op) and the deploy discipline (dev pair before any OTA).
- The project memory note on deploying via service restart while containers are running — confirm
what the deploy script does about restarts before running it, and record the answer.
</read_first>
<action>
Build the backend from `core/` and the frontend from `neode-ui/`, then grep the built frontend
bundle for a string introduced by this phase to prove the build is not stale.
Deploy to archi-dev-box and then to x250-dev using the established deploy script, one at a time.
After each, verify the deployed binary's sha256 matches the locally built artifact, and record
both hashes. After each deploy, check that the node's app containers are still running and record
the result — a deploy that takes containers down is a finding, not a success.
Add a short deployment section to `01-REVIEW-FINDINGS.md` recording: the built artifact hashes,
the two target hostnames, the per-target sha256 match, the container-survival result, and the
frontend bundle grep result.
Do not deploy to any other fleet node, do not cut a release, and do not publish an OTA — this
phase ends at the dev pair plus the verification in plan 01-10.
</action>
<verify>
<automated>grep -Eq 'sha256' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo build --release -p archipelago` exits 0 and `cd neode-ui && npm run build` exits 0.
- The built frontend bundle contains a string introduced by this phase — assert with a grep over `web/dist/neode-ui/assets/` for the badge ring class name added in plan 01-03.
- The deployment section records two target hostnames, two sha256 pairs that match, and a container-survival result per target.
- No release tag was created and no OTA manifest was published — confirmed by `git tag --points-at HEAD` producing no output, recorded in the SUMMARY.
</acceptance_criteria>
<done>The phase's code is running on both dev-pair nodes, provably the artifact that was built, with containers intact.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer workstation → fleet node (deploy) | A built binary crosses onto a live node over SSH |
| review process → codebase | A fix applied during review changes federation trust-adjacent code |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-37 | Tampering | a deployed binary differing from the one built and tested | high | mitigate | Per-target sha256 comparison against the local artifact, both hashes recorded (Task 3) |
| T-01-38 | Denial of Service | a deploy restarting the service and killing running app containers | high | mitigate | The established deploy script is read before use and container survival is checked and recorded per target; a container loss is filed as a finding |
| T-01-39 | Elevation of Privilege | a review fix loosening federation trust or join verification | high | mitigate | CONTEXT.md's scope fence is a required read; findings needing trust-code changes are deferred with the reason rather than patched here; the full test suite gates each fix |
| T-01-40 | Repudiation | a finding quietly dropped so a known issue leaves no trace | medium | mitigate | The row-count-equals-disposition-count check and the summing counts section make an omission detectable; the prohibitions above state the rule |
| T-01-41 | Information Disclosure | deploy credentials or node passwords committed while recording deployment evidence | high | mitigate | The deployment section records hostnames and hashes only; per CLAUDE.md, never commit secrets. Stage by explicit path and review the diff before committing |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs && npm run build` — green.
- `01-REVIEW-FINDINGS.md` row count equals its disposition count, and the summary counts sum to it.
- Both dev-pair nodes report a matching sha256 and surviving containers.
</verification>
<success_criteria>
- A findings document covers six named areas, including those with no findings.
- Every finding has exactly one evidenced disposition; fixed rows cite commit and test, deferred rows cite reason and owner.
- Every codebase-concerns federation/mesh claim was re-verified against current code and git history before being filed or dismissed.
- The phase is deployed to the dev pair, sha256-verified, with containers intact and no release cut.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md` when done.
Stage by explicit path, commit each fix separately, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,153 @@
---
phase: 01-federation-mesh-hardening
plan: 10
type: execute
wave: 6
depends_on: ["01-09"]
files_modified: []
autonomous: false
requirements: [FED-01, FED-02, FED-05, FED-06]
must_haves:
truths:
- "An operator who removes a federated peer on a live node does not see it reappear after at least two subsequent sync cycles"
- "A peer whose sync is failing shows the operator-visible sync-error badge on the live node, and the badge clears once that peer syncs successfully"
- "The channel-open flow works against a real node on the dev preview: the own-node URI copies, a trusted federated node opens in one click, a meshed Lightning peer can be sent a request, and a manually pasted URI is accepted"
- "The paid tick renders the branded ring on both payment-success surfaces on the dev preview, at a narrow and a desktop viewport, without clipping"
- "The demo and a real node behave the same through the mesh chat surface — aliasing a peer, reacting, editing, deleting, and sending an attachment produce the same modals and the same outcome on both"
prohibitions:
- statement: "The phase MUST NOT be signed off on demo evidence alone — every criterion in this checkpoint that names a real node is exercised against a real node, because a demo-only pass is exactly the divergence class this phase exists to remove"
category: transparency
artifacts: []
key_links: []
---
<objective>
Consolidate every human-gated verification this phase owes into one sign-off, run against the dev
pair rather than the demo.
Purpose: `01-VALIDATION.md` lists three manual-only verifications (removal sticking across real sync
cycles, the channel-open flow end to end, and the paid-tick visual), and CONTEXT.md adds the user's
own requirement that FED-05 and FED-06 are verified on the dev preview against archi-dev **before any
deploy**. Rather than interrupting each implementation plan with its own checkpoint, they are gathered
here so the operator is asked once, after the code is on the dev pair.
Output: a recorded sign-off, or a list of issues that becomes the input to a gap-closure pass.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-VALIDATION.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md
</context>
## Artifacts this phase produces
This plan produces no new symbols. It verifies the artifacts produced by plans 01-01 through 01-09:
the serialized federation store, the sync-error badge, the mesh Lightning identity and request
messages, the federation Lightning URI field, the channel-open picker modal, the branded paid tick,
and the demo RPC parity harness.
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: Phase 1 consolidated verification on the dev pair</name>
<what-built>
Phase 1 in full, deployed to archi-dev-box and x250-dev and sha256-verified by plan 01-09:
- Federation node-store writes are serialized behind one lock with an atomic node-list write, so
a removal issued during a sync pass can no longer be undone by that sync.
- One periodic federation sync loop instead of two; per-peer sync failures are persisted and
shown as a badge on the node row, clearing when the peer recovers; out-of-order snapshots can
no longer move a peer's state backwards.
- A new channel-open picker modal reached from the Lightning panel's existing Open Channel
button: your own node's Lightning URI with a copy button, trusted federated nodes listed by
hostname with a one-click open, meshed peers running Lightning listed separately with a
request flow, and a manual URI paste fallback.
- The payment-success tick now uses the screensaver ring with its EQ segments on both the send
modal and the scan modal.
- The demo backend answers every mesh and federation RPC the UI calls, and reactions, edits,
deletes, and peer aliasing change demo state instead of returning a bare acknowledgement.
</what-built>
<how-to-verify>
Run these against the dev pair, not the demo, except where a step says demo.
1. **Removal sticks (FED-01).** On archi-dev-box, open the Federation view and remove a federated
peer. Wait through at least two auto-sync cycles — the loop runs every 90 seconds, so give it
four minutes — then reload. Expected: the peer is gone and stays gone. Then try removing a peer
that no longer exists (repeat the removal): expected an error message, not a silent success.
2. **Sync errors are visible (FED-02).** Make one federated peer unreachable — take its node off
the network, or block it — and wait one sync cycle. Expected: that node's row shows a sync-error
badge, and hovering it shows the error text and when it happened. Bring the peer back and wait
one more cycle. Expected: the badge clears on its own.
3. **Channel opening (FED-05).** Open the dev preview pointed at archi-dev and go to the Lightning
channels panel, then click Open Channel. Expected: a full-screen modal (the backdrop covers the
whole window and clicking outside closes it), showing your node's Lightning URI at the top.
Click Copy Lightning URI: expected the label flips to Copied! for about two seconds, and pasting
elsewhere gives the complete URI even though the on-screen text is shortened. Check the trusted
list shows your federated nodes by hostname with their FIPS or Tor badge. Check the meshed
Lightning peers list below it. Click Request Channel on a meshed peer, add a short message, and
send: expected a "request sent" style confirmation that does NOT claim a channel is open or
connected. Click Paste URI Manually, enter something malformed such as text with no at-sign:
expected a format message and no attempt to open. Then paste a valid peer URI: expected the
normal open flow. Finally, double-click Open Channel on a trusted node: expected one open
attempt, with the button disabled while it runs.
4. **Paid tick (FED-06).** On the dev preview, trigger a payment success in the send modal and in
the scan modal. Expected: the checkmark now sits inside the screensaver-style ring with the
radiating segment lines, at both a narrow phone width and a desktop width, with nothing cut off
by the edge of the card. The amount and the SENT wording are unchanged.
5. **Demo and real node match (FED-04).** On the demo, rename a mesh peer, react to a message,
edit one, delete one, and send a small file attachment. Then do the same on archi-dev.
Expected: the same modals appear in the same situations, the changes are visible in both, and
the browser console shows no "Method not found" errors on either.
If anything fails, describe what you saw and which numbered step it was — that becomes the gap
list for a closure pass rather than a re-run of the whole phase.
</how-to-verify>
<resume-signal>Type "approved" to sign off Phase 1, or describe the issues by step number.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator judgement → phase sign-off | A human verdict gates whether this phase is considered complete |
| live fleet node → operator observation | Verification runs against real nodes carrying real federation trust and real Lightning funds |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-42 | Repudiation | signing off on demo evidence while a real node still fails | high | mitigate | Each step names where it runs; the prohibition above forbids demo-only sign-off; step 5 explicitly compares the two |
| T-01-43 | Elevation of Privilege | a removed peer regaining federation membership unnoticed because the check was too short | high | mitigate | Step 1 requires waiting at least two sync cycles at the 90-second interval, stated as a wall-clock duration rather than "a while" |
| T-01-44 | Denial of Service | the verification itself taking a live node off the network and leaving it that way | medium | mitigate | Step 2 restores the peer as part of the step and requires observing the badge clear, so the node cannot be left isolated as a side effect |
| T-01-45 | Spoofing | a channel opened against a peer-advertised URI during verification sending funds to the wrong node | high | mitigate | Step 3's request path targets a meshed peer with a request, not an open; the one-click open is exercised only against a bilaterally-trusted federated node the operator already federated with |
</threat_model>
<verification>
The operator's response is the verification. An "approved" response completes the phase; any
described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`.
</verification>
<success_criteria>
- All five numbered checks were exercised, each in the place it names.
- The operator either approved or produced a numbered issue list.
- The outcome is recorded in the SUMMARY, including which node each check ran against.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-10-SUMMARY.md` when done, recording the
verdict, the node each check ran against, and any issue text verbatim.
</output>