mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-30 07:37:36 +00:00
Corrections to the user, reference and design documentation and to source comments where they disagree with the code. No behaviour or wire change; the tests added here pin the behaviour the corrected text now states. Correct the OpenWrt tutorial on restricting port forwards. The OpenWrt gateway tutorial said the FIPS mesh firewall is the layer that limits which peers reach a port forward, pointed at the mesh firewall how-to, and told readers to add a fips-firewall drop-in for the listen port. None of that applies: the OpenWrt package ships neither the mesh firewall baseline nor fips-firewall.service, and on Linux the baseline hooks the input chain only and never sees a forward to a LAN host. A reader following the text would leave the forward open to every mesh peer that can reach the AP. The tutorial now says that the package's fips zone accepts port forwards from every mesh peer by default, and shows the fw4 rules that restrict one: an accept for the allowed peer followed by a REJECT for the rest, matching the target's port, with the note that an accept alone restricts nothing because other peers fall through to the packaged accept. The summary bullet and the troubleshooting row point at fw4 instead of the mesh firewall, and Linux readers are sent to the forward-hook recipe in deploy-gateway.md. Describe conntrack-less pinning and BLE evictions correctly. The gateway troubleshooting guide said that with no readable conntrack source a mapping is reclaimed on its TTL and grace period alone. It left out what the pool does on top of that: no mapping counts as having carried traffic, so a re-query does not extend a name (only a used mapping is refreshed, and the answer TTL is capped at the time left since creation), and at the ceiling a new name can replace any mapping. The guide now quotes the daemon's whole warning, states both effects, and says when mappings count traffic again: those created after conntrack reads have succeeded for three ticks in a row. The doc comment on the BLE pool's eviction counter said it counts a non-static peer being displaced. There is no static-peer notion in the pool; it records the eviction of an unverified link past its verify grace by a newcomer, which is the only kind the pool makes. Bring the IPv6 adapter docs and comments in line with the code. Several statements about the IPv6 adapter disagreed with the code: - FIPS_IPV6_OVERHEAD was described as 77 bytes on top of the 106-byte FIPS_OVERHEAD; 77 is the total (106 + 4 + 7 - 40). - The effective-MTU table called a 1472-byte transport the standard deployment; the UDP default is 1280. - All ICMPv6 errors were said to be rate-limited by a per-source token bucket. Only Packet Too Big is limited, by a per-source minimum interval of 100 ms; Destination Unreachable is not limited. - The TUN writer was said to clamp MSS on inbound SYN-ACKs only, and clamped SYN-ACKs were listed as one of its packet sources. The clamp acts on any SYN-flagged segment, in both the reader and the writer. The design docs, the MTU how-to, the writer's doc comments and its trace message now say so. - The design doc said the reader needs no "is this for me?" check. It has one: a packet to the node's own address is written back to the TUN, which macOS utun needs. - The app-owned TUN docs (design doc and the enable_app_owned_tun rustdoc) said FIPS neither filters off-mesh destinations nor answers them with ICMPv6 there, and that the embedder clamps only outbound SYNs. On that path an off-mesh destination is looked up and answered with Destination Unreachable on app_inbound_rx (an oversize packet with Packet Too Big), and no MSS is clamped in either direction. - The transport_mtu fallback comment said "UDP first, then Ethernet"; it takes a configured UDP instance's MTU, else 1280. A new test pins the app-owned behaviour the rustdoc now states: an off-mesh unicast destination is answered with Destination Unreachable from the node's own address on app_inbound_rx, and a multicast destination gets no answer. State what the debug inject command and the outbound path do. The debug_commands documentation (the config field's doc comment, the configuration reference and the Inject parameters doc) said inject writes only into one of the calling client's own flows, and so grants the client nothing it does not already have. inject looks the flow up by id in the node-wide flow table, so it can write into any flow on the node, including one another client opened or a listener accepted; the comment on the command itself and the native API reference already said so. All three now say the same, and the claim that it grants nothing new is gone. The handle_native_outbound doc said an error is reported to the client rather than as an ICMPv6 message. Nothing is reported: an oversize datagram is counted in drop_oversize and dropped, a send or session-initiation failure is logged at debug, and a datagram past the pending-queue bounds is dropped. The doc now lists those. Stop claiming a withdrawn advert is deleted with NIP-09. The Nostr events reference, the discovery design doc and the generic port-advertisement doc said the node withdraws its advert with a NIP-09 kind 5 deletion when advertising is turned off or the responder retires. The node never sends one. The delete plan is produced only when advertising is off and an advert event id is recorded, but an id is recorded only after a successful publish, which needs advertising on, and the advertise flag is fixed when the advert machine is built. A transient missing advert and shutdown both deliberately skip the delete, because on relays that apply deletions strictly it can also remove the replacement the next start publishes. The docs now say that no deletion is sent, and that an advert the node stops publishing stays on relays until its NIP-40 expiration, advert_ttl_secs after publication, unless a newer advert replaces it. The generic description keeps deletion as something a responder may do, and says FIPS does not. The doc comments on the publish plan's delete variant, plan_publish and clear_event_id now say that the delete path is never reached by a running node, and why. Correct the traversal signaling description. The Nostr events reference and the design docs disagreed with the signaling code in four places: - inReplyTo was described as the offer's event id. The responder sets it to the offer's nonce, the initiator matches answers by nonce, and the validator refuses any other value as a session mismatch. An implementation written from the docs would have every answer dropped. - The answer's PunchHint was said to tell both sides when to probe. Only the responder probes on it; the initiator does not read the hint and starts probing from its own punch_* settings when the answer arrives. Fixed in the reference, the port-advertisement doc and the discovery design doc. - All three event kinds were said to be signed with the node's identity key. The kind 21059 wrap is signed by a one-time key; only the seal inside it is signed with the identity key. - The FIPS advert example did not say that FIPS emits only the d, protocol, version and expiration tags and carries its signaling relays and STUN servers in the content as signalRelays and stunServers, present only with a udp:nat endpoint. Two tests pin the behaviour the docs now state: an answer whose inReplyTo is the offer's event id is refused as a session mismatch, and a signal's wrap is signed by a key other than the sender's while unwrapping recovers the sender's identity from the seal. Label the second msg2 DH as a repeat of es, not se. The IK msg2 code, the noise module doc, the wire-formats reference, the mesh-layer design doc and the msg2 diagram all called the second DH in msg2 `se`. Both sides actually pair the responder's static key with the initiator's ephemeral key, which is msg1's `es` pairing, so the step mixes the msg1 `es` value a second time and nothing computes Noise IK's `se`. Relabel it as `es` again everywhere, say in the code that it is not Noise's `se`, that it is the FMP v1 wire format and cannot change without breaking v1 peers, and that it leaves FMP v1 open to key compromise impersonation (FRC-1201 Section 9.3). Rename the two local bindings to `es2`. No behaviour or wire change. Correct the path MTU notification doc and the fips-mtu design doc. The PathMtuNotification doc said it is sent by a node that learns a path MTU from transit router feedback or ICMP Packet Too Big. It is sent by the session destination, echoing the forward-path minimum it saw in the envelope's path_mtu; a received MtuExceeded only updates local state. fips-mtu.md said TCP reports a per-connection MTU from TCP_MAXSEG. TCP reports the configured mtu and checks sends against it. It also said the TCP clamp mirror is tighter-only and never loosened. Entries are released on PathBroken (at most once a second per destination), peer removal and session timeout, discovery-learned entries expire with the coordinate cache TTL, and a link seed from a different transport replaces the stored value. Describe that lifecycle, and add a short section on the defences against forged signals: the 256-byte floors, the admission gate, corroboration against what this node has sent, and the rate-limited release. Correct transport comments and docs on TCP MTU, mDNS, BLE, Ethernet, Tor and UDP. Several transport comments and docs described behaviour the code does not have: - TCP: the config doc, configuration reference and transport-layer doc said the per-connection MTU comes from TCP_MAXSEG. TCP reports the configured mtu to FMP and checks sends against it. - mDNS: the module and supervisor comments said identity is proven by a Noise XX handshake. FMP here is Noise IK. Drop the note in the Nostr discovery doc that pointed at those comments. - BLE: the pubkey-exchange prefix doc said it is distinguished from FMP packets of version 1 or more. FMP is version 0, so the framer cannot tell them apart, as the caution below it already says. - Ethernet: the config doc said the runtime requires Linux. It is built for Linux (AF_PACKET) and macOS (BPF). - Tor: the config and transport docs, and the onion-service how-to, said control_port mode runs an inbound onion service. It only adds control-port monitoring to SOCKS5 outbound; directory mode is the only inbound mode. - UDP: the security reference said a UDP instance has no inbound exposure until bind_addr is set. A declared UDP instance listens on 0.0.0.0:2121 by default unless outbound_only is set. Correct the spanning tree docs on signatures, sequence, sizes, cache invalidation and flap dampening. - The TreeAnnounce signature was described as covering the entire message. It covers SHA-256 of the 48-byte declaration (node_addr, parent, sequence, timestamp); version and ancestry are not signed. - The sequence was said to increment on parent change. It also increments on becoming root and on an ancestry change under the same parent. - The TreeAnnounce size table added msg_type twice in the link-overhead column. The measured sizes are 168/264/328/488 bytes; note the 36-byte link overhead under the table. - Several design docs and a rate-limit comment said a parent change flushes the coordinate cache. The node drops only cached coordinates that route through it (parent switch or ancestry change) or sit under another root (becoming root), and both on parent loss or loop detection. - Flap dampening was described as engaging after more than flap_threshold switches in a sliding window. It engages when the count reaches flap_threshold, within a fixed window opened by the first switch. Correct the bloom filter frame size, triggers and propagation bound. - The wire-formats reference gave the v1 FilterAnnounce with link overhead as 1,072 bytes. It is 1,071 (1,035 payload plus 16 header, 4 timestamp and 16 tag), as the bloom design doc already says. - The bloom design doc listed local state changes (new identity, leaf-only dependent changes) as a send trigger. The identity does not change at runtime and nothing outside the tests adds a leaf-only dependent, so drop it. - The bloom and spanning tree docs bounded propagation at about 500 ms per hop. Announces leave only on the node's maintenance tick, so the bound is about one node.tick_interval_secs per hop (1 s by default); the 500 ms debounce is a floor below that. Correct forwarding docs on ranking, loop freedom and the coordinate cache. - The mesh-operation doc called link_cost an uncommon tie-breaker resolved by tree distance. Candidate ranking compares link cost first; tree distance and node_addr break ties. - Several docs and comments said the strict-progress check prevents routing loops even with stale coordinates. Each hop judges progress on its own cached coordinates for the destination, so the check does not guarantee loop freedom when hops hold different coordinates. Say that, and that the TTL bounds any loop. - The session-layer doc said FMP can fall back to bloom-only routing without cached coordinates. find_next_hop returns no next hop on a cache miss and the caller signals CoordsRequired. - The insert_coord_hint doc said a foreign-root destination can never be selected by the bloom step. A peer advertising coordinates under that root can be; the refusal stands for the reflector reason given in the same paragraph. - The find_next_hop and try_warm_coord_cache_ref doc blocks sat above the wrong functions, so rustdoc gave those two no docs and gave insert_coord_hint and warm_coord the wrong opening line. Move each block onto its function and fix the find_next_hop ranking wording. Correct FSP restart handling, error datagram sizes and XK labels. - The session-layer doc said an epoch mismatch on a later handshake marks a peer restart and triggers re-establishment. FSP exchanges the epoch but never reads it. A SessionSetup naming an established peer only arms a handshake beside the running session, and new keys are adopted at msg3 only when the authenticated static key matches. Describe that, and rename the status row accordingly. - Wire-format sizes: SessionDatagram + CoordsRequired is 74 bytes, not 70; SessionDatagram + PathBroken is 76 + 16n, not "70+"; PathBroken alone is 40 + 16n, not 35 + 16n; CoordsRequired with link overhead is 110, not 111; and the CoordsWarmup example is 238, not 239. State in the summary table which rows include link overhead. - Three FSP wire comments called the session handshake Noise IK. It is Noise XK, as the module's own phase table says. Correct the rationale for dropping our own looped-back request. The comment on the own-request check in classify_request, its test and the handle_lookup_response doc said that without the check the answer to our own lookup would be reverse-path forwarded to the peer that looped the request, and that recent_requests decides originator status. That stopped being true when classify_response began deciding originator status from the pending lookup before it reads the transit record. Say what the check still does: it stops the copy being forwarded a second time and keeps our own id out of the transit record, so a response after the lookup completes has nothing to be relayed by. Extend the test to pin that: once the lookup is accepted, a later response for the same id is unsolicited.
353 lines
15 KiB
Markdown
353 lines
15 KiB
Markdown
# FIPS Spanning Tree
|
||
|
||
This document describes the spanning tree algorithms and data structures
|
||
used by FIPS for coordinate-based routing. It is a supporting reference
|
||
for readers who want to understand the tree internals — for how the
|
||
spanning tree fits into the overall mesh operation, see
|
||
[fips-mesh-operation.md](fips-mesh-operation.md).
|
||
|
||
## What Is a Spanning Tree?
|
||
|
||
A spanning tree is a subset of the links in a mesh network that:
|
||
|
||
- **Reaches every node** — no node is disconnected
|
||
- **Contains no cycles** — there is exactly one path between any two nodes
|
||
- **Has a single root** — one distinguished node from which all paths descend
|
||
- **Assigns every non-root node exactly one parent** — creating a
|
||
hierarchy from leaves to root
|
||
|
||
Because a tree has no cycles, any node's position can be described by
|
||
its path to the root. This path serves as a coordinate in a virtual
|
||
address space, enabling distance calculations and routing decisions
|
||
without global topology knowledge.
|
||
|
||
There is nothing special about the root node other than providing the
|
||
center of the coordinate system. Being root implies no additional
|
||
processing, routing, or operational burden — the root runs the same
|
||
protocol as every other node.
|
||
|
||
## Purpose
|
||
|
||
The FIPS spanning tree gives every node in the mesh a **coordinate** — its
|
||
ancestry path from itself to the root. These coordinates enable:
|
||
|
||
- Distance calculation between any two nodes without global topology
|
||
knowledge
|
||
- Greedy routing where each hop reduces distance to the destination
|
||
- Strict-progress forwarding: each hop forwards only to a peer closer to
|
||
the destination by its own view of the destination's coordinates (when
|
||
hops hold different coordinates this does not rule out a loop; the TTL
|
||
bounds it)
|
||
|
||
## Root Discovery
|
||
|
||
The root is the node with the **lexicographically smallest node_addr** among
|
||
all reachable nodes. There is no election protocol, no voting, no
|
||
negotiation — each node independently discovers the same root by
|
||
evaluating the TreeAnnounce messages from its peers and selecting the
|
||
minimum root.
|
||
|
||
When a node first joins the network with no peers, it is its own root. As
|
||
it connects to peers and receives their TreeAnnounce messages, it discovers
|
||
smaller node_addrs and converges to the global root.
|
||
|
||
If the network partitions, each segment independently discovers its own root
|
||
(the smallest node_addr in that segment). When segments rejoin, all nodes
|
||
discover the globally-smallest root through TreeAnnounce exchange and
|
||
reconverge to a single tree.
|
||
|
||
## Parent Selection
|
||
|
||
Parent selection and reselection is the primary means by which the mesh
|
||
self-organizes into an efficient routing structure. By having each node
|
||
choose the parent with the best measured link performance, packet
|
||
routing up and down the tree follows the best available path that
|
||
reduces distance to the destination.
|
||
|
||
Each node selects a single parent from among its direct peers. Parent
|
||
selection uses cost-weighted depth to balance tree depth against link
|
||
quality.
|
||
|
||
### Selection Criteria
|
||
|
||
1. **Find the smallest root** visible across all peers' TreeAnnounce messages
|
||
2. Compute **effective depth** for each candidate peer:
|
||
`effective_depth = peer.depth + link_cost`, where
|
||
`link_cost = etx * (1.0 + srtt_ms / 100.0)` using locally measured MMP
|
||
metrics. During cold start (no peer has MMP data yet), candidates without
|
||
measurements default to `link_cost = 1.0`, preserving pure depth-based
|
||
behavior. Once any peer has MMP data, unmeasured candidates are excluded
|
||
so that a freshly connected peer cannot win parent selection on the
|
||
default cost alone. An unmeasured peer is still taken to correct the
|
||
root when no measured peer reaches the smallest root, but never to win
|
||
a cost-based switch.
|
||
3. Apply **hysteresis**: switch parents only when the best candidate's
|
||
effective depth is significantly better than the current parent's:
|
||
`best_eff_depth < current_eff_depth * (1.0 - parent_hysteresis)`
|
||
(default `parent_hysteresis = 0.2`, requiring 20% improvement)
|
||
|
||
### Mandatory Switch Triggers
|
||
|
||
Two conditions bypass both hysteresis and the hold-down timer, triggering
|
||
immediate parent reselection:
|
||
|
||
1. **Parent loss**: Current parent is no longer in the peer set (link
|
||
broken, peer disconnected)
|
||
2. **Better root**: A peer advertises a smaller root than the current
|
||
tree's root — always switch regardless of effective depth
|
||
|
||
### Stability Mechanisms
|
||
|
||
- **Hold-down timer** (`hold_down_secs`, default 30s): After any parent
|
||
switch, non-mandatory re-evaluation is suppressed to allow MMP metrics
|
||
to stabilize on the new link. Mandatory switches (parent loss, root
|
||
change) bypass the hold-down.
|
||
- **Periodic re-evaluation** (`reeval_interval_secs`, default 60s):
|
||
Re-evaluates parent selection using current MMP link costs, independent
|
||
of TreeAnnounce traffic. This catches link degradation after the tree
|
||
has stabilized and change-driven TreeAnnounce gossip has stopped. It
|
||
runs only on a node with two or more peers, and when it finds no
|
||
reason to switch parent or become root it re-broadcasts the unchanged
|
||
declaration.
|
||
- **Flap dampening** (`flap_threshold` / `flap_window_secs` /
|
||
`flap_dampening_secs`): If a node's parent switches reach
|
||
`flap_threshold` (default 4) within a fixed window of
|
||
`flap_window_secs` (default 60s) opened by the first switch, an
|
||
extended hold-down of `flap_dampening_secs` (default 120s) is
|
||
imposed. This reduces TreeAnnounce storms from link flapping without
|
||
delaying legitimate reconvergence. Mandatory switches (parent loss,
|
||
root change) bypass dampening.
|
||
- **Local-only metrics**: Link costs use only locally measured MMP data
|
||
(ETX and SRTT). No cumulative path costs are propagated, avoiding
|
||
the trust problems inherent in self-reported cost metrics in a
|
||
permissionless network.
|
||
- **Loop rejection**: Candidates whose advertised ancestry already contains
|
||
the local node are skipped, preventing two nodes from selecting each
|
||
other as parent and entering an alternating coordinate loop.
|
||
|
||
### After Parent Change
|
||
|
||
When a node changes its parent:
|
||
|
||
1. Increment its own sequence number
|
||
2. Recompute its coordinates from the new ancestry path
|
||
3. Sign a new TreeAnnounce declaration
|
||
4. Announce to all peers
|
||
5. Drop cached coordinates that route through this node (parent switch
|
||
or ancestry change) or sit under another root (becoming root); both on
|
||
parent loss or loop detection. Other cached entries are kept.
|
||
|
||
## Coordinate Computation
|
||
|
||
A node's coordinate is its full ancestry path from itself to the root:
|
||
|
||
```text
|
||
coords(N) = [N, Parent(N), Parent(Parent(N)), ..., Root]
|
||
```
|
||
|
||
Coordinates are ordered self-to-root. For a node D at depth 4:
|
||
|
||
```text
|
||
coords(D) = [D, P1, P2, P3, Root]
|
||
```
|
||
|
||
The root's coordinate is simply `[Root]` (depth 0).
|
||
|
||
## Tree Distance
|
||
|
||
Tree distance between two nodes is the number of hops through their lowest
|
||
common ancestor (LCA). Because coordinates are ordered self-to-root, common
|
||
ancestry appears as a common suffix.
|
||
|
||
```text
|
||
tree_distance(a, b):
|
||
lca_depth = longest_common_suffix_length(a.coords, b.coords)
|
||
a_to_lca = len(a.coords) - lca_depth
|
||
b_to_lca = len(b.coords) - lca_depth
|
||
return a_to_lca + b_to_lca
|
||
```
|
||
|
||
Example: If A has coordinates `[A, X, Y, Root]` and B has coordinates
|
||
`[B, Z, Y, Root]`, the common suffix is `[Y, Root]` (length 2). Distance =
|
||
(4 - 2) + (4 - 2) = 4 hops.
|
||
|
||
The self-distance check in greedy routing uses this calculation: a packet is
|
||
forwarded to a peer only if the peer is strictly closer to the destination
|
||
than the current node.
|
||
|
||
## TreeAnnounce Processing
|
||
|
||
When a node receives a TreeAnnounce from peer P:
|
||
|
||
1. **Validate version**: Reject if version ≠ 0x01
|
||
2. **Verify signature**: Check P's declaration signature using P's known
|
||
public key (established during Noise IK handshake)
|
||
3. **Verify identity**: Confirm the declaration's node_addr matches the
|
||
sender's known identity
|
||
4. **Check freshness**: If `sequence ≤ stored sequence for P`, discard
|
||
(stale or duplicate)
|
||
5. **Update peer state**: Store P's tree declaration and ancestry
|
||
6. **Evaluate parent selection**: Re-run parent selection with the updated
|
||
peer state
|
||
|
||
### Propagation Rules
|
||
|
||
A node re-announces (propagates) only when its own state changes:
|
||
|
||
- **Root changed**: Always propagate — this is a significant topology event
|
||
- **Depth changed**: Always propagate — affects routing distance calculations
|
||
- **Mid-chain ancestor swap**: A reroute that replaces an interior ancestor
|
||
without changing the root or the path length still alters the node's
|
||
coordinate path, so it propagates. Without this, downstream peers would
|
||
route into a phantom intermediate that no longer appears on the parent's
|
||
tree.
|
||
- **Sequence-only refresh**: Does NOT propagate beyond depth 1 — peers that
|
||
receive a sequence-only update do not re-announce, because their own root,
|
||
depth, and address path have not changed
|
||
|
||
This means TreeAnnounce cascades through the tree proportional to depth,
|
||
not network size. A change at depth D affects at most D nodes along the
|
||
branch, and each only re-announces to its peers.
|
||
|
||
### Rate Limiting
|
||
|
||
- **Minimum interval**: 500ms between announcements to the same peer
|
||
- **Coalescing**: If changes occur during cooldown, they are coalesced and
|
||
sent as a single announcement after the cooldown expires
|
||
- **Convergence time**: A tree of depth D reconverges in roughly D × 0.5s
|
||
to D × 1.0s
|
||
|
||
The transport accepting a TreeAnnounce is not delivery. Each announce stays
|
||
outstanding until the link's receiver reports confirm it, by the same rule
|
||
as a FilterAnnounce (see "Update Triggers" in
|
||
[fips-bloom-filters.md](fips-bloom-filters.md)). It is resent when the
|
||
reports show a loss, and once after 30 s when they cannot confirm it, with
|
||
the same budgets and backoff: per declaration sequence per session, one
|
||
unchecked resend and three on loss, spaced by a per-peer backoff of 1, 2,
|
||
4 ... up to 60 s. A resend carries the current declaration, so a peer that
|
||
missed an older one gets the newer position, and one that already holds it
|
||
ignores it as not fresher. The periodic re-evaluation under Stability
|
||
Mechanisms, which re-broadcasts an unchanged declaration, stays as the
|
||
backstop on a node with two or more peers.
|
||
|
||
### Transitive Trust (v1)
|
||
|
||
In the v1 protocol, only the sender's outer signature on the TreeAnnounce
|
||
is verified. Ancestry entries beyond the direct peer (the sender's parent,
|
||
grandparent, etc.) are accepted on transitive trust through the
|
||
authenticated sender. The sender is a known, authenticated peer — if it
|
||
claims a particular ancestry, v1 trusts that claim.
|
||
|
||
Future protocol versions may add per-entry signatures in the ancestry chain
|
||
for stronger verification.
|
||
|
||
## Sequence Numbers and Timestamps
|
||
|
||
### Sequence Number
|
||
|
||
- Type: u64, monotonically increasing
|
||
- Incremented on each parent change
|
||
- Used for freshness: incoming TreeAnnounce with sequence ≤ stored sequence
|
||
for that peer is discarded
|
||
- Higher sequence numbers always supersede lower ones
|
||
|
||
### Timestamp
|
||
|
||
- Type: u64, Unix seconds
|
||
- Advisory only — not used in any decision logic, as there is no way to
|
||
verify its accuracy from peers
|
||
|
||
## Reconvergence
|
||
|
||
### Single Node Failure
|
||
|
||
When a node fails (link timeout or disconnect):
|
||
|
||
1. Nodes that had the failed node as their parent lose their parent
|
||
2. Parent loss triggers immediate reselection from remaining peers
|
||
3. Each affected node recomputes coordinates and announces
|
||
4. Changes cascade down the subtree proportional to depth
|
||
|
||
### Partition
|
||
|
||
When the network partitions:
|
||
|
||
1. Nodes in each segment lose peers across the partition boundary
|
||
2. If the root was in the other segment, affected nodes discover a new segment
|
||
root (smallest node_addr in their segment)
|
||
3. Each segment reconverges independently
|
||
|
||
### Partition Merge
|
||
|
||
When two partitions rejoin:
|
||
|
||
1. Nodes at the boundary exchange TreeAnnounce messages with new peers
|
||
2. Both segments discover each other's root
|
||
3. The globally-smaller root wins; the other segment's nodes switch parents
|
||
4. Switching nodes drop cached coordinates that route through them or sit
|
||
under the losing root (stale cross-partition coordinates)
|
||
5. Bloom filters update within about one `node.tick_interval_secs` per
|
||
hop (1 s at the default; the 500 ms debounce is a floor, the tick sets
|
||
the cadence), restoring reachability information
|
||
|
||
## Bounded State
|
||
|
||
Each node's spanning tree state is O(P × D), where P is the number of
|
||
direct peers and D is the tree depth. This is NOT O(N) where N is the
|
||
network size.
|
||
|
||
What a node stores:
|
||
|
||
- Its own declaration (coordinates, sequence, timestamp, signature)
|
||
- Each peer's declaration and ancestry chain (P entries, each with D
|
||
ancestry entries)
|
||
|
||
What a node does NOT know:
|
||
|
||
- Other subtrees branching off its ancestors
|
||
- Siblings of ancestors
|
||
- Nodes in distant parts of the network
|
||
|
||
Example: In a 1000-node network with depth 10 and 5 peers, a node stores
|
||
~50 ancestry entries — not 1000 routing table entries.
|
||
|
||
## Timing Parameters
|
||
|
||
| Parameter | Default | Description |
|
||
| --------- | ------- | ----------- |
|
||
| PARENT_HYSTERESIS | 0.2 (20%) | Fractional improvement in effective depth required for same-root switch |
|
||
| HOLD_DOWN_SECS | 30s | Suppress non-mandatory re-evaluation after parent switch |
|
||
| REEVAL_INTERVAL_SECS | 60s | Periodic cost-based parent re-evaluation interval |
|
||
| FLAP_THRESHOLD | 4 | Parent switches in window before dampening engages |
|
||
| FLAP_WINDOW_SECS | 60s | Fixed window, opened by the first switch, for counting parent switches |
|
||
| FLAP_DAMPENING_SECS | 120s | Extended hold-down duration once the flap threshold is reached |
|
||
| ANNOUNCE_MIN_INTERVAL | 500ms | Minimum between announcements to same peer |
|
||
|
||
## Implementation Status
|
||
|
||
| Feature | Status |
|
||
| ------- | ------ |
|
||
| Root discovery (smallest node_addr) | **Implemented** |
|
||
| Cost-based parent selection with hysteresis | **Implemented** |
|
||
| Hold-down timer after parent change | **Implemented** |
|
||
| Periodic cost-based parent re-evaluation | **Implemented** |
|
||
| Coordinate computation | **Implemented** |
|
||
| TreeAnnounce gossip | **Implemented** |
|
||
| Signature verification (outer) | **Implemented** |
|
||
| Sequence-based freshness | **Implemented** |
|
||
| Rate limiting (500ms per peer) | **Implemented** |
|
||
| Selective coord cache invalidation on parent change | **Implemented** |
|
||
| Flap dampening (extended hold-down on rapid switches) | **Implemented** |
|
||
| Loop rejection (ancestry self-check in `evaluate_parent`) | **Implemented** |
|
||
| Mid-chain ancestor swap propagation | **Implemented** |
|
||
| Per-ancestry-entry signatures | Future direction |
|
||
|
||
## References
|
||
|
||
- [fips-mesh-operation.md](fips-mesh-operation.md) — How the spanning tree
|
||
fits into mesh routing
|
||
- [../reference/wire-formats.md](../reference/wire-formats.md) —
|
||
TreeAnnounce wire format
|
||
- [spanning-tree-dynamics.md](spanning-tree-dynamics.md) — Convergence
|
||
scenario walkthroughs
|