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

14 KiB

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

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 §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 PLAINPacket.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:

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.receivepath_request_handler(data, packet).

4. Each peer: parse and dedup

Transport.py:2800-2843. Per ../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 §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 §4.1 for the body bytes), with two distinctions:

  1. Outer packet context = PATH_RESPONSE (0x0B) instead of NONE (0x00):

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