reticiulum-specification/flows/path-discovery.md
Rob 0bf03d924d Expand §7.2 + add path-discovery flow
Closes Tier 1 #5. The previous §7.2 was four bullet points naming the
"answer with an announce" rule but missing every wire detail —
implementation-time the SF mobile client got steps 4 (dedup) and 5
(local-destination check) wrong on its first cut and the bug only
surfaced as "I can message my own destination but no one else can
reply".

§7.2 is now six sub-sections:

  §7.2.1  Path-request packet parse rules. The handler's slice
          recipe with branching on payload length (32B = leaf form
          target||tag; 48B+ = transport form target||transport_id||
          tag); tag cap at 16B; tagless-request rejection.

  §7.2.2  Tag-based dedup via Transport.discovery_pr_tags. The
          unique_tag = dest_hash || tag construction, the 32000-
          entry cap, why missing this turns a leaf into a broadcast-
          storm amplifier on retransmits.

  §7.2.3  The five-way dispatch in Transport.path_request:
          local-destination / transit-knows-path / local-client-
          forward / discovery-recursive / drop. Branches 1 and 5
          are the only ones a leaf needs.

  §7.2.4  Path-response announce wire format. Body byte-identical
          to a regular announce (§4.1); only the outer packet
          context byte differs (NONE → PATH_RESPONSE 0x0B).
          PR_TAG_WINDOW=30s body-cache that serves identical wire
          bytes to racing relays so transit dedup converges.

  §7.2.5  Timing constants: PATH_REQUEST_GRACE = 0.4s, +
          PATH_REQUEST_RG = 1.5s for roaming-mode interfaces.
          Local-destination and local-client originator branches
          bypass the grace.

  §7.2.6  Minimum responsibility for a non-transport leaf — the
          six-step protocol-level recipe.

flows/path-discovery.md: 9-step chronology covering both
single-hop leaf-owns-target and two-hop transit-relay-knows-path
cases. Wire-byte ladder diagrams for both. Notes the ingress-limit
bypass for path-responses (Transport.py:1632-1639), the
receive_path_responses opt-in for handler dispatch
(Transport.py:1989-1991), and the timeout/escalation path through
LXMRouter.process_outbound's MAX_PATHLESS_TRIES retry counter.

flows/README.md status table updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:50:10 -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.0.

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:2707-2745. 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 PLAIN (RNS/Packet.py:192-194 skips encryption for PLAIN-type destinations).

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