reticiulum-specification/flows/path-discovery.md
Rob cfd0d8249b Re-anchor against RNS 1.2.4 / LXMF 0.9.7 + track upstream distribution shift
Upstream RNS 1.2.4 (2026-05-07) announces it is "probably the last
release that is also published to GitHub" — pip continues until rnpkg
is complete and RNS is self-hosting. All 13 verifiers pass against
1.2.4 / 0.9.7; no wire-format, signing, or protocol behavior changed
between 1.2.0 and 1.2.4, so the changes here are purely currency:

- Pin tools/requirements.txt to rns==1.2.4 / lxmf==0.9.7 so the
  verifier stays reproducible if upstream stops mirroring to PyPI
  before the migration is ready.
- Add an "Upstream distribution shift" watch-list to todo.md (local
  Reticulum node, repo destination hash, rnpkg install/upgrade
  commands, rsg signature verification, mirroring source citations).
- Bump SPEC.md frontmatter and re-anchor ~50 line citations across
  Identity.py, Transport.py, Resource.py, Link.py, Reticulum.py,
  Packet.py, and LXMF/* (Identity.py drift was the heaviest at +13
  to +31 lines; Transport.py was variable). Fix one numeric
  (MAX_RANDOM_BLOBS = 32 → 64) and one semantic (§6.6.3 LRPROOF MTU
  clamp citation pointed at the wrong location — corrected to point
  at the transit-relay clamp at Transport.py:1539-1556).
- Update §10.4 decompression-bomb hazard to note upstream's 1.1.9 cap
  adoption, with citations to Resource.py:686-691 and Buffer.py:95-97
  plus a "do not use one-shot bz2.decompress()" warning.
- Re-anchor 11 flows/ files (version pins + ~30 line citations).
- Bump version labels in tools/README.md, test-vectors/README.md, and
  4 verifier docstrings + 2 hardcoded print strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:42:25 -04:00

199 lines
14 KiB
Markdown

# Flow: discover a path to an unknown destination
What happens chronologically when one node wants to reach another whose path is missing from `Transport.path_table`. This flow drives the well-known `rnstransport.path.request` destination (dest_hash `6b9f66014d9853faab220fba47d02761`, see [`../SPEC.md`](../SPEC.md) §1.2 and §7.1) and the path-response announce that returns along the reverse path.
Pinned against **RNS 1.2.4**.
Out of scope: the LXMF outbound retry that gates this flow (`flows/send-opportunistic-lxmf.md` step 4); the periodic `Transport.jobs` cycle that ages out stale paths; transport-mode rebroadcast of a known path on behalf of a remote requester (Tier 3 spec gap, see `../todo.md`).
---
## Preconditions
- Initiator has at least one `RNS.Identity` with private keys.
- Initiator has *no* `path_table` entry for the target — `RNS.Transport.has_path(dest_hash)` returns False. If a path entry exists, this flow does not run; the LXMF outbound goes straight through with the cached path.
- At least one node within reach knows the target — either because the target is directly attached to it (leaf-owns-destination case) or because it has the target in its own `path_table` (transport-relay-knows-path case). If nobody on the mesh knows, the request times out and the LXMF retry escalates per `LXMRouter.process_outbound`.
---
## Sequence
### 1. Initiator: `RNS.Transport.request_path(dest_hash)`
`RNS/Transport.py:2713-2750`. Triggered by:
- `LXMRouter.handle_outbound` when `not has_path(dest) and method == OPPORTUNISTIC` (see `send-opportunistic-lxmf.md` step 4).
- `LXMRouter.process_outbound` retry path after `MAX_PATHLESS_TRIES`.
- Application code calling `Transport.request_path()` directly (rncp, rnpath, rnstatus utilities all do this).
`request_path` constructs a `Packet` addressed to the well-known path-request destination:
```python
request_tag = tag or Identity.get_random_hash() # 16B random unless caller supplied
if Reticulum.transport_enabled():
payload = destination_hash + Transport.identity.hash + request_tag # 48 bytes
else:
payload = destination_hash + request_tag # 32 bytes
path_request_dst = Destination(None, OUT, PLAIN, "rnstransport", "path", "request")
packet = Packet(path_request_dst, payload,
packet_type=DATA, transport_type=BROADCAST,
header_type=HEADER_1, attached_interface=on_interface)
packet.send()
Transport.path_requests[destination_hash] = time.time()
```
The well-known dest hash `6b9f66014d9853faab220fba47d02761` is computed as `SHA256(SHA256("rnstransport.path.request")[:10])[:16]` per [`../SPEC.md`](../SPEC.md) §1.2 (the `identity=None` branch of `Destination.hash`). It is the same on every node — no per-node uniqueness — because the destination is `PLAIN` with no identity attached.
The packet is `DATA` (not ANNOUNCE), `BROADCAST` transport, `HEADER_1`, and `context = NONE`. The body is **unencrypted** because the outer destination is `PLAIN``Packet.pack` falls through to `self.destination.encrypt(self.data)` at `RNS/Packet.py:215`, and `Destination.encrypt` returns `plaintext` unchanged for `Destination.PLAIN` (`RNS/Destination.py:592-593`).
Initiator records `Transport.path_requests[destination_hash] = time.time()` for `PATH_REQUEST_GATE_TIMEOUT = 120s` rate-limiting (prevents the same node from repeating identical path? requests faster than `PATH_REQUEST_MI = 20s`).
### 2. Initiator: `Transport.outbound` broadcasts the request
The path? packet then runs through the standard outbound flow (`send-opportunistic-lxmf.md` steps 7-8) but lands in the broadcast branch (`Transport.py:1119+`) because its destination is `PLAIN` and not in the `path_table`. Result: the packet is transmitted on every OUT-flagged interface, not just one. This is intentional — until we know where the target lives, we have to ask everyone.
If the initiator is a transport node, the broadcast is subject to `announce_cap` rate limiting just like an announce (`Transport.py:1196+`). Leaf clients have no announce_cap and broadcast unthrottled.
### 3. Each peer: `Transport.inbound` recognizes the path-request destination
Every node on every interface that hears the broadcast packet runs it through `Transport.inbound`. The dispatch by `(packet_type, destination_type)` lands in the `DATA + PLAIN` branch (`Transport.py:2087-2103` — same code as the opportunistic-LXMF DATA dispatch in `flows/receive-opportunistic-lxmf.md` step 6, but for a PLAIN destination instead of SINGLE).
The destination lookup hits `Transport.destinations_map[dest_hash]` for the well-known path-request destination — every node has this destination registered automatically during `Transport.__init__` at `Transport.py:237-240`:
```python
Transport.path_request_destination = RNS.Destination(
None, RNS.Destination.IN, RNS.Destination.PLAIN,
Transport.APP_NAME, "path", "request",
)
Transport.path_request_destination.set_packet_callback(Transport.path_request_handler)
```
Control reaches `Destination.receive``path_request_handler(data, packet)`.
### 4. Each peer: parse and dedup
`Transport.py:2800-2843`. Per [`../SPEC.md`](../SPEC.md) §7.2.1, the handler extracts:
```
data[ 0:16] destination_hash — the requested target
data[16:32] requesting_transport_instance OR tag_bytes (depending on length)
data[32:..] tag_bytes — only present when len(data) > 32
```
Tagless requests are dropped. Otherwise the handler builds `unique_tag = destination_hash || tag_bytes[:16]` and consults `discovery_pr_tags` to dedup (see §7.2.2). Duplicate requests are silently ignored without further processing — important because the broadcast in step 2 will reach the same relay via multiple interfaces in a meshy topology.
### 5. Each peer: dispatch in `Transport.path_request`
`Transport.py:2846-2973`, also documented in [`../SPEC.md`](../SPEC.md) §7.2.3. Five mutually-exclusive branches:
- **Branch 1 — local destination (the responder we want):** `destination_hash` is in `Transport.destinations_map`. Call `local_destination.announce(path_response=True, tag=tag, attached_interface=...)`. **Goto step 6.**
- **Branch 2 — transit relay knows the path:** `transport_enabled` AND `dest_hash` in `path_table`. Pull the cached announce packet from `path_table[dest_hash][IDX_PT_PACKET]`, queue it for rebroadcast on the receiving interface with `PATH_REQUEST_GRACE = 0.4s` delay (or `+1.5s` on roaming-mode interfaces). Out of scope for this flow doc — see Tier 3 todo.
- **Branch 3 — local client → forward:** request came from a local-client interface and we don't know the path. Forward the request to every other interface with a fresh random tag.
- **Branch 4 — transport-enabled, unknown, discovery-allowed:** record a `discovery_path_requests` entry (15s timeout) and forward the request to every other interface preserving the original tag.
- **Branch 5 — no match:** drop silently.
For a leaf client only Branches 1 and 5 matter. Branches 2-4 are transport-node behaviour.
### 6. Responder: `Destination.announce(path_response=True, tag=tag, ...)`
`RNS/Destination.py:243-318`. Builds an announce packet identical in body to a regular periodic announce (see `flows/receive-announce.md` for the validation side and [`../SPEC.md`](../SPEC.md) §4.1 for the body bytes), with two distinctions:
1. **Outer packet context = `PATH_RESPONSE (0x0B)`** instead of `NONE (0x00)`:
```python
if path_response: announce_context = RNS.Packet.PATH_RESPONSE
else: announce_context = RNS.Packet.NONE
```
This is the only wire-byte difference between a path response and a regular re-announce.
2. **`tag` parameter caches the body for `PR_TAG_WINDOW = 30s`** (`Destination.py:260-278`). If multiple relays forward the same `path?` to this leaf in quick succession, the leaf serves the **same announce bytes** to all of them — same `random_hash`, same `signature`, identical wire bytes — so transit-relay dedup logic (path-table `random_blobs` cache, see [`../SPEC.md`](../SPEC.md) §4.5 step 6.3) treats the multiple incoming responses as one.
The path-response announce is sent on `attached_interface` only (the interface the request arrived on), not broadcast on all interfaces — so the response travels back along the same path the request came in on.
### 7. Reverse path traversal
The path-response announce is itself a regular ANNOUNCE packet, broadcast-routed. As it traverses each transit node, the standard receive-announce flow runs (`flows/receive-announce.md`):
- Step 2: signature-only quick check, increment per-interface ingress-frequency deque.
- Step 3: ingress-rate limit check — but **path-responses bypass ingress limiting** because the destination is in `Transport.path_requests` (recently-requested-by-us) or `Transport.discovery_path_requests` (recently-requested-on-behalf-of). See `Transport.py:1632-1639`.
- Step 5: full `validate_announce` — same signature, dest_hash, collision checks as any announce.
- Step 7: path table population. **The hop count is whatever the cached announce stored** (`Transport.py:2890`: `packet.hops = path_table[dest_hash][IDX_PT_HOPS]` if served from cache by Branch 2); for Branch 1 responses, hops counts up naturally as the announce traverses back.
- Step 8: announce handler fan-out. **Path-response announces are filtered OUT of regular handler dispatch by default** (`Transport.py:1989-1991`):
```python
if packet.context == RNS.Packet.PATH_RESPONSE:
execute_callback = handler.receive_path_responses == True
```
So an LXMF delivery handler that doesn't opt in via `receive_path_responses = True` won't see path-response announces as "new contacts", but will still benefit from the path-table side effect.
### 8. Initiator: path table populated, deferred LXM resumes
When the path-response announce arrives back at the initiator, `validate_announce` populates `Transport.path_table[dest_hash]` with `[recv_time, received_from, hops, expires, random_blobs, receiving_interface, packet_hash]` per [`../SPEC.md`](../SPEC.md) §4.5 step 6.3.
`Transport.has_path(dest_hash)` now returns True. The next time `LXMRouter.process_outbound` checks the deferred LXM (after `LXMRouter.PATH_REQUEST_WAIT` from `send-opportunistic-lxmf.md` step 4), it finds the path is known and proceeds to the encrypt-and-send branch in `LXMessage.send`.
### 9. Timeout and escalation
If no response arrives within `Transport.PATH_REQUEST_TIMEOUT = 15s`, the `discovery_path_requests` entry on transit relays is aged out by the `Transport.jobs` cycle (`Transport.py:778-783`). The originating LXM stays in `pending_outbound`; on the next retry tick after `LXMRouter.PATH_REQUEST_WAIT`, `LXMRouter.process_outbound` checks `has_path()` again, finds it still False, and re-issues `request_path`. This continues up to `LXMRouter.MAX_DELIVERY_ATTEMPTS` (typically 5) before the message is marked failed.
The full outer retry behaviour (drop_path then re-request, the `MAX_PATHLESS_TRIES + 1` rediscovery branch at `LXMRouter.py:2577-2586`) is documented in `flows/send-opportunistic-lxmf.md` step 12.
---
## Wire-byte ladder
Single-hop request, leaf-client target (no transport_id in request):
```
Initiator Mesh / Relay Target leaf
1. PATH? request (DATA, dest=well-known ──────────────────────►──────────────────────►
PLAIN destination 6b9f...02761,
ctx=0x00)
payload = target_dest_hash(16) || tag(16)
broadcast on every OUT interface
2. PATH-RESPONSE announce (ANNOUNCE, ◄────────────────────── Branch 1: leaf
dest=target_dest_hash, ctx=0x0B) owns dest →
announces with
ctx=PATH_RESPONSE
forwarded along reverse path back ◄──────────────────────
to initiator
```
Two-hop request, transit relay knows the path:
```
Initiator Transit relay Target leaf (offline)
1. PATH? request ──────────────────────►
payload = target || transport_id || tag
2. (no broadcast forward) Branch 2: relay
pulls cached announce
from its path_table
3. PATH-RESPONSE announce (cached ◄──────────────────────
announce body, ctx rewritten
to PATH_RESPONSE)
PATH_REQUEST_GRACE = 0.4s delay before
relay rebroadcasts
```
In the second case, the target leaf doesn't see the request at all — the relay answers from its cache. This is the "transport-enabled rnsd in the middle" case that makes Reticulum resilient to leaf-node sleep/wake cycles: as long as one relay heard the leaf's most recent announce, peers can find it for the duration of `Transport.AP_PATH_TIME` after that.
---
## Source map
| Step | File | Function / line |
|---|---|---|
| 1 | `RNS/Transport.py` | `request_path`, line 2707 |
| 2 | `RNS/Transport.py` | `outbound` broadcast branch, line 1119+ |
| 3 | `RNS/Transport.py` | `Transport.__init__` registration, line 237; PLAIN+DATA dispatch, line 2087+ |
| 4 | `RNS/Transport.py` | `path_request_handler`, line 2800 |
| 5 | `RNS/Transport.py` | `path_request`, line 2846 (5-way dispatch) |
| 6 | `RNS/Destination.py` | `announce(path_response=True)`, line 243 |
| 7 | `RNS/Transport.py` | inbound ANNOUNCE branch, line 1623; ingress-limit bypass, line 1637 |
| 7 | `RNS/Transport.py` | path-response handler filter, line 1989-1991 |
| 8 | `LXMF/LXMRouter.py` | `process_outbound` resume after path-resolution, line 2566 |
| 9 | `RNS/Transport.py` | `discovery_path_requests` cleanup, line 778-783 |