--- phase: 01-federation-mesh-hardening plan: 04 subsystem: mesh tags: [lightning, mesh, FED-05, typed-envelope, channel-open] status: complete requires: - "01-CONTEXT.md's LOCKED FED-05 scope: the picker's public/other list is meshed peers that have Lightning installed" - "RESEARCH.md Pitfall 5 — neither datum existed; PATTERNS.md — peer capability advertisement has no analog" provides: - "lnd.getinfo carries identity_pubkey + uris (or an honest absence)" - "MeshMessageType::LightningInfo = 26 + LightningInfoPayload + is_valid_lightning_uri()" - "MeshPeer.lightning_uri, populated only by an explicit advertisement" - "mesh.lightning-peers (deduplicated, deterministically ordered) and mesh.send-lightning-info (target required)" affects: - "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/mesh/listener/decode.rs (unplanned — see Deviations)" - "core/archipelago/src/mesh/listener/session.rs (unplanned — see Deviations)" - "core/archipelago/src/mesh/mod.rs (unplanned — see Deviations)" - "core/archipelago/src/api/rpc/mesh/typed_messages.rs" - "core/archipelago/src/api/rpc/dispatcher.rs" tech-stack: added: [] patterns: - "Extract a pure function at the seam so a contract is testable without a live service (map_identity, build_lightning_peer_list, parse_send_lightning_target) — the same shape Task 1's plan prescribed, reused for Task 3 where no handler test harness exists" - "Validate unauthenticated RF input BEFORE touching stored state, so a malformed message cannot destroy a good prior value" - "Mutation testing as evidence that tests are load-bearing, where pre-implementation failure output was not captured" key-files: created: [] 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/mesh/listener/decode.rs" - "core/archipelago/src/mesh/listener/session.rs" - "core/archipelago/src/mesh/mod.rs" - "core/archipelago/src/api/rpc/mesh/typed_messages.rs" - "core/archipelago/src/api/rpc/dispatcher.rs" decisions: - "is_valid_lightning_uri deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose" - "Dedup keys on identity_pubkey_hex() (the authenticating key), lowercased, never the firmware routing key — T-01-11" - "last_heard compared as a parsed RFC3339 timestamp, not as a string, so a differing UTC offset cannot misorder 'newest wins'" - "Three unplanned files were touched: all three rebuild MeshPeer wholesale and would have silently wiped lightning_uri" requirements-completed: [] metrics: duration: "~1h" completed: 2026-08-02 tasks_completed: 3 tasks_total: 3 --- # 01-04 — the two Lightning facts the channel-open picker needs (FED-05) ## What shipped | Task | Delivered | |---|---| | 1 | `lnd.getinfo` deserializes and returns `identity_pubkey` + `uris`; a pubkey that is not 66 hex chars maps to `None` rather than being forwarded | | 2 | `MeshMessageType::LightningInfo = 26`, `LightningInfoPayload { uri, alias? }`, `is_valid_lightning_uri()`, `MeshPeer.lightning_uri`, and a validating inbound dispatch arm | | 3 | `mesh.lightning-peers` (filtered, deduplicated, stable-ordered) and `mesh.send-lightning-info` (explicit target required), both registered in the dispatcher | ## The part that was not in the plan, and mattered most `MeshPeer.lightning_uri` was specified as a field addition. It is, but **three separate code paths rebuild a `MeshPeer` wholesale**, and every one of them would have silently discarded the new field: 1. **`listener/decode.rs` — the identity-advert path.** A wholesale `peers.insert()` that hand-preserves only `advert_name` and `lat`/`lon`. Its own comment records why those two are there: Reticulum "re-emits identity adverts every announce tick", which had previously been renaming every federated contact once a minute. A stored Lightning URI would have been erased on the same schedule. 2. **`listener/session.rs` — `refresh_contacts`.** Rebuilds the record from the radio snapshot, which carries no Lightning datum. 3. **`mesh/mod.rs` — federation seeding.** Same shape. All three now carry the previous value forward. Without this the feature would have appeared to work in tests and quietly emptied the picker on a live node — the failure mode is an absence, which is exactly the kind that does not announce itself. ## Security posture - **T-01-12 (tampering):** the inbound arm validates the URI *before* the write and returns early on failure, so a malformed advertisement from anyone in range cannot blank out a real peer's entry. Asserted by test, not just by reading. - **T-01-13 (disclosure):** `mesh.send-lightning-info` requires an explicit `contact_id`. There is no broadcast form, and the test asserts that `{}`, `{"broadcast": true}` and an out-of-range id are all refused rather than treated as "send to everyone". - **T-01-11 (spoofing):** dedup keys on the authenticating key, never the firmware routing key. - **T-01-15 (EoP):** `server.rs` is untouched — `git diff HEAD` on it is empty, and `is_peer_allowed_path` still occurs 13 times. The peer allow-list was not widened. - Wire compatibility: discriminant 26 was unused, so a node predating this fails to decode the message rather than mis-decoding it as another type. The optional `alias` is `skip_serializing_if`, asserted to cost fewer bytes on air when absent — this rides LoRa. ## Evidence - **`cargo test -p archipelago`: 1087 passed, 0 failed, 2 ignored.** - New tests: 5 (`lnd::info`), 5 (`mesh::message_types`), 6 (`lightning_peer_tests`). - `cargo clippy --all-targets`: **no warnings in any touched module** (two `useless_format` lints in the new test code were fixed, not waived). - Every acceptance-criteria grep met, including the negative one on `server.rs`. ## Deviation: TDD ordering on Task 1 The plan required the SUMMARY to record "the pre-implementation failing output of the fixture tests". Tests and implementation were written in the same pass, so **that output does not exist and is not reproduced here.** Rather than drop the requirement's intent — *prove the tests are load-bearing* — a mutation test was run in its place. `is_valid_identity_pubkey` was replaced with `true`, and the suite re-run: ``` 3 failed: api::rpc::lnd::info::tests::malformed_pubkey_is_dropped_rather_than_propagated api::rpc::lnd::info::tests::a_malformed_pubkey_does_not_discard_the_advertised_uris api::rpc::lnd::info::tests::valid_pubkey_shape_matches_the_openchannel_rule ``` The mutation was reverted and its absence verified. This is stronger evidence than a pre-implementation red run (which only shows the code is absent, not that the assertions bind), but it is a deviation from the ordering the plan asked for, and is recorded as one. ## On-node verification (archi-dev-box, deployed 23:16) Deployed at commit `6b3693dc` off a clean tree and exercised over the real RPC surface: | Call | Result | |---|---| | `lnd.getinfo` | `identity_pubkey: 024a5fd7de13623aeec81095…` — a real key, deserialized by the field that did not exist before this plan. `alias: "Archipelago Node"` | | `mesh.lightning-peers` | `{"peers":[]}` — **success with an empty array**, not an error. The FED-05 empty edge, proven on hardware rather than only in a unit test | | `mesh.send-lightning-info` `{}` | refused: *"Missing contact_id: … requires an explicit target and has no broadcast form"* — T-01-13's mitigation observed live | | `mesh.send-lightning-info` with a target | refused: *"This node has no advertised Lightning URI to share — LND may be down, or configured with no externally reachable address"* | **A real finding from that last row:** this node's `lnd.getinfo` returns `uris: []`. Its LND has no externally reachable address configured, so it *cannot* advertise itself to a peer — the handler correctly refuses rather than sending an empty advertisement a peer would store as an undialable target. The receive and list halves work; the send half is inert on any node whose LND advertises no URI. **01-06 must not assume the local node can always share its own URI**, and the picker needs a sensible state for "you have nothing to share yet". ## Open / handed on - **The mesh leg is still unproven end to end.** Two archy nodes with LND and a radio link are needed to watch a real advertisement traverse the air and land in a peer's `lightning_uri`. Everything above is either unit-level or single-node RPC. - `mesh.lightning-peers` is a data source with no consumer until **01-06** builds the picker UI (which `depends_on` this plan). - `MeshPeer.lightning_uri`'s doc mentions federation seeding as a future source; this plan does not implement it, and `mesh/mod.rs` only preserves an existing value.